module PgGraph::Type class Error < StandardError; end class Node < HashTree::Set include Constrain alias_method :uid, :path def guid() root.name + "." + uid end alias_method :name, :key # The Ruby model identifier of the object. It corresponds to #name def identifier() PgGraph.inflector.camelize(name) end # The full Ruby model identifier of the object within a database. It # corresponds to #uid def schema_identifier() identifier end # :call-seq: initialize(database, name, attach: true) def initialize(parent, name, **opts) constrain parent, Node, NilClass constrain name, String, NilClass super end def inspect(payload = inspect_inner) "#<#{self.class}:#{name.inspect}#{payload ? " #{payload}" : ""}>" end def inspect_inner() nil end protected # Nodes with nil keys are not attached. This is used in PgCatalogSchema to # avoid being included in the list of schemas def do_attach(key, child) super if child end # Forward list of methods to object. The arguments should be strings or symbols def self.forward_method(object, *methods) for method in Array(methods).flatten class_eval("def #{method}(*args) #{object}.#{method}(*args) end") end end end # Included by tables, records, and fields module TableObject # Circular definitions. Classes that include TableObject should redefine at least # one of the methods to break the loop def table() record_type.table end def table_type() table.type end def record_type() table_type.record_type end end # Database is the top-level object and #read is the starting point for # loading the type system in PgGraph::Type.new class Database < Node def guid() name end alias_method :schemas, :values attr_reader :catalog # List of all tables in the database def tables() schemas.map(&:tables).flatten end # The reflector object. The reflector object takes a name of a link field # and produce the field name in this table and the corrsponding field name # in the other table attr_reader :reflector def initialize(name, reflector = PgGraph::Reflector.new) constrain name, String constrain reflector, PgGraph::Reflector super(nil, name) @catalog = PgCatalogSchema.new(self) @reflector = reflector end def dot_lookup(key) super || catalog[key] end # TODO # def to_yaml() end # def self.load_yaml() end # Make Database pretend it is a PgGraph::Type object def is_a?(klass) klass == PgGraph::Type || super end def read(arg, reflector = nil, ignore: []) constrain arg, PgMeta, PgConn @reflector = reflector || @reflector case arg # #read_meta is a member of Database but defined in read.rb when PgMeta; read_meta(arg, ignore: ignore) when PgConn; read_meta(PgMeta.new(arg), ignore: ignore) end end end class Schema < Node alias_method :database, :parent alias_method :objects, :values def tables() objects.select { |child| child.is_a?(Table) } end def types() objects.select { |child| child.is_a?(Type) } end def record_types() objects.select { |child| child.is_a?(RecordType) } end # :call-seq: initialize(database, name, attach: true) # # +attach:false+ is used when initializing the pg_catalog schema object def initialize(database, name, **opts) constrain database, Database super(database, name, **opts) end end class PgCatalogSchema < Schema def schema_identifier() nil end def initialize(database) super(database, "pg_catalog", attach: false) end end # Schema objects are owned by the schema. This includes tables and # user-defined types but not records that are owned by their tables class SchemaObject < Node alias_method :schema, :parent def initialize(schema, name) constrain schema, Schema super end def schema_identifier [schema.schema_identifier, identifier].compact.join("::") end end # A table. Note that a table doesn't have child objects. Instead it has a type # property that has the record type as child class Table < SchemaObject include TableObject # Qualified name of table (ie. schema.table). FIXME: We already have Node#path??? attr_reader :path # Table type. Initially nil. Initialized by RecordType.new attr_reader :type alias_method :table_type, :type # Required TableObject method forward_method :type, :keys, :fields, :key?, :[], :columns, :value_columns, :column? # Parent table if table is a derived table and otherwise nil. Initialized by #read attr_reader :supertable # True if table is a parent table. Initialized by #read def supertable?() @has_subtables || false end # True if table is a derived table def subtable?() !@supertable.nil? end def mm_table?() @mm_table end def nm_table?() @nm_table end # Array of tables in the transitive closure of table dependencies. It is # initialized by Database#read_meta attr_reader :depending_tables # This is a hack since graph_db doesn't model views. It is needed because # PgGraph::Data needs to know which materialized views to refresh after # data has been loaded attr_reader :depending_materialized_views def initialize( schema, name, mm_table: false, nm_table: false, depending_materialized_views: []) PgGraph.inflector.plural?(name) or raise Error, "Table names should be plural: #{name.inspect}" super(schema, name) @path = "#{schema.name}.#{name}" @mm_table = mm_table || nm_table @nm_table = nm_table @depending_tables = [] @depending_materialized_views = depending_materialized_views end protected def type=(t) @type = t end end # A type class Type < SchemaObject def rank() raise Error, "Abstract method" end def array?() false end def tuple?() false end def value?() false end def initialize(schema, name) super(schema, name) end # Return array type of self. Schema defaults to the schema of self def array_type(schema = self.schema) array_name = PgGraph.inflector.type2array(name) @array_type ||= schema[array_name] || ArrayType.new(schema, nil, self) end end # TODO: Rename to CatalogType or PgType or PostgresType class SimpleType < Type alias_method :postgres_type, :key attr_reader :ruby_class def identifier() ruby_class.to_s end def rank() 0 end def value?() true end def initialize(schema, postgres_type) super(schema, postgres_type) @ruby_class = PgGraph.inflector.postgres_type2ruby_class(postgres_type) end end class CompositeType < Type alias_method :fields, :values def rank() 1 end def tuple?() true end end class RecordType < CompositeType include TableObject # List of postgres columns. The columns are sorted by ordinal def columns() @columns ||= begin cols = fields.map { |field| case field when SimpleColumn; field when KindRecordColumn; if field.kind_column&.parent nil else field.kind_column end else nil end }.compact.sort_by(&:ordinal) # @columns_hash = @columns.map { |column| [column.name, column] }.to_h cols end end def postgres_columns() @postgres_columns ||= begin cols = fields.map { |field| case field when SimpleColumn; field when KindRecordColumn; if field.kind_column&.parent nil else field.kind_column end else nil end }.compact.sort_by(&:ordinal) # @columns_ hash = @columns.map { |column| [column.name, column] }.to_h cols end end # TODO: Rename #columns to #postgres_columns and #abstract_columns to #columns def abstract_columns @abstract_columns ||= fields.map { |field| case field when SimpleColumn; field when KindRecordColumn; field.kind_column if field.kind_column.parent.nil? else nil end }.compact.sort_by(&:ordinal) end # field.is_a?(SimpleColumn) || field.is_a?(KindReference}.sort_by(&:ordinal) end # List of columns excluding generated fields def value_columns() columns.select { |column| !column.generated? } end # True iff name if the name of a postgres column # def column?(name) self[name].is_a?(SimpleColumn) end def column?(name) raise "See the rename TODO above" @columns_hash.key?(name) end # Associated Table object attr_reader :table # Redefine array_type def array_type() table_type end # A record type has the schema as parent. Note that a record is # initialized with a Table object and not a TableType object def initialize(table) constrain table, Table super(table.schema, PgGraph.inflector.table2record_type(table.name)) @table = table table_type = TableType.new(table.schema, self) # FIXME @table.send(:type=, table_type) end def attach(*args) @columns = nil super end end class ArrayType < Type DEFAULT_MAX_DIMENSIONS = 5 attr_reader :element_type attr_reader :dimensions def rank() 2 end def array?() true end def initialize(schema, name, element_type, dimensions = 1) constrain element_type, Type self.class < TableObject or !element_type.is_a?(TableObject) or raise "Illegal element type" dimensions <= ArrayType.max_dimensions or raise Error, "Array dimension overflow" super(schema, name || PgGraph.inflector.type2array(element_type.name)) @element_type = element_type @dimensions = dimensions end def identifier "[#{element_type.identifier}]" end def schema_identifier "[#{element_type.schema_identifier}]" end private @max_dimensions = DEFAULT_MAX_DIMENSIONS def self.max_dimensions() @max_dimensions end def self.max_dimensions=(max) @max_dimensions = max end end # Note that the name of a TableType object is the record name in brackets # while the name of a Table object is the pluralized record name class TableType < ArrayType include TableObject alias_method :record_type, :element_type forward_method :record_type, :keys, :fields, :key?, :[], :columns, :value_columns, :column? def array_type() raise Error, "Array of TableType is not allowed" end def initialize(schema, record_type) constrain record_type, RecordType super(schema, nil, record_type) end def identifier "{#{element_type.identifier}}" end def schema_identifier "{#{element_type.schema_identifier}}" end end class Field < Node alias_method :composite_type, :parent forward_method :type, :rank attr_reader :type def initialize(composite_type, name, type) constrain composite_type, CompositeType, NilClass self.class < TableObject or !type.is_a?(TableObject) or raise "Illegal field type" super(composite_type, name) @type = type end def identifier() name end def schema_identifier composite_type.schema_identifier + '.' + identifier end end # A field in a RecordType class Column < Field include TableObject alias_method :record_type, :parent # nil for TableColumn and SubRecordColumn objects attr_reader :postgres_column # Return true/false or nil if postgres_column is nil def primary_key?() @postgres_column && @primary_key end # Return true/false or nil if postgres_column is an identity column. # Sub-keys are primary keys but not identity keys def identity?() @postgres_column && @identity end # Return true/false or nil def nullable?() @postgres_column && @nullable end # Return true/false or nil def unique?() @postgres_column && @unique end # Return true/false or nil def readonly?() @postgres_column && @readonly end # Return true/false or nil def generated?() @postgres_column && @generated end # True if column is a reference def reference?() @postgres_column && @reference end # True if column is a kind reference def kind?() @postgres_column && @kind end protected def initialize( record_type, name, postgres_column, type, primary_key: false, identity: false, reference: false, kind: false, nullable: true, unique: false, readonly: false, generated: false) constrain record_type, RecordType, NilClass constrain type, Type super(record_type, name, type) @postgres_column = postgres_column @primary_key = primary_key @identity = identity @reference = reference @kind = kind @nullable = nullable @unique = unique @readonly = readonly @generated = generated end end # Note that this includes array columns (for now) class SimpleColumn < Column # Ordinal of column in the database attr_reader :ordinal forward_method :type, :postgres_type, :ruby_class def initialize(record_type, name, postgres_column, type, **opts) constrain type, Type !type.is_a?(TableObject) or raise "Illegal field type: #{type.class}" @ordinal = opts.delete(:ordinal) super end # Return true if the ruby literal match the column type def literal?(text) text.is_a?(ruby_class) end end class RecordColumn < Column # Postgres column in the embedding record that links to the referenced # record attr_reader :this_link_column # Postgres column in the referenced record that links to the embedding # record attr_reader :that_link_column # TODO: Is postgres_column == this_link_column? def initialize(record_type, name, postgres_column, type, this_link_column, that_link_column, **opts) constrain type, RecordType, NilClass super(record_type, name, postgres_column, type, reference: true, **opts) @this_link_column = this_link_column @that_link_column = that_link_column end forward_method :type, :keys, :fields, :key?, :[], :columns, :column? # Redefine #dot_lookup to make #dot enter the target record def dot_lookup(key) type[key] end end class KindRecordColumn < RecordColumn # A SimpleColumn object. Note that it doesn't have a parent to avoid having # two columns with the same name: This structured KindRecordColumn object # and the underlying kind column attr_reader :kind_column forward_method :kind_column, :literal? def initialize( record_type, name, postgres_column, type, this_link_column, that_link_column, kind_column, **opts) constrain kind_column, SimpleColumn super(record_type, name, postgres_column, type, this_link_column, that_link_column, kind: true, **opts) @kind_column = kind_column end end # A record in a 1:1 relationship with another record linked by identical IDs. # SuperRecordColumn is on the "child" side of the relation because a child # has a super record - hence the name class SuperRecordColumn < RecordColumn def initialize(record_type, name = nil, type) name ||= type.name super( record_type, name, "id", type, "id", "id", primary_key: true, nullable: false, unique: true, readonly: true) end end # SubRecordColumn is on the "parent" side of the relation because a a parent # has a sub record - hence the name class SubRecordColumn < RecordColumn def initialize(record_type, name = nil, type) name ||= type.name super( record_type, name, "id", type, "id", "id", primary_key: nil, nullable: nil, unique: nil, readonly: nil) end end class TableColumn < Column # Postgres column in the embedding record that links (directly or # indirectly) to the subject table. This is typically the id column attr_reader :this_link_column # Postgres column in the subject table that directly or indirectly links to # the embedding record. This is typically the '_id' column of # that table in 1:N relations attr_reader :that_link_column forward_method :type, :keys, :fields, :key?, :[], :columns, :column? def initialize(record_type, name, type, this_link_column, that_link_column, **opts) constrain record_type, RecordType constrain type, TableType super(record_type, name, nil, type, **opts) @this_link_column = this_link_column @that_link_column = that_link_column end def dot_lookup(key) type[key] end end class MmTableColumn < TableColumn # This table in the relationship alias_method :this_table, :table alias_method :this_table_type, :table_type alias_method :this_record_type, :record_type # The other table in the relationship def that_table() that_table_type.table end alias_method :that_table_type, :type def that_record_type() that_table_type.record_type end # The link table between the two tables def mm_table() mm_table_type.table end def mm_record_type() mm_table_type.record_type end attr_reader :mm_table_type # Postgres column in the NM table that links to this table. This is # typically the '_id' column attr_reader :this_mm_column # Postgres column in the NM table that links to the embedding record. This # is typically the '_id' column attr_reader :that_mm_column # :unique in this context means that the associated records of this column # are unique def initialize( record_type, name, that_table_type, mm_table_type, this_link_column, this_mm_column, that_mm_column, that_link_column, **opts) # puts "MmTableColumn#initialize" # indent { # puts "record: #{record_type}" # puts "column: #{name}" # puts this_mm_column # puts this_link_column # puts that_mm_column # puts that_link_column # } constrain record_type, RecordType constrain that_table_type, TableType constrain mm_table_type, TableType mm_table_type.table.mm_table? or raise Error, "Link table expected" super(record_type, name, that_table_type, this_link_column, that_link_column, **opts) @mm_table_type = mm_table_type @this_mm_column = this_mm_column @that_mm_column = that_mm_column end end class NmTableColumn < MmTableColumn alias_method :nm_table, :mm_table alias_method :nm_record_type, :mm_record_type alias_method :nm_table_type, :mm_table_type alias_method :this_nm_column, :this_mm_column alias_method :that_nm_column, :that_mm_column end end