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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -0
- data/README.md +65 -1
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +20 -7
- data/lib/pglpg/element.rb +16 -3
- data/lib/pglpg/graph.rb +8 -4
- data/lib/pglpg/model/base.rb +327 -0
- data/lib/pglpg/model/edge.rb +104 -0
- data/lib/pglpg/model/node.rb +202 -0
- data/lib/pglpg/model/relation.rb +327 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +47 -8
- data/lib/pglpg/reachability.rb +131 -0
- data/lib/pglpg/schema/edge_type.rb +14 -3
- data/lib/pglpg/schema/graph_type.rb +6 -5
- data/lib/pglpg/schema/node_type.rb +16 -7
- data/lib/pglpg/storage.rb +30 -16
- data/lib/pglpg/testing/leak_examples.rb +26 -5
- data/lib/pglpg/testing.rb +122 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +45 -10
- data/lib/pglpg.rb +1 -0
- metadata +7 -1
|
@@ -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
|
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.
|
|
@@ -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
|
-
|
|
286
|
-
sql << " JOIN " << edges_table << " AS " << e << " ON "
|
|
287
|
-
|
|
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 " <<
|
|
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 = " <<
|
|
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)
|