require 'constrain' require 'yaml' include Constrain module PgGraph::Data class Error < StandardError; end class Node # Type of the node (a PgGraph::Type::Node object) attr_reader :type # Type of the value of a node def value_type() type end # Dimension of the node attr_reader :dimension def initialize(type, dimension: nil) constrain type, PgGraph::Type::Node Dimension.validate(dimension) if dimension @type = type @dimension = dimension end # Node converted to plain ruby objects structured as given by #dimension. # This is the same as #value except for M:M associations def data() @impl end # Node converted to plain ruby objects structured as given by the dimension # kind but with flattened M:M associations so duplicates can happen def value() data end # Usually the node itself but associations redefines this to be the # associated object. It is used in client code to reference the value # object of an associated field, ie: +node.object.value()+ will give you the # value of a field even if the field is an association def object() self end def inspect(payload = inspect_inner) "#<#{self.class}:#{uid}#{payload ? " #{payload}" : ""}>" end def to_h() raise NotThis end def to_yaml() raise NotThis end protected def inspect_inner() nil end end class DatabaseObject < Node # The database (Data::Database) of this objectD. It is defined "globally" # because #dot requires quick access to the containing database def database() raise NotThis end # Unique id within a database. Note that #uid has to be defined before # #initialize is called as it uses the UID as a key in the database-wide # lookup table def uid() type.uid end # Global unique id including database def guid() type.guid end # Name of object. Defaults to the name of the type def name() type.name end def initialize(*args, **opts) super database.send(:add_object, self) end def dot(path) database.dot["#{uid}.#{path}"] end # def ==(other) raise PgGraph::NotThis end def <=>(other) uid <=> other.uid end end class Database < DatabaseObject # Redefine DatabaseObject#database to return self def database() self end # List of Schema objects def schemas() @impl.values end # +data_source+ can be a PgConn object, a Hash, or nil def initialize(type, data_source = nil) constrain type, PgGraph::Type::Database constrain data_source, PgConn, Hash, NilClass @queued_objects = [] # has to go before super @objects = nil # Object cache. Maps from UID to object. Has to go before super super(type) initialize_impl read(data_source) if data_source end def dup() Database.new(type, to_h) end # Make Database pretend it is a PgGraph::Data object def is_a?(klass) klass == PgGraph::Type or super end # Get schema by name forward_to :@impl, :[] # Iterate schemas def each(&block) schemas.each { |schema| yield schema } end # Return database as a hash from schema name to schema hash def data() @impl.map { |k,v| [k, v.data] }.to_h end # Return object by the given UID def dot(uid) @objects ||= map_objects[uid] end # TODO: Maybe use this instead of #dot def lookup(path, id = nil) object = (@objects ||= map_objects)[path] id.nil? ? object : object[id] end def ==(other) to_h == other.to_h end alias_method :to_h, :data # The #to_yaml format is like #to_h but with records formatted as an array # instead of as a map from ID to record def to_yaml() @impl.map { |k,v| [k, v.to_yaml] }.to_h end # Note that #to_sql in derived classes should be deleted FIXME def to_sql(format: :sql, ids: {}, delete: :all, files: []) render = SqlRender.new(self, format, ids: ids, delete: delete, files: files) render.to_s end def to_exec_sql(ids: {}, delete: :all) to_sql(format: :exec, ids: ids, delete: delete) end def to_psql_sql(files = [], ids: {}, delete: :all) to_sql(format: :psql, ids: ids, delete: delete, files: files) end # :call-seq: # read(connection) # read(hash) # read(yaml) # # Read data from a source. Returns self # def read(arg) constrain arg, PgConn, Hash case arg when PgConn; read_connection(arg) when Hash; read_hash(arg) end self end # Write data to database. +ids+ is a hash from table UID to id. If a record # has an ID equal to or less than the corresponding ID in +ids+, the SQL # will be rendered as an 'update' statement instead of an 'insert' # statement. Returns self def write(connection, ids: {}, delete: :all) constrain connection, PgConn constrain ids, String => Integer connection.exec(to_exec_sql(ids: ids, delete: delete)) self end private # Sets up a database with empty tables def initialize_impl @impl = {} # Hash from schema name to Schema object type.schemas.each { |schema| @impl[schema.name] = Schema.new(self, schema) } schemas.each { |schema| schema.tables.each { |table| table.initialize_associations } } end def add_object(object) @queued_objects << object end def map_objects() r = @queued_objects.map { |object| [object.uid, object] }.to_h @queued_objects = [] r end end class Schema < DatabaseObject attr_reader :database # List of Table objects def tables() @impl.values end def initialize(database, type) constrain database, Database constrain type, PgGraph::Type::Schema @database = database super(type) @impl = {} # A hash from table name to Table object type.tables.each { |table| @impl[table.name] = Table.new(self, table) } end # Get table by name forward_to :@impl, :[] # Iterate tables def each(&block) tables.each { |table| yield table } end # Return schema as a hash from table name to table hash def data() @impl.map { |k,v| [k, v.data] }.to_h end alias_method :to_h, :data # def to_sql() # tables.select { |table| !table.empty? }.map(&:to_sql).join("\n") # end def to_yaml() @impl.map { |k,v| [k, v.to_yaml] }.to_h end end # Note that Query objects have the same name as the referenced table but # different UID. Queries are not included in the schemas list of tables class Table < DatabaseObject # Redefine DatabaseObject#database def database() schema.database end # Schema of table attr_reader :schema # Cached association objects. Map from record or table field name to # Association object. Associations are cached in Table because they have # non-trivial constructors. Initialized by #initialize_associations attr_reader :associations # List of Record objects (including duplicates) def records() @impl.values end def initialize(schema, type, dimension: 2) constrain schema, Schema constrain type, PgGraph::Type::Table @schema = schema super(type, dimension: dimension) @impl = {} # A table is implemented as a hash from integer ID to Record object @associations = {} # initialized by #initialize_associations end def initialize_associations db = schema.database type.fields.each { |column| next if column.is_a?(PgGraph::Type::SimpleColumn) that_table = db[column.type.schema.name][column.type.table.name] association = case column when PgGraph::Type::KindRecordColumn KindAssociation.new(1, self, that_table, column.this_link_column.to_sym, column.that_link_column.to_sym) when PgGraph::Type::RecordColumn Association.new(1, self, that_table, column.this_link_column.to_sym, column.that_link_column.to_sym) when PgGraph::Type::NmTableColumn, PgGraph::Type::MmTableColumn dimension = column.is_a?(PgGraph::Type::NmTableColumn) ? 2 : 3 nm_table = db[column.mm_table.schema.name][column.mm_table.name] LinkAssociation.new(dimension, self, that_table, nm_table, column.this_link_column.to_sym, column.this_mm_column.to_sym, column.that_mm_column.to_sym, column.that_link_column.to_sym) when PgGraph::Type::TableColumn Association.new(2, self, that_table, column.this_link_column.to_sym, column.that_link_column.to_sym) else raise NotHere end @associations[column.name.to_sym] = association } end # Number of records in the table (including duplicates) def size() records.size end # True if table is empty def empty?() @impl.empty? end # Return record with the given ID def [](id) @impl[id] end # True if the table contains a record with the given ID def id?(id) @impl.key?(id) end # List of record IDs (excluding duplicates) def ids() @impl.keys end # Max. record ID. Equal to 0 if the table is empty def max_id() @max_id ||= (@impl.keys.max || 0) end # :call-seq: # select(id, &block) # select(ids, &block) # # Select a number of records given by an ID or an array of IDs. Returns a # Record object in the first case and a TableSelect otherwise. The # :dimension option can be set to 3 to get a MmTableSelect object # instead # def select(arg = nil, dimension: nil, &block) constrain arg, Integer, [Integer], NilClass constrain dimension, Integer, NilClass case arg when Integer candidates = [self[arg]] dimension ||= 1 when Array candidates = arg.map { |id| self[id] }.compact dimension ||= 2 when NilClass candidates = records dimension ||= 2 end candidates = candidates.select { |record| yield(record) } if block_given? Dimension.value_class(dimension).new(self, candidates) end # Iterate records def each(&block) @impl.values.each { |record| yield record } end # Return table as a hash from record ID to record hash def data() @impl.map { |k,v| [k, v.data] }.to_h end alias_method :to_h, :data def to_yaml() @impl.map { |k,v| v.to_h } end def inspect_inner() "[" + records.map(&:inspect_inner).join(", ") + "])" end protected def add_record(record) !@impl.key?(record.id) or raise "Duplicate record ID: #{record.id}" @impl[record.id] = record end end class Record < DatabaseObject # Redefine UID to include ID of record def uid() "#{table.uid}[#{id}]" end def self.split_uid(uid) uid =~ /^(.*?)\[(\d+)\]$/ [$1, $2.to_i] end # Redefine GUID to include ID of record def guid() "#{table.guid}[#{id}]" end # Redefine name to be the record ID" def name() id end # Redefine DatabaseObject#database def database() table.database end # Table of record attr_reader :table # ID of record (Integer) def id() @impl[:id].value end # def id() @id end def initialize(table, columns) constrain table, Table constrain columns, Symbol => PgGraph::RUBY_CLASSES constrain columns, lambda { |h| h.key?(:id) }, "No :id field" @table = table # has to go before super super(table.type.record_type, dimension: 1) @impl = columns.map { |k,v| [k, Column.new(self, k, v)] }.to_h for name, association in table.associations if association.is_a?(KindAssociation) #&& @impl.key?(name) next if !@impl.key?(name) column = @impl.delete(name) @impl[name] = KindRecordField.new(self, name, association, column) else !@impl.key?(name) or raise "Duplicate field: #{name}" field_class = Dimension.field_class(association.dimension) @impl[name] = field_class.new(self, name, association) end end # puts "Record#initialize(#{table.uid.inspect}, #{columns.inspect})" # puts " columns: #{self.columns}" # puts " fields: #{fields}" # puts " names: #{names}" # puts " value_columns: #{value_columns}" table.send(:add_record, self) end # Return Field object with the given name def [](name) @impl[name.to_sym] end # True if the record contains a field with the given name def field?(name) @impl.key?(name.to_sym) end # List of field names def names() @impl.keys end # List of Field objects in the record def fields() @impl.values end # List of Column objects in the record def columns() @impl.values.select { |field| field.is_a?(Column) } end # List of columns excluding generated columns def value_columns() columns.select { |column| !column.type.generated? }.uniq end # List of association objects in the record def associations() @impl.values.select { |field| field.is_a?(AssociationField) } end # Iterate fields def each(&block) fields.each { |field| yield field } end # Return data of record as a hash of from column name to value. Note that # association fields are not included # def data() @impl.select { |k,v| v.is_a?(Column) }.map { |k,v| [k, v.data] }.to_h end def data() @impl.select { |k,v| v.is_a?(Column) || v.is_a?(KindRecordField) }.map { |k,v| [k, v.data] }.to_h end alias_method :to_h, :data # FIXME: Is this in use? # def to_sql() # "(" + type.columns.map { |column_type| # field?(column_type.name) ? self[column_type.name]&.to_sql || 'NULL' : 'DEFAULT' # }.join(", ") + ")" # end def to_yaml() data end def inspect_inner() "{" + columns.map(&:inspect_inner).join(', ') + "}" end end class Field < DatabaseObject # Redefine #uid def uid() "#{record.uid}.#{name}" end # Redefine #uid def guid() "#{record.guid}.#{name}" end # Redefine #name to return a Symbol def name() super.to_sym end # Redefine DatabaseObject#database def database() record.database end # Record of field attr_reader :record def initialize(record, name, **opts) constrain record, Record constrain name, String, Symbol @record = record # has to go before super super(record.type[name.to_s], **opts) end def to_h() { name: value } end end class Column < Field def initialize(record, name, value) constrain value, *PgGraph::RUBY_CLASSES super(record, name, dimension: 0) @impl = value end # TODO: Escape sequences # FIXME: Not in use (and WRONG!) def to_sql $stderr.puts "* SHOULDN'T BE CALLED IN DATA.RB **************************" raise "Oops" if value.nil? 'NULL' else if type.ruby_class <= Numeric value elsif type.ruby_class <= Array if value.empty? "'{}'" else "'{'#{value.join("', '")}'}" end else "'#{PG::Connection.escape_string(value.to_s)}'" end end end def inspect() super value.inspect end def inspect_inner() "#{name}: #{value.is_a?(String) ? "'#{value}'" : value.inspect}" end end class AssociationField < Field forward_to :association, :dimension # The table of the associated record(s) def table() association.that_table end # The association object attr_reader :association def initialize(record, name, association) constrain association, Association super(record, name, dimension: association.dimension) @association = association end # Return the value of the association. This is either a Record, TableValue, # or MmTableValue depending on the dimension of the association def object() @object ||= Dimension.value_class(dimension).new(table, association.get_records(record)) end end class RecordField < AssociationField forward_to :object, :id, :[], :field?, :names, :fields, :columns, :associations, :value, :data, :to_h end class KindRecordField < RecordField def initialize(record, name, association, column) constrain column, Column super(record, name, association) @column = column end def value_type() @column.value_type end def value() @column.value end def data() @column.data end end class TableField < AssociationField forward_to :object, :schema, :records, :size, :empty?, :[], :id?, :ids, :value, :data, :associations end class MmTableField < AssociationField forward_to :object, :schema, :records, :size, :empty?, :[], :id?, :ids, :value, :data, :associations, :count end end