pglpg 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.
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ # A fixed-length pattern query, built immutably from Context#match.
5
+ #
6
+ # ctx.match
7
+ # .node(:p, :Person, uid: "PER-12")
8
+ # .out(:WORKS_ON, :j, :Project, edge: :w)
9
+ # .out(:BASED_AT, :l, :Location)
10
+ # .where(j: { headcount: Pglpg.gt(0) })
11
+ # .return(:p, :j, :l)
12
+ # .order(j: :headcount)
13
+ # .limit(50)
14
+ #
15
+ # The visibility policy is applied to every node and edge in the pattern.
16
+ # Each result row is a frozen Hash from variable to Node or Edge.
17
+ class Query
18
+ include Enumerable
19
+
20
+ NodePattern = Data.define(:var, :label, :ref_id)
21
+ EdgePattern = Data.define(:var, :type, :direction, :from_var, :to_var, :named)
22
+ State = Data.define(:nodes, :edges, :conditions, :returns, :orders, :limit, :offset, :distinct, :last_var)
23
+
24
+ EMPTY = State.new(nodes: {}.freeze, edges: [].freeze, conditions: {}.freeze, returns: nil, orders: [].freeze,
25
+ limit: nil, offset: nil, distinct: false, last_var: nil)
26
+
27
+ attr_reader :context
28
+
29
+ def initialize(context, state = EMPTY)
30
+ @context = context
31
+ @state = state
32
+ freeze
33
+ end
34
+
35
+ # The start node. Properties given as keywords are equality conditions.
36
+ def node(var, label = nil, ref: nil, **props)
37
+ raise ArgumentError, "a pattern has one start node; branch with from:" unless @state.nodes.empty?
38
+
39
+ var = new_var!(var)
40
+ label = check_label!(label)
41
+ context.graph.check_ref!(ref, :node) if ref
42
+ q = with(nodes: { var => NodePattern.new(var:, label:, ref_id: ref&.id) }.freeze, last_var: var)
43
+ props.empty? ? q : q.where(var => props)
44
+ end
45
+
46
+ # Follows an outgoing edge of +type+ from +from+ (default: the last node).
47
+ def out(type, var, label = nil, edge: nil, from: nil, **props) = hop(:out, type, var, label, edge, from, props)
48
+
49
+ # Follows an incoming edge of +type+ into +from+ (default: the last node).
50
+ def in(type, var, label = nil, edge: nil, from: nil, **props) = hop(:in, type, var, label, edge, from, props)
51
+
52
+ # Structured conditions per variable: where(j: { headcount: Pglpg.gt(0) }).
53
+ def where(by_var)
54
+ conditions = @state.conditions.transform_values(&:dup)
55
+ by_var.each do |var, props|
56
+ var = var.to_sym
57
+ element_type = element_type!(var)
58
+ props.each do |name, value|
59
+ prop = element_type.property_named(name) or
60
+ raise ArgumentError, "#{element_type.name} has no property #{name.inspect}"
61
+ raise ArgumentError, "list property #{prop.name} cannot be filtered in 0.1" unless prop.comparable?
62
+
63
+ condition = Condition.wrap(value)
64
+ validate_condition!(prop, condition)
65
+ (conditions[var] ||= []) << [prop, condition]
66
+ end
67
+ end
68
+ with(conditions: conditions.transform_values(&:freeze).freeze)
69
+ end
70
+
71
+ def return(*vars)
72
+ vars = vars.flatten.map(&:to_sym)
73
+ raise ArgumentError, "return needs at least one variable" if vars.empty?
74
+
75
+ vars.each { |v| raise ArgumentError, "#{v} is not a returnable variable" unless returnable.include?(v) }
76
+ with(returns: vars.uniq.freeze)
77
+ end
78
+
79
+ # order(j: :headcount) or order(j: { name: :desc }). Ties are broken by
80
+ # element ids, so the order is deterministic.
81
+ def order(by_var)
82
+ orders = @state.orders.dup
83
+ by_var.each do |var, spec|
84
+ var = var.to_sym
85
+ element_type = element_type!(var)
86
+ spec = { spec => :asc } unless spec.is_a?(Hash)
87
+ spec.each do |name, dir|
88
+ prop = element_type.property_named(name) or
89
+ raise ArgumentError, "#{element_type.name} has no property #{name.inspect}"
90
+ raise ArgumentError, "direction must be :asc or :desc" unless %i[asc desc].include?(dir)
91
+ raise ArgumentError, "list property #{prop.name} cannot be ordered" unless prop.comparable?
92
+
93
+ orders << [var, prop, dir]
94
+ end
95
+ end
96
+ with(orders: orders.freeze)
97
+ end
98
+
99
+ def limit(n)
100
+ raise ArgumentError, "limit must be a positive Integer" unless n.is_a?(Integer) && n.positive?
101
+ raise ArgumentError, "limit exceeds max_rows (#{max_rows})" if n > max_rows
102
+
103
+ with(limit: n)
104
+ end
105
+
106
+ def offset(n)
107
+ raise ArgumentError, "offset must be a non-negative Integer" unless n.is_a?(Integer) && !n.negative?
108
+
109
+ with(offset: n)
110
+ end
111
+
112
+ def distinct = with(distinct: true)
113
+
114
+ def to_a
115
+ raise ArgumentError, "the pattern has no start node" if @state.nodes.empty?
116
+
117
+ rows = context.read(compile(:rows))
118
+ if @state.limit.nil? && rows.size > max_rows
119
+ raise TooManyRows, "more than #{max_rows} rows; add a limit"
120
+ end
121
+
122
+ rows.map { |row| decode(row) }
123
+ end
124
+
125
+ def each(&) = to_a.each(&)
126
+
127
+ def first = limit(1).to_a.first
128
+
129
+ def count = context.read(compile(:count)).first["count"].to_i
130
+
131
+ def exists? = context.read(compile(:exists)).first["exists"] == true
132
+
133
+ # The SQL and bind values, for tests and debugging.
134
+ def to_sql
135
+ sql = compile(:rows)
136
+ [sql.to_s, sql.binds.map(&:value)]
137
+ end
138
+
139
+ # EXPLAIN output for the query, one line per entry. Runs under the same
140
+ # statement timeout as other reads, since ANALYZE executes the query.
141
+ def explain(analyze: false, buffers: false)
142
+ options = ["FORMAT TEXT", ("ANALYZE" if analyze), ("BUFFERS" if buffers)].compact.join(", ")
143
+ sql = compile(:rows).prepend("EXPLAIN (#{options}) ")
144
+ context.read(sql).map { _1.fetch("QUERY PLAN") }
145
+ end
146
+
147
+ def inspect = "#<Pglpg::Query #{to_sql.first}>"
148
+
149
+ private
150
+
151
+ def with(**changes) = Query.new(context, @state.with(**changes))
152
+
153
+ def max_rows = Pglpg.configuration.max_rows
154
+
155
+ def hop(direction, type, var, label, edge, from, props)
156
+ raise ArgumentError, "start the pattern with node" if @state.nodes.empty?
157
+
158
+ edge_type = context.graph.type.edge_type!(type)
159
+ from = (from || @state.last_var).to_sym
160
+ raise ArgumentError, "#{from} is not a node variable" unless @state.nodes.key?(from)
161
+
162
+ var = var.to_sym
163
+ label = check_label!(label)
164
+ nodes = @state.nodes
165
+ if nodes.key?(var)
166
+ if label && label != nodes[var].label
167
+ raise ArgumentError, "#{var} is already bound with label #{nodes[var].label.inspect}"
168
+ end
169
+ else
170
+ new_var!(var)
171
+ nodes = nodes.merge(var => NodePattern.new(var:, label:, ref_id: nil)).freeze
172
+ end
173
+
174
+ # Checked against the node map including this hop's new node.
175
+ edge_var = edge ? new_var!(edge, nodes) : :"_e#{@state.edges.size}"
176
+ pattern = EdgePattern.new(var: edge_var, type: edge_type.name, direction:, from_var: from, to_var: var,
177
+ named: !edge.nil?)
178
+ q = with(nodes:, edges: (@state.edges + [pattern]).freeze, last_var: var)
179
+ props.empty? ? q : q.where(var => props)
180
+ end
181
+
182
+ def new_var!(var, nodes = @state.nodes)
183
+ var = var.to_sym
184
+ raise ArgumentError, "variable names starting with _ are reserved" if var.start_with?("_")
185
+ if nodes.key?(var) || @state.edges.any? { _1.var == var }
186
+ raise ArgumentError, "variable #{var} is already bound"
187
+ end
188
+
189
+ var
190
+ end
191
+
192
+ def check_label!(label)
193
+ return nil if label.nil?
194
+
195
+ context.graph.type.node_type!(label).name
196
+ end
197
+
198
+ # The type whose properties a variable may be filtered and ordered by.
199
+ def element_type!(var)
200
+ if (node = @state.nodes[var])
201
+ raise ArgumentError, "give #{var} a label to filter or order by its properties" unless node.label
202
+
203
+ return context.graph.type.node_type!(node.label)
204
+ end
205
+ edge = @state.edges.find { _1.var == var && _1.named }
206
+ raise ArgumentError, "unknown variable #{var.inspect}" unless edge
207
+
208
+ context.graph.type.edge_type!(edge.type)
209
+ end
210
+
211
+ def returnable = @state.nodes.keys + @state.edges.select(&:named).map(&:var)
212
+
213
+ def returns = @state.returns || returnable
214
+
215
+ # -- compilation -----------------------------------------------------------
216
+
217
+ def aliases
218
+ node_aliases = @state.nodes.keys.each_with_index.to_h { |v, i| [v, "n#{i}"] }
219
+ edge_aliases = @state.edges.each_with_index.to_h { |e, i| [e.var, "e#{i}"] }
220
+ node_aliases.merge(edge_aliases)
221
+ end
222
+
223
+ def compile(kind)
224
+ if @state.distinct && (stray = @state.orders.map(&:first) - returns).any?
225
+ raise ArgumentError, "with distinct, order only by returned variables (not #{stray.join(", ")})"
226
+ end
227
+
228
+ names = aliases
229
+ sql = Sql.new(context.graph.connection)
230
+ inner = kind != :rows
231
+ sql << (kind == :count ? "SELECT count(*) AS count FROM (" : "SELECT EXISTS (") if inner
232
+ sql << "SELECT "
233
+ sql << "DISTINCT " if @state.distinct
234
+ select_list(sql, kind, names)
235
+ body(sql, names)
236
+ order_by(sql, names) if kind == :rows
237
+ if kind == :rows
238
+ sql << " LIMIT "
239
+ sql.bind(@state.limit || (max_rows + 1), "bigint")
240
+ elsif @state.limit
241
+ sql << " LIMIT "
242
+ sql.bind(@state.limit, "bigint")
243
+ end
244
+ if @state.offset
245
+ sql << " OFFSET "
246
+ sql.bind(@state.offset, "bigint")
247
+ end
248
+ sql << (kind == :count ? ") AS pglpg_rows" : ") AS \"exists\"") if inner
249
+ sql
250
+ end
251
+
252
+ def select_list(sql, kind, names)
253
+ if kind == :rows
254
+ returns.each_with_index do |var, i|
255
+ sql << ", " if i.positive?
256
+ sql << "to_jsonb(" << names.fetch(var) << ") AS " << context.graph.connection.quote_column_name(var.to_s)
257
+ end
258
+ if @state.distinct
259
+ @state.orders.each_with_index do |(var, prop, _), i|
260
+ sql << ", "
261
+ order_expr(sql, names.fetch(var), prop)
262
+ sql << " AS _o#{i}"
263
+ end
264
+ returns.each_with_index { |var, i| sql << ", " << names.fetch(var) << ".id AS _i#{i}" }
265
+ end
266
+ elsif @state.distinct
267
+ returns.each_with_index do |var, i|
268
+ sql << ", " if i.positive?
269
+ sql << names.fetch(var) << ".id"
270
+ end
271
+ else
272
+ sql << "1"
273
+ end
274
+ end
275
+
276
+ def body(sql, names)
277
+ nodes_table = context.table_sql(context.graph.nodes_table)
278
+ edges_table = context.table_sql(context.graph.edges_table)
279
+ start = @state.nodes.values.first
280
+ sql << " FROM " << nodes_table << " AS " << names.fetch(start.var)
281
+ bound = [start.var]
282
+
283
+ @state.edges.each_with_index do |edge, i|
284
+ e = names.fetch(edge.var)
285
+ near, far = edge.direction == :out ? %w[source_id target_id] : %w[target_id source_id]
286
+ sql << " JOIN " << edges_table << " AS " << e << " ON " << e << "." << near << " = " << names.fetch(edge.from_var)
287
+ sql << ".id AND " << e << ".type = "
288
+ sql.bind(edge.type.to_s, "text")
289
+ sql << " AND "
290
+ context.append_edge_visible(sql, e)
291
+ conditions_sql(sql, edge.var, e)
292
+ # Different edges (the GQL default match mode).
293
+ @state.edges.first(i).each do |other|
294
+ sql << " AND " << e << ".id <> " << names.fetch(other.var) << ".id" if other.type == edge.type
295
+ end
296
+
297
+ if bound.include?(edge.to_var)
298
+ sql << " AND " << e << "." << far << " = " << names.fetch(edge.to_var) << ".id"
299
+ else
300
+ n = names.fetch(edge.to_var)
301
+ sql << " JOIN " << nodes_table << " AS " << n << " ON " << n << ".id = " << e << "." << far << " AND "
302
+ node_conditions(sql, @state.nodes.fetch(edge.to_var), n)
303
+ bound << edge.to_var
304
+ end
305
+ end
306
+
307
+ sql << " WHERE "
308
+ node_conditions(sql, start, names.fetch(start.var))
309
+ end
310
+
311
+ def node_conditions(sql, node, n)
312
+ context.append_node_visible(sql, n)
313
+ if node.label
314
+ # A literal, so the planner can match partial (key) indexes.
315
+ sql << " AND " << n << ".labels @> ARRAY["
316
+ sql.literal(node.label) << "]::text[]"
317
+ end
318
+ if node.ref_id
319
+ sql << " AND " << n << ".id = "
320
+ sql.bind(node.ref_id, "bigint")
321
+ end
322
+ conditions_sql(sql, node.var, n)
323
+ end
324
+
325
+ def conditions_sql(sql, var, a)
326
+ key = key_of(var)
327
+ (@state.conditions[var] || []).each do |prop, cond|
328
+ sql << " AND "
329
+ case cond.op
330
+ when :eq
331
+ if key && key.property == prop.name
332
+ # Matches the key's partial unique index.
333
+ sql << a << ".labels @> ARRAY["
334
+ sql.literal(key.declared_on) << "]::text[] AND (" << a << ".properties ->> "
335
+ sql.literal(prop.name) << ") = "
336
+ sql.bind(prop.encode(cond.value), "text")
337
+ next
338
+ end
339
+ sql << a << ".properties @> "
340
+ sql.bind_json({ prop.name.to_s => prop.encode(cond.value) })
341
+ when :absent
342
+ sql << "(" << a << ".properties -> "
343
+ sql.literal(prop.name) << ") IS NULL"
344
+ when :in
345
+ order_expr(sql, a, prop)
346
+ sql << " = ANY("
347
+ sql.bind_text_array(cond.value.map { encoded_text(prop, _1) })
348
+ sql << "::#{prop.sql_cast}[])"
349
+ else
350
+ order_expr(sql, a, prop)
351
+ sql << " " << Condition::OPS.fetch(cond.op) << " "
352
+ sql.bind(encoded_text(prop, cond.value), "text")
353
+ sql << "::#{prop.sql_cast}"
354
+ end
355
+ end
356
+ end
357
+
358
+ def key_of(var)
359
+ label = @state.nodes[var]&.label
360
+ label && context.graph.type.node_type!(label).key
361
+ end
362
+
363
+ # Validates values up front, so errors surface where the query is built.
364
+ def validate_condition!(prop, condition)
365
+ case condition.op
366
+ when :absent then nil
367
+ when :in
368
+ raise ArgumentError, "#{prop.name}: in needs at least one value" if condition.value.empty?
369
+
370
+ condition.value.each { encoded_text(prop, _1) }
371
+ else encoded_text(prop, condition.value)
372
+ end
373
+ end
374
+
375
+ def encoded_text(prop, value)
376
+ raise ArgumentError, "#{prop.name}: nil is only allowed as a plain value (absent)" if value.nil?
377
+
378
+ prop.encode(value).to_s
379
+ end
380
+
381
+ def order_expr(sql, a, prop)
382
+ sql << "(" << a << ".properties ->> "
383
+ sql.literal(prop.name) << ")::#{prop.sql_cast}"
384
+ end
385
+
386
+ def order_by(sql, names)
387
+ sql << " ORDER BY "
388
+ parts = 0
389
+ @state.orders.each_with_index do |(var, prop, dir), i|
390
+ sql << ", " if parts.positive?
391
+ @state.distinct ? sql << "_o#{i}" : order_expr(sql, names.fetch(var), prop)
392
+ sql << (dir == :desc ? " DESC NULLS LAST" : " ASC NULLS LAST")
393
+ parts += 1
394
+ end
395
+ id_vars = @state.distinct ? returns : names.keys
396
+ id_vars.each_with_index do |var, i|
397
+ sql << ", " if parts.positive?
398
+ @state.distinct ? sql << "_i#{i}" : sql << names.fetch(var) << ".id"
399
+ parts += 1
400
+ end
401
+ end
402
+
403
+ def decode(row)
404
+ returns.to_h do |var|
405
+ json = JSON.parse(row.fetch(var.to_s))
406
+ element = @state.nodes.key?(var) ? context.graph.build_node(json) : context.graph.build_edge(json)
407
+ [var, element]
408
+ end.freeze
409
+ end
410
+ end
411
+ end
data/lib/pglpg/ref.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ # An immutable, graph-scoped reference to a node or an edge, backed by the
5
+ # element's bigint id. Business keys are for lookup and upsert only; a Ref
6
+ # is how elements refer to each other.
7
+ Ref = Data.define(:graph, :kind, :id) do
8
+ def initialize(graph:, kind:, id:)
9
+ kind = kind.to_sym
10
+ raise ArgumentError, "kind must be :node or :edge" unless %i[node edge].include?(kind)
11
+ raise ArgumentError, "id must be a positive Integer" unless id.is_a?(Integer) && id.positive?
12
+
13
+ super(graph: graph.to_sym, kind:, id:)
14
+ end
15
+
16
+ def node? = kind == :node
17
+
18
+ def edge? = kind == :edge
19
+
20
+ # "graph:kind:id", accepted by Graph#parse_ref.
21
+ def to_s = "#{graph}:#{kind}:#{id}"
22
+
23
+ def inspect = "#<Pglpg::Ref #{self}>"
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ module Schema
5
+ # An application column on the nodes or edges table, such as
6
+ # +classification+. Visibility policies filter on these real, indexed
7
+ # columns; writes set them through +attrs:+. Options: +null:+, +default:+,
8
+ # +limit:+, +precision:+, +scale:+ and, for edge columns, +covering: true+.
9
+ class Column
10
+ RESERVED = %i[id key_label labels properties type source_id target_id created_at updated_at].freeze
11
+
12
+ attr_reader :name, :type, :options
13
+
14
+ def initialize(name, type, **options)
15
+ @name = Schema.check_name!(name, "column")
16
+ raise SchemaError, "#{@name} is a reserved column name" if RESERVED.include?(@name)
17
+
18
+ @type = type.to_sym
19
+ @options = options.freeze
20
+ freeze
21
+ end
22
+
23
+ def null? = options.fetch(:null, true)
24
+
25
+ # Included in the edge traversal indexes (edge columns only).
26
+ def covering? = options.fetch(:covering, false)
27
+
28
+ def default? = options.key?(:default)
29
+
30
+ # Required on insert: NOT NULL and no default.
31
+ def required? = !null? && !default?
32
+
33
+ def sql_type(connection)
34
+ connection.type_to_sql(type, **options.slice(:limit, :precision, :scale))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ module Schema
5
+ # An edge type: exactly one label (its name), property types, and the
6
+ # node labels its source and target must carry.
7
+ class EdgeType < ElementType
8
+ attr_reader :from, :to, :unique
9
+
10
+ def initialize(name, from:, to:, unique: nil, symmetric: false, temporal: false)
11
+ super(name)
12
+ raise SchemaError, "symmetric edge types are planned for 0.2 (#{name})" if symmetric
13
+ raise SchemaError, "temporal edge types are planned for 0.3 (#{name})" if temporal
14
+ raise SchemaError, "unique: must be nil or :pair (#{name})" unless [nil, :pair].include?(unique)
15
+
16
+ @from = Schema.check_name!(from, "type")
17
+ @to = Schema.check_name!(to, "type")
18
+ @unique = unique
19
+ end
20
+
21
+ # At most one edge of this type between the same source and target.
22
+ def unique_pair? = unique == :pair
23
+
24
+ def resolve!(graph_type)
25
+ [from, to].each do |label|
26
+ next if graph_type.node_types.key?(label)
27
+
28
+ raise SchemaError, "edge #{name} refers to unknown node type #{label}"
29
+ end
30
+ @properties = own_properties.dup.freeze
31
+ freeze
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ module Schema
5
+ NAME_FORMAT = /\A[A-Za-z_][A-Za-z0-9_]*\z/
6
+
7
+ def self.check_name!(name, what)
8
+ raise SchemaError, "invalid #{what} name #{name.inspect}" unless name.to_s.match?(NAME_FORMAT)
9
+
10
+ name.to_sym
11
+ end
12
+
13
+ # Shared by node and edge types: declared properties. Types are closed:
14
+ # a property that is not declared is rejected.
15
+ class ElementType
16
+ attr_reader :name
17
+
18
+ def initialize(name)
19
+ @name = Schema.check_name!(name, "type")
20
+ @own_properties = {}
21
+ end
22
+
23
+ def property(name, type, required: false, values: nil, of: nil)
24
+ name = Schema.check_name!(name, "property")
25
+ raise SchemaError, "#{self.name}.#{name} is declared twice" if @own_properties.key?(name)
26
+
27
+ @own_properties[name] = Property.new(name, type, required:, values:, of:)
28
+ end
29
+
30
+ def own_properties = @own_properties
31
+
32
+ # All properties, inherited ones included.
33
+ def properties = @properties || @own_properties
34
+
35
+ def property_named(name) = properties[name.to_sym]
36
+
37
+ def required_properties = properties.values.select(&:required?)
38
+
39
+ # Validates +props+ and returns the JSON hash to store. nil values are
40
+ # dropped (null means absent). With +partial+, required properties may be
41
+ # missing but may not be set to nil.
42
+ def encode_properties(props, partial: false)
43
+ props = (props || {}).transform_keys(&:to_sym)
44
+ unknown = props.keys - properties.keys
45
+ raise ValidationError, "#{name} has no propert#{unknown.size == 1 ? "y" : "ies"} #{unknown.join(", ")}" if unknown.any?
46
+
47
+ required_properties.each do |p|
48
+ next if partial && !props.key?(p.name)
49
+ raise ValidationError, "#{name}.#{p.name} is required" if props[p.name].nil?
50
+ end
51
+ props.each_with_object({}) do |(k, v), out|
52
+ encoded = properties.fetch(k).encode(v)
53
+ out[k.to_s] = encoded unless encoded.nil?
54
+ end
55
+ end
56
+
57
+ def decode_properties(json)
58
+ (json || {}).each_with_object({}) do |(k, v), out|
59
+ prop = properties[k.to_sym]
60
+ out[k.to_sym] = prop ? prop.decode(v) : v
61
+ end.freeze
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ module Schema
5
+ # The schema of one graph: its node types, edge types and application
6
+ # columns. Built by Builder and frozen once resolved.
7
+ class GraphType
8
+ attr_reader :node_types, :edge_types, :node_columns, :edge_columns
9
+
10
+ def initialize
11
+ @node_types = {}
12
+ @edge_types = {}
13
+ @node_columns = {}
14
+ @edge_columns = {}
15
+ end
16
+
17
+ def node_type!(name)
18
+ node_types.fetch(name.to_sym) { raise ArgumentError, "unknown node type #{name.inspect}" }
19
+ end
20
+
21
+ def edge_type!(name)
22
+ edge_types.fetch(name.to_sym) { raise ArgumentError, "unknown edge type #{name.inspect}" }
23
+ end
24
+
25
+ # Concrete node types that carry +label+ (the type itself and its subtypes).
26
+ def node_types_with_label(label)
27
+ node_types.values.select { _1.label?(label) }
28
+ end
29
+
30
+ def resolve!
31
+ node_types.each_value { _1.resolve!(self) }
32
+ edge_types.each_value { _1.resolve!(self) }
33
+ [node_types, edge_types, node_columns, edge_columns].each(&:freeze)
34
+ freeze
35
+ end
36
+ end
37
+
38
+ # The block DSL of Pglpg::Graph.define.
39
+ class Builder
40
+ def initialize(graph_type)
41
+ @graph_type = graph_type
42
+ end
43
+
44
+ def node(name, extends: [], abstract: false, &block)
45
+ type = NodeType.new(name, extends:, abstract:)
46
+ raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
47
+
48
+ NodeBuilder.new(type).instance_eval(&block) if block
49
+ @graph_type.node_types[type.name] = type
50
+ end
51
+
52
+ def edge(name, from:, to:, unique: nil, symmetric: false, temporal: false, &block)
53
+ type = EdgeType.new(name, from:, to:, unique:, symmetric:, temporal:)
54
+ raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
55
+
56
+ ElementBuilder.new(type).instance_eval(&block) if block
57
+ @graph_type.edge_types[type.name] = type
58
+ end
59
+
60
+ def node_column(name, type, **options) = add_column(@graph_type.node_columns, name, type, options)
61
+
62
+ def edge_column(name, type, **options) = add_column(@graph_type.edge_columns, name, type, options)
63
+
64
+ # Adds the same application column to both tables.
65
+ def column(name, type, **options)
66
+ node_column(name, type, **options)
67
+ edge_column(name, type, **options)
68
+ end
69
+
70
+ private
71
+
72
+ def declared?(name) = @graph_type.node_types.key?(name) || @graph_type.edge_types.key?(name)
73
+
74
+ def add_column(columns, name, type, options)
75
+ column = Column.new(name, type, **options)
76
+ raise SchemaError, "column #{column.name} is declared twice" if columns.key?(column.name)
77
+
78
+ columns[column.name] = column
79
+ end
80
+ end
81
+
82
+ class ElementBuilder
83
+ def initialize(type)
84
+ @type = type
85
+ end
86
+
87
+ def property(name, type, required: false, values: nil, of: nil)
88
+ @type.property(name, type, required:, values:, of:)
89
+ end
90
+ end
91
+
92
+ class NodeBuilder < ElementBuilder
93
+ # Declares an exclusive key. Interactive inserts get a value from
94
+ # +generate+ (default: a random UUID); callers never choose it.
95
+ def key(name, generate: nil)
96
+ @type.key_property(name, generate:)
97
+ end
98
+ end
99
+ end
100
+ end