pg_graph 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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