pglpg 0.2.0 → 0.4.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 +101 -0
- data/README.md +227 -8
- data/lib/pglpg/context.rb +55 -8
- data/lib/pglpg/element.rb +8 -1
- data/lib/pglpg/errors.rb +19 -0
- data/lib/pglpg/graph.rb +5 -2
- data/lib/pglpg/match_parser.rb +308 -0
- data/lib/pglpg/model/base.rb +11 -1
- data/lib/pglpg/model/edge.rb +67 -2
- data/lib/pglpg/model/node.rb +13 -8
- data/lib/pglpg/model/relation.rb +9 -2
- data/lib/pglpg/query.rb +21 -7
- data/lib/pglpg/reachability.rb +2 -0
- data/lib/pglpg/schema/edge_type.rb +28 -6
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +21 -7
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -31
- data/lib/pglpg/storage.rb +121 -8
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +43 -15
- data/lib/pglpg/testing.rb +10 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +89 -40
- data/lib/pglpg.rb +3 -0
- metadata +4 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Pglpg
|
|
6
|
+
# The text front end builds the same immutable query state as the Ruby DSL.
|
|
7
|
+
class MatchParser
|
|
8
|
+
MAX_BYTES = 8 * 1024
|
|
9
|
+
MAX_HOPS = 8
|
|
10
|
+
MAX_PREDICATES = 32
|
|
11
|
+
MAX_PARAMETERS = 32
|
|
12
|
+
|
|
13
|
+
IDENTIFIER = /[A-Za-z_][A-Za-z_0-9]*/
|
|
14
|
+
PARAMETER = /\$#{IDENTIFIER}/
|
|
15
|
+
TOKEN = /#{PARAMETER}|#{IDENTIFIER}|[0-9]+|->|<-|[^\s]/
|
|
16
|
+
KEYWORDS = %w[MATCH WHERE AND RETURN DISTINCT ORDER BY ASC DESC LIMIT].freeze
|
|
17
|
+
UNSUPPORTED_WORDS = %w[OPTIONAL CREATE INSERT DELETE DETACH SET REMOVE MERGE
|
|
18
|
+
CALL WITH UNION OFFSET SKIP GROUP HAVING].freeze
|
|
19
|
+
CONDITION_PREFIXES = %w[NOT ANY ALL NONE SINGLE EXISTS].freeze
|
|
20
|
+
CONDITION_OPERATORS = %w[IS IN NOT STARTS ENDS CONTAINS LIKE BETWEEN].freeze
|
|
21
|
+
CONDITION_CONNECTORS = %w[OR XOR].freeze
|
|
22
|
+
Token = Data.define(:text, :position)
|
|
23
|
+
|
|
24
|
+
def initialize(context, text, params)
|
|
25
|
+
raise ArgumentError, "MATCH text must be a String" unless text.is_a?(String)
|
|
26
|
+
|
|
27
|
+
@context = context
|
|
28
|
+
@text = text
|
|
29
|
+
@params = params
|
|
30
|
+
@bindings = {}
|
|
31
|
+
@types = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse
|
|
35
|
+
validate_text!
|
|
36
|
+
@scanner = StringScanner.new(@text)
|
|
37
|
+
advance
|
|
38
|
+
expect("MATCH")
|
|
39
|
+
start = node_pattern
|
|
40
|
+
hops = []
|
|
41
|
+
hops << edge_pattern while %w[- <- ~].include?(@token.text)
|
|
42
|
+
fail_at(:unsupported, "multiple patterns are outside the MATCH subset") if @token.text == ","
|
|
43
|
+
predicates = read_predicates
|
|
44
|
+
expect("RETURN")
|
|
45
|
+
distinct = accept("DISTINCT")
|
|
46
|
+
returns = [return_binding]
|
|
47
|
+
returns << return_binding while accept(",")
|
|
48
|
+
order = read_order if accept("ORDER")
|
|
49
|
+
limit = take_integer if accept("LIMIT")
|
|
50
|
+
accept(";")
|
|
51
|
+
expect_end!
|
|
52
|
+
|
|
53
|
+
if distinct && order && returns.none? { |var| var.text == order.first.text }
|
|
54
|
+
fail_at(:syntax, "DISTINCT ordering requires a returned binding", order.first)
|
|
55
|
+
end
|
|
56
|
+
check_cap(hops.size, MAX_HOPS, "hops", hops[MAX_HOPS]&.first)
|
|
57
|
+
check_cap(predicates.size, MAX_PREDICATES, "WHERE predicates", predicates[MAX_PREDICATES]&.first)
|
|
58
|
+
row_limit = validate_limit(limit)
|
|
59
|
+
values = parameter_values(predicates.map(&:last))
|
|
60
|
+
|
|
61
|
+
query = build_pattern(start, hops)
|
|
62
|
+
predicates.each do |var, name, param|
|
|
63
|
+
property!(var, name)
|
|
64
|
+
begin
|
|
65
|
+
query = query.where(var.text.to_sym => { name.text.to_sym => values.fetch(param.text.delete_prefix("$")) })
|
|
66
|
+
rescue ValidationError, ArgumentError, RangeError
|
|
67
|
+
fail_at(:parameter, "invalid equality value", param)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
returns.each { type_for!(_1) }
|
|
71
|
+
query = query.return(*returns.map { _1.text.to_sym })
|
|
72
|
+
query = query.distinct if distinct
|
|
73
|
+
if order
|
|
74
|
+
var, name, direction = order
|
|
75
|
+
property!(var, name)
|
|
76
|
+
query = query.order(var.text.to_sym => { name.text.to_sym => direction })
|
|
77
|
+
end
|
|
78
|
+
row_limit ? query.limit(row_limit) : query
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def validate_text!
|
|
84
|
+
unless [Encoding::UTF_8, Encoding::US_ASCII].include?(@text.encoding)
|
|
85
|
+
fail_at(:syntax, "text must use UTF-8 or US-ASCII", Token.new(nil, 0))
|
|
86
|
+
end
|
|
87
|
+
if @text.bytesize > MAX_BYTES
|
|
88
|
+
fail_at(:limit_exceeded, "text exceeds #{MAX_BYTES} bytes", Token.new(nil, MAX_BYTES))
|
|
89
|
+
end
|
|
90
|
+
return if @text.valid_encoding?
|
|
91
|
+
|
|
92
|
+
offset = 0
|
|
93
|
+
@text.each_char do |char|
|
|
94
|
+
fail_at(:syntax, "invalid text encoding", Token.new(nil, offset)) unless char.valid_encoding?
|
|
95
|
+
offset += char.bytesize
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def advance
|
|
100
|
+
@scanner.skip(/\s+/)
|
|
101
|
+
position = @scanner.pos
|
|
102
|
+
if (comment = @scanner.scan(%r{//|/\*}))
|
|
103
|
+
fail_at(:unsupported, "comments are outside the MATCH subset", Token.new(comment, position))
|
|
104
|
+
end
|
|
105
|
+
@token = Token.new(@scanner.scan(TOKEN), position)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def take
|
|
109
|
+
token = @token
|
|
110
|
+
advance
|
|
111
|
+
token
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def accept(text)
|
|
115
|
+
take if @token.text&.casecmp?(text)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def expect(text)
|
|
119
|
+
accept(text) || unexpected("expected #{text}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def expect_end!
|
|
123
|
+
unexpected("expected end of input") if @token.text
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def unexpected(message)
|
|
127
|
+
text = @token.text
|
|
128
|
+
unsupported = text && (UNSUPPORTED_WORDS.include?(text.upcase) || %w[{ } | & ! * + %].include?(text))
|
|
129
|
+
fail_at(unsupported ? :unsupported : :syntax, message)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def identifier
|
|
133
|
+
return take if @token.text&.match?(/\A#{IDENTIFIER}\z/)
|
|
134
|
+
|
|
135
|
+
unexpected("expected an identifier")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def binding
|
|
139
|
+
token = identifier
|
|
140
|
+
name = token.text
|
|
141
|
+
if name.start_with?("_") || KEYWORDS.include?(name.upcase) || @bindings.key?(name)
|
|
142
|
+
fail_at(:syntax, "reserved or duplicate binding", token)
|
|
143
|
+
end
|
|
144
|
+
@bindings[name] = true
|
|
145
|
+
token
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def node_pattern
|
|
149
|
+
expect("(")
|
|
150
|
+
var = binding
|
|
151
|
+
expect(":")
|
|
152
|
+
label = identifier
|
|
153
|
+
expect(")")
|
|
154
|
+
[var, label]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def edge_pattern
|
|
158
|
+
connector = take
|
|
159
|
+
direction, ending = case connector.text
|
|
160
|
+
when "-" then [:out, "->"]
|
|
161
|
+
when "<-" then [:in, "-"]
|
|
162
|
+
when "~" then [:both, "~"]
|
|
163
|
+
end
|
|
164
|
+
expect("[")
|
|
165
|
+
var = binding unless @token.text == ":"
|
|
166
|
+
expect(":")
|
|
167
|
+
type = identifier
|
|
168
|
+
expect("]")
|
|
169
|
+
if direction == :out && @token.text == "-"
|
|
170
|
+
fail_at(:unsupported, "use a directed arrow or a symmetric tilde")
|
|
171
|
+
end
|
|
172
|
+
expect(ending)
|
|
173
|
+
[connector, direction, var, type, node_pattern]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def read_predicates
|
|
177
|
+
return [] unless accept("WHERE")
|
|
178
|
+
|
|
179
|
+
predicates = []
|
|
180
|
+
loop do
|
|
181
|
+
# Contextual keywords can still name a binding in a property reference.
|
|
182
|
+
prefix = CONDITION_PREFIXES.include?(@token.text&.upcase) && !@scanner.check(/\s*\./)
|
|
183
|
+
if @token.text == "(" || prefix
|
|
184
|
+
fail_at(:unsupported_condition, "only AND-joined equality predicates are supported")
|
|
185
|
+
end
|
|
186
|
+
var, property = property_reference
|
|
187
|
+
unless accept("=")
|
|
188
|
+
fail_at(:unsupported_condition, "only equality predicates are supported") if %w[< > !].include?(@token.text) ||
|
|
189
|
+
CONDITION_OPERATORS.include?(@token.text&.upcase)
|
|
190
|
+
unexpected("expected =")
|
|
191
|
+
end
|
|
192
|
+
unless @token.text&.match?(/\A#{PARAMETER}\z/)
|
|
193
|
+
fail_at(:unsupported_condition, "equality requires a named parameter") if @token.text && @token.text != "$" && !KEYWORDS.include?(@token.text.upcase)
|
|
194
|
+
unexpected("expected a named parameter")
|
|
195
|
+
end
|
|
196
|
+
predicates << [var, property, take]
|
|
197
|
+
fail_at(:unsupported_condition, "only AND conjunctions are supported") if CONDITION_CONNECTORS.include?(@token.text&.upcase)
|
|
198
|
+
break unless accept("AND")
|
|
199
|
+
end
|
|
200
|
+
predicates
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def property_reference
|
|
204
|
+
var = identifier
|
|
205
|
+
expect(".")
|
|
206
|
+
[var, identifier]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def return_binding
|
|
210
|
+
var = identifier
|
|
211
|
+
fail_at(:unsupported, "RETURN accepts element bindings only") if %w[. ( AS].include?(@token.text&.upcase)
|
|
212
|
+
var
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def read_order
|
|
216
|
+
expect("BY")
|
|
217
|
+
var, name = property_reference
|
|
218
|
+
direction = accept("DESC") ? :desc : :asc
|
|
219
|
+
accept("ASC") if direction == :asc
|
|
220
|
+
fail_at(:unsupported, "ORDER BY accepts one term") if @token.text == ","
|
|
221
|
+
[var, name, direction]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def take_integer
|
|
225
|
+
return take if @token.text&.match?(/\A[0-9]+\z/)
|
|
226
|
+
|
|
227
|
+
unexpected("expected a decimal integer")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def validate_limit(token)
|
|
231
|
+
return unless token
|
|
232
|
+
|
|
233
|
+
limit = token.text.to_i
|
|
234
|
+
fail_at(:syntax, "LIMIT must be positive", token) if limit.zero?
|
|
235
|
+
check_cap(limit, Pglpg.configuration.max_rows, "LIMIT", token)
|
|
236
|
+
limit
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def parameter_values(references)
|
|
240
|
+
origin = Token.new(nil, 0)
|
|
241
|
+
fail_at(:parameter, "params must be a Hash", origin) unless @params.is_a?(Hash)
|
|
242
|
+
named = references.to_h { |token| [token.text.delete_prefix("$"), token] }
|
|
243
|
+
# The predicate cap also bounds the number of distinct references.
|
|
244
|
+
check_cap(@params.size, MAX_PARAMETERS, "parameter entries", origin)
|
|
245
|
+
|
|
246
|
+
values = {}
|
|
247
|
+
@params.each do |key, value|
|
|
248
|
+
unless (key.is_a?(String) || key.is_a?(Symbol)) &&
|
|
249
|
+
key.to_s.ascii_only? && key.to_s.match?(/\A#{IDENTIFIER}\z/)
|
|
250
|
+
fail_at(:parameter, "invalid parameter key", origin)
|
|
251
|
+
end
|
|
252
|
+
name = key.to_s
|
|
253
|
+
token = named[name] || origin
|
|
254
|
+
fail_at(:parameter, "duplicate parameter key", token) if values.key?(name)
|
|
255
|
+
fail_at(:parameter, "unused parameter", token) unless named.key?(name)
|
|
256
|
+
if value.nil? || value.is_a?(Array) || value.is_a?(Condition)
|
|
257
|
+
fail_at(:parameter, "equality requires a scalar value", token)
|
|
258
|
+
end
|
|
259
|
+
values[name] = value.is_a?(String) ? value.dup.freeze : value
|
|
260
|
+
end
|
|
261
|
+
named.each { |name, token| fail_at(:parameter, "missing parameter", token) unless values.key?(name) }
|
|
262
|
+
values
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def build_pattern(start, hops)
|
|
266
|
+
schema = @context.graph.type
|
|
267
|
+
var, label = start
|
|
268
|
+
bind_type(var, label, schema.node_types)
|
|
269
|
+
query = @context.match.node(var.text, label.text)
|
|
270
|
+
hops.each do |connector, direction, edge, type, target|
|
|
271
|
+
edge_type = bind_type(edge, type, schema.edge_types)
|
|
272
|
+
if edge_type.symmetric? != (direction == :both)
|
|
273
|
+
fail_at(:unsupported, "edge direction does not match its type", connector)
|
|
274
|
+
end
|
|
275
|
+
var, label = target
|
|
276
|
+
bind_type(var, label, schema.node_types)
|
|
277
|
+
query = query.public_send(direction, type.text, var.text, label.text, edge: edge&.text)
|
|
278
|
+
end
|
|
279
|
+
query
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def bind_type(var, name, types)
|
|
283
|
+
type = types[name.text.to_sym] || fail_at(:unknown_name, "unknown label or edge type", name)
|
|
284
|
+
@types[var.text] = type if var
|
|
285
|
+
type
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def type_for!(var)
|
|
289
|
+
@types[var.text] || fail_at(:unknown_name, "unknown binding", var)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def property!(var, name)
|
|
293
|
+
property = type_for!(var).property_named(name.text)
|
|
294
|
+
fail_at(:unknown_name, "unknown declared property", name) unless property
|
|
295
|
+
fail_at(:unsupported, "property is not comparable", name) unless property.comparable?
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def check_cap(count, max, unit, token)
|
|
299
|
+
fail_at(:limit_exceeded, "maximum #{unit} is #{max}", token) if count > max
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def fail_at(code, message, token = @token)
|
|
303
|
+
raise ParseError.new(message, code:, text: @text, position: token.position,
|
|
304
|
+
token: token.text || ("EOF" if token.position == @text.bytesize)), cause: nil
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
private_constant :MatchParser
|
|
308
|
+
end
|
data/lib/pglpg/model/base.rb
CHANGED
|
@@ -64,6 +64,14 @@ module Pglpg
|
|
|
64
64
|
|
|
65
65
|
property_names.each { attribute(_1, STRICT) }
|
|
66
66
|
column_names.each { attribute(_1, STRICT) }
|
|
67
|
+
if kind == :edge && schema_type.temporal?
|
|
68
|
+
Temporal::NAMES.each do |name|
|
|
69
|
+
if instance_methods(false).include?(name) || instance_methods(false).include?(:"#{name}=")
|
|
70
|
+
raise SchemaError, "#{schema_type.name}.#{name} clashes with temporal metadata"
|
|
71
|
+
end
|
|
72
|
+
attribute(name, Edge::BOUND)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
67
75
|
@graph = graph
|
|
68
76
|
@schema_type = schema_type
|
|
69
77
|
@property_names = property_names.freeze
|
|
@@ -173,7 +181,7 @@ module Pglpg
|
|
|
173
181
|
# only. Returns false when validation fails. Refusals (WriteRejected,
|
|
174
182
|
# NotTrusted), timeouts and database errors raise; changes are kept on
|
|
175
183
|
# any failure. +writer:+ hands over the context's trusted writer, for
|
|
176
|
-
# trusted-only operations;
|
|
184
|
+
# trusted-only operations; generated keys remain unassignable.
|
|
177
185
|
#
|
|
178
186
|
# Success means the statement succeeded in the caller's transaction.
|
|
179
187
|
# Local state is what was assigned, not a stored snapshot (generated
|
|
@@ -197,6 +205,7 @@ module Pglpg
|
|
|
197
205
|
# Deletes the element through the guarded write API (a node only
|
|
198
206
|
# through the trusted writer). Returns true.
|
|
199
207
|
def destroy(writer: nil)
|
|
208
|
+
context&.writable!
|
|
200
209
|
raise ArgumentError, "only a persisted record can be destroyed" unless persisted?
|
|
201
210
|
|
|
202
211
|
delete_element(write_target(writer))
|
|
@@ -252,6 +261,7 @@ module Pglpg
|
|
|
252
261
|
# belong to this record's own context (identity).
|
|
253
262
|
def write_target(writer)
|
|
254
263
|
raise ArgumentError, "#{self.class} has no context; build it with Model.in(ctx).new" unless context
|
|
264
|
+
context.writable!
|
|
255
265
|
raise ArgumentError, "#{self.class} was destroyed" if destroyed?
|
|
256
266
|
return context if writer.nil?
|
|
257
267
|
raise ArgumentError, "writer: expects a Pglpg::Writer" unless writer.is_a?(Pglpg::Writer)
|
data/lib/pglpg/model/edge.rb
CHANGED
|
@@ -9,6 +9,12 @@ module Pglpg
|
|
|
9
9
|
# graph Kg, type: :WORKS_ON
|
|
10
10
|
# end
|
|
11
11
|
class Edge < Base
|
|
12
|
+
class BoundType < StrictType
|
|
13
|
+
def cast(value) = Temporal.date(value)
|
|
14
|
+
alias deserialize cast
|
|
15
|
+
alias serialize cast
|
|
16
|
+
end
|
|
17
|
+
BOUND = BoundType.new.freeze
|
|
12
18
|
class << self
|
|
13
19
|
def base_class = Model::Edge
|
|
14
20
|
|
|
@@ -43,6 +49,14 @@ module Pglpg
|
|
|
43
49
|
|
|
44
50
|
def symmetric? = schema_type.symmetric?
|
|
45
51
|
|
|
52
|
+
def temporal? = schema_type.temporal?
|
|
53
|
+
|
|
54
|
+
def key
|
|
55
|
+
descriptor = schema_type.key or return nil
|
|
56
|
+
values = descriptor.properties.to_h { [_1, attribute(_1.to_s)] }
|
|
57
|
+
descriptor.property ? values[descriptor.property] : values.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
46
60
|
# The endpoint opposite +ref+ (the same node for a self-loop).
|
|
47
61
|
def other(ref)
|
|
48
62
|
return target if ref == source
|
|
@@ -62,10 +76,24 @@ module Pglpg
|
|
|
62
76
|
end
|
|
63
77
|
|
|
64
78
|
validate :validate_endpoints
|
|
79
|
+
validate :validate_validity
|
|
80
|
+
validate :validate_keys
|
|
65
81
|
|
|
66
82
|
private
|
|
67
83
|
|
|
84
|
+
def _write_attribute(name, value)
|
|
85
|
+
if Temporal::NAMES.include?(name.to_sym) && temporal?
|
|
86
|
+
value = BOUND.cast(value)
|
|
87
|
+
(@validity_assigned ||= {})[name.to_sym] = true
|
|
88
|
+
end
|
|
89
|
+
super(name, value)
|
|
90
|
+
end
|
|
91
|
+
|
|
68
92
|
def load_identity(element)
|
|
93
|
+
@validity_assigned = {}
|
|
94
|
+
if temporal?
|
|
95
|
+
Temporal::NAMES.each { @attributes.write_from_database(_1.to_s, element.public_send(_1)) }
|
|
96
|
+
end
|
|
69
97
|
@source = element.source
|
|
70
98
|
@target = element.target
|
|
71
99
|
self
|
|
@@ -83,16 +111,53 @@ module Pglpg
|
|
|
83
111
|
|
|
84
112
|
# A symmetric edge is stored in canonical order, so the record takes it.
|
|
85
113
|
def create_element(writer, properties, attrs)
|
|
86
|
-
@ref = writer.insert_edge(schema_type.name, from: source, to: target, properties:, attrs
|
|
114
|
+
@ref = writer.insert_edge(schema_type.name, from: source, to: target, properties:, attrs:, **validity_changes)
|
|
87
115
|
@source, @target = @target, @source if symmetric? && @source.id > @target.id
|
|
88
116
|
end
|
|
89
117
|
|
|
90
118
|
def update_element(writer, properties, attrs)
|
|
91
|
-
writer.update_edge(ref, properties:, attrs
|
|
119
|
+
writer.update_edge(ref, properties:, attrs:, **validity_changes)
|
|
92
120
|
end
|
|
93
121
|
|
|
94
122
|
def delete_element(writer) = writer.delete_edge(ref)
|
|
95
123
|
|
|
124
|
+
def validity_changes
|
|
125
|
+
return {} unless temporal?
|
|
126
|
+
|
|
127
|
+
names = new_record? ? (@validity_assigned || {}).keys : Temporal::NAMES.select { attribute_changed?(_1.to_s) }
|
|
128
|
+
names.to_h { [_1, public_send(_1)] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_validity
|
|
132
|
+
return unless temporal?
|
|
133
|
+
|
|
134
|
+
if new_record? && !(@validity_assigned || {}).key?(:valid_from)
|
|
135
|
+
errors.add(:valid_from, "must be explicitly assigned (nil means unbounded)")
|
|
136
|
+
end
|
|
137
|
+
Temporal.validate_interval!(valid_from, valid_to)
|
|
138
|
+
rescue ValidationError => e
|
|
139
|
+
errors.add(:valid_from, e.message)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def required_now?(name)
|
|
143
|
+
return false if new_record? && schema_type.key&.generated? && schema_type.key.property == name
|
|
144
|
+
|
|
145
|
+
super
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_keys
|
|
149
|
+
key = schema_type.key
|
|
150
|
+
if new_record?
|
|
151
|
+
if key&.generated? && !attribute(key.property.to_s).nil?
|
|
152
|
+
errors.add(key.property, "is generated and cannot be assigned")
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
schema_type.immutable_properties.each do |property|
|
|
156
|
+
errors.add(property, "cannot be changed") if attribute_changed?(property.to_s)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
96
161
|
def validate_endpoints
|
|
97
162
|
return unless new_record?
|
|
98
163
|
|
data/lib/pglpg/model/node.rb
CHANGED
|
@@ -131,10 +131,13 @@ module Pglpg
|
|
|
131
131
|
# of the model's type.
|
|
132
132
|
def schema_type = @schema_type || self.class.schema_type
|
|
133
133
|
|
|
134
|
-
#
|
|
134
|
+
# A legacy scalar key, a composite property/value map, or nil.
|
|
135
135
|
def key
|
|
136
136
|
key = schema_type.key or return nil
|
|
137
|
-
|
|
137
|
+
values = key.properties.to_h do |name|
|
|
138
|
+
[name, self.class.property_names.include?(name) ? attribute(name.to_s) : extra_properties[name]]
|
|
139
|
+
end
|
|
140
|
+
key.property ? values[key.property] : values.freeze
|
|
138
141
|
end
|
|
139
142
|
|
|
140
143
|
validate :validate_node
|
|
@@ -178,21 +181,23 @@ module Pglpg
|
|
|
178
181
|
target.delete_node(ref)
|
|
179
182
|
end
|
|
180
183
|
|
|
181
|
-
def key_property = schema_type.key&.property
|
|
184
|
+
def key_property = schema_type.key&.generated? ? schema_type.key.property : nil
|
|
182
185
|
|
|
183
186
|
# The generated key is not required on insert.
|
|
184
187
|
def required_now?(name) = new_record? ? name != key_property : super
|
|
185
188
|
|
|
186
|
-
#
|
|
189
|
+
# Generated keys cannot be assigned; every stored key is immutable.
|
|
187
190
|
def validate_node
|
|
188
191
|
errors.add(:base, "#{schema_type.name} is abstract") if new_record? && schema_type.abstract?
|
|
189
|
-
key =
|
|
190
|
-
return unless key
|
|
192
|
+
key = schema_type.key
|
|
193
|
+
return unless key
|
|
191
194
|
|
|
192
195
|
if new_record?
|
|
193
|
-
|
|
196
|
+
if key.generated? && self.class.property_names.include?(key.property) && !attribute(key.property.to_s).nil?
|
|
197
|
+
errors.add(key.property, "is generated and cannot be assigned")
|
|
198
|
+
end
|
|
194
199
|
else
|
|
195
|
-
|
|
200
|
+
key.properties.each do |property|
|
|
196
201
|
errors.add(property, "cannot be changed") if attribute_changed?(property.to_s)
|
|
197
202
|
end
|
|
198
203
|
end
|
data/lib/pglpg/model/relation.rb
CHANGED
|
@@ -99,10 +99,16 @@ module Pglpg
|
|
|
99
99
|
|
|
100
100
|
# Builds and saves a record; returns it, saved or not (check
|
|
101
101
|
# persisted? or errors).
|
|
102
|
-
def create(writer: nil, **attributes)
|
|
102
|
+
def create(writer: nil, **attributes)
|
|
103
|
+
context.writable!
|
|
104
|
+
new(**attributes).tap { _1.save(writer:) }
|
|
105
|
+
end
|
|
103
106
|
|
|
104
107
|
# As #create, but raises RecordInvalid when validation fails.
|
|
105
|
-
def create!(writer: nil, **attributes)
|
|
108
|
+
def create!(writer: nil, **attributes)
|
|
109
|
+
context.writable!
|
|
110
|
+
new(**attributes).tap { _1.save!(writer:) }
|
|
111
|
+
end
|
|
106
112
|
|
|
107
113
|
# The compiled Pglpg::Query, for debugging (to_sql, explain).
|
|
108
114
|
def to_query = compiled
|
|
@@ -148,6 +154,7 @@ module Pglpg
|
|
|
148
154
|
tree.each do |name, children|
|
|
149
155
|
association = owner_model.respond_to?(:associations) && owner_model.associations[name]
|
|
150
156
|
raise ArgumentError, "#{owner_model} has no association #{name}" unless association
|
|
157
|
+
context.temporal_mode!([context.graph.type.edge_type!(association.edge_type)])
|
|
151
158
|
validate_tree(target_model(owner_model, association), children)
|
|
152
159
|
end
|
|
153
160
|
end
|
data/lib/pglpg/query.rb
CHANGED
|
@@ -246,6 +246,7 @@ module Pglpg
|
|
|
246
246
|
raise ArgumentError, "with distinct, order only by returned variables (not #{stray.join(", ")})"
|
|
247
247
|
end
|
|
248
248
|
|
|
249
|
+
context.temporal_mode!(@state.edges.map { context.graph.type.edge_type!(_1.type) })
|
|
249
250
|
names = aliases
|
|
250
251
|
sql = Sql.new(context.graph.connection)
|
|
251
252
|
inner = kind != :rows
|
|
@@ -322,6 +323,7 @@ module Pglpg
|
|
|
322
323
|
sql.bind(edge.type.to_s, "text")
|
|
323
324
|
sql << " AND "
|
|
324
325
|
context.append_edge_visible(sql, e)
|
|
326
|
+
context.append_temporal(sql, e, [context.graph.type.edge_type!(edge.type)])
|
|
325
327
|
conditions_sql(sql, edge.var, e)
|
|
326
328
|
# Different edges (the GQL default match mode).
|
|
327
329
|
@state.edges.first(i).each do |other|
|
|
@@ -367,12 +369,20 @@ module Pglpg
|
|
|
367
369
|
sql << " AND "
|
|
368
370
|
case cond.op
|
|
369
371
|
when :eq
|
|
370
|
-
if key && key.
|
|
371
|
-
#
|
|
372
|
-
|
|
373
|
-
|
|
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) ? "-> " : "->> ")
|
|
374
383
|
sql.literal(prop.name) << ") = "
|
|
375
|
-
|
|
384
|
+
encoded = prop.encode(cond.value)
|
|
385
|
+
key.json_component?(prop.name) ? sql.bind_json(encoded) : sql.bind(encoded, "text")
|
|
376
386
|
next
|
|
377
387
|
end
|
|
378
388
|
sql << a << ".properties @> "
|
|
@@ -395,8 +405,12 @@ module Pglpg
|
|
|
395
405
|
end
|
|
396
406
|
|
|
397
407
|
def key_of(var)
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
400
414
|
end
|
|
401
415
|
|
|
402
416
|
# Validates values up front, so errors surface where the query is built.
|
data/lib/pglpg/reachability.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Pglpg
|
|
|
21
21
|
@graph = context.graph
|
|
22
22
|
@from = @graph.check_ref!(from, :node)
|
|
23
23
|
@types = edge_types!(via, direction)
|
|
24
|
+
@context.temporal_mode!(@types.map { @graph.type.edge_type!(_1) })
|
|
24
25
|
@direction = direction
|
|
25
26
|
@max_depth = positive_integer!(max_depth, "max_depth")
|
|
26
27
|
@max_nodes = positive_integer!(max_nodes, "max_nodes")
|
|
@@ -87,6 +88,7 @@ module Pglpg
|
|
|
87
88
|
sql << " WHERE " << e << "." << near << " = ANY(b.frontier) AND " << e << ".type = ANY("
|
|
88
89
|
sql.bind_text_array(@types) << ") AND "
|
|
89
90
|
@context.append_edge_visible(sql, e)
|
|
91
|
+
@context.append_temporal(sql, e, @types.map { @graph.type.edge_type!(_1) })
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def edge_types!(via, direction)
|
|
@@ -9,17 +9,22 @@ module Pglpg
|
|
|
9
9
|
# once with source_id < target_id (<= with allow_self_loops), and one
|
|
10
10
|
# endpoint carries +from+ and the other +to+, in either order.
|
|
11
11
|
class EdgeType < ElementType
|
|
12
|
-
attr_reader :from, :to, :unique
|
|
12
|
+
attr_reader :from, :to, :unique, :key, :pair_key
|
|
13
13
|
|
|
14
|
-
def initialize(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false)
|
|
15
|
-
super(name)
|
|
16
|
-
raise SchemaError, "temporal
|
|
17
|
-
|
|
14
|
+
def initialize(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false, open: false)
|
|
15
|
+
super(name, open:)
|
|
16
|
+
raise SchemaError, "temporal: must be true or false" unless [true, false].include?(temporal)
|
|
17
|
+
@temporal = temporal
|
|
18
|
+
unless unique.nil? || unique == :pair || (unique.is_a?(Array) && !unique.empty?)
|
|
19
|
+
raise SchemaError, "unique: must be nil, :pair or a nonempty property list (#{name})"
|
|
20
|
+
end
|
|
21
|
+
@pair_key = Key.new(unique, declared_on: self.name, generation: false) if unique.is_a?(Array)
|
|
22
|
+
raise SchemaError, "pair uniqueness has too many components" if @pair_key && @pair_key.properties.size > 30
|
|
18
23
|
raise SchemaError, "allow_self_loops: needs symmetric: true (#{name})" if allow_self_loops && !symmetric
|
|
19
24
|
|
|
20
25
|
@from = Schema.check_name!(from, "type")
|
|
21
26
|
@to = Schema.check_name!(to, "type")
|
|
22
|
-
@unique = unique
|
|
27
|
+
@unique = unique.is_a?(Array) ? unique.dup.freeze : unique
|
|
23
28
|
@symmetric = symmetric == true
|
|
24
29
|
@allow_self_loops = allow_self_loops == true
|
|
25
30
|
end
|
|
@@ -28,6 +33,20 @@ module Pglpg
|
|
|
28
33
|
# (the same unordered pair, for a symmetric type).
|
|
29
34
|
def unique_pair? = unique == :pair
|
|
30
35
|
|
|
36
|
+
def unique_pair_properties = pair_key ? pair_key.properties : [].freeze
|
|
37
|
+
|
|
38
|
+
def unique_tuple? = unique_pair? || !pair_key.nil?
|
|
39
|
+
|
|
40
|
+
def immutable_properties = ((key&.properties || []) + unique_pair_properties).uniq
|
|
41
|
+
|
|
42
|
+
def key_property(*names, generate: nil)
|
|
43
|
+
raise SchemaError, "#{name} declares more than one key" if @key
|
|
44
|
+
|
|
45
|
+
@key = Key.new(names, declared_on: name, generate:)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def temporal? = @temporal
|
|
49
|
+
|
|
31
50
|
def symmetric? = @symmetric
|
|
32
51
|
|
|
33
52
|
def allow_self_loops? = @allow_self_loops
|
|
@@ -39,6 +58,9 @@ module Pglpg
|
|
|
39
58
|
raise SchemaError, "edge #{name} refers to unknown node type #{label}"
|
|
40
59
|
end
|
|
41
60
|
@properties = own_properties.dup.freeze
|
|
61
|
+
resolve_open!(graph_type.edge_columns)
|
|
62
|
+
@key&.resolve!(self)
|
|
63
|
+
@pair_key&.resolve!(self)
|
|
42
64
|
freeze
|
|
43
65
|
end
|
|
44
66
|
end
|