pglpg 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +101 -0
- data/README.md +227 -8
- data/lib/pglpg/context.rb +55 -8
- data/lib/pglpg/element.rb +8 -1
- data/lib/pglpg/errors.rb +19 -0
- data/lib/pglpg/graph.rb +5 -2
- data/lib/pglpg/match_parser.rb +308 -0
- data/lib/pglpg/model/base.rb +11 -1
- data/lib/pglpg/model/edge.rb +67 -2
- data/lib/pglpg/model/node.rb +13 -8
- data/lib/pglpg/model/relation.rb +9 -2
- data/lib/pglpg/query.rb +21 -7
- data/lib/pglpg/reachability.rb +2 -0
- data/lib/pglpg/schema/edge_type.rb +28 -6
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +21 -7
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -31
- data/lib/pglpg/storage.rb +121 -8
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +43 -15
- data/lib/pglpg/testing.rb +10 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +89 -40
- data/lib/pglpg.rb +3 -0
- metadata +4 -1
|
@@ -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
|
data/lib/pglpg/storage.rb
CHANGED
|
@@ -60,7 +60,7 @@ module Pglpg
|
|
|
60
60
|
source_id bigint NOT NULL REFERENCES #{n} (id) ON DELETE RESTRICT,
|
|
61
61
|
target_id bigint NOT NULL REFERENCES #{n} (id) ON DELETE RESTRICT,
|
|
62
62
|
properties jsonb NOT NULL DEFAULT '{}',
|
|
63
|
-
#{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(),
|
|
64
64
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
65
65
|
CHECK (jsonb_typeof(properties) = 'object')
|
|
66
66
|
)
|
|
@@ -83,18 +83,29 @@ module Pglpg
|
|
|
83
83
|
type.node_types.each_value do |t|
|
|
84
84
|
has_label = "labels @> ARRAY[#{connection.quote(t.name.to_s)}]::text[]"
|
|
85
85
|
if (key = t.own_key)
|
|
86
|
-
expressions = key.
|
|
87
|
-
list << [:index, nodes_table, "key_#{t.name}_#{key.property}",
|
|
86
|
+
expressions = key.expressions(connection).join(", ")
|
|
87
|
+
list << [:index, nodes_table, "key_#{t.name}_#{key.property || key.properties.join("_")}",
|
|
88
88
|
"ON #{n} (#{expressions}) WHERE #{has_label}"]
|
|
89
89
|
end
|
|
90
|
+
key_component_checks(list, connection, nodes_table, t, t.own_key, has_label) if t.own_key
|
|
90
91
|
t.own_properties.each_value.select(&:required?).each do |p|
|
|
91
92
|
list << [:check, nodes_table, "req_#{t.name}_#{p.name}", "NOT (#{has_label}) OR #{typed_check(connection, p)}"]
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
temporal_storage = type.temporal? || temporal_columns?(connection)
|
|
95
97
|
type.edge_types.each_value do |t|
|
|
96
98
|
is_type = "type = #{connection.quote(t.name.to_s)}"
|
|
97
|
-
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) }
|
|
98
109
|
if t.symmetric?
|
|
99
110
|
# Canonical orientation, so a pair is stored one way only (ADR-0008).
|
|
100
111
|
list << [:check, edges_table, "sym_#{t.name}",
|
|
@@ -123,6 +134,28 @@ module Pglpg
|
|
|
123
134
|
connection.transaction(requires_new: true) { synchronize_constraints(connection) }
|
|
124
135
|
end
|
|
125
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")
|
|
152
|
+
end
|
|
153
|
+
synchronize_constraints(connection, acknowledged: names.map(&:name))
|
|
154
|
+
backfill.call(connection) if backfill
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
126
159
|
# Rewrites stored label sets to match the graph type.
|
|
127
160
|
def relabel!(connection = graph.connection)
|
|
128
161
|
return 0 if concrete_types.empty?
|
|
@@ -148,7 +181,12 @@ module Pglpg
|
|
|
148
181
|
|
|
149
182
|
private
|
|
150
183
|
|
|
151
|
-
def synchronize_constraints(connection)
|
|
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
|
|
152
190
|
wanted = constraints(connection)
|
|
153
191
|
existing = existing_constraints(connection)
|
|
154
192
|
(existing.keys - wanted.keys).each do |name|
|
|
@@ -160,9 +198,70 @@ module Pglpg
|
|
|
160
198
|
end
|
|
161
199
|
end
|
|
162
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
|
|
163
205
|
nil
|
|
164
206
|
end
|
|
165
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
|
+
|
|
166
265
|
def type = graph.type
|
|
167
266
|
|
|
168
267
|
def q(connection, name) = connection.quote_table_name(name)
|
|
@@ -192,6 +291,20 @@ module Pglpg
|
|
|
192
291
|
|
|
193
292
|
# Rejects a missing key, JSON null and the wrong JSON type: a CHECK passes
|
|
194
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
|
+
|
|
195
308
|
def typed_check(connection, prop)
|
|
196
309
|
"(jsonb_typeof(properties->#{connection.quote(prop.name.to_s)}) = #{connection.quote(prop.json_type)}) IS TRUE"
|
|
197
310
|
end
|
|
@@ -206,17 +319,17 @@ module Pglpg
|
|
|
206
319
|
def existing_constraints(connection)
|
|
207
320
|
prefixes = [nodes_table, edges_table].map { "pglpg\\_#{_1.gsub("_", "\\_")}\\_%" }
|
|
208
321
|
rows = connection.select_rows(<<~SQL, "Pglpg constraints")
|
|
209
|
-
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
|
|
210
323
|
WHERE t.relname IN (#{connection.quote(nodes_table)}, #{connection.quote(edges_table)})
|
|
211
324
|
AND t.relnamespace = to_regnamespace(current_schema())
|
|
212
325
|
AND (c.conname LIKE #{connection.quote(prefixes[0])} OR c.conname LIKE #{connection.quote(prefixes[1])})
|
|
213
326
|
UNION ALL
|
|
214
|
-
SELECT i.indexname, 'index', i.tablename FROM pg_indexes i
|
|
327
|
+
SELECT i.indexname, 'index', i.tablename, true, NULL FROM pg_indexes i
|
|
215
328
|
WHERE i.tablename IN (#{connection.quote(nodes_table)}, #{connection.quote(edges_table)})
|
|
216
329
|
AND i.schemaname = current_schema()
|
|
217
330
|
AND (i.indexname LIKE #{connection.quote(prefixes[0])} OR i.indexname LIKE #{connection.quote(prefixes[1])})
|
|
218
331
|
SQL
|
|
219
|
-
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: }] }
|
|
220
333
|
end
|
|
221
334
|
end
|
|
222
335
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Pglpg
|
|
6
|
+
# Internal validity metadata, deliberately separate from application columns.
|
|
7
|
+
module Temporal
|
|
8
|
+
NAMES = %i[valid_from valid_to].freeze
|
|
9
|
+
OMITTED = Object.new.freeze
|
|
10
|
+
MIN = Date.new(1, 1, 1, Date::GREGORIAN).freeze
|
|
11
|
+
MAX = Date.new(9999, 12, 31, Date::GREGORIAN).freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# PostgreSQL dates are proleptic Gregorian calendar dates. Interpret Ruby
|
|
16
|
+
# Dates by their civil fields, never by timezone or an implicit Time cast.
|
|
17
|
+
def date(value, error: ValidationError)
|
|
18
|
+
return nil if value.nil?
|
|
19
|
+
raise error, "expected an exact Date in 0001-01-01..9999-12-31" unless value.instance_of?(Date)
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
result = Date.new(value.year, value.month, value.day, Date::GREGORIAN)
|
|
23
|
+
rescue Date::Error
|
|
24
|
+
raise error, "invalid Gregorian date"
|
|
25
|
+
end
|
|
26
|
+
raise error, "date outside 0001-01-01..9999-12-31" unless (MIN..MAX).cover?(result)
|
|
27
|
+
|
|
28
|
+
result.freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bounds(type, lower, upper, required: false)
|
|
32
|
+
supplied = { valid_from: lower, valid_to: upper }.reject { |_, v| v.equal?(OMITTED) }
|
|
33
|
+
unless type.temporal?
|
|
34
|
+
raise ArgumentError, "#{type.name} is not temporal" unless supplied.empty?
|
|
35
|
+
return {}.freeze
|
|
36
|
+
end
|
|
37
|
+
raise ArgumentError, "temporal insert/upsert requires valid_from:" if required && lower.equal?(OMITTED)
|
|
38
|
+
|
|
39
|
+
result = supplied.transform_values { date(_1) }
|
|
40
|
+
validate_interval!(result[:valid_from], result[:valid_to])
|
|
41
|
+
result.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_interval!(lower, upper)
|
|
45
|
+
raise ValidationError, "valid_from must precede valid_to" if lower && upper && lower >= upper
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|