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