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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +209 -7
- data/lib/pglpg/context.rb +53 -8
- data/lib/pglpg/element.rb +8 -1
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +5 -2
- 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 +9 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +89 -40
- data/lib/pglpg.rb +2 -0
- metadata +3 -1
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
|
|
@@ -10,16 +10,21 @@ module Pglpg
|
|
|
10
10
|
name.to_sym
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
75
|
+
props = normalize_properties(props)
|
|
44
76
|
unknown = props.keys - properties.keys
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
97
|
-
@type.key_property(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
86
|
-
|
|
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
|