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.
@@ -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
@@ -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; it never permits assigning a key.
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)
@@ -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
 
@@ -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
- # The value of the type's key property, or nil.
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
- self.class.property_names.include?(key.property) ? attribute(key.property.to_s) : extra_properties[key.property]
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
- # Keys are generated on insert and never change (ADR-0005, ADR-0010).
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 = key_property
190
- return unless key && self.class.property_names.include?(key)
192
+ key = schema_type.key
193
+ return unless key
191
194
 
192
195
  if new_record?
193
- errors.add(key, "is generated and cannot be assigned") unless attribute(key.to_s).nil?
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
- schema_type.key.properties.each do |property|
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
@@ -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) = new(**attributes).tap { _1.save(writer:) }
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) = new(**attributes).tap { _1.save!(writer:) }
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.property == prop.name
371
- # Matches the key's partial unique index.
372
- sql << a << ".labels @> ARRAY["
373
- sql.literal(key.declared_on) << "]::text[] AND (" << a << ".properties ->> "
372
+ if key && key.properties.include?(prop.name)
373
+ # Match every component of the same partial index used by the
374
+ # uniqueness arbiter, including jsonb numeric/boolean equality.
375
+ if @state.nodes.key?(var)
376
+ sql << a << ".labels @> ARRAY["
377
+ sql.literal(key.declared_on) << "]::text[]"
378
+ else
379
+ sql << a << ".type = "
380
+ sql.literal(key.declared_on)
381
+ end
382
+ sql << " AND (" << a << ".properties " << (key.json_component?(prop.name) ? "-> " : "->> ")
374
383
  sql.literal(prop.name) << ") = "
375
- sql.bind(prop.encode(cond.value), "text")
384
+ encoded = prop.encode(cond.value)
385
+ key.json_component?(prop.name) ? sql.bind_json(encoded) : sql.bind(encoded, "text")
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
- label = @state.nodes[var]&.label
399
- label && context.graph.type.node_type!(label).key
408
+ if (node = @state.nodes[var])
409
+ node.label && context.graph.type.node_type!(node.label).key
410
+ else
411
+ edge = @state.edges.find { _1.var == var }
412
+ edge && context.graph.type.edge_type!(edge.type).key
413
+ end
400
414
  end
401
415
 
402
416
  # Validates values up front, so errors surface where the query is built.
@@ -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 edge types are planned for 0.3 (#{name})" if temporal
17
- raise SchemaError, "unique: must be nil or :pair (#{name})" unless [nil, :pair].include?(unique)
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