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.
@@ -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, "symmetric edge types are planned for 0.2 (#{name})" if symmetric
13
- raise SchemaError, "temporal edge types are planned for 0.3 (#{name})" if temporal
14
- raise SchemaError, "unique: must be nil or :pair (#{name})" unless [nil, :pair].include?(unique)
14
+ def initialize(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false, open: false)
15
+ super(name, open:)
16
+ raise SchemaError, "temporal: must be true or false" unless [true, false].include?(temporal)
17
+ @temporal = temporal
18
+ unless unique.nil? || unique == :pair || (unique.is_a?(Array) && !unique.empty?)
19
+ raise SchemaError, "unique: must be nil, :pair or a nonempty property list (#{name})"
20
+ end
21
+ @pair_key = Key.new(unique, declared_on: self.name, generation: false) if unique.is_a?(Array)
22
+ raise SchemaError, "pair uniqueness has too many components" if @pair_key && @pair_key.properties.size > 30
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
- # Shared by node and edge types: declared properties. Types are closed:
14
- # a property that is not declared is rejected.
13
+ # Declared properties stay typed. Explicit open types also accept a
14
+ # restricted JSON-native value set without guessing a schema for extras.
15
15
  class ElementType
16
16
  attr_reader :name
17
17
 
18
- def initialize(name)
18
+ def initialize(name, open: false)
19
+ raise SchemaError, "open: must be true or false" unless open.equal?(true) || open.equal?(false)
20
+
19
21
  @name = Schema.check_name!(name, "type")
20
22
  @own_properties = {}
23
+ @open = open
21
24
  end
22
25
 
26
+ def open? = @open
27
+
23
28
  def property(name, type, required: false, values: nil, of: nil)
24
29
  name = Schema.check_name!(name, "property")
25
30
  raise SchemaError, "#{self.name}.#{name} is declared twice" if @own_properties.key?(name)
@@ -36,20 +41,50 @@ module Pglpg
36
41
 
37
42
  def required_properties = properties.values.select(&:required?)
38
43
 
44
+ # @api private
45
+ # Construction-only resolution; the descriptor is frozen afterwards.
46
+ def resolve_open!(columns, parents: [])
47
+ @open ||= parents.any?(&:open?)
48
+ @extra_reserved_names = (Column::RESERVED + Temporal::NAMES + columns.keys).uniq.freeze
49
+ end
50
+
51
+ # Normalize before the writer generates/merges keys, so ambiguous
52
+ # String/Symbol aliases cannot disappear before validation.
53
+ # @api private
54
+ def normalize_properties(props)
55
+ return (props || {}).transform_keys(&:to_sym) unless open?
56
+
57
+ (props || {}).each_with_object({}) do |(name, value), out|
58
+ unless (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(NAME_FORMAT)
59
+ raise ValidationError, "invalid open property name"
60
+ end
61
+ name = name.to_sym
62
+ raise ValidationError, "duplicate property #{name}" if out.key?(name)
63
+ if !properties.key?(name) && @extra_reserved_names.include?(name)
64
+ raise ValidationError, "#{name} is reserved and cannot be an extra property"
65
+ end
66
+
67
+ out[name] = value
68
+ end
69
+ end
70
+
39
71
  # Validates +props+ and returns the JSON hash to store. nil values are
40
72
  # dropped (null means absent). With +partial+, required properties may be
41
73
  # missing but may not be set to nil.
42
74
  def encode_properties(props, partial: false)
43
- props = (props || {}).transform_keys(&:to_sym)
75
+ props = normalize_properties(props)
44
76
  unknown = props.keys - properties.keys
45
- raise ValidationError, "#{name} has no propert#{unknown.size == 1 ? "y" : "ies"} #{unknown.join(", ")}" if unknown.any?
77
+ if !open? && unknown.any?
78
+ raise ValidationError, "#{name} has no propert#{unknown.size == 1 ? "y" : "ies"} #{unknown.join(", ")}"
79
+ end
46
80
 
47
81
  required_properties.each do |p|
48
82
  next if partial && !props.key?(p.name)
49
83
  raise ValidationError, "#{name}.#{p.name} is required" if props[p.name].nil?
50
84
  end
51
85
  props.each_with_object({}) do |(k, v), out|
52
- encoded = properties.fetch(k).encode(v)
86
+ prop = properties[k]
87
+ encoded = prop ? prop.encode(v) : encode_extra(v)
53
88
  out[k.to_s] = encoded unless encoded.nil?
54
89
  end
55
90
  end
@@ -57,9 +92,47 @@ module Pglpg
57
92
  def decode_properties(json)
58
93
  (json || {}).each_with_object({}) do |(k, v), out|
59
94
  prop = properties[k.to_sym]
60
- out[k.to_sym] = prop ? prop.decode(v) : v
95
+ out[k.to_sym] = prop ? prop.decode(v) : freeze_extra(v)
61
96
  end.freeze
62
97
  end
98
+
99
+ private
100
+
101
+ def encode_extra(value)
102
+ return nil if value.nil?
103
+ return encode_extra_scalar(value) unless value.instance_of?(Array)
104
+
105
+ kinds = value.map { (_1.equal?(true) || _1.equal?(false)) ? :boolean : _1.class }.uniq
106
+ raise ValidationError, "extra property lists must be homogeneous scalar arrays" if kinds.size > 1
107
+
108
+ value.map { encode_extra_scalar(_1) }
109
+ end
110
+
111
+ def encode_extra_scalar(value)
112
+ if value.instance_of?(String)
113
+ begin
114
+ text = value.encode(Encoding::UTF_8)
115
+ rescue EncodingError
116
+ raise ValidationError, "extra property strings must be valid UTF-8"
117
+ end
118
+ raise ValidationError, "extra property strings must be valid UTF-8" unless text.valid_encoding?
119
+ raise ValidationError, "extra property strings cannot contain NUL" if text.include?("\0")
120
+
121
+ return text
122
+ end
123
+ return value if value.instance_of?(Integer) || value.equal?(true) || value.equal?(false)
124
+ return value if value.instance_of?(Float) && value.finite?
125
+
126
+ raise ValidationError, "extra property values must be JSON-native scalars or homogeneous scalar arrays"
127
+ end
128
+
129
+ def freeze_extra(value)
130
+ case value
131
+ when Array then value.each { freeze_extra(_1) }
132
+ when Hash then value.each { |key, child| freeze_extra(key); freeze_extra(child) }
133
+ end
134
+ value.freeze
135
+ end
63
136
  end
64
137
  end
65
138
  end
@@ -27,9 +27,15 @@ module Pglpg
27
27
  node_types.values.select { _1.label?(label) }
28
28
  end
29
29
 
30
+ def temporal? = edge_types.values.any?(&:temporal?)
31
+
30
32
  def resolve!
31
33
  node_types.each_value { _1.resolve!(self) }
32
34
  edge_types.each_value { _1.resolve!(self) }
35
+ if temporal?
36
+ collisions = (node_columns.keys + edge_columns.keys + node_types.values.flat_map { _1.properties.keys } + edge_types.values.flat_map { _1.properties.keys }) & Temporal::NAMES
37
+ raise SchemaError, "reserved temporal names: #{collisions.join(", ")}" unless collisions.empty?
38
+ end
33
39
  [node_types, edge_types, node_columns, edge_columns].each(&:freeze)
34
40
  freeze
35
41
  end
@@ -41,19 +47,19 @@ module Pglpg
41
47
  @graph_type = graph_type
42
48
  end
43
49
 
44
- def node(name, extends: [], abstract: false, &block)
45
- type = NodeType.new(name, extends:, abstract:)
50
+ def node(name, extends: [], abstract: false, open: false, &block)
51
+ type = NodeType.new(name, extends:, abstract:, open:)
46
52
  raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
47
53
 
48
54
  NodeBuilder.new(type).instance_eval(&block) if block
49
55
  @graph_type.node_types[type.name] = type
50
56
  end
51
57
 
52
- def edge(name, from:, to:, unique: nil, symmetric: false, 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
- ElementBuilder.new(type).instance_eval(&block) if block
62
+ EdgeBuilder.new(type).instance_eval(&block) if block
57
63
  @graph_type.edge_types[type.name] = type
58
64
  end
59
65
 
@@ -89,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); callers never choose it.
95
- def key(name, generate: nil)
96
- @type.key_property(name, generate:)
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(name, generate: nil)
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(property: name.to_sym, declared_on: self.name, generator: generate || DEFAULT_KEY_GENERATOR)
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
- prop = @properties[key.property]
84
- raise SchemaError, "key #{key.declared_on}.#{key.property} is not a declared property" unless prop
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
- tables_sql(connection).each { connection.execute(_1) }
31
- sync_constraints!(connection)
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
- list << [:index, nodes_table, "key_#{t.name}_#{key.property}",
85
- "ON #{n} ((properties->>#{connection.quote(key.property.to_s)})) WHERE #{has_label}"]
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 << [:index, edges_table, "pair_#{t.name}", "ON #{e} (source_id, target_id) WHERE #{is_type}"] if t.unique_pair?
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
- # Adds missing generated constraints and drops stale ones.
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
- wanted = constraints(connection)
114
- existing = existing_constraints(connection)
115
- (existing.keys - wanted.keys).each do |name|
116
- info = existing[name]
117
- if info[:kind] == :index
118
- connection.execute("DROP INDEX #{q(connection, name)}")
119
- else
120
- connection.execute("ALTER TABLE #{q(connection, info[:table])} DROP CONSTRAINT #{q(connection, name)}")
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