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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +249 -0
- data/README.md +269 -3
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +71 -13
- data/lib/pglpg/element.rb +23 -3
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +13 -6
- data/lib/pglpg/model/base.rb +337 -0
- data/lib/pglpg/model/edge.rb +169 -0
- data/lib/pglpg/model/node.rb +207 -0
- data/lib/pglpg/model/relation.rb +334 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +68 -15
- data/lib/pglpg/reachability.rb +133 -0
- data/lib/pglpg/schema/edge_type.rb +41 -8
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +23 -8
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -22
- data/lib/pglpg/storage.rb +147 -20
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +67 -18
- data/lib/pglpg/testing.rb +131 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +127 -43
- data/lib/pglpg.rb +3 -0
- metadata +9 -1
|
@@ -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
|
data/lib/pglpg/model.rb
ADDED
|
@@ -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
|
-
|
|
286
|
-
sql << " JOIN " << edges_table << " AS " << e << " ON "
|
|
287
|
-
|
|
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 " <<
|
|
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 = " <<
|
|
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.
|
|
332
|
-
#
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|