pg_graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/data/data.rb ADDED
@@ -0,0 +1,551 @@
1
+ require 'constrain'
2
+ require 'yaml'
3
+
4
+ include Constrain
5
+
6
+ module PgGraph::Data
7
+ class Error < StandardError; end
8
+
9
+ class Node
10
+ # Type of the node (a PgGraph::Type::Node object)
11
+ attr_reader :type
12
+
13
+ # Type of the value of a node
14
+ def value_type() type end
15
+
16
+ # Dimension of the node
17
+ attr_reader :dimension
18
+
19
+ def initialize(type, dimension: nil)
20
+ constrain type, PgGraph::Type::Node
21
+ Dimension.validate(dimension) if dimension
22
+ @type = type
23
+ @dimension = dimension
24
+ end
25
+
26
+ # Node converted to plain ruby objects structured as given by #dimension.
27
+ # This is the same as #value except for M:M associations
28
+ def data() @impl end
29
+
30
+ # Node converted to plain ruby objects structured as given by the dimension
31
+ # kind but with flattened M:M associations so duplicates can happen
32
+ def value() data end
33
+
34
+ # Usually the node itself but associations redefines this to be the
35
+ # associated object. It is used in client code to reference the value
36
+ # object of an associated field, ie: +node.object.value()+ will give you the
37
+ # value of a field even if the field is an association
38
+ def object() self end
39
+
40
+ def inspect(payload = inspect_inner)
41
+ "#<#{self.class}:#{uid}#{payload ? " #{payload}" : ""}>"
42
+ end
43
+
44
+ def to_h() raise NotThis end
45
+ def to_yaml() raise NotThis end
46
+
47
+ protected
48
+ def inspect_inner() nil end
49
+ end
50
+
51
+ class DatabaseObject < Node
52
+ # The database (Data::Database) of this objectD. It is defined "globally"
53
+ # because #dot requires quick access to the containing database
54
+ def database() raise NotThis end
55
+
56
+ # Unique id within a database. Note that #uid has to be defined before
57
+ # #initialize is called as it uses the UID as a key in the database-wide
58
+ # lookup table
59
+ def uid() type.uid end
60
+
61
+ # Global unique id including database
62
+ def guid() type.guid end
63
+
64
+ # Name of object. Defaults to the name of the type
65
+ def name() type.name end
66
+
67
+ def initialize(*args, **opts)
68
+ super
69
+ database.send(:add_object, self)
70
+ end
71
+
72
+ def dot(path) database.dot["#{uid}.#{path}"] end
73
+ # def ==(other) raise PgGraph::NotThis end
74
+ def <=>(other) uid <=> other.uid end
75
+ end
76
+
77
+ class Database < DatabaseObject
78
+ # Redefine DatabaseObject#database to return self
79
+ def database() self end
80
+
81
+ # List of Schema objects
82
+ def schemas() @impl.values end
83
+
84
+ # +data_source+ can be a PgConn object, a Hash, or nil
85
+ def initialize(type, data_source = nil)
86
+ constrain type, PgGraph::Type::Database
87
+ constrain data_source, PgConn, Hash, NilClass
88
+ @queued_objects = [] # has to go before super
89
+ @objects = nil # Object cache. Maps from UID to object. Has to go before super
90
+ super(type)
91
+ initialize_impl
92
+ read(data_source) if data_source
93
+ end
94
+
95
+ def dup() Database.new(type, to_h) end
96
+
97
+ # Make Database pretend it is a PgGraph::Data object
98
+ def is_a?(klass) klass == PgGraph::Type or super end
99
+
100
+ # Get schema by name
101
+ forward_to :@impl, :[]
102
+
103
+ # Iterate schemas
104
+ def each(&block) schemas.each { |schema| yield schema } end
105
+
106
+ # Return database as a hash from schema name to schema hash
107
+ def data() @impl.map { |k,v| [k, v.data] }.to_h end
108
+
109
+ # Return object by the given UID
110
+ def dot(uid) @objects ||= map_objects[uid] end
111
+
112
+ # TODO: Maybe use this instead of #dot
113
+ def lookup(path, id = nil)
114
+ object = (@objects ||= map_objects)[path]
115
+ id.nil? ? object : object[id]
116
+ end
117
+
118
+ def ==(other) to_h == other.to_h end
119
+
120
+ alias_method :to_h, :data
121
+
122
+ # The #to_yaml format is like #to_h but with records formatted as an array
123
+ # instead of as a map from ID to record
124
+ def to_yaml() @impl.map { |k,v| [k, v.to_yaml] }.to_h end
125
+
126
+ # Note that #to_sql in derived classes should be deleted FIXME
127
+ def to_sql(format: :sql, ids: {}, delete: :all, files: [])
128
+ render = SqlRender.new(self, format, ids: ids, delete: delete, files: files)
129
+ render.to_s
130
+ end
131
+
132
+ def to_exec_sql(ids: {}, delete: :all)
133
+ to_sql(format: :exec, ids: ids, delete: delete)
134
+ end
135
+
136
+ def to_psql_sql(files = [], ids: {}, delete: :all)
137
+ to_sql(format: :psql, ids: ids, delete: delete, files: files)
138
+ end
139
+
140
+ # :call-seq:
141
+ # read(connection)
142
+ # read(hash)
143
+ # read(yaml)
144
+ #
145
+ # Read data from a source. Returns self
146
+ #
147
+ def read(arg)
148
+ constrain arg, PgConn, Hash
149
+ case arg
150
+ when PgConn; read_connection(arg)
151
+ when Hash; read_hash(arg)
152
+ end
153
+ self
154
+ end
155
+
156
+ # Write data to database. +ids+ is a hash from table UID to id. If a record
157
+ # has an ID equal to or less than the corresponding ID in +ids+, the SQL
158
+ # will be rendered as an 'update' statement instead of an 'insert'
159
+ # statement. Returns self
160
+ def write(connection, ids: {}, delete: :all)
161
+ constrain connection, PgConn
162
+ constrain ids, String => Integer
163
+ connection.exec(to_exec_sql(ids: ids, delete: :all))
164
+ self
165
+ end
166
+
167
+ private
168
+ # Sets up a database with empty tables
169
+ def initialize_impl
170
+ @impl = {} # Hash from schema name to Schema object
171
+ type.schemas.each { |schema| @impl[schema.name] = Schema.new(self, schema) }
172
+ schemas.each { |schema| schema.tables.each { |table| table.initialize_associations } }
173
+ end
174
+
175
+ def add_object(object)
176
+ @queued_objects << object
177
+ end
178
+
179
+ def map_objects()
180
+ r = @queued_objects.map { |object| [object.uid, object] }.to_h
181
+ @queued_objects = []
182
+ r
183
+ end
184
+ end
185
+
186
+ class Schema < DatabaseObject
187
+ attr_reader :database
188
+
189
+ # List of Table objects
190
+ def tables() @impl.values end
191
+
192
+ def initialize(database, type)
193
+ constrain database, Database
194
+ constrain type, PgGraph::Type::Schema
195
+ @database = database
196
+ super(type)
197
+ @impl = {} # A hash from table name to Table object
198
+ type.tables.each { |table| @impl[table.name] = Table.new(self, table) }
199
+ end
200
+
201
+ # Get table by name
202
+ forward_to :@impl, :[]
203
+
204
+ # Iterate tables
205
+ def each(&block) tables.each { |table| yield table } end
206
+
207
+ # Return schema as a hash from table name to table hash
208
+ def data() @impl.map { |k,v| [k, v.data] }.to_h end
209
+
210
+ alias_method :to_h, :data
211
+ # def to_sql()
212
+ # tables.select { |table| !table.empty? }.map(&:to_sql).join("\n")
213
+ # end
214
+ def to_yaml() @impl.map { |k,v| [k, v.to_yaml] }.to_h end
215
+ end
216
+
217
+ # Note that Query objects have the same name as the referenced table but
218
+ # different UID. Queries are not included in the schemas list of tables
219
+ class Table < DatabaseObject
220
+ # Redefine DatabaseObject#database
221
+ def database() schema.database end
222
+
223
+ # Schema of table
224
+ attr_reader :schema
225
+
226
+ # Cached association objects. Map from record or table field name to
227
+ # Association object. Associations are cached in Table because they have
228
+ # non-trivial constructors. Initialized by #initialize_associations
229
+ attr_reader :associations
230
+
231
+ # List of Record objects (including duplicates)
232
+ def records() @impl.values end
233
+
234
+ def initialize(schema, type, dimension: 2)
235
+ constrain schema, Schema
236
+ constrain type, PgGraph::Type::Table
237
+ @schema = schema
238
+ super(type, dimension: dimension)
239
+ @impl = {} # A table is implemented as a hash from integer ID to Record object
240
+ @associations = {} # initialized by #initialize_associations
241
+ end
242
+
243
+ def initialize_associations
244
+ db = schema.database
245
+ type.fields.each { |column|
246
+ next if column.is_a?(PgGraph::Type::SimpleColumn)
247
+ that_table = db[column.type.schema.name][column.type.table.name]
248
+ association =
249
+ case column
250
+ when PgGraph::Type::KindRecordColumn
251
+ KindAssociation.new(1,
252
+ self, that_table,
253
+ column.this_link_column.to_sym,
254
+ column.that_link_column.to_sym)
255
+ when PgGraph::Type::RecordColumn
256
+ Association.new(1,
257
+ self, that_table,
258
+ column.this_link_column.to_sym,
259
+ column.that_link_column.to_sym)
260
+ when PgGraph::Type::NmTableColumn, PgGraph::Type::MmTableColumn
261
+ dimension = column.is_a?(PgGraph::Type::NmTableColumn) ? 2 : 3
262
+ nm_table = db[column.mm_table.schema.name][column.mm_table.name]
263
+ LinkAssociation.new(dimension,
264
+ self, that_table, nm_table,
265
+ column.this_link_column.to_sym, column.this_mm_column.to_sym,
266
+ column.that_mm_column.to_sym, column.that_link_column.to_sym)
267
+ when PgGraph::Type::TableColumn
268
+ Association.new(2,
269
+ self, that_table,
270
+ column.this_link_column.to_sym,
271
+ column.that_link_column.to_sym)
272
+ else
273
+ raise NotHere
274
+ end
275
+ @associations[column.name.to_sym] = association
276
+ }
277
+ end
278
+
279
+ # Number of records in the table (including duplicates)
280
+ def size() records.size end
281
+
282
+ # True if table is empty
283
+ def empty?() @impl.empty? end
284
+
285
+ # Return record with the given ID
286
+ def [](id) @impl[id] end
287
+
288
+ # True if the table contains a record with the given ID
289
+ def id?(id) @impl.key?(id) end
290
+
291
+ # List of record IDs (excluding duplicates)
292
+ def ids() @impl.keys end
293
+
294
+ # Max. record ID. Equal to 0 if the table is empty
295
+ def max_id() @max_id ||= (@impl.keys.max || 0) end
296
+
297
+ # :call-seq:
298
+ # select(id, &block)
299
+ # select(ids, &block)
300
+ #
301
+ # Select a number of records given by an ID or an array of IDs. Returns a
302
+ # Record object in the first case and a TableSelect otherwise. The
303
+ # :dimension option can be set to 3 to get a MmTableSelect object
304
+ # instead
305
+ #
306
+ def select(arg = nil, dimension: nil, &block)
307
+ constrain arg, Integer, [Integer], NilClass
308
+ constrain dimension, Integer, NilClass
309
+ case arg
310
+ when Integer
311
+ candidates = [self[arg]]
312
+ dimension ||= 1
313
+ when Array
314
+ candidates = arg.map { |id| self[id] }.compact
315
+ dimension ||= 2
316
+ when NilClass
317
+ candidates = records
318
+ dimension ||= 2
319
+ end
320
+ candidates = candidates.select { |record| yield(record) } if block_given?
321
+ Dimension.value_class(dimension).new(self, candidates)
322
+ end
323
+
324
+ # Iterate records
325
+ def each(&block) @impl.values.each { |record| yield record } end
326
+
327
+ # Return table as a hash from record ID to record hash
328
+ def data() @impl.map { |k,v| [k, v.data] }.to_h end
329
+
330
+ alias_method :to_h, :data
331
+
332
+ def to_yaml() @impl.map { |k,v| v.to_h } end
333
+
334
+ def inspect_inner() "[" + records.map(&:inspect_inner).join(", ") + "])" end
335
+
336
+ protected
337
+ def add_record(record)
338
+ !@impl.key?(record.id) or raise "Duplicate record ID: #{record.id}"
339
+ @impl[record.id] = record
340
+ end
341
+ end
342
+
343
+ class Record < DatabaseObject
344
+ # Redefine UID to include ID of record
345
+ def uid() "#{table.uid}[#{id}]" end
346
+
347
+ def self.split_uid(uid)
348
+ uid =~ /^(.*?)\[(\d+)\]$/
349
+ [$1, $2.to_i]
350
+ end
351
+
352
+ # Redefine GUID to include ID of record
353
+ def guid() "#{table.guid}[#{id}]" end
354
+
355
+ # Redefine name to be the record ID"
356
+ def name() id end
357
+
358
+ # Redefine DatabaseObject#database
359
+ def database() table.database end
360
+
361
+ # Table of record
362
+ attr_reader :table
363
+
364
+ # ID of record (Integer)
365
+ def id() @impl[:id].value end
366
+ # def id() @id end
367
+
368
+ def initialize(table, columns)
369
+ constrain table, Table
370
+ constrain columns, Symbol => PgGraph::RUBY_CLASSES
371
+ constrain columns, lambda { |h| h.key?(:id) }, "No :id field"
372
+ @table = table # has to go before super
373
+ super(table.type.record_type, dimension: 1)
374
+ @impl = columns.map { |k,v| [k, Column.new(self, k, v)] }.to_h
375
+ for name, association in table.associations
376
+ if association.is_a?(KindAssociation) #&& @impl.key?(name)
377
+ next if !@impl.key?(name)
378
+ column = @impl.delete(name)
379
+ @impl[name] = KindRecordField.new(self, name, association, column)
380
+ else
381
+ !@impl.key?(name) or raise "Duplicate field: #{name}"
382
+ field_class = Dimension.field_class(association.dimension)
383
+ @impl[name] = field_class.new(self, name, association)
384
+ end
385
+ end
386
+ # puts "Record#initialize(#{table.uid.inspect}, #{columns.inspect})"
387
+ # puts " columns: #{self.columns}"
388
+ # puts " fields: #{fields}"
389
+ # puts " names: #{names}"
390
+ # puts " value_columns: #{value_columns}"
391
+ table.send(:add_record, self)
392
+ end
393
+
394
+ # Return Field object with the given name
395
+ def [](name) @impl[name.to_sym] end
396
+
397
+ # True if the record contains a field with the given name
398
+ def field?(name) @impl.key?(name.to_sym) end
399
+
400
+ # List of field names
401
+ def names() @impl.keys end
402
+
403
+ # List of Field objects in the record
404
+ def fields() @impl.values end
405
+
406
+ # List of Column objects in the record
407
+ def columns() @impl.values.select { |field| field.is_a?(Column) } end
408
+
409
+ # List of columns excluding generated columns
410
+ def value_columns()
411
+ columns.select { |column| !column.type.generated? }.uniq
412
+ end
413
+
414
+ # List of association objects in the record
415
+ def associations() @impl.values.select { |field| field.is_a?(AssociationField) } end
416
+
417
+ # Iterate fields
418
+ def each(&block) fields.each { |field| yield field } end
419
+
420
+ # Return data of record as a hash of from column name to value. Note that
421
+ # association fields are not included
422
+ # def data() @impl.select { |k,v| v.is_a?(Column) }.map { |k,v| [k, v.data] }.to_h end
423
+
424
+ def data()
425
+ @impl.select { |k,v| v.is_a?(Column) || v.is_a?(KindRecordField) }.map { |k,v| [k, v.data] }.to_h
426
+ end
427
+
428
+ alias_method :to_h, :data
429
+
430
+ # FIXME: Is this in use?
431
+ # def to_sql()
432
+ # "(" + type.columns.map { |column_type|
433
+ # field?(column_type.name) ? self[column_type.name]&.to_sql || 'NULL' : 'DEFAULT'
434
+ # }.join(", ") + ")"
435
+ # end
436
+
437
+ def to_yaml() data end
438
+
439
+ def inspect_inner() "{" + columns.map(&:inspect_inner).join(', ') + "}" end
440
+ end
441
+
442
+ class Field < DatabaseObject
443
+ # Redefine #uid
444
+ def uid() "#{record.uid}.#{name}" end
445
+
446
+ # Redefine #uid
447
+ def guid() "#{record.guid}.#{name}" end
448
+
449
+ # Redefine #name to return a Symbol
450
+ def name() super.to_sym end
451
+
452
+ # Redefine DatabaseObject#database
453
+ def database() record.database end
454
+
455
+ # Record of field
456
+ attr_reader :record
457
+
458
+ def initialize(record, name, **opts)
459
+ constrain record, Record
460
+ constrain name, String, Symbol
461
+ @record = record # has to go before super
462
+ super(record.type[name.to_s], **opts)
463
+ end
464
+
465
+ def to_h() { name: value } end
466
+ end
467
+
468
+ class Column < Field
469
+ def initialize(record, name, value)
470
+ constrain value, *PgGraph::RUBY_CLASSES
471
+ super(record, name, dimension: 0)
472
+ @impl = value
473
+ end
474
+
475
+ # TODO: Escape sequences
476
+ # FIXME: Not in use (and WRONG!)
477
+ def to_sql
478
+ $stderr.puts "* SHOULDN'T BE CALLED IN DATA.RB **************************"
479
+ raise "Oops"
480
+ if value.nil?
481
+ 'NULL'
482
+ else
483
+ if type.ruby_class <= Numeric
484
+ value
485
+ elsif type.ruby_class <= Array
486
+ if value.empty?
487
+ "'{}'"
488
+ else
489
+ "'{'#{value.join("', '")}'}"
490
+ end
491
+ else
492
+ "'#{PG::Connection.escape_string(value.to_s)}'"
493
+ end
494
+ end
495
+ end
496
+
497
+ def inspect() super value.inspect end
498
+ def inspect_inner() "#{name}: #{value.is_a?(String) ? "'#{value}'" : value.inspect}" end
499
+ end
500
+
501
+ class AssociationField < Field
502
+ forward_to :association, :dimension
503
+
504
+ # The table of the associated record(s)
505
+ def table() association.that_table end
506
+
507
+ # The association object
508
+ attr_reader :association
509
+
510
+ def initialize(record, name, association)
511
+ constrain association, Association
512
+ super(record, name, dimension: association.dimension)
513
+ @association = association
514
+ end
515
+
516
+ # Return the value of the association. This is either a Record, TableValue,
517
+ # or MmTableValue depending on the dimension of the association
518
+ def object()
519
+ @object ||= Dimension.value_class(dimension).new(table, association.get_records(record))
520
+ end
521
+ end
522
+
523
+ class RecordField < AssociationField
524
+ forward_to :object, :id, :[], :field?, :names, :fields, :columns, :associations, :value, :data, :to_h
525
+ end
526
+
527
+ class KindRecordField < RecordField
528
+ def initialize(record, name, association, column)
529
+ constrain column, Column
530
+ super(record, name, association)
531
+ @column = column
532
+ end
533
+
534
+ def value_type() @column.value_type end
535
+ def value() @column.value end
536
+ def data() @column.data end
537
+ end
538
+
539
+ class TableField < AssociationField
540
+ forward_to :object, :schema, :records, :size, :empty?, :[], :id?, :ids, :value, :data, :associations
541
+ end
542
+
543
+ class MmTableField < AssociationField
544
+ forward_to :object, :schema, :records, :size, :empty?, :[], :id?, :ids, :value, :data, :associations, :count
545
+ end
546
+ end
547
+
548
+
549
+
550
+
551
+
@@ -0,0 +1,51 @@
1
+ module PgGraph::Data
2
+ # Dimension of the node (corresponding kind shown in parenthesis):
3
+ #
4
+ # 0 A single value (:value)
5
+ # 1 A single record (:record)
6
+ # 2 A map from id to record (:table)
7
+ # 3 A map from id to array of duplicate records (:table)
8
+ #
9
+ # The dimension defines class and structures of GraphData objects
10
+ #
11
+ class Dimension
12
+ def self.valid?(dimension, min: 0)
13
+ dimension.is_a?(Integer) && (min..3).include?(dimension)
14
+ end
15
+
16
+ def self.kind(dimension)
17
+ case dimension
18
+ when 0; :value
19
+ when 1; :record
20
+ when 2, 3; :table
21
+ end
22
+ end
23
+
24
+ def self.field_class(dimension)
25
+ validate(dimension)
26
+ case dimension
27
+ when 0; Column
28
+ when 1; RecordField
29
+ when 2; TableField
30
+ when 3; MmTableField
31
+ end
32
+ end
33
+
34
+ def self.value_class(dimension)
35
+ validate(dimension, min: 1)
36
+ case dimension
37
+ when 1; RecordValue
38
+ when 2; TableValue
39
+ when 3; MmTableValue
40
+ end
41
+ end
42
+
43
+ def self.validate(dim, min: 0, unwind: 1)
44
+ constrain dim, Integer, unwind: unwind
45
+ constrain dim,
46
+ lambda { |d| Dimension.valid?(d, min: min) },
47
+ "Out of range value: #{dim}",
48
+ unwind: unwind
49
+ end
50
+ end
51
+ end
data/lib/data/read.rb ADDED
@@ -0,0 +1,44 @@
1
+
2
+ require 'pg_conn'
3
+
4
+ module PgGraph::Data
5
+ class Database
6
+ # load data from connection
7
+ def read_connection(conn)
8
+ constrain conn, PgConn::Connection
9
+ initialize_impl
10
+ for schema in type.schemas
11
+ for table in schema.tables
12
+ for record in conn.records("select * from #{table.uid}")
13
+ Record.new(self[schema.name][table.name], record)
14
+ end
15
+ end
16
+ end
17
+ self
18
+ end
19
+
20
+ # load data from hash. The hash can be produced by #to_h or #to_yaml
21
+ #
22
+ # TODO: Check for unknown keys
23
+ def read_hash(hash)
24
+ constrain hash, Hash
25
+ initialize_impl
26
+
27
+ # Works on to #to_h and #to_yaml hashes because it is easier to handle
28
+ # the two formats dynamically than detecting which format to use
29
+ for schema in type.schemas
30
+ next if !hash.key?(schema.name)
31
+ hash_schema = hash[schema.name]
32
+ for table in schema.tables
33
+ next if !hash[schema.name].key?(table.name)
34
+ hash_table = hash_schema[table.name]
35
+ records = hash_table.is_a?(Hash) ? hash_table.values : hash_table
36
+ for record in records
37
+ Record.new(self[schema.name][table.name], record)
38
+ end
39
+ end
40
+ end
41
+ self
42
+ end
43
+ end
44
+ end