pglpg 0.2.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.
@@ -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
@@ -10,16 +10,21 @@ module Pglpg
10
10
  name.to_sym
11
11
  end
12
12
 
13
- # Shared by node and edge types: declared properties. Types are closed:
14
- # a property that is not declared is rejected.
13
+ # Declared properties stay typed. Explicit open types also accept a
14
+ # restricted JSON-native value set without guessing a schema for extras.
15
15
  class ElementType
16
16
  attr_reader :name
17
17
 
18
- def initialize(name)
18
+ def initialize(name, open: false)
19
+ raise SchemaError, "open: must be true or false" unless open.equal?(true) || open.equal?(false)
20
+
19
21
  @name = Schema.check_name!(name, "type")
20
22
  @own_properties = {}
23
+ @open = open
21
24
  end
22
25
 
26
+ def open? = @open
27
+
23
28
  def property(name, type, required: false, values: nil, of: nil)
24
29
  name = Schema.check_name!(name, "property")
25
30
  raise SchemaError, "#{self.name}.#{name} is declared twice" if @own_properties.key?(name)
@@ -36,20 +41,50 @@ module Pglpg
36
41
 
37
42
  def required_properties = properties.values.select(&:required?)
38
43
 
44
+ # @api private
45
+ # Construction-only resolution; the descriptor is frozen afterwards.
46
+ def resolve_open!(columns, parents: [])
47
+ @open ||= parents.any?(&:open?)
48
+ @extra_reserved_names = (Column::RESERVED + Temporal::NAMES + columns.keys).uniq.freeze
49
+ end
50
+
51
+ # Normalize before the writer generates/merges keys, so ambiguous
52
+ # String/Symbol aliases cannot disappear before validation.
53
+ # @api private
54
+ def normalize_properties(props)
55
+ return (props || {}).transform_keys(&:to_sym) unless open?
56
+
57
+ (props || {}).each_with_object({}) do |(name, value), out|
58
+ unless (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(NAME_FORMAT)
59
+ raise ValidationError, "invalid open property name"
60
+ end
61
+ name = name.to_sym
62
+ raise ValidationError, "duplicate property #{name}" if out.key?(name)
63
+ if !properties.key?(name) && @extra_reserved_names.include?(name)
64
+ raise ValidationError, "#{name} is reserved and cannot be an extra property"
65
+ end
66
+
67
+ out[name] = value
68
+ end
69
+ end
70
+
39
71
  # Validates +props+ and returns the JSON hash to store. nil values are
40
72
  # dropped (null means absent). With +partial+, required properties may be
41
73
  # missing but may not be set to nil.
42
74
  def encode_properties(props, partial: false)
43
- props = (props || {}).transform_keys(&:to_sym)
75
+ props = normalize_properties(props)
44
76
  unknown = props.keys - properties.keys
45
- raise ValidationError, "#{name} has no propert#{unknown.size == 1 ? "y" : "ies"} #{unknown.join(", ")}" if unknown.any?
77
+ if !open? && unknown.any?
78
+ raise ValidationError, "#{name} has no propert#{unknown.size == 1 ? "y" : "ies"} #{unknown.join(", ")}"
79
+ end
46
80
 
47
81
  required_properties.each do |p|
48
82
  next if partial && !props.key?(p.name)
49
83
  raise ValidationError, "#{name}.#{p.name} is required" if props[p.name].nil?
50
84
  end
51
85
  props.each_with_object({}) do |(k, v), out|
52
- encoded = properties.fetch(k).encode(v)
86
+ prop = properties[k]
87
+ encoded = prop ? prop.encode(v) : encode_extra(v)
53
88
  out[k.to_s] = encoded unless encoded.nil?
54
89
  end
55
90
  end
@@ -57,9 +92,47 @@ module Pglpg
57
92
  def decode_properties(json)
58
93
  (json || {}).each_with_object({}) do |(k, v), out|
59
94
  prop = properties[k.to_sym]
60
- out[k.to_sym] = prop ? prop.decode(v) : v
95
+ out[k.to_sym] = prop ? prop.decode(v) : freeze_extra(v)
61
96
  end.freeze
62
97
  end
98
+
99
+ private
100
+
101
+ def encode_extra(value)
102
+ return nil if value.nil?
103
+ return encode_extra_scalar(value) unless value.instance_of?(Array)
104
+
105
+ kinds = value.map { (_1.equal?(true) || _1.equal?(false)) ? :boolean : _1.class }.uniq
106
+ raise ValidationError, "extra property lists must be homogeneous scalar arrays" if kinds.size > 1
107
+
108
+ value.map { encode_extra_scalar(_1) }
109
+ end
110
+
111
+ def encode_extra_scalar(value)
112
+ if value.instance_of?(String)
113
+ begin
114
+ text = value.encode(Encoding::UTF_8)
115
+ rescue EncodingError
116
+ raise ValidationError, "extra property strings must be valid UTF-8"
117
+ end
118
+ raise ValidationError, "extra property strings must be valid UTF-8" unless text.valid_encoding?
119
+ raise ValidationError, "extra property strings cannot contain NUL" if text.include?("\0")
120
+
121
+ return text
122
+ end
123
+ return value if value.instance_of?(Integer) || value.equal?(true) || value.equal?(false)
124
+ return value if value.instance_of?(Float) && value.finite?
125
+
126
+ raise ValidationError, "extra property values must be JSON-native scalars or homogeneous scalar arrays"
127
+ end
128
+
129
+ def freeze_extra(value)
130
+ case value
131
+ when Array then value.each { freeze_extra(_1) }
132
+ when Hash then value.each { |key, child| freeze_extra(key); freeze_extra(child) }
133
+ end
134
+ value.freeze
135
+ end
63
136
  end
64
137
  end
65
138
  end
@@ -27,9 +27,15 @@ module Pglpg
27
27
  node_types.values.select { _1.label?(label) }
28
28
  end
29
29
 
30
+ def temporal? = edge_types.values.any?(&:temporal?)
31
+
30
32
  def resolve!
31
33
  node_types.each_value { _1.resolve!(self) }
32
34
  edge_types.each_value { _1.resolve!(self) }
35
+ if temporal?
36
+ collisions = (node_columns.keys + edge_columns.keys + node_types.values.flat_map { _1.properties.keys } + edge_types.values.flat_map { _1.properties.keys }) & Temporal::NAMES
37
+ raise SchemaError, "reserved temporal names: #{collisions.join(", ")}" unless collisions.empty?
38
+ end
33
39
  [node_types, edge_types, node_columns, edge_columns].each(&:freeze)
34
40
  freeze
35
41
  end
@@ -41,19 +47,19 @@ module Pglpg
41
47
  @graph_type = graph_type
42
48
  end
43
49
 
44
- def node(name, extends: [], abstract: false, &block)
45
- type = NodeType.new(name, extends:, abstract:)
50
+ def node(name, extends: [], abstract: false, open: false, &block)
51
+ type = NodeType.new(name, extends:, abstract:, open:)
46
52
  raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
47
53
 
48
54
  NodeBuilder.new(type).instance_eval(&block) if block
49
55
  @graph_type.node_types[type.name] = type
50
56
  end
51
57
 
52
- def edge(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false, &block)
53
- type = EdgeType.new(name, from:, to:, unique:, symmetric:, allow_self_loops:, temporal:)
58
+ def edge(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false, open: false, &block)
59
+ type = EdgeType.new(name, from:, to:, unique:, symmetric:, allow_self_loops:, temporal:, open:)
54
60
  raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
55
61
 
56
- ElementBuilder.new(type).instance_eval(&block) if block
62
+ EdgeBuilder.new(type).instance_eval(&block) if block
57
63
  @graph_type.edge_types[type.name] = type
58
64
  end
59
65
 
@@ -89,12 +95,20 @@ module Pglpg
89
95
  end
90
96
  end
91
97
 
98
+ class EdgeBuilder < ElementBuilder
99
+ def key(*names, generate: nil, scope: nil)
100
+ raise SchemaError, "Edge scope: is not supported" unless scope.nil?
101
+
102
+ @type.key_property(*names, generate:)
103
+ end
104
+ end
105
+
92
106
  class NodeBuilder < ElementBuilder
93
107
  # Declares an exclusive key. Interactive inserts get a value from
94
108
  # +generate+ (default: a random UUID). Scoped keys may be caller-chosen
95
109
  # with an explicit key_scope_allowed grant.
96
- def key(name, generate: nil, scope: nil)
97
- @type.key_property(name, generate:, scope:)
110
+ def key(*names, generate: nil, scope: nil)
111
+ @type.key_property(*names, generate:, scope:)
98
112
  end
99
113
  end
100
114
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Pglpg
6
+ module Schema
7
+ # One required, immutable property tuple. Shared by schema validation,
8
+ # index generation and ON CONFLICT so their equality cannot diverge.
9
+ class Key
10
+ TYPES = %i[string integer boolean enum].freeze
11
+ DEFAULT_GENERATOR = ->(_type) { SecureRandom.uuid }
12
+
13
+ attr_reader :properties, :declared_on, :scope, :generator
14
+
15
+ def initialize(names, declared_on:, generate: nil, scope: nil, generation: true)
16
+ @names = names.map { Schema.check_name!(_1, "key property") }.freeze
17
+ raise SchemaError, "a key needs distinct properties" if @names.empty? || @names.uniq != @names
18
+ raise SchemaError, "scope needs a single key property" if scope && @names.size != 1
19
+ raise SchemaError, "key generator must respond to call" if generate && !generate.respond_to?(:call)
20
+ raise SchemaError, "composite keys cannot have a generator" if generate && @names.size != 1
21
+
22
+ @scope = scope && Schema.check_name!(scope, "key scope")
23
+ @properties = [@scope, *@names].compact.freeze
24
+ raise SchemaError, "scope must differ from key property" if @properties.uniq != @properties
25
+ raise SchemaError, "key has too many components" if @properties.size > 32
26
+
27
+ @declared_on = declared_on
28
+ @generator = generate
29
+ @generation = generation
30
+ end
31
+
32
+ # Legacy single/scoped keys expose their generated property.
33
+ def property = @names.size == 1 ? @names.first : nil
34
+
35
+ def generated? = !generator.nil?
36
+
37
+ def generate(type) = generator.call(type)
38
+
39
+ def resolve!(type)
40
+ properties.each do |name|
41
+ prop = type.properties[name]
42
+ raise SchemaError, "key #{declared_on}.#{name} is not a declared property" unless prop
43
+ unless prop.required? && TYPES.include?(prop.type)
44
+ raise SchemaError, "key #{declared_on}.#{name} must be a required #{TYPES.join('/')} property"
45
+ end
46
+ end
47
+ if scope && properties.any? { type.properties.fetch(_1).type != :string }
48
+ raise SchemaError, "scoped keys require string properties"
49
+ end
50
+ if @generator && type.properties.fetch(property).type != :string
51
+ raise SchemaError, "only single string keys can have generators"
52
+ end
53
+ if @generation && property && type.properties.fetch(property).type == :string
54
+ @generator ||= DEFAULT_GENERATOR
55
+ end
56
+ @types = properties.to_h { [_1, type.properties.fetch(_1).type] }.freeze
57
+ freeze
58
+ end
59
+
60
+ def json_component?(name) = %i[integer boolean].include?(@types.fetch(name))
61
+
62
+ def expressions(connection)
63
+ properties.map do |name|
64
+ operator = json_component?(name) ? "->" : "->>"
65
+ "(properties#{operator}#{connection.quote(name.to_s)})"
66
+ end
67
+ end
68
+
69
+ # Lookups accept literal complete keys, never query operators/arrays/nil.
70
+ def validate_values!(type, values)
71
+ unless values.keys.sort == properties.sort
72
+ raise ArgumentError, "#{declared_on} requires the complete key #{properties.inspect}"
73
+ end
74
+ properties.each do |name|
75
+ raise ValidationError, "#{declared_on}.#{name} is required" if values[name].nil?
76
+
77
+ type.properties.fetch(name).encode(values[name])
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,27 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module Pglpg
6
4
  module Schema
7
5
  # A node type: a key label, inherited labels and property types
8
6
  # (GQL node types, PG-Schema inheritance).
9
7
  class NodeType < ElementType
10
- # An exclusive, mandatory string key, optionally scoped by another
11
- # required string property. Caller-chosen scoped keys need an explicit
12
- # occupancy grant; omitted keys use +generator+.
13
- Key = Struct.new(:property, :declared_on, :generator, :scope, keyword_init: true) do
14
- def properties = [scope, property].compact.freeze
15
-
16
- def generate(type) = generator.call(type)
17
- end
18
-
19
- DEFAULT_KEY_GENERATOR = ->(_type) { SecureRandom.uuid }
20
-
21
8
  attr_reader :parents, :labels, :key, :ancestors
22
9
 
23
- def initialize(name, extends: [], abstract: false)
24
- super(name)
10
+ def initialize(name, extends: [], abstract: false, open: false)
11
+ super(name, open:)
25
12
  @parents = Array(extends).map { Schema.check_name!(_1, "type") }.freeze
26
13
  @abstract = abstract ? true : false
27
14
  @own_key = nil
@@ -29,11 +16,10 @@ module Pglpg
29
16
 
30
17
  def abstract? = @abstract
31
18
 
32
- def key_property(name, generate: nil, scope: nil)
19
+ def key_property(*names, generate: nil, scope: nil)
33
20
  raise SchemaError, "#{self.name} declares more than one key" if @own_key
34
- raise SchemaError, "key generator for #{self.name} must respond to call" if generate && !generate.respond_to?(:call)
35
21
 
36
- @own_key = Key.new(property: name.to_sym, declared_on: self.name, generator: generate || DEFAULT_KEY_GENERATOR, scope: scope && Schema.check_name!(scope, "key scope"))
22
+ @own_key = Key.new(names, declared_on: self.name, generate:, scope:)
37
23
  end
38
24
 
39
25
  def own_key = @own_key
@@ -51,6 +37,7 @@ module Pglpg
51
37
  @ancestors = parent_types.flat_map { [_1.name, *_1.ancestors] }.uniq.freeze
52
38
  @labels = ([name] + @ancestors).map(&:to_s).sort.freeze
53
39
  @properties = merge_properties(parent_types)
40
+ resolve_open!(graph_type.node_columns, parents: parent_types)
54
41
  @key = resolve_key(parent_types)
55
42
  @resolved = true
56
43
  freeze
@@ -82,19 +69,8 @@ module Pglpg
82
69
  key = keys.first
83
70
  return nil unless key
84
71
 
85
- prop = @properties[key.property]
86
- raise SchemaError, "key #{key.declared_on}.#{key.property} is not a declared property" unless prop
87
- raise SchemaError, "key #{key.declared_on}.#{key.property} must be required" unless prop.required?
88
- raise SchemaError, "key #{key.declared_on}.#{key.property} must be a string" unless prop.type == :string
89
-
90
- if key.scope
91
- raise SchemaError, "scope must differ from key property" if key.scope == key.property
92
- scope = @properties[key.scope]
93
- unless scope && scope.required? && scope.type == :string
94
- raise SchemaError, "key scope #{key.scope} must be a declared required string property"
95
- end
96
- end
97
- key.freeze
72
+ # Inherited descriptors are already resolved and immutable.
73
+ key.frozen? ? key : key.resolve!(self)
98
74
  end
99
75
  end
100
76
  end