pglpg 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -22,33 +22,36 @@
22
22
  # (rows, order, counts, exists?, first, pagination, lookups) is unchanged.
23
23
  # Execution time and timeouts are outside this guarantee.
24
24
  RSpec.shared_examples "a pglpg visibility policy" do
25
- leak_graph = lambda do |visibility, columns|
25
+ leak_graph = lambda do |visibility, columns, temporal, open|
26
26
  Pglpg::Graph.define(:pglpg_leak, nodes_table: "pglpg_leak_nodes", edges_table: "pglpg_leak_edges",
27
27
  visibility:, write_policy: Pglpg::Testing::AllowAll,
28
28
  trusted_writer_allowed: ->(_context) { true }) do
29
29
  columns.each { |name, (type, options)| column(name, type, **(options || {})) }
30
- node :Person do
30
+ node :Person, open: open do
31
31
  property :uid, :string, required: true
32
32
  property :name, :string
33
33
  property :rank, :integer
34
34
  property :namespace, :string, required: true
35
35
  key :uid, scope: :namespace
36
36
  end
37
- node :Project do
37
+ node :Project, open: open do
38
38
  property :uid, :string, required: true
39
- property :title, :string
40
- key :uid
39
+ property :title, :string, required: true
40
+ key :uid, :title
41
41
  end
42
42
  node :Location do
43
43
  property :uid, :string, required: true
44
44
  key :uid
45
45
  end
46
- edge :WORKS_ON, from: :Person, to: :Project do
46
+ edge :WORKS_ON, from: :Person, to: :Project, temporal: temporal, open: open do
47
47
  property :role, :string
48
+ property :uid, :string, required: true
49
+ property :batch, :integer, required: true
50
+ key :uid, :batch
48
51
  end
49
- edge :BASED_AT, from: :Project, to: :Location
50
- edge :KNOWS, from: :Person, to: :Person
51
- edge :FRIEND, from: :Person, to: :Person, symmetric: true
52
+ edge :BASED_AT, from: :Project, to: :Location, temporal: temporal
53
+ edge :KNOWS, from: :Person, to: :Person, temporal: temporal
54
+ edge :FRIEND, from: :Person, to: :Person, symmetric: true, temporal: temporal
52
55
  end
53
56
  end
54
57
 
@@ -59,17 +62,32 @@ RSpec.shared_examples "a pglpg visibility policy" do
59
62
  end
60
63
  end
61
64
 
62
- let(:pglpg_leak_graph) { leak_graph.call(pglpg_visibility, pglpg_columns) }
63
- let(:pglpg_seed_graph) { leak_graph.call(Pglpg::Testing::SeeAll, pglpg_columns) }
65
+ # Optional: also exercise temporal validity with the application policy.
66
+ let(:pglpg_temporal) { false }
67
+ # Optional: also exercise extra properties through reads/models.
68
+ let(:pglpg_open_types) { false }
69
+
70
+ let(:pglpg_leak_graph) { leak_graph.call(pglpg_visibility, pglpg_columns, pglpg_temporal, pglpg_open_types) }
71
+ let(:pglpg_seed_graph) { leak_graph.call(Pglpg::Testing::SeeAll, pglpg_columns, pglpg_temporal, pglpg_open_types) }
64
72
 
65
73
  it "keeps every observation unchanged when hidden data is added" do
66
74
  pglpg_leak_graph.storage.create!
67
75
  seed = pglpg_seed_graph.for(viewer: nil).trusted_writer
68
76
  vis = pglpg_visible_attrs
69
77
  hid = pglpg_hidden_attrs
70
- person = ->(uid, name, rank, attrs) { seed.insert_node(:Person, properties: { uid:, name:, rank:, namespace: attrs == hid ? "hidden" : "visible" }, attrs:) }
71
- node = ->(type, uid, attrs, **props) { seed.insert_node(type, properties: { uid:, **props }, attrs:) }
72
- edge = ->(type, from, to, attrs, **props) { seed.insert_edge(type, from:, to:, properties: props, attrs:) }
78
+ person = lambda do |uid, name, rank, attrs|
79
+ extras = pglpg_open_types ? { source_note: uid, evidence: ["one", "two"] } : {}
80
+ seed.insert_node(:Person, properties: { uid:, name:, rank:, namespace: attrs == hid ? "hidden" : "visible", **extras }, attrs:)
81
+ end
82
+ node = lambda do |type, uid, attrs, **props|
83
+ extras = pglpg_open_types && type == :Project ? { source_note: uid } : {}
84
+ seed.insert_node(type, properties: { uid:, **props, **extras }, attrs:)
85
+ end
86
+ edge = lambda do |type, from, to, attrs, **props|
87
+ props = props.merge(uid: "#{from.id}-#{to.id}", batch: 1) if type == :WORKS_ON
88
+ props = props.merge(source_note: "evidence", evidence: [1, 2]) if pglpg_open_types && type == :WORKS_ON
89
+ seed.insert_edge(type, from:, to:, properties: props, attrs:, **(pglpg_temporal ? { valid_from: nil } : {}))
90
+ end
73
91
 
74
92
  # Created first, so it has the smallest id: symmetric edges to it are
75
93
  # stored with the hidden node in the source column.
@@ -92,7 +110,7 @@ RSpec.shared_examples "a pglpg visibility policy" do
92
110
  ]
93
111
  visible_refs = [p1, p2, p3, j1, j2, l1, *visible_edges]
94
112
 
95
- ctx = pglpg_leak_graph.for(viewer: pglpg_viewer)
113
+ ctx = pglpg_leak_graph.for(viewer: pglpg_viewer, **(pglpg_temporal ? { as_of: Date.new(2020, 6, 1) } : {}))
96
114
  before = Pglpg::Testing.observe(ctx, visible_refs)
97
115
  # Three visible People plus two visible Projects in this fixture.
98
116
  raise "lazy association observations are missing" unless before.keys.count { _1.to_s.start_with?("assoc_") } == 5
@@ -127,6 +145,16 @@ RSpec.shared_examples "a pglpg visibility policy" do
127
145
  edge.(:KNOWS, h1, h2, hid)
128
146
  ]
129
147
 
148
+ if pglpg_temporal
149
+ # Visible but ineligible edges must be just as unobservable as hidden
150
+ # data, including expired shortcuts and a disjoint-time bridge.
151
+ [[p1, p3], [p3, p2]].each do |from, to|
152
+ hidden_refs << seed.insert_edge(:KNOWS, from:, to:, attrs: vis, valid_from: nil, valid_to: Date.new(2020, 6, 1))
153
+ end
154
+ hidden_refs << seed.insert_edge(:FRIEND, from: p1, to: p3, attrs: vis, valid_from: Date.new(2020, 6, 2))
155
+ hidden_refs << seed.insert_edge(:WORKS_ON, from: p1, to: j2, properties: { uid: "expired", batch: 1 }, attrs: vis, valid_from: nil, valid_to: Date.new(2020, 6, 1))
156
+ end
157
+
130
158
  after = Pglpg::Testing.observe(ctx, visible_refs)
131
159
  before.each_key { |name| expect(after[name]).to eq(before[name]), "observation #{name} changed" }
132
160
 
data/lib/pglpg/testing.rb CHANGED
@@ -69,6 +69,15 @@ module Pglpg
69
69
  out[:last_by_name] = element_data(m.().node(:p, :Person).order(p: { name: :desc }).first&.fetch(:p))
70
70
  out[:find] = refs.map { element_data(ctx.find(_1)) }
71
71
  out[:find_node] = %w[P1 P2 P3 H1].map { element_data(ctx.find_node(:Person, namespace: _1 == "H1" ? "hidden" : "visible", uid: _1)) }
72
+ out[:find_composite_node] = [["J1", "Apollo"], ["J2", "Gemini"], ["HJ", "Apollo"]].map do |uid, title|
73
+ element_data(ctx.find_node(:Project, uid:, title:))
74
+ end
75
+ persons = refs.select(&:node?).select { ctx.find(_1)&.key_label == :Person }
76
+ projects = refs.select(&:node?).select { ctx.find(_1)&.key_label == :Project }
77
+ out[:find_edge] = persons.product(projects).map do |person, project|
78
+ element_data(ctx.find_edge(:WORKS_ON, uid: "#{person.id}-#{project.id}", batch: 1))
79
+ end
80
+ out[:find_expired_edge] = element_data(ctx.find_edge(:WORKS_ON, uid: "expired", batch: 1)) if ctx.as_of.instance_of?(Date)
72
81
  out.merge(observe_models(ctx, refs))
73
82
  end
74
83
 
data/lib/pglpg/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pglpg
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/pglpg/writer.rb CHANGED
@@ -11,7 +11,7 @@ module Pglpg
11
11
  # Scope-wide occupancy permission, distinct from write or read access.
12
12
  KeyScopeRequest = Data.define(:context, :domain, :scope)
13
13
 
14
- WriteRequest = Data.define(:viewer, :operation, :type, :target, :from, :to, :key, :properties, :attrs, :trusted)
14
+ WriteRequest = Data.define(:viewer, :operation, :type, :target, :from, :to, :key, :properties, :attrs, :trusted, :validity, :validity_supplied)
15
15
 
16
16
  # Compiles writes into single statements. Visibility of the existing row,
17
17
  # the policy's conditions on it and endpoint checks all run inside the
@@ -21,6 +21,7 @@ module Pglpg
21
21
  attr_reader :context
22
22
 
23
23
  def initialize(context, trusted:)
24
+ context.writable!
24
25
  @context = context
25
26
  @graph = context.graph
26
27
  @trusted = trusted
@@ -28,14 +29,8 @@ module Pglpg
28
29
 
29
30
  def insert_node(type, properties: {}, attrs: {})
30
31
  node_type = concrete_node_type!(type)
31
- props = properties.transform_keys(&:to_sym)
32
- if (key = node_type.key)
33
- if props.key?(key.property)
34
- raise ArgumentError, "#{node_type.name}.#{key.property} is a key and is generated" unless @trusted || key.scope
35
- else
36
- props[key.property] = key.generate(node_type.name)
37
- end
38
- end
32
+ props = prepare_key(node_type, properties)
33
+ key = node_type.key
39
34
  json = node_type.encode_properties(props)
40
35
  cols = encode_attrs(@graph.type.node_columns, attrs, insert: true)
41
36
  authorize!(:insert_node, node_type.name, properties: props, attrs:)
@@ -56,7 +51,7 @@ module Pglpg
56
51
  @graph.check_ref!(ref, :node)
57
52
  node = @context.find(ref) or raise WriteRejected
58
53
  node_type = @graph.type.node_type!(node.key_label)
59
- props = properties.transform_keys(&:to_sym)
54
+ props = node_type.normalize_properties(properties)
60
55
  raise ArgumentError, "keys cannot be changed" if node_type.key && (props.keys & node_type.key.properties).any?
61
56
 
62
57
  json = node_type.encode_properties(props, partial: true)
@@ -81,9 +76,9 @@ module Pglpg
81
76
  node_type = concrete_node_type!(type)
82
77
  node_key = node_type.key or raise ArgumentError, "#{node_type.name} has no key"
83
78
  key = key.transform_keys(&:to_sym)
84
- raise ArgumentError, "#{node_type.name} is upserted by #{node_key.properties.inspect}" unless key.keys.sort == node_key.properties.sort
79
+ node_key.validate_values!(node_type, key)
85
80
 
86
- props = properties.transform_keys(&:to_sym)
81
+ props = node_type.normalize_properties(properties)
87
82
  raise ArgumentError, "pass the key in key:, not properties:" if (props.keys & node_key.properties).any?
88
83
 
89
84
  props = props.merge(key)
@@ -94,11 +89,7 @@ module Pglpg
94
89
  sql = new_sql
95
90
  insert_node_values(sql, node_type, json, cols)
96
91
  sql << " ON CONFLICT ("
97
- node_key.properties.each_with_index do |property, i|
98
- sql << ", " if i.positive?
99
- sql << "(properties->>"
100
- sql.literal(property) << ")"
101
- end
92
+ sql << node_key.expressions(@graph.connection).join(", ")
102
93
  sql << ") WHERE labels @> ARRAY["
103
94
  sql.literal(node_key.declared_on) << "]::text[] DO UPDATE SET "
104
95
  set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
@@ -126,32 +117,50 @@ module Pglpg
126
117
  true
127
118
  end
128
119
 
129
- def insert_edge(type, from:, to:, properties: {}, attrs: {})
120
+ def insert_edge(type, from:, to:, properties: {}, attrs: {}, valid_from: Temporal::OMITTED, valid_to: Temporal::OMITTED)
130
121
  edge_type = @graph.type.edge_type!(type)
131
- if edge_type.unique_pair? && !@trusted
132
- raise ArgumentError, "#{edge_type.name} is unique per pair; write it through the trusted writer"
122
+ if edge_type.unique_tuple? && !@trusted
123
+ raise ArgumentError, "#{edge_type.name} is unique per pair or property tuple; write it through the trusted writer"
133
124
  end
134
125
 
135
- write_edge(:insert_edge, edge_type, from, to, properties, attrs)
126
+ write_edge(:insert_edge, edge_type, from, to, properties, attrs, valid_from, valid_to)
136
127
  end
137
128
 
138
- # Trusted only, for edge types declared unique: :pair.
139
- def upsert_edge(type, from:, to:, properties: {}, attrs: {})
129
+ # Trusted only. A declared edge key takes precedence as conflict arbiter;
130
+ # otherwise the endpoint/property tuple is used.
131
+ def upsert_edge(type, from:, to:, key: nil, properties: {}, attrs: {}, valid_from: Temporal::OMITTED, valid_to: Temporal::OMITTED)
140
132
  trusted!(:upsert_edge)
141
133
  edge_type = @graph.type.edge_type!(type)
142
- raise ArgumentError, "#{edge_type.name} is not unique per pair; use insert_edge" unless edge_type.unique_pair?
143
-
144
- write_edge(:upsert_edge, edge_type, from, to, properties, attrs)
134
+ if edge_type.key
135
+ raise ArgumentError, "#{edge_type.name} requires key:" unless key.is_a?(Hash)
136
+
137
+ key = key.transform_keys(&:to_sym)
138
+ edge_type.key.validate_values!(edge_type, key)
139
+ properties = edge_type.normalize_properties(properties)
140
+ raise ArgumentError, "pass the key in key:, not properties:" if (properties.keys & edge_type.key.properties).any?
141
+
142
+ properties = properties.merge(key)
143
+ else
144
+ raise ArgumentError, "#{edge_type.name} has no property key" unless key.nil?
145
+ raise ArgumentError, "#{edge_type.name} is not unique; use insert_edge" unless edge_type.unique_tuple?
146
+ end
147
+ write_edge(:upsert_edge, edge_type, from, to, properties, attrs, valid_from, valid_to, key:)
145
148
  end
146
149
 
147
- def update_edge(ref, properties: {}, attrs: {})
150
+ def update_edge(ref, properties: {}, attrs: {}, valid_from: Temporal::OMITTED, valid_to: Temporal::OMITTED)
148
151
  @graph.check_ref!(ref, :edge)
149
- edge = @context.find(ref) or raise WriteRejected
152
+ edge = @graph.for(viewer: @context.viewer, as_of: :any).find(ref) or raise WriteRejected
150
153
  edge_type = @graph.type.edge_type!(edge.type)
151
- props = properties.transform_keys(&:to_sym)
154
+ validity = Temporal.bounds(edge_type, valid_from, valid_to)
155
+ Temporal.validate_interval!(validity.fetch(:valid_from, edge.valid_from), validity.fetch(:valid_to, edge.valid_to))
156
+ props = edge_type.normalize_properties(properties)
157
+ if (props.keys & edge_type.immutable_properties).any?
158
+ raise ArgumentError, "key and uniqueness properties cannot be changed"
159
+ end
152
160
  json = edge_type.encode_properties(props, partial: true)
153
161
  cols = encode_attrs(@graph.type.edge_columns, attrs, insert: false)
154
- request = authorize!(:update_edge, edge_type.name, target: ref, properties: props, attrs:)
162
+ cols += validity_columns(validity)
163
+ request = authorize!(:update_edge, edge_type.name, target: ref, properties: props, attrs:, validity:)
155
164
 
156
165
  sql = new_sql
157
166
  sql << "UPDATE " << table(@graph.edges_table) << " AS tgt SET "
@@ -165,7 +174,7 @@ module Pglpg
165
174
 
166
175
  def delete_edge(ref)
167
176
  @graph.check_ref!(ref, :edge)
168
- edge = @context.find(ref) or raise WriteRejected
177
+ edge = @graph.for(viewer: @context.viewer, as_of: :any).find(ref) or raise WriteRejected
169
178
  request = authorize!(:delete_edge, edge.type, target: ref)
170
179
 
171
180
  sql = new_sql
@@ -179,7 +188,8 @@ module Pglpg
179
188
 
180
189
  private
181
190
 
182
- def write_edge(operation, edge_type, from, to, properties, attrs)
191
+ def write_edge(operation, edge_type, from, to, properties, attrs, valid_from, valid_to, key: nil)
192
+ validity = Temporal.bounds(edge_type, valid_from, valid_to, required: true)
183
193
  @graph.check_ref!(from, :node)
184
194
  @graph.check_ref!(to, :node)
185
195
  if edge_type.symmetric?
@@ -190,10 +200,12 @@ module Pglpg
190
200
  # Stored in canonical orientation, so either order names the same pair.
191
201
  from, to = to, from if from.id > to.id
192
202
  end
193
- props = properties.transform_keys(&:to_sym)
203
+ props = prepare_key(edge_type, properties)
194
204
  json = edge_type.encode_properties(props)
195
205
  cols = encode_attrs(@graph.type.edge_columns, attrs, insert: true)
196
- request = authorize!(operation, edge_type.name, from:, to:, properties: props, attrs:)
206
+ update_cols = cols + validity_columns(validity)
207
+ cols += validity_columns(edge_type.temporal? ? { valid_from: nil, valid_to: nil }.merge(validity) : {})
208
+ request = authorize!(operation, edge_type.name, from:, to:, key:, properties: props, attrs:, validity:)
197
209
 
198
210
  sql = new_sql
199
211
  sql << "INSERT INTO " << table(@graph.edges_table) << " AS tgt (type, source_id, target_id, properties"
@@ -221,17 +233,49 @@ module Pglpg
221
233
  # Endpoints stay locked against concurrent change until commit.
222
234
  sql << " FOR SHARE OF s, t"
223
235
  if operation == :upsert_edge
224
- sql << " ON CONFLICT (source_id, target_id) WHERE type = "
236
+ expressions = if edge_type.key
237
+ edge_type.key.expressions(@graph.connection)
238
+ else
239
+ ["source_id", "target_id", *(edge_type.pair_key&.expressions(@graph.connection) || [])]
240
+ end
241
+ sql << " ON CONFLICT (" << expressions.join(", ") << ") WHERE type = "
225
242
  sql.literal(edge_type.name) << " DO UPDATE SET "
226
243
  set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
227
- set_columns(sql, cols, excluded: true)
228
- sql << " WHERE "
244
+ set_columns(sql, update_cols, excluded: true)
245
+ # A property key must never redirect an existing relationship. Pair
246
+ # properties are immutable too, including when a global key arbitrates.
247
+ sql << " WHERE tgt.source_id = EXCLUDED.source_id AND tgt.target_id = EXCLUDED.target_id"
248
+ edge_type.unique_pair_properties.each do |property|
249
+ sql << " AND tgt.properties->"
250
+ sql.literal(property) << " = EXCLUDED.properties->"
251
+ sql.literal(property)
252
+ end
253
+ sql << " AND "
229
254
  existing_row(sql, :edge, request)
230
255
  end
231
256
  sql << " RETURNING id"
232
257
  ref(:edge, write(sql))
233
258
  end
234
259
 
260
+ def prepare_key(type, properties)
261
+ props = type.normalize_properties(properties)
262
+ key = type.key
263
+ return props unless key
264
+ unless key.generated?
265
+ raise ArgumentError, "#{type.name} has a caller-chosen key; use the trusted writer" unless @trusted
266
+
267
+ return props
268
+ end
269
+ if props.key?(key.property)
270
+ unless @trusted || (type.is_a?(Schema::NodeType) && key.scope)
271
+ raise ArgumentError, "#{type.name}.#{key.property} is a key and is generated"
272
+ end
273
+ else
274
+ props[key.property] = key.generate(type.name)
275
+ end
276
+ props
277
+ end
278
+
235
279
  def endpoint_labels(sql, source_label, target_label)
236
280
  sql << "(s.labels @> "
237
281
  sql.bind_text_array([source_label]) << " AND t.labels @> "
@@ -300,12 +344,17 @@ module Pglpg
300
344
  end
301
345
  end
302
346
 
347
+ def validity_columns(validity)
348
+ validity.map { |name, value| [Schema::Column.new(name, :date), value&.iso8601] }
349
+ end
350
+
303
351
  def null_keys(props) = props.select { |_, v| v.nil? }.keys.map(&:to_s)
304
352
 
305
- def authorize!(operation, type, target: nil, from: nil, to: nil, key: nil, properties: {}, attrs: {})
353
+ def authorize!(operation, type, target: nil, from: nil, to: nil, key: nil, properties: {}, attrs: {}, validity: {}.freeze)
306
354
  request = WriteRequest.new(viewer: @context.viewer, operation:, type: type.to_sym, target:, from:, to:, key:,
307
355
  properties: properties.freeze, attrs: attrs.transform_keys(&:to_sym).freeze,
308
- trusted: @trusted)
356
+ trusted: @trusted, validity:,
357
+ validity_supplied: Temporal::NAMES.to_h { [_1, validity.key?(_1)] }.freeze)
309
358
  raise WriteRejected unless @graph.write_policy.authorize?(request) == true
310
359
 
311
360
  request
@@ -348,7 +397,7 @@ module Pglpg
348
397
  rescue ActiveRecord::StatementInvalid => e
349
398
  case e.cause
350
399
  when PG::UniqueViolation, PG::ForeignKeyViolation, PG::RestrictViolation then raise WriteRejected, cause: nil
351
- when PG::NotNullViolation, PG::CheckViolation then raise ValidationError, e.message
400
+ when PG::NotNullViolation, PG::CheckViolation then raise ValidationError, "write violates schema constraints", cause: nil
352
401
  else raise
353
402
  end
354
403
  end
data/lib/pglpg.rb CHANGED
@@ -6,8 +6,10 @@ require "json"
6
6
  require_relative "pglpg/version"
7
7
  require_relative "pglpg/errors"
8
8
  require_relative "pglpg/configuration"
9
+ require_relative "pglpg/temporal"
9
10
  require_relative "pglpg/schema/property"
10
11
  require_relative "pglpg/schema/element_type"
12
+ require_relative "pglpg/schema/key"
11
13
  require_relative "pglpg/schema/node_type"
12
14
  require_relative "pglpg/schema/edge_type"
13
15
  require_relative "pglpg/schema/column"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pglpg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Fefilov
@@ -66,10 +66,12 @@ files:
66
66
  - lib/pglpg/schema/edge_type.rb
67
67
  - lib/pglpg/schema/element_type.rb
68
68
  - lib/pglpg/schema/graph_type.rb
69
+ - lib/pglpg/schema/key.rb
69
70
  - lib/pglpg/schema/node_type.rb
70
71
  - lib/pglpg/schema/property.rb
71
72
  - lib/pglpg/sql.rb
72
73
  - lib/pglpg/storage.rb
74
+ - lib/pglpg/temporal.rb
73
75
  - lib/pglpg/testing.rb
74
76
  - lib/pglpg/testing/leak_examples.rb
75
77
  - lib/pglpg/version.rb