pglpg 0.1.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 +249 -0
- data/README.md +269 -3
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +71 -13
- data/lib/pglpg/element.rb +23 -3
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +13 -6
- data/lib/pglpg/model/base.rb +337 -0
- data/lib/pglpg/model/edge.rb +169 -0
- data/lib/pglpg/model/node.rb +207 -0
- data/lib/pglpg/model/relation.rb +334 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +68 -15
- data/lib/pglpg/reachability.rb +133 -0
- data/lib/pglpg/schema/edge_type.rb +41 -8
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +23 -8
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -22
- data/lib/pglpg/storage.rb +147 -20
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +67 -18
- data/lib/pglpg/testing.rb +131 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +127 -43
- data/lib/pglpg.rb +3 -0
- metadata +9 -1
|
@@ -4,23 +4,53 @@ module Pglpg
|
|
|
4
4
|
module Schema
|
|
5
5
|
# An edge type: exactly one label (its name), property types, and the
|
|
6
6
|
# node labels its source and target must carry.
|
|
7
|
+
#
|
|
8
|
+
# A symmetric edge type is undirected (ADR-0008): each edge is stored
|
|
9
|
+
# once with source_id < target_id (<= with allow_self_loops), and one
|
|
10
|
+
# endpoint carries +from+ and the other +to+, in either order.
|
|
7
11
|
class EdgeType < ElementType
|
|
8
|
-
attr_reader :from, :to, :unique
|
|
12
|
+
attr_reader :from, :to, :unique, :key, :pair_key
|
|
9
13
|
|
|
10
|
-
def initialize(name, from:, to:, unique: nil, symmetric: false, temporal: false)
|
|
11
|
-
super(name)
|
|
12
|
-
raise SchemaError, "
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
23
|
+
raise SchemaError, "allow_self_loops: needs symmetric: true (#{name})" if allow_self_loops && !symmetric
|
|
15
24
|
|
|
16
25
|
@from = Schema.check_name!(from, "type")
|
|
17
26
|
@to = Schema.check_name!(to, "type")
|
|
18
|
-
@unique = unique
|
|
27
|
+
@unique = unique.is_a?(Array) ? unique.dup.freeze : unique
|
|
28
|
+
@symmetric = symmetric == true
|
|
29
|
+
@allow_self_loops = allow_self_loops == true
|
|
19
30
|
end
|
|
20
31
|
|
|
21
|
-
# At most one edge of this type between the same source and target
|
|
32
|
+
# At most one edge of this type between the same source and target
|
|
33
|
+
# (the same unordered pair, for a symmetric type).
|
|
22
34
|
def unique_pair? = unique == :pair
|
|
23
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
|
+
|
|
50
|
+
def symmetric? = @symmetric
|
|
51
|
+
|
|
52
|
+
def allow_self_loops? = @allow_self_loops
|
|
53
|
+
|
|
24
54
|
def resolve!(graph_type)
|
|
25
55
|
[from, to].each do |label|
|
|
26
56
|
next if graph_type.node_types.key?(label)
|
|
@@ -28,6 +58,9 @@ module Pglpg
|
|
|
28
58
|
raise SchemaError, "edge #{name} refers to unknown node type #{label}"
|
|
29
59
|
end
|
|
30
60
|
@properties = own_properties.dup.freeze
|
|
61
|
+
resolve_open!(graph_type.edge_columns)
|
|
62
|
+
@key&.resolve!(self)
|
|
63
|
+
@pair_key&.resolve!(self)
|
|
31
64
|
freeze
|
|
32
65
|
end
|
|
33
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, temporal: false, &block)
|
|
53
|
-
type = EdgeType.new(name, from:, to:, unique:, symmetric:, 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,11 +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
|
-
# +generate+ (default: a random UUID)
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
# +generate+ (default: a random UUID). Scoped keys may be caller-chosen
|
|
109
|
+
# with an explicit key_scope_allowed grant.
|
|
110
|
+
def key(*names, generate: nil, scope: nil)
|
|
111
|
+
@type.key_property(*names, generate:, scope:)
|
|
97
112
|
end
|
|
98
113
|
end
|
|
99
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,25 +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 key on one property (PG-Keys). +generator+
|
|
11
|
-
# produces values for interactive inserts, which never accept a
|
|
12
|
-
# caller-chosen key.
|
|
13
|
-
Key = Struct.new(:property, :declared_on, :generator, keyword_init: true) do
|
|
14
|
-
def generate(type) = generator.call(type)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
DEFAULT_KEY_GENERATOR = ->(_type) { SecureRandom.uuid }
|
|
18
|
-
|
|
19
8
|
attr_reader :parents, :labels, :key, :ancestors
|
|
20
9
|
|
|
21
|
-
def initialize(name, extends: [], abstract: false)
|
|
22
|
-
super(name)
|
|
10
|
+
def initialize(name, extends: [], abstract: false, open: false)
|
|
11
|
+
super(name, open:)
|
|
23
12
|
@parents = Array(extends).map { Schema.check_name!(_1, "type") }.freeze
|
|
24
13
|
@abstract = abstract ? true : false
|
|
25
14
|
@own_key = nil
|
|
@@ -27,11 +16,10 @@ module Pglpg
|
|
|
27
16
|
|
|
28
17
|
def abstract? = @abstract
|
|
29
18
|
|
|
30
|
-
def key_property(
|
|
19
|
+
def key_property(*names, generate: nil, scope: nil)
|
|
31
20
|
raise SchemaError, "#{self.name} declares more than one key" if @own_key
|
|
32
|
-
raise SchemaError, "key generator for #{self.name} must respond to call" if generate && !generate.respond_to?(:call)
|
|
33
21
|
|
|
34
|
-
@own_key = Key.new(
|
|
22
|
+
@own_key = Key.new(names, declared_on: self.name, generate:, scope:)
|
|
35
23
|
end
|
|
36
24
|
|
|
37
25
|
def own_key = @own_key
|
|
@@ -49,6 +37,7 @@ module Pglpg
|
|
|
49
37
|
@ancestors = parent_types.flat_map { [_1.name, *_1.ancestors] }.uniq.freeze
|
|
50
38
|
@labels = ([name] + @ancestors).map(&:to_s).sort.freeze
|
|
51
39
|
@properties = merge_properties(parent_types)
|
|
40
|
+
resolve_open!(graph_type.node_columns, parents: parent_types)
|
|
52
41
|
@key = resolve_key(parent_types)
|
|
53
42
|
@resolved = true
|
|
54
43
|
freeze
|
|
@@ -80,12 +69,8 @@ module Pglpg
|
|
|
80
69
|
key = keys.first
|
|
81
70
|
return nil unless key
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
raise SchemaError, "key #{key.declared_on}.#{key.property} must be required" unless prop.required?
|
|
86
|
-
raise SchemaError, "key #{key.declared_on}.#{key.property} must be a string" unless prop.type == :string
|
|
87
|
-
|
|
88
|
-
key
|
|
72
|
+
# Inherited descriptors are already resolved and immutable.
|
|
73
|
+
key.frozen? ? key : key.resolve!(self)
|
|
89
74
|
end
|
|
90
75
|
end
|
|
91
76
|
end
|
data/lib/pglpg/storage.rb
CHANGED
|
@@ -27,8 +27,10 @@ module Pglpg
|
|
|
27
27
|
raise ConfigurationError, "pglpg needs PostgreSQL 18 or later"
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
connection.transaction(requires_new: true) do
|
|
31
|
+
tables_sql(connection).each { connection.execute(_1) }
|
|
32
|
+
synchronize_constraints(connection)
|
|
33
|
+
end
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def drop!(connection = graph.connection)
|
|
@@ -58,7 +60,7 @@ module Pglpg
|
|
|
58
60
|
source_id bigint NOT NULL REFERENCES #{n} (id) ON DELETE RESTRICT,
|
|
59
61
|
target_id bigint NOT NULL REFERENCES #{n} (id) ON DELETE RESTRICT,
|
|
60
62
|
properties jsonb NOT NULL DEFAULT '{}',
|
|
61
|
-
#{columns_sql(connection, type.edge_columns)}created_at timestamptz NOT NULL DEFAULT now(),
|
|
63
|
+
#{type.temporal? ? "valid_from date, valid_to date, " : ""}#{columns_sql(connection, type.edge_columns)}created_at timestamptz NOT NULL DEFAULT now(),
|
|
62
64
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
63
65
|
CHECK (jsonb_typeof(properties) = 'object')
|
|
64
66
|
)
|
|
@@ -81,17 +83,34 @@ module Pglpg
|
|
|
81
83
|
type.node_types.each_value do |t|
|
|
82
84
|
has_label = "labels @> ARRAY[#{connection.quote(t.name.to_s)}]::text[]"
|
|
83
85
|
if (key = t.own_key)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
expressions = key.expressions(connection).join(", ")
|
|
87
|
+
list << [:index, nodes_table, "key_#{t.name}_#{key.property || key.properties.join("_")}",
|
|
88
|
+
"ON #{n} (#{expressions}) WHERE #{has_label}"]
|
|
86
89
|
end
|
|
90
|
+
key_component_checks(list, connection, nodes_table, t, t.own_key, has_label) if t.own_key
|
|
87
91
|
t.own_properties.each_value.select(&:required?).each do |p|
|
|
88
92
|
list << [:check, nodes_table, "req_#{t.name}_#{p.name}", "NOT (#{has_label}) OR #{typed_check(connection, p)}"]
|
|
89
93
|
end
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
temporal_storage = type.temporal? || temporal_columns?(connection)
|
|
92
97
|
type.edge_types.each_value do |t|
|
|
93
98
|
is_type = "type = #{connection.quote(t.name.to_s)}"
|
|
94
|
-
list << [:
|
|
99
|
+
list << [:check, edges_table, "validity_#{t.name}", temporal_check(connection, t)] if temporal_storage
|
|
100
|
+
if t.unique_tuple?
|
|
101
|
+
expressions = ["source_id", "target_id", *(t.pair_key&.expressions(connection) || [])].join(", ")
|
|
102
|
+
list << [:index, edges_table, "pair_#{t.name}", "ON #{e} (#{expressions}) WHERE #{is_type}"]
|
|
103
|
+
end
|
|
104
|
+
if t.key
|
|
105
|
+
list << [:index, edges_table, "key_#{t.name}_#{t.key.properties.join("_")}",
|
|
106
|
+
"ON #{e} (#{t.key.expressions(connection).join(", ")}) WHERE #{is_type}"]
|
|
107
|
+
end
|
|
108
|
+
[t.key, t.pair_key].compact.each { key_component_checks(list, connection, edges_table, t, _1, is_type) }
|
|
109
|
+
if t.symmetric?
|
|
110
|
+
# Canonical orientation, so a pair is stored one way only (ADR-0008).
|
|
111
|
+
list << [:check, edges_table, "sym_#{t.name}",
|
|
112
|
+
"NOT (#{is_type}) OR source_id #{t.allow_self_loops? ? "<=" : "<"} target_id"]
|
|
113
|
+
end
|
|
95
114
|
t.own_properties.each_value.select(&:required?).each do |p|
|
|
96
115
|
list << [:check, edges_table, "req_#{t.name}_#{p.name}", "NOT (#{is_type}) OR #{typed_check(connection, p)}"]
|
|
97
116
|
end
|
|
@@ -108,20 +127,33 @@ module Pglpg
|
|
|
108
127
|
end
|
|
109
128
|
end
|
|
110
129
|
|
|
111
|
-
#
|
|
130
|
+
# Atomic on the supplied connection: its own transaction or a savepoint
|
|
131
|
+
# within an ActiveRecord-managed caller transaction. Backfills belong in
|
|
132
|
+
# an explicit outer transaction on the same connection.
|
|
112
133
|
def sync_constraints!(connection = graph.connection)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
connection.transaction(requires_new: true) { synchronize_constraints(connection) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Acknowledges only the named temporal types. The caller supplies any
|
|
138
|
+
# backfill, on the same locked connection, after checks are installed.
|
|
139
|
+
def enable_temporal!(types:, backfill: nil, connection: graph.connection)
|
|
140
|
+
names = Array(types).map { type.edge_type!(_1) }.uniq
|
|
141
|
+
raise ArgumentError, "types: needs temporal edge types" if names.empty? || names.any? { !_1.temporal? }
|
|
142
|
+
raise ArgumentError, "backfill must be callable" if backfill && !backfill.respond_to?(:call)
|
|
143
|
+
|
|
144
|
+
connection.transaction(requires_new: true) do
|
|
145
|
+
lock_temporal_table(connection)
|
|
146
|
+
check_temporal_columns!(connection, allow_missing: true)
|
|
147
|
+
acknowledge_temporal!(connection, names.map(&:name))
|
|
148
|
+
existing = connection.columns(edges_table).map(&:name)
|
|
149
|
+
Temporal::NAMES.each do |name|
|
|
150
|
+
next if existing.include?(name.to_s)
|
|
151
|
+
connection.execute("ALTER TABLE #{q(connection, edges_table)} ADD COLUMN #{q(connection, name)} date")
|
|
121
152
|
end
|
|
153
|
+
synchronize_constraints(connection, acknowledged: names.map(&:name))
|
|
154
|
+
backfill.call(connection) if backfill
|
|
155
|
+
nil
|
|
122
156
|
end
|
|
123
|
-
(wanted.keys - existing.keys).each { connection.execute(wanted[_1][:sql]) }
|
|
124
|
-
nil
|
|
125
157
|
end
|
|
126
158
|
|
|
127
159
|
# Rewrites stored label sets to match the graph type.
|
|
@@ -149,6 +181,87 @@ module Pglpg
|
|
|
149
181
|
|
|
150
182
|
private
|
|
151
183
|
|
|
184
|
+
def synchronize_constraints(connection, acknowledged: [])
|
|
185
|
+
if type.temporal? || temporal_columns?(connection)
|
|
186
|
+
lock_temporal_table(connection)
|
|
187
|
+
check_temporal_columns!(connection)
|
|
188
|
+
acknowledge_temporal!(connection, acknowledged)
|
|
189
|
+
end
|
|
190
|
+
wanted = constraints(connection)
|
|
191
|
+
existing = existing_constraints(connection)
|
|
192
|
+
(existing.keys - wanted.keys).each do |name|
|
|
193
|
+
info = existing[name]
|
|
194
|
+
if info[:kind] == :index
|
|
195
|
+
connection.execute("DROP INDEX #{q(connection, name)}")
|
|
196
|
+
else
|
|
197
|
+
connection.execute("ALTER TABLE #{q(connection, info[:table])} DROP CONSTRAINT #{q(connection, name)}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
(wanted.keys - existing.keys).each { connection.execute(wanted[_1][:sql]) }
|
|
201
|
+
wanted.each_key do |name|
|
|
202
|
+
next unless existing[name] && existing[name][:validated] == false
|
|
203
|
+
connection.execute("ALTER TABLE #{q(connection, existing[name][:table])} VALIDATE CONSTRAINT #{q(connection, name)}")
|
|
204
|
+
end
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def lock_temporal_table(connection)
|
|
209
|
+
connection.execute("LOCK TABLE #{q(connection, edges_table)} IN ACCESS EXCLUSIVE MODE")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def temporal_columns?(connection)
|
|
213
|
+
return false unless connection.data_source_exists?(edges_table)
|
|
214
|
+
return false unless (connection.columns(edges_table).map(&:name) & Temporal::NAMES.map(&:to_s)).any?
|
|
215
|
+
return true if (type.edge_columns.keys & Temporal::NAMES).empty?
|
|
216
|
+
|
|
217
|
+
# Legacy application columns are allowed until explicitly migrated.
|
|
218
|
+
# Once metadata checks exist, redeclaring them as application columns
|
|
219
|
+
# must not silently bypass the temporal-disable transition.
|
|
220
|
+
existing_constraints(connection).values.any? do |info|
|
|
221
|
+
info[:table] == edges_table && Temporal::NAMES.all? { info[:definition]&.include?(_1.to_s) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def check_temporal_columns!(connection, allow_missing: false)
|
|
226
|
+
if (type.edge_columns.keys & Temporal::NAMES).any?
|
|
227
|
+
raise TemporalMigrationRequired, "remove reserved validity metadata from application column declarations"
|
|
228
|
+
end
|
|
229
|
+
columns = connection.columns(edges_table).to_h { [_1.name, _1] }
|
|
230
|
+
Temporal::NAMES.each do |name|
|
|
231
|
+
column = columns[name.to_s]
|
|
232
|
+
if column.nil?
|
|
233
|
+
raise TemporalMigrationRequired, "run enable_temporal! to add #{name}" unless allow_missing
|
|
234
|
+
elsif column.type != :date || !column.null || column.default || column.default_function
|
|
235
|
+
raise TemporalMigrationRequired, "#{name} must be a nullable date column without a default; migrate incompatible columns explicitly"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def acknowledge_temporal!(connection, acknowledged)
|
|
241
|
+
existing = existing_constraints(connection)
|
|
242
|
+
type.edge_types.each_value do |t|
|
|
243
|
+
next unless t.temporal?
|
|
244
|
+
name = constraint_name(edges_table, "validity_#{t.name}", temporal_check(connection, t))
|
|
245
|
+
next if existing[name] && existing[name][:validated] != false
|
|
246
|
+
next if acknowledged.include?(t.name)
|
|
247
|
+
populated = connection.select_value("SELECT EXISTS (SELECT 1 FROM #{q(connection, edges_table)} WHERE type = #{connection.quote(t.name.to_s)})")
|
|
248
|
+
raise TemporalMigrationRequired, "acknowledge #{t.name} with enable_temporal!(types: [#{t.name.inspect}])" if populated
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def temporal_check(connection, edge_type)
|
|
253
|
+
body = if edge_type.temporal?
|
|
254
|
+
checks = Temporal::NAMES.map do |name|
|
|
255
|
+
"(#{name} IS NULL OR #{name} BETWEEN DATE '0001-01-01' AND DATE '9999-12-31')"
|
|
256
|
+
end
|
|
257
|
+
checks << "(valid_from IS NULL OR valid_to IS NULL OR valid_from < valid_to)"
|
|
258
|
+
checks.join(" AND ")
|
|
259
|
+
else
|
|
260
|
+
"valid_from IS NULL AND valid_to IS NULL"
|
|
261
|
+
end
|
|
262
|
+
"NOT (type = #{connection.quote(edge_type.name.to_s)}) OR (#{body})"
|
|
263
|
+
end
|
|
264
|
+
|
|
152
265
|
def type = graph.type
|
|
153
266
|
|
|
154
267
|
def q(connection, name) = connection.quote_table_name(name)
|
|
@@ -178,6 +291,20 @@ module Pglpg
|
|
|
178
291
|
|
|
179
292
|
# Rejects a missing key, JSON null and the wrong JSON type: a CHECK passes
|
|
180
293
|
# on NULL, so the comparison is wrapped in IS TRUE.
|
|
294
|
+
def key_component_checks(list, connection, table, type, key, predicate)
|
|
295
|
+
key.properties.each do |name|
|
|
296
|
+
prop = type.properties.fetch(name)
|
|
297
|
+
value = "properties->#{connection.quote(name.to_s)}"
|
|
298
|
+
detail = case prop.type
|
|
299
|
+
when :integer then "CASE WHEN jsonb_typeof(#{value}) = 'number' THEN (#{value})::numeric = trunc((#{value})::numeric) ELSE false END"
|
|
300
|
+
when :enum then "properties->>#{connection.quote(name.to_s)} IN (#{prop.values.map { connection.quote(_1) }.join(", ")})"
|
|
301
|
+
end
|
|
302
|
+
next unless detail
|
|
303
|
+
|
|
304
|
+
list << [:check, table, "keyval_#{type.name}_#{name}", "NOT (#{predicate}) OR (#{detail}) IS TRUE"]
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
181
308
|
def typed_check(connection, prop)
|
|
182
309
|
"(jsonb_typeof(properties->#{connection.quote(prop.name.to_s)}) = #{connection.quote(prop.json_type)}) IS TRUE"
|
|
183
310
|
end
|
|
@@ -192,17 +319,17 @@ module Pglpg
|
|
|
192
319
|
def existing_constraints(connection)
|
|
193
320
|
prefixes = [nodes_table, edges_table].map { "pglpg\\_#{_1.gsub("_", "\\_")}\\_%" }
|
|
194
321
|
rows = connection.select_rows(<<~SQL, "Pglpg constraints")
|
|
195
|
-
SELECT c.conname, 'check', t.relname FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
|
322
|
+
SELECT c.conname, 'check', t.relname, c.convalidated, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
|
196
323
|
WHERE t.relname IN (#{connection.quote(nodes_table)}, #{connection.quote(edges_table)})
|
|
197
324
|
AND t.relnamespace = to_regnamespace(current_schema())
|
|
198
325
|
AND (c.conname LIKE #{connection.quote(prefixes[0])} OR c.conname LIKE #{connection.quote(prefixes[1])})
|
|
199
326
|
UNION ALL
|
|
200
|
-
SELECT i.indexname, 'index', i.tablename FROM pg_indexes i
|
|
327
|
+
SELECT i.indexname, 'index', i.tablename, true, NULL FROM pg_indexes i
|
|
201
328
|
WHERE i.tablename IN (#{connection.quote(nodes_table)}, #{connection.quote(edges_table)})
|
|
202
329
|
AND i.schemaname = current_schema()
|
|
203
330
|
AND (i.indexname LIKE #{connection.quote(prefixes[0])} OR i.indexname LIKE #{connection.quote(prefixes[1])})
|
|
204
331
|
SQL
|
|
205
|
-
rows.to_h { |name, kind, table| [name, { kind: kind.to_sym, table: }] }
|
|
332
|
+
rows.to_h { |name, kind, table, validated, definition| [name, { kind: kind.to_sym, table:, validated:, definition: }] }
|
|
206
333
|
end
|
|
207
334
|
end
|
|
208
335
|
end
|