pglpg 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +249 -0
- data/README.md +269 -3
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +71 -13
- data/lib/pglpg/element.rb +23 -3
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +13 -6
- data/lib/pglpg/model/base.rb +337 -0
- data/lib/pglpg/model/edge.rb +169 -0
- data/lib/pglpg/model/node.rb +207 -0
- data/lib/pglpg/model/relation.rb +334 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +68 -15
- data/lib/pglpg/reachability.rb +133 -0
- data/lib/pglpg/schema/edge_type.rb +41 -8
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +23 -8
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -22
- data/lib/pglpg/storage.rb +147 -20
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +67 -18
- data/lib/pglpg/testing.rb +131 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +127 -43
- data/lib/pglpg.rb +3 -0
- metadata +9 -1
|
@@ -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,31 +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
|
+
key :uid, scope: :namespace
|
|
35
36
|
end
|
|
36
|
-
node :Project do
|
|
37
|
+
node :Project, open: open do
|
|
37
38
|
property :uid, :string, required: true
|
|
38
|
-
property :title, :string
|
|
39
|
-
key :uid
|
|
39
|
+
property :title, :string, required: true
|
|
40
|
+
key :uid, :title
|
|
40
41
|
end
|
|
41
42
|
node :Location do
|
|
42
43
|
property :uid, :string, required: true
|
|
43
44
|
key :uid
|
|
44
45
|
end
|
|
45
|
-
edge :WORKS_ON, from: :Person, to: :Project do
|
|
46
|
+
edge :WORKS_ON, from: :Person, to: :Project, temporal: temporal, open: open do
|
|
46
47
|
property :role, :string
|
|
48
|
+
property :uid, :string, required: true
|
|
49
|
+
property :batch, :integer, required: true
|
|
50
|
+
key :uid, :batch
|
|
47
51
|
end
|
|
48
|
-
edge :BASED_AT, from: :Project, to: :Location
|
|
49
|
-
edge :KNOWS, from: :Person, to: :Person
|
|
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
|
|
50
55
|
end
|
|
51
56
|
end
|
|
52
57
|
|
|
@@ -57,18 +62,36 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
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) }
|
|
62
72
|
|
|
63
73
|
it "keeps every observation unchanged when hidden data is added" do
|
|
64
74
|
pglpg_leak_graph.storage.create!
|
|
65
75
|
seed = pglpg_seed_graph.for(viewer: nil).trusted_writer
|
|
66
76
|
vis = pglpg_visible_attrs
|
|
67
77
|
hid = pglpg_hidden_attrs
|
|
68
|
-
person =
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
71
91
|
|
|
92
|
+
# Created first, so it has the smallest id: symmetric edges to it are
|
|
93
|
+
# stored with the hidden node in the source column.
|
|
94
|
+
h0 = person.("H0", "Abe", 0, hid)
|
|
72
95
|
p1 = person.("P1", "Ann", 1, vis)
|
|
73
96
|
p2 = person.("P2", "Bob", 2, vis)
|
|
74
97
|
p3 = person.("P3", "Cid", 3, vis)
|
|
@@ -81,12 +104,16 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
81
104
|
edge.(:WORKS_ON, p3, j2, vis, role: "lead"),
|
|
82
105
|
edge.(:BASED_AT, j1, l1, vis),
|
|
83
106
|
edge.(:KNOWS, p1, p2, vis),
|
|
84
|
-
edge.(:KNOWS, p2, p3, vis)
|
|
107
|
+
edge.(:KNOWS, p2, p3, vis),
|
|
108
|
+
edge.(:FRIEND, p2, p1, vis),
|
|
109
|
+
edge.(:FRIEND, p3, p2, vis)
|
|
85
110
|
]
|
|
86
111
|
visible_refs = [p1, p2, p3, j1, j2, l1, *visible_edges]
|
|
87
112
|
|
|
88
|
-
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) } : {}))
|
|
89
114
|
before = Pglpg::Testing.observe(ctx, visible_refs)
|
|
115
|
+
# Three visible People plus two visible Projects in this fixture.
|
|
116
|
+
raise "lazy association observations are missing" unless before.keys.count { _1.to_s.start_with?("assoc_") } == 5
|
|
90
117
|
if before[:persons].size != 3 || before[:works_on].size != 3
|
|
91
118
|
raise "the visibility policy hides the visible fixture; check pglpg_visible_attrs"
|
|
92
119
|
end
|
|
@@ -96,7 +123,13 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
96
123
|
hj = node.(:Project, "HJ", hid, title: "Apollo")
|
|
97
124
|
hl = node.(:Location, "HL", hid)
|
|
98
125
|
hidden_refs = [
|
|
99
|
-
h1, h2, hj, hl,
|
|
126
|
+
h0, h1, h2, hj, hl,
|
|
127
|
+
# symmetric edges: hidden between visible nodes, and visible ones through
|
|
128
|
+
# hidden nodes stored at either end (a hidden bridge p1 ~ h0 ~ p3)
|
|
129
|
+
edge.(:FRIEND, p3, p1, hid),
|
|
130
|
+
edge.(:FRIEND, p1, h0, vis),
|
|
131
|
+
edge.(:FRIEND, h0, p3, vis),
|
|
132
|
+
edge.(:FRIEND, p2, h1, vis),
|
|
100
133
|
# hidden edges between visible nodes
|
|
101
134
|
edge.(:WORKS_ON, p3, j1, hid, role: "lead"),
|
|
102
135
|
edge.(:KNOWS, p3, p1, hid),
|
|
@@ -112,10 +145,26 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
112
145
|
edge.(:KNOWS, h1, h2, hid)
|
|
113
146
|
]
|
|
114
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
|
+
|
|
115
158
|
after = Pglpg::Testing.observe(ctx, visible_refs)
|
|
116
159
|
before.each_key { |name| expect(after[name]).to eq(before[name]), "observation #{name} changed" }
|
|
117
160
|
|
|
118
161
|
hidden_refs.each { |ref| expect(ctx.find(ref)).to be_nil, "#{ref} is visible" }
|
|
119
|
-
expect(ctx.find_node(:Person, uid: "H1")).to be_nil
|
|
162
|
+
expect(ctx.find_node(:Person, namespace: "hidden", uid: "H1")).to be_nil
|
|
163
|
+
|
|
164
|
+
models = Pglpg::Testing.leak_models(pglpg_leak_graph)
|
|
165
|
+
[h0, h1, h2].each do |ref|
|
|
166
|
+
expect { models[:Person].in(ctx).find(ref) }.to raise_error(Pglpg::Model::RecordNotFound), "#{ref} is visible"
|
|
167
|
+
end
|
|
168
|
+
expect { models[:Project].in(ctx).find(hj) }.to raise_error(Pglpg::Model::RecordNotFound), "#{hj} is visible"
|
|
120
169
|
end
|
|
121
170
|
end
|
data/lib/pglpg/testing.rb
CHANGED
|
@@ -35,7 +35,12 @@ module Pglpg
|
|
|
35
35
|
projects: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project).return(:j).distinct },
|
|
36
36
|
page: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project).limit(2).offset(1) },
|
|
37
37
|
ann: -> { m.().node(:p, :Person, name: "Ann").out(:WORKS_ON, :j, :Project) },
|
|
38
|
-
cid_knows: -> { m.().node(:p, :Person, name: "Cid").out(:KNOWS, :q) }
|
|
38
|
+
cid_knows: -> { m.().node(:p, :Person, name: "Cid").out(:KNOWS, :q) },
|
|
39
|
+
friends: -> { m.().node(:p, :Person).both(:FRIEND, :q, :Person, edge: :f) },
|
|
40
|
+
friend_edges: -> { m.().node(:p, :Person).both(:FRIEND, :q, edge: :f).return(:f).distinct },
|
|
41
|
+
friend_chain: -> { m.().node(:a, :Person).both(:FRIEND, :b).both(:FRIEND, :c) },
|
|
42
|
+
ann_friends: -> { m.().node(:p, :Person, name: "Ann").both(:FRIEND, :q) },
|
|
43
|
+
friends_page: -> { m.().node(:p, :Person).both(:FRIEND, :q).limit(2).offset(1) }
|
|
39
44
|
}
|
|
40
45
|
out = {}
|
|
41
46
|
queries.each do |name, q|
|
|
@@ -44,12 +49,135 @@ module Pglpg
|
|
|
44
49
|
out[:"#{name}_exists"] = q.().exists?
|
|
45
50
|
out[:"#{name}_first"] = q.().first&.transform_values { element_data(_1) }
|
|
46
51
|
end
|
|
52
|
+
reach = {
|
|
53
|
+
knows: { via: %i[KNOWS] },
|
|
54
|
+
knows_in: { via: %i[KNOWS], direction: :in },
|
|
55
|
+
friends: { via: %i[FRIEND], direction: :both },
|
|
56
|
+
works: { via: %i[WORKS_ON BASED_AT] },
|
|
57
|
+
works_in: { via: %i[WORKS_ON BASED_AT], direction: :in },
|
|
58
|
+
knows_capped: { via: %i[KNOWS], max_nodes: 1 },
|
|
59
|
+
friends_capped: { via: %i[FRIEND], direction: :both, max_nodes: 1 }
|
|
60
|
+
}
|
|
61
|
+
# Within the application's row cap, which may be smaller than 10.
|
|
62
|
+
budget = [10, Pglpg.configuration.max_rows].min
|
|
63
|
+
reach.each do |name, args|
|
|
64
|
+
out[:"reach_#{name}"] = refs.select(&:node?).map do |ref|
|
|
65
|
+
r = ctx.reachable(from: ref, max_depth: 3, max_nodes: budget, **args)
|
|
66
|
+
[r.nodes.map { |node, depth| [node.id, depth] }, r.complete, r.stopped_at_depth]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
47
69
|
out[:last_by_name] = element_data(m.().node(:p, :Person).order(p: { name: :desc }).first&.fetch(:p))
|
|
48
70
|
out[:find] = refs.map { element_data(ctx.find(_1)) }
|
|
49
|
-
out[:find_node] = %w[P1 P2 P3 H1].map { element_data(ctx.find_node(:Person, uid: _1)) }
|
|
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)
|
|
81
|
+
out.merge(observe_models(ctx, refs))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The same observations through the model layer (ADR-0010).
|
|
85
|
+
def observe_models(ctx, refs)
|
|
86
|
+
models = leak_models(ctx.graph)
|
|
87
|
+
relations = {
|
|
88
|
+
persons: -> { models[:Person].in(ctx).order(:name) },
|
|
89
|
+
ranked: -> { models[:Person].in(ctx).where(rank: Pglpg.gte(2)).order(rank: :desc) },
|
|
90
|
+
persons_page: -> { models[:Person].in(ctx).order(:name).limit(2).offset(1) },
|
|
91
|
+
projects: -> { models[:Project].in(ctx) },
|
|
92
|
+
works_on: -> { models[:WORKS_ON].in(ctx).order(:role) },
|
|
93
|
+
leads: -> { models[:WORKS_ON].in(ctx).where(role: "lead") },
|
|
94
|
+
knows: -> { models[:KNOWS].in(ctx) },
|
|
95
|
+
friends: -> { models[:FRIEND].in(ctx) },
|
|
96
|
+
friends_page: -> { models[:FRIEND].in(ctx).limit(1).offset(1) }
|
|
97
|
+
}
|
|
98
|
+
out = {}
|
|
99
|
+
relations.each do |name, r|
|
|
100
|
+
out[:"model_#{name}"] = r.().map { model_data(_1) }
|
|
101
|
+
out[:"model_#{name}_count"] = r.().count
|
|
102
|
+
out[:"model_#{name}_exists"] = r.().exists?
|
|
103
|
+
out[:"model_#{name}_first"] = model_data(r.().first)
|
|
104
|
+
out[:"model_#{name}_pluck"] = r.().pluck(:id, *r.().model.attribute_names)
|
|
105
|
+
end
|
|
106
|
+
out[:model_find_by] = %w[P1 P2 P3 H1].map { model_data(models[:Person].in(ctx).find_by(uid: _1)) }
|
|
107
|
+
out[:model_find] = refs.map do |ref|
|
|
108
|
+
model = ref.node? ? models[:Person] : models[:WORKS_ON]
|
|
109
|
+
model_data(model.in(ctx).find(ref))
|
|
110
|
+
rescue Pglpg::Model::RecordNotFound
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
out.merge!(observe_associations(ctx, models))
|
|
114
|
+
owners = models[:Person].in(ctx).with_associations(:known, :known_by, :friends, :projects).to_a +
|
|
115
|
+
models[:Project].in(ctx).with_associations(:people).to_a
|
|
116
|
+
eager = observe_associations(ctx, models, owners)
|
|
117
|
+
eager.each { |name, value| raise "preload differs from lazy #{name}" unless value == out.fetch(name) }
|
|
118
|
+
out[:eager_associations] = eager
|
|
119
|
+
nested = models[:Person].in(ctx).with_associations(friends: :friends, projects: :people)
|
|
120
|
+
out[:eager_nested] = nested.map do |owner|
|
|
121
|
+
[owner.uid, { friends: :friends, projects: :people }.map do |name, child|
|
|
122
|
+
owner.public_send(name).map do |target|
|
|
123
|
+
cached = target.public_send(child).map { model_data(_1) }
|
|
124
|
+
lazy = target.class.in(ctx).find(target.ref).public_send(child).map { model_data(_1) }
|
|
125
|
+
raise "nested preload differs from lazy #{name}.#{child}" unless cached == lazy
|
|
126
|
+
|
|
127
|
+
[model_data(target), cached]
|
|
128
|
+
end
|
|
129
|
+
end]
|
|
130
|
+
end
|
|
50
131
|
out
|
|
51
132
|
end
|
|
52
133
|
|
|
134
|
+
# Association target and binding queries from every visible owner.
|
|
135
|
+
def observe_associations(ctx, models, owners = nil)
|
|
136
|
+
pair = ->((node, edge)) { [model_data(node), model_data(edge)] }
|
|
137
|
+
owners ||= models[:Person].in(ctx).order(:uid).to_a + models[:Project].in(ctx).order(:uid).to_a
|
|
138
|
+
owners.to_h do |owner|
|
|
139
|
+
data = owner.class.associations.keys.to_h do |name|
|
|
140
|
+
targets = -> { owner.public_send(name) }
|
|
141
|
+
[name, {
|
|
142
|
+
rows: targets.().map { model_data(_1) },
|
|
143
|
+
count: targets.().count,
|
|
144
|
+
exists: targets.().exists?,
|
|
145
|
+
first: model_data(targets.().first),
|
|
146
|
+
page: targets.().limit(1).offset(1).map { model_data(_1) },
|
|
147
|
+
pairs: targets.().with_rel.map(&pair),
|
|
148
|
+
pairs_count: targets.().with_rel.count,
|
|
149
|
+
pairs_page: targets.().with_rel.limit(1).offset(1).map(&pair),
|
|
150
|
+
pluck: targets.().pluck(:id, *targets.().model.attribute_names),
|
|
151
|
+
pairs_pluck: targets.().with_rel.pluck(:id, rel: [:id, *models.fetch(owner.class.associations[name].edge_type).attribute_names])
|
|
152
|
+
}]
|
|
153
|
+
end
|
|
154
|
+
data[:leads] = owner.projects.rel_where(role: "lead").map { model_data(_1) } if owner.respond_to?(:projects)
|
|
155
|
+
[:"assoc_#{owner.uid}", data]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def model_data(record)
|
|
160
|
+
return nil if record.nil?
|
|
161
|
+
|
|
162
|
+
data = [record.class.schema_type.name, record.id, record.attributes, record.extra_properties]
|
|
163
|
+
data + (record.is_a?(Pglpg::Model::Edge) ? [record.source.id, record.target.id] : [record.labels])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# One model per type of the leak graph, built once per graph.
|
|
167
|
+
def leak_models(leak_graph)
|
|
168
|
+
(@leak_models ||= ObjectSpace::WeakKeyMap.new)[leak_graph] ||=
|
|
169
|
+
%i[Person Project WORKS_ON KNOWS FRIEND].to_h do |type|
|
|
170
|
+
base = leak_graph.type.node_types.key?(type) ? Pglpg::Model::Node : Pglpg::Model::Edge
|
|
171
|
+
[type, Class.new(base) { graph(leak_graph, type:) }]
|
|
172
|
+
end.tap do |models|
|
|
173
|
+
models[:Person].has_many :projects, :out, edge: :WORKS_ON
|
|
174
|
+
models[:Person].has_many :known, :out, edge: :KNOWS
|
|
175
|
+
models[:Person].has_many :known_by, :in, edge: :KNOWS
|
|
176
|
+
models[:Person].has_many :friends, :both, edge: :FRIEND
|
|
177
|
+
models[:Project].has_many :people, :in, edge: :WORKS_ON
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
53
181
|
def element_data(element)
|
|
54
182
|
case element
|
|
55
183
|
when nil then nil
|
|
@@ -61,4 +189,5 @@ module Pglpg
|
|
|
61
189
|
end
|
|
62
190
|
end
|
|
63
191
|
|
|
192
|
+
require_relative "model"
|
|
64
193
|
require_relative "testing/leak_examples" if defined?(RSpec)
|
data/lib/pglpg/version.rb
CHANGED
data/lib/pglpg/writer.rb
CHANGED
|
@@ -8,14 +8,20 @@ module Pglpg
|
|
|
8
8
|
# target: the Ref being changed (update and delete), else nil
|
|
9
9
|
# from, to: endpoint Refs for edge inserts and upserts
|
|
10
10
|
# key: the key for upserts
|
|
11
|
-
|
|
11
|
+
# Scope-wide occupancy permission, distinct from write or read access.
|
|
12
|
+
KeyScopeRequest = Data.define(:context, :domain, :scope)
|
|
13
|
+
|
|
14
|
+
WriteRequest = Data.define(:viewer, :operation, :type, :target, :from, :to, :key, :properties, :attrs, :trusted, :validity, :validity_supplied)
|
|
12
15
|
|
|
13
16
|
# Compiles writes into single statements. Visibility of the existing row,
|
|
14
17
|
# the policy's conditions on it and endpoint checks all run inside the
|
|
15
18
|
# statement, so they hold against the row the statement actually touches,
|
|
16
19
|
# even under concurrent writes.
|
|
17
20
|
class Writer
|
|
21
|
+
attr_reader :context
|
|
22
|
+
|
|
18
23
|
def initialize(context, trusted:)
|
|
24
|
+
context.writable!
|
|
19
25
|
@context = context
|
|
20
26
|
@graph = context.graph
|
|
21
27
|
@trusted = trusted
|
|
@@ -23,17 +29,17 @@ module Pglpg
|
|
|
23
29
|
|
|
24
30
|
def insert_node(type, properties: {}, attrs: {})
|
|
25
31
|
node_type = concrete_node_type!(type)
|
|
26
|
-
props = properties
|
|
27
|
-
|
|
28
|
-
if props.key?(key.property)
|
|
29
|
-
raise ArgumentError, "#{node_type.name}.#{key.property} is a key and is generated" unless @trusted
|
|
30
|
-
else
|
|
31
|
-
props[key.property] = key.generate(node_type.name)
|
|
32
|
-
end
|
|
33
|
-
end
|
|
32
|
+
props = prepare_key(node_type, properties)
|
|
33
|
+
key = node_type.key
|
|
34
34
|
json = node_type.encode_properties(props)
|
|
35
35
|
cols = encode_attrs(@graph.type.node_columns, attrs, insert: true)
|
|
36
36
|
authorize!(:insert_node, node_type.name, properties: props, attrs:)
|
|
37
|
+
if key&.scope && !@trusted && properties.keys.map(&:to_sym).include?(key.property)
|
|
38
|
+
scope = { key.scope => props.fetch(key.scope).dup.freeze }.freeze
|
|
39
|
+
request = KeyScopeRequest.new(context:, domain: key.declared_on, scope:)
|
|
40
|
+
allowed = @graph.key_scope_allowed
|
|
41
|
+
raise WriteRejected unless allowed.respond_to?(:call) && allowed.call(request) == true
|
|
42
|
+
end
|
|
37
43
|
|
|
38
44
|
sql = new_sql
|
|
39
45
|
insert_node_values(sql, node_type, json, cols)
|
|
@@ -45,8 +51,8 @@ module Pglpg
|
|
|
45
51
|
@graph.check_ref!(ref, :node)
|
|
46
52
|
node = @context.find(ref) or raise WriteRejected
|
|
47
53
|
node_type = @graph.type.node_type!(node.key_label)
|
|
48
|
-
props =
|
|
49
|
-
raise ArgumentError, "keys cannot be changed" if node_type.key && props.
|
|
54
|
+
props = node_type.normalize_properties(properties)
|
|
55
|
+
raise ArgumentError, "keys cannot be changed" if node_type.key && (props.keys & node_type.key.properties).any?
|
|
50
56
|
|
|
51
57
|
json = node_type.encode_properties(props, partial: true)
|
|
52
58
|
cols = encode_attrs(@graph.type.node_columns, attrs, insert: false)
|
|
@@ -70,10 +76,10 @@ module Pglpg
|
|
|
70
76
|
node_type = concrete_node_type!(type)
|
|
71
77
|
node_key = node_type.key or raise ArgumentError, "#{node_type.name} has no key"
|
|
72
78
|
key = key.transform_keys(&:to_sym)
|
|
73
|
-
|
|
79
|
+
node_key.validate_values!(node_type, key)
|
|
74
80
|
|
|
75
|
-
props =
|
|
76
|
-
raise ArgumentError, "pass the key in key:, not properties:" if props.
|
|
81
|
+
props = node_type.normalize_properties(properties)
|
|
82
|
+
raise ArgumentError, "pass the key in key:, not properties:" if (props.keys & node_key.properties).any?
|
|
77
83
|
|
|
78
84
|
props = props.merge(key)
|
|
79
85
|
json = node_type.encode_properties(props)
|
|
@@ -82,8 +88,9 @@ module Pglpg
|
|
|
82
88
|
|
|
83
89
|
sql = new_sql
|
|
84
90
|
insert_node_values(sql, node_type, json, cols)
|
|
85
|
-
sql << " ON CONFLICT (
|
|
86
|
-
sql.
|
|
91
|
+
sql << " ON CONFLICT ("
|
|
92
|
+
sql << node_key.expressions(@graph.connection).join(", ")
|
|
93
|
+
sql << ") WHERE labels @> ARRAY["
|
|
87
94
|
sql.literal(node_key.declared_on) << "]::text[] DO UPDATE SET "
|
|
88
95
|
set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
|
|
89
96
|
set_columns(sql, cols, excluded: true)
|
|
@@ -110,32 +117,50 @@ module Pglpg
|
|
|
110
117
|
true
|
|
111
118
|
end
|
|
112
119
|
|
|
113
|
-
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)
|
|
114
121
|
edge_type = @graph.type.edge_type!(type)
|
|
115
|
-
if edge_type.
|
|
116
|
-
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"
|
|
117
124
|
end
|
|
118
125
|
|
|
119
|
-
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)
|
|
120
127
|
end
|
|
121
128
|
|
|
122
|
-
# Trusted only
|
|
123
|
-
|
|
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)
|
|
124
132
|
trusted!(:upsert_edge)
|
|
125
133
|
edge_type = @graph.type.edge_type!(type)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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:)
|
|
129
148
|
end
|
|
130
149
|
|
|
131
|
-
def update_edge(ref, properties: {}, attrs: {})
|
|
150
|
+
def update_edge(ref, properties: {}, attrs: {}, valid_from: Temporal::OMITTED, valid_to: Temporal::OMITTED)
|
|
132
151
|
@graph.check_ref!(ref, :edge)
|
|
133
|
-
edge = @context.find(ref) or raise WriteRejected
|
|
152
|
+
edge = @graph.for(viewer: @context.viewer, as_of: :any).find(ref) or raise WriteRejected
|
|
134
153
|
edge_type = @graph.type.edge_type!(edge.type)
|
|
135
|
-
|
|
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
|
|
136
160
|
json = edge_type.encode_properties(props, partial: true)
|
|
137
161
|
cols = encode_attrs(@graph.type.edge_columns, attrs, insert: false)
|
|
138
|
-
|
|
162
|
+
cols += validity_columns(validity)
|
|
163
|
+
request = authorize!(:update_edge, edge_type.name, target: ref, properties: props, attrs:, validity:)
|
|
139
164
|
|
|
140
165
|
sql = new_sql
|
|
141
166
|
sql << "UPDATE " << table(@graph.edges_table) << " AS tgt SET "
|
|
@@ -149,7 +174,7 @@ module Pglpg
|
|
|
149
174
|
|
|
150
175
|
def delete_edge(ref)
|
|
151
176
|
@graph.check_ref!(ref, :edge)
|
|
152
|
-
edge = @context.find(ref) or raise WriteRejected
|
|
177
|
+
edge = @graph.for(viewer: @context.viewer, as_of: :any).find(ref) or raise WriteRejected
|
|
153
178
|
request = authorize!(:delete_edge, edge.type, target: ref)
|
|
154
179
|
|
|
155
180
|
sql = new_sql
|
|
@@ -163,13 +188,24 @@ module Pglpg
|
|
|
163
188
|
|
|
164
189
|
private
|
|
165
190
|
|
|
166
|
-
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)
|
|
167
193
|
@graph.check_ref!(from, :node)
|
|
168
194
|
@graph.check_ref!(to, :node)
|
|
169
|
-
|
|
195
|
+
if edge_type.symmetric?
|
|
196
|
+
if from.id == to.id && !edge_type.allow_self_loops?
|
|
197
|
+
raise ArgumentError, "#{edge_type.name} does not allow self-loops"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Stored in canonical orientation, so either order names the same pair.
|
|
201
|
+
from, to = to, from if from.id > to.id
|
|
202
|
+
end
|
|
203
|
+
props = prepare_key(edge_type, properties)
|
|
170
204
|
json = edge_type.encode_properties(props)
|
|
171
205
|
cols = encode_attrs(@graph.type.edge_columns, attrs, insert: true)
|
|
172
|
-
|
|
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:)
|
|
173
209
|
|
|
174
210
|
sql = new_sql
|
|
175
211
|
sql << "INSERT INTO " << table(@graph.edges_table) << " AS tgt (type, source_id, target_id, properties"
|
|
@@ -183,26 +219,69 @@ module Pglpg
|
|
|
183
219
|
end
|
|
184
220
|
sql << " FROM " << table(@graph.nodes_table) << " AS s, " << table(@graph.nodes_table) << " AS t WHERE s.id = "
|
|
185
221
|
sql.bind(from.id, "bigint") << " AND t.id = "
|
|
186
|
-
sql.bind(to.id, "bigint") << " AND
|
|
187
|
-
sql
|
|
188
|
-
|
|
222
|
+
sql.bind(to.id, "bigint") << " AND ("
|
|
223
|
+
endpoint_labels(sql, edge_type.from, edge_type.to)
|
|
224
|
+
if edge_type.symmetric? && edge_type.from != edge_type.to
|
|
225
|
+
# Either endpoint may carry either label.
|
|
226
|
+
sql << " OR "
|
|
227
|
+
endpoint_labels(sql, edge_type.to, edge_type.from)
|
|
228
|
+
end
|
|
229
|
+
sql << ") AND "
|
|
189
230
|
@context.append_node_visible(sql, "s")
|
|
190
231
|
sql << " AND "
|
|
191
232
|
@context.append_node_visible(sql, "t")
|
|
192
233
|
# Endpoints stay locked against concurrent change until commit.
|
|
193
234
|
sql << " FOR SHARE OF s, t"
|
|
194
235
|
if operation == :upsert_edge
|
|
195
|
-
|
|
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 = "
|
|
196
242
|
sql.literal(edge_type.name) << " DO UPDATE SET "
|
|
197
243
|
set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
|
|
198
|
-
set_columns(sql,
|
|
199
|
-
|
|
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 "
|
|
200
254
|
existing_row(sql, :edge, request)
|
|
201
255
|
end
|
|
202
256
|
sql << " RETURNING id"
|
|
203
257
|
ref(:edge, write(sql))
|
|
204
258
|
end
|
|
205
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
|
+
|
|
279
|
+
def endpoint_labels(sql, source_label, target_label)
|
|
280
|
+
sql << "(s.labels @> "
|
|
281
|
+
sql.bind_text_array([source_label]) << " AND t.labels @> "
|
|
282
|
+
sql.bind_text_array([target_label]) << ")"
|
|
283
|
+
end
|
|
284
|
+
|
|
206
285
|
def insert_node_values(sql, node_type, json, cols)
|
|
207
286
|
sql << "INSERT INTO " << table(@graph.nodes_table) << " AS tgt (key_label, labels, properties"
|
|
208
287
|
cols.each { |c, _| sql << ", " << ident(c.name) }
|
|
@@ -265,12 +344,17 @@ module Pglpg
|
|
|
265
344
|
end
|
|
266
345
|
end
|
|
267
346
|
|
|
347
|
+
def validity_columns(validity)
|
|
348
|
+
validity.map { |name, value| [Schema::Column.new(name, :date), value&.iso8601] }
|
|
349
|
+
end
|
|
350
|
+
|
|
268
351
|
def null_keys(props) = props.select { |_, v| v.nil? }.keys.map(&:to_s)
|
|
269
352
|
|
|
270
|
-
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)
|
|
271
354
|
request = WriteRequest.new(viewer: @context.viewer, operation:, type: type.to_sym, target:, from:, to:, key:,
|
|
272
355
|
properties: properties.freeze, attrs: attrs.transform_keys(&:to_sym).freeze,
|
|
273
|
-
trusted: @trusted
|
|
356
|
+
trusted: @trusted, validity:,
|
|
357
|
+
validity_supplied: Temporal::NAMES.to_h { [_1, validity.key?(_1)] }.freeze)
|
|
274
358
|
raise WriteRejected unless @graph.write_policy.authorize?(request) == true
|
|
275
359
|
|
|
276
360
|
request
|
|
@@ -312,8 +396,8 @@ module Pglpg
|
|
|
312
396
|
id.to_i
|
|
313
397
|
rescue ActiveRecord::StatementInvalid => e
|
|
314
398
|
case e.cause
|
|
315
|
-
when PG::UniqueViolation, PG::ForeignKeyViolation, PG::RestrictViolation then raise WriteRejected
|
|
316
|
-
when PG::NotNullViolation, PG::CheckViolation then raise ValidationError,
|
|
399
|
+
when PG::UniqueViolation, PG::ForeignKeyViolation, PG::RestrictViolation then raise WriteRejected, cause: nil
|
|
400
|
+
when PG::NotNullViolation, PG::CheckViolation then raise ValidationError, "write violates schema constraints", cause: nil
|
|
317
401
|
else raise
|
|
318
402
|
end
|
|
319
403
|
end
|