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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +209 -7
- data/lib/pglpg/context.rb +53 -8
- data/lib/pglpg/element.rb +8 -1
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +5 -2
- 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 +9 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +89 -40
- data/lib/pglpg.rb +2 -0
- metadata +3 -1
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
|
|
@@ -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
|
-
|
|
63
|
-
let(:
|
|
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 =
|
|
71
|
-
|
|
72
|
-
|
|
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
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
|
|
32
|
-
|
|
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 =
|
|
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
|
-
|
|
79
|
+
node_key.validate_values!(node_type, key)
|
|
85
80
|
|
|
86
|
-
props =
|
|
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.
|
|
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.
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
228
|
-
|
|
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,
|
|
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.
|
|
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
|