pglpg 0.1.0 → 0.3.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,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ module Model
5
+ # A query over one model's elements as seen through a Context, built
6
+ # immutably from Model.in. It compiles to a Pglpg::Query, so the
7
+ # visibility policy, deterministic ordering, max_rows and the statement
8
+ # timeout all apply as they do there.
9
+ #
10
+ # Person.in(ctx).where(rank: Pglpg.gte(2)).order(name: :desc).limit(20)
11
+ class Relation
12
+ include Enumerable
13
+
14
+ attr_reader :model, :context
15
+
16
+ def initialize(model, context, query = nil, filtered: false)
17
+ @model = model
18
+ @context = context
19
+ @query = query || model.pattern(context)
20
+ @filtered = filtered
21
+ @shaped = false
22
+ @preload = {}.freeze
23
+ freeze
24
+ end
25
+
26
+ # Structured conditions on the model's properties, as in Query#where.
27
+ def where(**props) = derive(@query.where(var => props))
28
+
29
+ # order(:name), order(name: :desc) or order(:rank, name: :desc). Ties are
30
+ # broken by element ids.
31
+ def order(*names, **spec) = derive(order_query(var, names, spec), shaped: true)
32
+
33
+ def limit(n) = derive(@query.limit(n), shaped: true)
34
+
35
+ def offset(n) = derive(@query.offset(n), shaped: true)
36
+
37
+ def to_a
38
+ records = rows
39
+ preload(records, @preload, model) unless @preload.empty?
40
+ records
41
+ end
42
+
43
+ # Preload symbols/strings and nested hashes (array values, including [],
44
+ # are allowed). Calls merge; static association/model validation runs
45
+ # now, before SQL: ArgumentError for invalid trees, ConfigurationError
46
+ # for missing target models. to_a/each/first preload; count, exists?,
47
+ # pluck and find do not. Batches above max_rows raise TooManyRows.
48
+ def with_associations(*spec)
49
+ tree = merge_tree(@preload, parse_tree(spec, []))
50
+ validate_tree(model, tree)
51
+ copy = clone(freeze: false)
52
+ copy.instance_variable_set(:@preload, tree)
53
+ copy.freeze
54
+ end
55
+
56
+ def each(&) = to_a.each(&)
57
+
58
+ def first(n = nil) = n.nil? ? limit(1).to_a.first : limit(n).to_a
59
+
60
+ def count = compiled.count
61
+
62
+ def exists? = compiled.exists?
63
+
64
+ # The visible element of the model's type behind +ref+ (a Ref or its
65
+ # String form). Raises RecordNotFound when it is missing or hidden.
66
+ # Conditions, ordering and pagination do not apply, so find works only
67
+ # on the unfiltered relation.
68
+ def find(ref)
69
+ raise ArgumentError, "find ignores conditions and pagination; use where(...).first" if @filtered
70
+
71
+ ref = model.graph.parse_ref(ref) if ref.is_a?(String)
72
+ model.graph.check_ref!(ref, model.kind)
73
+ element = context.find(ref)
74
+ raise RecordNotFound unless element && model.matches?(element)
75
+
76
+ model.instantiate(context, element)
77
+ end
78
+
79
+ # The first element matching +props+, or nil.
80
+ def find_by(**props) = where(**props).first
81
+
82
+ # The values of attributes (or :id, :ref) of each element, in order:
83
+ # one value per element for one name, an Array per element for
84
+ # several. Elements are chosen first and projected afterwards, so two
85
+ # elements with equal values give two values (ADR-0010 §5).
86
+ # people.order(:name).pluck(:name) # => ["Ann", "Bob"]
87
+ # people.pluck(:id, :name) # => [[1, "Ann"], [2, "Bob"]]
88
+ def pluck(*names)
89
+ names = pluck_names!(model, names)
90
+ raise ArgumentError, "pluck needs at least one name" if names.empty?
91
+
92
+ rows.map { |record| unwrap(project(record, names)) }
93
+ end
94
+
95
+ # A new record bound to the relation's context.
96
+ def new(**attributes) = model.build(context, attributes)
97
+
98
+ alias build new
99
+
100
+ # Builds and saves a record; returns it, saved or not (check
101
+ # persisted? or errors).
102
+ def create(writer: nil, **attributes)
103
+ context.writable!
104
+ new(**attributes).tap { _1.save(writer:) }
105
+ end
106
+
107
+ # As #create, but raises RecordInvalid when validation fails.
108
+ def create!(writer: nil, **attributes)
109
+ context.writable!
110
+ new(**attributes).tap { _1.save!(writer:) }
111
+ end
112
+
113
+ # The compiled Pglpg::Query, for debugging (to_sql, explain).
114
+ def to_query = compiled
115
+
116
+ def inspect = "#<#{self.class.name} #{model.name || model.base_class.name} #{compiled.to_sql.first}>"
117
+
118
+ private
119
+
120
+ def rows = compiled.to_a.map { |row| load_row(row) }
121
+
122
+ def parse_tree(spec, ancestors)
123
+ raise ArgumentError, "cyclic preload tree" if ancestors.include?(spec.object_id)
124
+ ancestors = ancestors + [spec.object_id]
125
+ result = {}
126
+ spec.each do |item|
127
+ case item
128
+ when Symbol, String
129
+ key = item.to_sym
130
+ result[key] ||= {}.freeze
131
+ when Hash
132
+ raise ArgumentError, "cyclic preload tree" if ancestors.include?(item.object_id)
133
+ item.each do |key, value|
134
+ raise ArgumentError, "invalid association name" unless key.is_a?(Symbol) || key.is_a?(String)
135
+ children = value.is_a?(Array) ? value : [value]
136
+ result[key.to_sym] = merge_tree(result[key.to_sym] || {}, parse_tree(children, ancestors + [item.object_id]))
137
+ end
138
+ else
139
+ raise ArgumentError, "invalid preload specification"
140
+ end
141
+ end
142
+ result.freeze
143
+ end
144
+
145
+ def merge_tree(a, b)
146
+ a.merge(b) { |_key, left, right| merge_tree(left, right) }.freeze
147
+ end
148
+
149
+ def target_model(owner_model, association)
150
+ owner_model.association_model(association)
151
+ end
152
+
153
+ def validate_tree(owner_model, tree)
154
+ tree.each do |name, children|
155
+ association = owner_model.respond_to?(:associations) && owner_model.associations[name]
156
+ raise ArgumentError, "#{owner_model} has no association #{name}" unless association
157
+ context.temporal_mode!([context.graph.type.edge_type!(association.edge_type)])
158
+ validate_tree(target_model(owner_model, association), children)
159
+ end
160
+ end
161
+
162
+ def preload(owners, tree, owner_model)
163
+ return if owners.empty?
164
+ tree.each do |name, children|
165
+ association = owner_model.associations.fetch(name)
166
+ target = target_model(owner_model, association)
167
+ query = context.match.node_refs(:owner, owners.map(&:ref))
168
+ query = query.public_send(association.direction, association.edge_type, :n,
169
+ association.label || target.schema_type.name, edge: :e)
170
+ begin
171
+ pairs = query.return(:owner, :n).distinct.to_a
172
+ rescue TooManyRows
173
+ raise TooManyRows, "#{owner_model}##{name}: more than #{Pglpg.configuration.max_rows} distinct owner-target pairs; page the owners"
174
+ end
175
+ targets = {}
176
+ grouped = Hash.new { |h, k| h[k] = [] }
177
+ pairs.each do |row|
178
+ element = row.fetch(:n)
179
+ record = targets[element.ref] ||= target.instantiate(context, element)
180
+ grouped[row.fetch(:owner).ref] << record
181
+ end
182
+ owners.each do |owner|
183
+ cache = owner.instance_variable_get(:@association_cache) || {}
184
+ cache[name] = grouped[owner.ref].freeze
185
+ owner.instance_variable_set(:@association_cache, cache)
186
+ end
187
+ preload(targets.values, children, target)
188
+ end
189
+ end
190
+
191
+ def var = model.query_var
192
+
193
+ # The query that runs.
194
+ def compiled = @query
195
+
196
+ def load_row(row) = model.instantiate(context, row.fetch(var))
197
+
198
+ PLUCK_IDENTITY = %i[id ref].freeze
199
+
200
+ # Checked before any query runs.
201
+ def pluck_names!(model, names)
202
+ names.map(&:to_sym).each do |name|
203
+ next if PLUCK_IDENTITY.include?(name) || model.attribute_names.include?(name.to_s)
204
+
205
+ raise ArgumentError, "#{model.name || model.schema_type.name} has no attribute #{name.inspect} to pluck"
206
+ end
207
+ end
208
+
209
+ def project(record, names) = names.map { record.public_send(_1) }
210
+
211
+ def unwrap(values) = values.size == 1 ? values.first : values
212
+
213
+ def order_query(var, names, spec)
214
+ q = names.reduce(@query) { |acc, name| acc.order(var => name) }
215
+ spec.empty? ? q : q.order(var => spec)
216
+ end
217
+
218
+ def derived_copy(copy) = copy
219
+
220
+ # A copy with +query+; +shaped+ records ordering or pagination.
221
+ def derive(query, shaped: @shaped)
222
+ copy = clone(freeze: false)
223
+ copy.instance_variable_set(:@query, query)
224
+ derived_copy(copy)
225
+ copy.instance_variable_set(:@filtered, true)
226
+ copy.instance_variable_set(:@shaped, shaped)
227
+ copy.freeze
228
+ end
229
+ end
230
+
231
+ # The targets of an association (ADR-0010 §5): one row per distinct
232
+ # visible target, deduplicated before ordering, pagination and counts.
233
+ # Edge conditions (rel_where) mean "at least one matching visible edge".
234
+ #
235
+ # person.projects.rel_where(role: "lead").order(:title).limit(20)
236
+ # person.projects.with_rel.each { |project, works_on| ... }
237
+ class AssociationRelation < Relation
238
+ def initialize(model, context, query, edge_model:, cached: nil)
239
+ @edge_model = edge_model
240
+ @cached = cached
241
+ super(model, context, query, filtered: true)
242
+ end
243
+
244
+ def first(n = nil)
245
+ return super unless @cached
246
+ return to_a.first if n.nil?
247
+
248
+ raise ArgumentError, "limit must be a positive Integer" unless n.is_a?(Integer) && n.positive?
249
+ max_rows = Pglpg.configuration.max_rows
250
+ raise ArgumentError, "limit exceeds max_rows (#{max_rows})" if n > max_rows
251
+ to_a.first(n).freeze
252
+ end
253
+
254
+ def count = @cached ? @cached.size : super
255
+ def exists? = @cached ? !@cached.empty? : super
256
+ # Structured conditions on the association's edge properties.
257
+ def rel_where(**props) = derive(@query.where(e: props))
258
+
259
+ # A target has no single edge value when several edges lead to it.
260
+ def rel_order(*, **)
261
+ raise ArgumentError, "a target query can't be ordered by an edge property; use with_rel.rel_order"
262
+ end
263
+
264
+ # One row per matching node–edge pair: [target, edge]. Its count,
265
+ # limit and offset count pairs. Only on a relation without order,
266
+ # limit or offset, so pagination always applies to the shape it was
267
+ # written for.
268
+ def with_rel
269
+ raise ArgumentError, "with_rel cannot discard pending preloads" unless @preload.empty?
270
+
271
+ raise ArgumentError, "call with_rel before order, limit or offset" if @shaped
272
+
273
+ BindingRelation.new(model, context, @query, edge_model: @edge_model)
274
+ end
275
+
276
+ # Enumerates with_rel: yields each target with its edge.
277
+ def each_with_rel(&) = with_rel.each(&)
278
+
279
+ # Creating targets or edges through an association is deferred (ADR-0010).
280
+ def new(**) = raise(ArgumentError, "associations can't build records yet; use Model.in(ctx).new")
281
+
282
+ alias build new
283
+ alias create new
284
+ alias create! new
285
+
286
+ private
287
+
288
+ def rows = @cached || super
289
+
290
+ def derived_copy(copy)
291
+ copy.instance_variable_set(:@cached, nil)
292
+ super
293
+ end
294
+
295
+ def compiled = @query.return(var).distinct
296
+ end
297
+
298
+ # The node–edge pairs of an association, from AssociationRelation#with_rel.
299
+ class BindingRelation < AssociationRelation
300
+ # order(...) orders by target properties; rel_order(...) by edge ones.
301
+ def rel_order(*names, **spec) = derive(order_query(:e, names, spec), shaped: true)
302
+
303
+ def with_associations(*) = raise(ArgumentError, "preloading binding relations is not supported")
304
+
305
+ def with_rel = self
306
+
307
+ # One entry per pair: the target's +names+, then the edge's +rel+ names.
308
+ # ann.projects.with_rel.pluck(:title, rel: :role) # => [["Apollo", "lead"], ...]
309
+ def pluck(*names, rel: [])
310
+ rel = Array(rel)
311
+ names = pluck_names!(model, names)
312
+ if rel.any?
313
+ @edge_model or raise ConfigurationError, "no model is registered for the association's edge type"
314
+ rel = pluck_names!(@edge_model, rel)
315
+ end
316
+ raise ArgumentError, "pluck needs at least one name" if names.empty? && rel.empty?
317
+
318
+ to_a.map { |target, edge| unwrap(project(target, names) + project(edge, rel)) }
319
+ end
320
+
321
+ def each_with_rel(&) = each(&)
322
+
323
+ private
324
+
325
+ def compiled = @query.return(var, :e)
326
+
327
+ def load_row(row)
328
+ edge_model = @edge_model or
329
+ raise ConfigurationError, "no model is registered for edge type #{row.fetch(:e).type}"
330
+ [model.instantiate(context, row.fetch(var)), edge_model.instantiate(context, row.fetch(:e))]
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pglpg"
4
+ require "active_model"
5
+
6
+ module Pglpg
7
+ # The optional model layer (ADR-0010): ActiveModel classes backed by graph
8
+ # node and edge types, read through a Context.
9
+ #
10
+ # require "pglpg/model"
11
+ #
12
+ # class Person < Pglpg::Model::Node
13
+ # graph Kg, type: :Person
14
+ # end
15
+ #
16
+ # people = Person.in(Kg.for(viewer: current_viewer))
17
+ # people.where(rank: Pglpg.gte(2)).order(name: :desc).limit(20).to_a
18
+ # people.find(ref)
19
+ module Model
20
+ # A lookup found no visible element of the model's type. A hidden
21
+ # element and a missing one look the same.
22
+ class RecordNotFound < Error
23
+ def initialize(msg = "record not found")
24
+ super
25
+ end
26
+ end
27
+
28
+ @registries = ObjectSpace::WeakKeyMap.new
29
+ @lock = Mutex.new
30
+
31
+ class << self
32
+ # Registers +klass+ as the model of +type_name+ in +graph+. A class
33
+ # with the same name replaces an earlier one (code reloading); any
34
+ # other second model for the same type is a ConfigurationError.
35
+ def register(graph, kind, type_name, klass)
36
+ @lock.synchronize do
37
+ registry = (@registries[graph] ||= {})
38
+ existing = registry[[kind, type_name]]
39
+ if existing && !existing.equal?(klass) && (existing.name.nil? || existing.name != klass.name)
40
+ raise ConfigurationError, "#{type_name} in graph #{graph.name} already has a model (#{existing.inspect})"
41
+ end
42
+
43
+ registry[[kind, type_name]] = klass
44
+ end
45
+ end
46
+
47
+ # The model registered for +type_name+ in +graph+, or nil.
48
+ def lookup(graph, kind, type_name)
49
+ @lock.synchronize { @registries[graph]&.[]([kind, type_name]) }
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ require_relative "model/base"
56
+ require_relative "model/node"
57
+ require_relative "model/edge"
58
+ require_relative "model/relation"
data/lib/pglpg/query.rb CHANGED
@@ -17,7 +17,7 @@ module Pglpg
17
17
  class Query
18
18
  include Enumerable
19
19
 
20
- NodePattern = Data.define(:var, :label, :ref_id)
20
+ NodePattern = Data.define(:var, :label, :ref_id, :ref_ids)
21
21
  EdgePattern = Data.define(:var, :type, :direction, :from_var, :to_var, :named)
22
22
  State = Data.define(:nodes, :edges, :conditions, :returns, :orders, :limit, :offset, :distinct, :last_var)
23
23
 
@@ -39,16 +39,31 @@ module Pglpg
39
39
  var = new_var!(var)
40
40
  label = check_label!(label)
41
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)
42
+ q = with(nodes: { var => NodePattern.new(var:, label:, ref_id: ref&.id, ref_ids: nil) }.freeze, last_var: var)
43
43
  props.empty? ? q : q.where(var => props)
44
44
  end
45
45
 
46
+ # Internal batched start. A pattern has either ref_id or ref_ids, never both.
47
+ def node_refs(var, refs)
48
+ raise ArgumentError, "a pattern has one start node" unless @state.nodes.empty?
49
+
50
+ var = new_var!(var)
51
+ refs.each { context.graph.check_ref!(_1, :node) }
52
+ pattern = NodePattern.new(var:, label: nil, ref_id: nil, ref_ids: refs.map(&:id).freeze)
53
+ with(nodes: { var => pattern }.freeze, last_var: var)
54
+ end
55
+
46
56
  # Follows an outgoing edge of +type+ from +from+ (default: the last node).
47
57
  def out(type, var, label = nil, edge: nil, from: nil, **props) = hop(:out, type, var, label, edge, from, props)
48
58
 
49
59
  # Follows an incoming edge of +type+ into +from+ (default: the last node).
50
60
  def in(type, var, label = nil, edge: nil, from: nil, **props) = hop(:in, type, var, label, edge, from, props)
51
61
 
62
+ # Follows an edge of a symmetric +type+ from +from+ (default: the last
63
+ # node), whichever end it is stored at: GQL's ~[ ]~ (ADR-0008). Each edge
64
+ # binds once per orientation that fits, so a self-loop binds once.
65
+ def both(type, var, label = nil, edge: nil, from: nil, **props) = hop(:both, type, var, label, edge, from, props)
66
+
52
67
  # Structured conditions per variable: where(j: { headcount: Pglpg.gt(0) }).
53
68
  def where(by_var)
54
69
  conditions = @state.conditions.transform_values(&:dup)
@@ -156,6 +171,12 @@ module Pglpg
156
171
  raise ArgumentError, "start the pattern with node" if @state.nodes.empty?
157
172
 
158
173
  edge_type = context.graph.type.edge_type!(type)
174
+ if edge_type.symmetric? && direction != :both
175
+ raise ArgumentError, "#{edge_type.name} is symmetric; use both"
176
+ elsif !edge_type.symmetric? && direction == :both
177
+ raise ArgumentError, "#{edge_type.name} is directed; use out or in"
178
+ end
179
+
159
180
  from = (from || @state.last_var).to_sym
160
181
  raise ArgumentError, "#{from} is not a node variable" unless @state.nodes.key?(from)
161
182
 
@@ -168,7 +189,7 @@ module Pglpg
168
189
  end
169
190
  else
170
191
  new_var!(var)
171
- nodes = nodes.merge(var => NodePattern.new(var:, label:, ref_id: nil)).freeze
192
+ nodes = nodes.merge(var => NodePattern.new(var:, label:, ref_id: nil, ref_ids: nil)).freeze
172
193
  end
173
194
 
174
195
  # Checked against the node map including this hop's new node.
@@ -225,6 +246,7 @@ module Pglpg
225
246
  raise ArgumentError, "with distinct, order only by returned variables (not #{stray.join(", ")})"
226
247
  end
227
248
 
249
+ context.temporal_mode!(@state.edges.map { context.graph.type.edge_type!(_1.type) })
228
250
  names = aliases
229
251
  sql = Sql.new(context.graph.connection)
230
252
  inner = kind != :rows
@@ -282,12 +304,26 @@ module Pglpg
282
304
 
283
305
  @state.edges.each_with_index do |edge, i|
284
306
  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 = "
307
+ x = "#{names.fetch(edge.from_var)}.id"
308
+ sql << " JOIN " << edges_table << " AS " << e << " ON "
309
+ case edge.direction
310
+ when :out
311
+ sql << e << ".source_id = " << x
312
+ far = "#{e}.target_id"
313
+ when :in
314
+ sql << e << ".target_id = " << x
315
+ far = "#{e}.source_id"
316
+ else
317
+ # Either stored column; the far end is the other one. A self-loop
318
+ # matches both conditions but is still one row.
319
+ sql << "(" << e << ".source_id = " << x << " OR " << e << ".target_id = " << x << ")"
320
+ far = "(CASE WHEN #{e}.source_id = #{x} THEN #{e}.target_id ELSE #{e}.source_id END)"
321
+ end
322
+ sql << " AND " << e << ".type = "
288
323
  sql.bind(edge.type.to_s, "text")
289
324
  sql << " AND "
290
325
  context.append_edge_visible(sql, e)
326
+ context.append_temporal(sql, e, [context.graph.type.edge_type!(edge.type)])
291
327
  conditions_sql(sql, edge.var, e)
292
328
  # Different edges (the GQL default match mode).
293
329
  @state.edges.first(i).each do |other|
@@ -295,10 +331,10 @@ module Pglpg
295
331
  end
296
332
 
297
333
  if bound.include?(edge.to_var)
298
- sql << " AND " << e << "." << far << " = " << names.fetch(edge.to_var) << ".id"
334
+ sql << " AND " << far << " = " << names.fetch(edge.to_var) << ".id"
299
335
  else
300
336
  n = names.fetch(edge.to_var)
301
- sql << " JOIN " << nodes_table << " AS " << n << " ON " << n << ".id = " << e << "." << far << " AND "
337
+ sql << " JOIN " << nodes_table << " AS " << n << " ON " << n << ".id = " << far << " AND "
302
338
  node_conditions(sql, @state.nodes.fetch(edge.to_var), n)
303
339
  bound << edge.to_var
304
340
  end
@@ -319,6 +355,11 @@ module Pglpg
319
355
  sql << " AND " << n << ".id = "
320
356
  sql.bind(node.ref_id, "bigint")
321
357
  end
358
+ if node.ref_ids
359
+ sql << " AND " << n << ".id = ANY("
360
+ sql.bind_text_array(node.ref_ids.map(&:to_s))
361
+ sql << "::bigint[])"
362
+ end
322
363
  conditions_sql(sql, node.var, n)
323
364
  end
324
365
 
@@ -328,12 +369,20 @@ module Pglpg
328
369
  sql << " AND "
329
370
  case cond.op
330
371
  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 ->> "
372
+ if key && key.properties.include?(prop.name)
373
+ # Match every component of the same partial index used by the
374
+ # uniqueness arbiter, including jsonb numeric/boolean equality.
375
+ if @state.nodes.key?(var)
376
+ sql << a << ".labels @> ARRAY["
377
+ sql.literal(key.declared_on) << "]::text[]"
378
+ else
379
+ sql << a << ".type = "
380
+ sql.literal(key.declared_on)
381
+ end
382
+ sql << " AND (" << a << ".properties " << (key.json_component?(prop.name) ? "-> " : "->> ")
335
383
  sql.literal(prop.name) << ") = "
336
- sql.bind(prop.encode(cond.value), "text")
384
+ encoded = prop.encode(cond.value)
385
+ key.json_component?(prop.name) ? sql.bind_json(encoded) : sql.bind(encoded, "text")
337
386
  next
338
387
  end
339
388
  sql << a << ".properties @> "
@@ -356,8 +405,12 @@ module Pglpg
356
405
  end
357
406
 
358
407
  def key_of(var)
359
- label = @state.nodes[var]&.label
360
- label && context.graph.type.node_type!(label).key
408
+ if (node = @state.nodes[var])
409
+ node.label && context.graph.type.node_type!(node.label).key
410
+ else
411
+ edge = @state.edges.find { _1.var == var }
412
+ edge && context.graph.type.edge_type!(edge.type).key
413
+ end
361
414
  end
362
415
 
363
416
  # Validates values up front, so errors surface where the query is built.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ # The result of Context#reachable: visible nodes with their minimum depth,
5
+ # ordered by depth, then id. +complete+ is false when more visible nodes
6
+ # are reachable within max_depth than were returned; +stopped_at_depth+ is
7
+ # then the depth of the last returned node, and nil otherwise.
8
+ Reachability = Data.define(:nodes, :complete, :stopped_at_depth) do
9
+ def complete? = complete
10
+ end
11
+
12
+ # Compiles and runs Context#reachable as one recursive CTE whose rows are
13
+ # whole BFS levels (ADR-0009). Each level excludes visited nodes and is
14
+ # capped at remaining + 1, which is 1 when the result is exactly full: that
15
+ # one row is the probe that decides completeness.
16
+ class Reachable
17
+ DIRECTIONS = %i[out in both].freeze
18
+
19
+ def initialize(context, from:, via:, direction:, max_depth:, max_nodes:, deadline:)
20
+ @context = context
21
+ @graph = context.graph
22
+ @from = @graph.check_ref!(from, :node)
23
+ @types = edge_types!(via, direction)
24
+ @context.temporal_mode!(@types.map { @graph.type.edge_type!(_1) })
25
+ @direction = direction
26
+ @max_depth = positive_integer!(max_depth, "max_depth")
27
+ @max_nodes = positive_integer!(max_nodes, "max_nodes")
28
+ raise ArgumentError, "max_nodes exceeds max_rows (#{max_rows})" if @max_nodes > max_rows
29
+
30
+ @timeout = timeout_ms(deadline)
31
+ freeze
32
+ end
33
+
34
+ def call
35
+ rows = @context.read(compile, timeout: @timeout).map { [@graph.ref(:node, _1["id"].to_i), _1["depth"].to_i].freeze }
36
+ if rows.size > @max_nodes
37
+ nodes = rows.first(@max_nodes)
38
+ Reachability.new(nodes: nodes.freeze, complete: false, stopped_at_depth: nodes.last[1])
39
+ else
40
+ Reachability.new(nodes: rows.freeze, complete: true, stopped_at_depth: nil)
41
+ end
42
+ end
43
+
44
+ def compile
45
+ sql = Sql.new(@graph.connection)
46
+ nodes = @context.table_sql(@graph.nodes_table)
47
+ sql << "WITH RECURSIVE bfs(depth, frontier, visited) AS ("
48
+ sql << "SELECT 0, ARRAY[s.id], ARRAY[s.id] FROM " << nodes << " AS s WHERE s.id = "
49
+ sql.bind(@from.id, "bigint") << " AND "
50
+ @context.append_node_visible(sql, "s")
51
+ sql << " UNION ALL SELECT b.depth + 1, nxt.ids, b.visited || nxt.ids FROM bfs AS b"
52
+ sql << " CROSS JOIN LATERAL (SELECT ARRAY(SELECT DISTINCT n.id FROM ("
53
+ append_steps(sql)
54
+ sql << ") AS x JOIN " << nodes << " AS n ON n.id = x.far AND "
55
+ @context.append_node_visible(sql, "n")
56
+ sql << " WHERE n.id <> ALL(b.visited) ORDER BY n.id LIMIT "
57
+ # remaining + 1, where remaining = max_nodes - (cardinality(visited) - 1)
58
+ sql.bind(@max_nodes, "int") << " - cardinality(b.visited) + 2) AS ids"
59
+ # Without OFFSET 0 the subquery is inlined into both references to
60
+ # nxt.ids and each level is computed twice.
61
+ sql << " OFFSET 0) AS nxt WHERE b.depth < "
62
+ sql.bind(@max_depth, "int") << " AND cardinality(b.frontier) > 0 AND cardinality(b.visited) - 1 <= "
63
+ sql.bind(@max_nodes, "int") << ")"
64
+ sql << " SELECT b.depth, u.id FROM bfs AS b CROSS JOIN LATERAL unnest(b.frontier) WITH ORDINALITY AS u(id, ord)"
65
+ sql << " WHERE b.depth > 0 ORDER BY b.depth, u.ord"
66
+ end
67
+
68
+ private
69
+
70
+ def max_rows = Pglpg.configuration.max_rows
71
+
72
+ # The far ends of visible edges leaving the frontier, as column "far". A
73
+ # symmetric type is followed from both stored columns, as two branches
74
+ # that each use a traversal index.
75
+ def append_steps(sql)
76
+ case @direction
77
+ when :out then append_step(sql, "e0", "source_id", "target_id")
78
+ when :in then append_step(sql, "e0", "target_id", "source_id")
79
+ else
80
+ append_step(sql, "e0", "source_id", "target_id")
81
+ sql << " UNION ALL "
82
+ append_step(sql, "e1", "target_id", "source_id")
83
+ end
84
+ end
85
+
86
+ def append_step(sql, e, near, far)
87
+ sql << "SELECT " << e << "." << far << " AS far FROM " << @context.table_sql(@graph.edges_table) << " AS " << e
88
+ sql << " WHERE " << e << "." << near << " = ANY(b.frontier) AND " << e << ".type = ANY("
89
+ sql.bind_text_array(@types) << ") AND "
90
+ @context.append_edge_visible(sql, e)
91
+ @context.append_temporal(sql, e, @types.map { @graph.type.edge_type!(_1) })
92
+ end
93
+
94
+ def edge_types!(via, direction)
95
+ raise ArgumentError, "direction must be :out, :in or :both" unless DIRECTIONS.include?(direction)
96
+
97
+ types = (via.is_a?(Array) ? via : [via]).map { @graph.type.edge_type!(_1) }.uniq
98
+ raise ArgumentError, "via needs at least one edge type" if types.empty?
99
+
100
+ types.each do |t|
101
+ if t.symmetric? && direction != :both
102
+ raise ArgumentError, "#{t.name} is symmetric; use direction: :both"
103
+ elsif !t.symmetric? && direction == :both
104
+ raise ArgumentError, "#{t.name} is directed; use direction: :out or :in"
105
+ end
106
+ end
107
+ types.map(&:name).freeze
108
+ end
109
+
110
+ def positive_integer!(value, name)
111
+ raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
112
+
113
+ value
114
+ end
115
+
116
+ # The statement timeout in milliseconds, or nil to leave the session's.
117
+ # A configured timeout caps the deadline; nil or 0 configures no cap.
118
+ def timeout_ms(deadline)
119
+ configured = Pglpg.configuration.statement_timeout
120
+ cap = Integer(configured) if configured && Integer(configured).positive?
121
+ return cap if deadline.nil?
122
+
123
+ unless deadline.is_a?(Numeric) && deadline.real? && deadline.finite? && deadline.positive?
124
+ raise ArgumentError, "deadline must be a positive number of seconds"
125
+ end
126
+
127
+ # Rounded up, so a positive deadline never becomes 0 (no timeout). The
128
+ # round(6) drops float noise such as 0.3 * 1000 = 300.00000000000006.
129
+ ms = [(deadline * 1000).round(6).ceil, 1].max
130
+ cap ? [cap, ms].min : ms
131
+ end
132
+ end
133
+ end