pglpg 0.1.0 → 0.2.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,327 @@
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) = new(**attributes).tap { _1.save(writer:) }
103
+
104
+ # As #create, but raises RecordInvalid when validation fails.
105
+ def create!(writer: nil, **attributes) = new(**attributes).tap { _1.save!(writer:) }
106
+
107
+ # The compiled Pglpg::Query, for debugging (to_sql, explain).
108
+ def to_query = compiled
109
+
110
+ def inspect = "#<#{self.class.name} #{model.name || model.base_class.name} #{compiled.to_sql.first}>"
111
+
112
+ private
113
+
114
+ def rows = compiled.to_a.map { |row| load_row(row) }
115
+
116
+ def parse_tree(spec, ancestors)
117
+ raise ArgumentError, "cyclic preload tree" if ancestors.include?(spec.object_id)
118
+ ancestors = ancestors + [spec.object_id]
119
+ result = {}
120
+ spec.each do |item|
121
+ case item
122
+ when Symbol, String
123
+ key = item.to_sym
124
+ result[key] ||= {}.freeze
125
+ when Hash
126
+ raise ArgumentError, "cyclic preload tree" if ancestors.include?(item.object_id)
127
+ item.each do |key, value|
128
+ raise ArgumentError, "invalid association name" unless key.is_a?(Symbol) || key.is_a?(String)
129
+ children = value.is_a?(Array) ? value : [value]
130
+ result[key.to_sym] = merge_tree(result[key.to_sym] || {}, parse_tree(children, ancestors + [item.object_id]))
131
+ end
132
+ else
133
+ raise ArgumentError, "invalid preload specification"
134
+ end
135
+ end
136
+ result.freeze
137
+ end
138
+
139
+ def merge_tree(a, b)
140
+ a.merge(b) { |_key, left, right| merge_tree(left, right) }.freeze
141
+ end
142
+
143
+ def target_model(owner_model, association)
144
+ owner_model.association_model(association)
145
+ end
146
+
147
+ def validate_tree(owner_model, tree)
148
+ tree.each do |name, children|
149
+ association = owner_model.respond_to?(:associations) && owner_model.associations[name]
150
+ raise ArgumentError, "#{owner_model} has no association #{name}" unless association
151
+ validate_tree(target_model(owner_model, association), children)
152
+ end
153
+ end
154
+
155
+ def preload(owners, tree, owner_model)
156
+ return if owners.empty?
157
+ tree.each do |name, children|
158
+ association = owner_model.associations.fetch(name)
159
+ target = target_model(owner_model, association)
160
+ query = context.match.node_refs(:owner, owners.map(&:ref))
161
+ query = query.public_send(association.direction, association.edge_type, :n,
162
+ association.label || target.schema_type.name, edge: :e)
163
+ begin
164
+ pairs = query.return(:owner, :n).distinct.to_a
165
+ rescue TooManyRows
166
+ raise TooManyRows, "#{owner_model}##{name}: more than #{Pglpg.configuration.max_rows} distinct owner-target pairs; page the owners"
167
+ end
168
+ targets = {}
169
+ grouped = Hash.new { |h, k| h[k] = [] }
170
+ pairs.each do |row|
171
+ element = row.fetch(:n)
172
+ record = targets[element.ref] ||= target.instantiate(context, element)
173
+ grouped[row.fetch(:owner).ref] << record
174
+ end
175
+ owners.each do |owner|
176
+ cache = owner.instance_variable_get(:@association_cache) || {}
177
+ cache[name] = grouped[owner.ref].freeze
178
+ owner.instance_variable_set(:@association_cache, cache)
179
+ end
180
+ preload(targets.values, children, target)
181
+ end
182
+ end
183
+
184
+ def var = model.query_var
185
+
186
+ # The query that runs.
187
+ def compiled = @query
188
+
189
+ def load_row(row) = model.instantiate(context, row.fetch(var))
190
+
191
+ PLUCK_IDENTITY = %i[id ref].freeze
192
+
193
+ # Checked before any query runs.
194
+ def pluck_names!(model, names)
195
+ names.map(&:to_sym).each do |name|
196
+ next if PLUCK_IDENTITY.include?(name) || model.attribute_names.include?(name.to_s)
197
+
198
+ raise ArgumentError, "#{model.name || model.schema_type.name} has no attribute #{name.inspect} to pluck"
199
+ end
200
+ end
201
+
202
+ def project(record, names) = names.map { record.public_send(_1) }
203
+
204
+ def unwrap(values) = values.size == 1 ? values.first : values
205
+
206
+ def order_query(var, names, spec)
207
+ q = names.reduce(@query) { |acc, name| acc.order(var => name) }
208
+ spec.empty? ? q : q.order(var => spec)
209
+ end
210
+
211
+ def derived_copy(copy) = copy
212
+
213
+ # A copy with +query+; +shaped+ records ordering or pagination.
214
+ def derive(query, shaped: @shaped)
215
+ copy = clone(freeze: false)
216
+ copy.instance_variable_set(:@query, query)
217
+ derived_copy(copy)
218
+ copy.instance_variable_set(:@filtered, true)
219
+ copy.instance_variable_set(:@shaped, shaped)
220
+ copy.freeze
221
+ end
222
+ end
223
+
224
+ # The targets of an association (ADR-0010 §5): one row per distinct
225
+ # visible target, deduplicated before ordering, pagination and counts.
226
+ # Edge conditions (rel_where) mean "at least one matching visible edge".
227
+ #
228
+ # person.projects.rel_where(role: "lead").order(:title).limit(20)
229
+ # person.projects.with_rel.each { |project, works_on| ... }
230
+ class AssociationRelation < Relation
231
+ def initialize(model, context, query, edge_model:, cached: nil)
232
+ @edge_model = edge_model
233
+ @cached = cached
234
+ super(model, context, query, filtered: true)
235
+ end
236
+
237
+ def first(n = nil)
238
+ return super unless @cached
239
+ return to_a.first if n.nil?
240
+
241
+ raise ArgumentError, "limit must be a positive Integer" unless n.is_a?(Integer) && n.positive?
242
+ max_rows = Pglpg.configuration.max_rows
243
+ raise ArgumentError, "limit exceeds max_rows (#{max_rows})" if n > max_rows
244
+ to_a.first(n).freeze
245
+ end
246
+
247
+ def count = @cached ? @cached.size : super
248
+ def exists? = @cached ? !@cached.empty? : super
249
+ # Structured conditions on the association's edge properties.
250
+ def rel_where(**props) = derive(@query.where(e: props))
251
+
252
+ # A target has no single edge value when several edges lead to it.
253
+ def rel_order(*, **)
254
+ raise ArgumentError, "a target query can't be ordered by an edge property; use with_rel.rel_order"
255
+ end
256
+
257
+ # One row per matching node–edge pair: [target, edge]. Its count,
258
+ # limit and offset count pairs. Only on a relation without order,
259
+ # limit or offset, so pagination always applies to the shape it was
260
+ # written for.
261
+ def with_rel
262
+ raise ArgumentError, "with_rel cannot discard pending preloads" unless @preload.empty?
263
+
264
+ raise ArgumentError, "call with_rel before order, limit or offset" if @shaped
265
+
266
+ BindingRelation.new(model, context, @query, edge_model: @edge_model)
267
+ end
268
+
269
+ # Enumerates with_rel: yields each target with its edge.
270
+ def each_with_rel(&) = with_rel.each(&)
271
+
272
+ # Creating targets or edges through an association is deferred (ADR-0010).
273
+ def new(**) = raise(ArgumentError, "associations can't build records yet; use Model.in(ctx).new")
274
+
275
+ alias build new
276
+ alias create new
277
+ alias create! new
278
+
279
+ private
280
+
281
+ def rows = @cached || super
282
+
283
+ def derived_copy(copy)
284
+ copy.instance_variable_set(:@cached, nil)
285
+ super
286
+ end
287
+
288
+ def compiled = @query.return(var).distinct
289
+ end
290
+
291
+ # The node–edge pairs of an association, from AssociationRelation#with_rel.
292
+ class BindingRelation < AssociationRelation
293
+ # order(...) orders by target properties; rel_order(...) by edge ones.
294
+ def rel_order(*names, **spec) = derive(order_query(:e, names, spec), shaped: true)
295
+
296
+ def with_associations(*) = raise(ArgumentError, "preloading binding relations is not supported")
297
+
298
+ def with_rel = self
299
+
300
+ # One entry per pair: the target's +names+, then the edge's +rel+ names.
301
+ # ann.projects.with_rel.pluck(:title, rel: :role) # => [["Apollo", "lead"], ...]
302
+ def pluck(*names, rel: [])
303
+ rel = Array(rel)
304
+ names = pluck_names!(model, names)
305
+ if rel.any?
306
+ @edge_model or raise ConfigurationError, "no model is registered for the association's edge type"
307
+ rel = pluck_names!(@edge_model, rel)
308
+ end
309
+ raise ArgumentError, "pluck needs at least one name" if names.empty? && rel.empty?
310
+
311
+ to_a.map { |target, edge| unwrap(project(target, names) + project(edge, rel)) }
312
+ end
313
+
314
+ def each_with_rel(&) = each(&)
315
+
316
+ private
317
+
318
+ def compiled = @query.return(var, :e)
319
+
320
+ def load_row(row)
321
+ edge_model = @edge_model or
322
+ raise ConfigurationError, "no model is registered for edge type #{row.fetch(:e).type}"
323
+ [model.instantiate(context, row.fetch(var)), edge_model.instantiate(context, row.fetch(:e))]
324
+ end
325
+ end
326
+ end
327
+ 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.
@@ -282,9 +303,22 @@ module Pglpg
282
303
 
283
304
  @state.edges.each_with_index do |edge, i|
284
305
  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 = "
306
+ x = "#{names.fetch(edge.from_var)}.id"
307
+ sql << " JOIN " << edges_table << " AS " << e << " ON "
308
+ case edge.direction
309
+ when :out
310
+ sql << e << ".source_id = " << x
311
+ far = "#{e}.target_id"
312
+ when :in
313
+ sql << e << ".target_id = " << x
314
+ far = "#{e}.source_id"
315
+ else
316
+ # Either stored column; the far end is the other one. A self-loop
317
+ # matches both conditions but is still one row.
318
+ sql << "(" << e << ".source_id = " << x << " OR " << e << ".target_id = " << x << ")"
319
+ far = "(CASE WHEN #{e}.source_id = #{x} THEN #{e}.target_id ELSE #{e}.source_id END)"
320
+ end
321
+ sql << " AND " << e << ".type = "
288
322
  sql.bind(edge.type.to_s, "text")
289
323
  sql << " AND "
290
324
  context.append_edge_visible(sql, e)
@@ -295,10 +329,10 @@ module Pglpg
295
329
  end
296
330
 
297
331
  if bound.include?(edge.to_var)
298
- sql << " AND " << e << "." << far << " = " << names.fetch(edge.to_var) << ".id"
332
+ sql << " AND " << far << " = " << names.fetch(edge.to_var) << ".id"
299
333
  else
300
334
  n = names.fetch(edge.to_var)
301
- sql << " JOIN " << nodes_table << " AS " << n << " ON " << n << ".id = " << e << "." << far << " AND "
335
+ sql << " JOIN " << nodes_table << " AS " << n << " ON " << n << ".id = " << far << " AND "
302
336
  node_conditions(sql, @state.nodes.fetch(edge.to_var), n)
303
337
  bound << edge.to_var
304
338
  end
@@ -319,6 +353,11 @@ module Pglpg
319
353
  sql << " AND " << n << ".id = "
320
354
  sql.bind(node.ref_id, "bigint")
321
355
  end
356
+ if node.ref_ids
357
+ sql << " AND " << n << ".id = ANY("
358
+ sql.bind_text_array(node.ref_ids.map(&:to_s))
359
+ sql << "::bigint[])"
360
+ end
322
361
  conditions_sql(sql, node.var, n)
323
362
  end
324
363
 
@@ -0,0 +1,131 @@
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
+ @direction = direction
25
+ @max_depth = positive_integer!(max_depth, "max_depth")
26
+ @max_nodes = positive_integer!(max_nodes, "max_nodes")
27
+ raise ArgumentError, "max_nodes exceeds max_rows (#{max_rows})" if @max_nodes > max_rows
28
+
29
+ @timeout = timeout_ms(deadline)
30
+ freeze
31
+ end
32
+
33
+ def call
34
+ rows = @context.read(compile, timeout: @timeout).map { [@graph.ref(:node, _1["id"].to_i), _1["depth"].to_i].freeze }
35
+ if rows.size > @max_nodes
36
+ nodes = rows.first(@max_nodes)
37
+ Reachability.new(nodes: nodes.freeze, complete: false, stopped_at_depth: nodes.last[1])
38
+ else
39
+ Reachability.new(nodes: rows.freeze, complete: true, stopped_at_depth: nil)
40
+ end
41
+ end
42
+
43
+ def compile
44
+ sql = Sql.new(@graph.connection)
45
+ nodes = @context.table_sql(@graph.nodes_table)
46
+ sql << "WITH RECURSIVE bfs(depth, frontier, visited) AS ("
47
+ sql << "SELECT 0, ARRAY[s.id], ARRAY[s.id] FROM " << nodes << " AS s WHERE s.id = "
48
+ sql.bind(@from.id, "bigint") << " AND "
49
+ @context.append_node_visible(sql, "s")
50
+ sql << " UNION ALL SELECT b.depth + 1, nxt.ids, b.visited || nxt.ids FROM bfs AS b"
51
+ sql << " CROSS JOIN LATERAL (SELECT ARRAY(SELECT DISTINCT n.id FROM ("
52
+ append_steps(sql)
53
+ sql << ") AS x JOIN " << nodes << " AS n ON n.id = x.far AND "
54
+ @context.append_node_visible(sql, "n")
55
+ sql << " WHERE n.id <> ALL(b.visited) ORDER BY n.id LIMIT "
56
+ # remaining + 1, where remaining = max_nodes - (cardinality(visited) - 1)
57
+ sql.bind(@max_nodes, "int") << " - cardinality(b.visited) + 2) AS ids"
58
+ # Without OFFSET 0 the subquery is inlined into both references to
59
+ # nxt.ids and each level is computed twice.
60
+ sql << " OFFSET 0) AS nxt WHERE b.depth < "
61
+ sql.bind(@max_depth, "int") << " AND cardinality(b.frontier) > 0 AND cardinality(b.visited) - 1 <= "
62
+ sql.bind(@max_nodes, "int") << ")"
63
+ sql << " SELECT b.depth, u.id FROM bfs AS b CROSS JOIN LATERAL unnest(b.frontier) WITH ORDINALITY AS u(id, ord)"
64
+ sql << " WHERE b.depth > 0 ORDER BY b.depth, u.ord"
65
+ end
66
+
67
+ private
68
+
69
+ def max_rows = Pglpg.configuration.max_rows
70
+
71
+ # The far ends of visible edges leaving the frontier, as column "far". A
72
+ # symmetric type is followed from both stored columns, as two branches
73
+ # that each use a traversal index.
74
+ def append_steps(sql)
75
+ case @direction
76
+ when :out then append_step(sql, "e0", "source_id", "target_id")
77
+ when :in then append_step(sql, "e0", "target_id", "source_id")
78
+ else
79
+ append_step(sql, "e0", "source_id", "target_id")
80
+ sql << " UNION ALL "
81
+ append_step(sql, "e1", "target_id", "source_id")
82
+ end
83
+ end
84
+
85
+ def append_step(sql, e, near, far)
86
+ sql << "SELECT " << e << "." << far << " AS far FROM " << @context.table_sql(@graph.edges_table) << " AS " << e
87
+ sql << " WHERE " << e << "." << near << " = ANY(b.frontier) AND " << e << ".type = ANY("
88
+ sql.bind_text_array(@types) << ") AND "
89
+ @context.append_edge_visible(sql, e)
90
+ end
91
+
92
+ def edge_types!(via, direction)
93
+ raise ArgumentError, "direction must be :out, :in or :both" unless DIRECTIONS.include?(direction)
94
+
95
+ types = (via.is_a?(Array) ? via : [via]).map { @graph.type.edge_type!(_1) }.uniq
96
+ raise ArgumentError, "via needs at least one edge type" if types.empty?
97
+
98
+ types.each do |t|
99
+ if t.symmetric? && direction != :both
100
+ raise ArgumentError, "#{t.name} is symmetric; use direction: :both"
101
+ elsif !t.symmetric? && direction == :both
102
+ raise ArgumentError, "#{t.name} is directed; use direction: :out or :in"
103
+ end
104
+ end
105
+ types.map(&:name).freeze
106
+ end
107
+
108
+ def positive_integer!(value, name)
109
+ raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
110
+
111
+ value
112
+ end
113
+
114
+ # The statement timeout in milliseconds, or nil to leave the session's.
115
+ # A configured timeout caps the deadline; nil or 0 configures no cap.
116
+ def timeout_ms(deadline)
117
+ configured = Pglpg.configuration.statement_timeout
118
+ cap = Integer(configured) if configured && Integer(configured).positive?
119
+ return cap if deadline.nil?
120
+
121
+ unless deadline.is_a?(Numeric) && deadline.real? && deadline.finite? && deadline.positive?
122
+ raise ArgumentError, "deadline must be a positive number of seconds"
123
+ end
124
+
125
+ # Rounded up, so a positive deadline never becomes 0 (no timeout). The
126
+ # round(6) drops float noise such as 0.3 * 1000 = 300.00000000000006.
127
+ ms = [(deadline * 1000).round(6).ceil, 1].max
128
+ cap ? [cap, ms].min : ms
129
+ end
130
+ end
131
+ end
@@ -4,23 +4,34 @@ module Pglpg
4
4
  module Schema
5
5
  # An edge type: exactly one label (its name), property types, and the
6
6
  # node labels its source and target must carry.
7
+ #
8
+ # A symmetric edge type is undirected (ADR-0008): each edge is stored
9
+ # once with source_id < target_id (<= with allow_self_loops), and one
10
+ # endpoint carries +from+ and the other +to+, in either order.
7
11
  class EdgeType < ElementType
8
12
  attr_reader :from, :to, :unique
9
13
 
10
- def initialize(name, from:, to:, unique: nil, symmetric: false, temporal: false)
14
+ def initialize(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false)
11
15
  super(name)
12
- raise SchemaError, "symmetric edge types are planned for 0.2 (#{name})" if symmetric
13
16
  raise SchemaError, "temporal edge types are planned for 0.3 (#{name})" if temporal
14
17
  raise SchemaError, "unique: must be nil or :pair (#{name})" unless [nil, :pair].include?(unique)
18
+ raise SchemaError, "allow_self_loops: needs symmetric: true (#{name})" if allow_self_loops && !symmetric
15
19
 
16
20
  @from = Schema.check_name!(from, "type")
17
21
  @to = Schema.check_name!(to, "type")
18
22
  @unique = unique
23
+ @symmetric = symmetric == true
24
+ @allow_self_loops = allow_self_loops == true
19
25
  end
20
26
 
21
- # At most one edge of this type between the same source and target.
27
+ # At most one edge of this type between the same source and target
28
+ # (the same unordered pair, for a symmetric type).
22
29
  def unique_pair? = unique == :pair
23
30
 
31
+ def symmetric? = @symmetric
32
+
33
+ def allow_self_loops? = @allow_self_loops
34
+
24
35
  def resolve!(graph_type)
25
36
  [from, to].each do |label|
26
37
  next if graph_type.node_types.key?(label)