pglpg 0.1.0 → 0.2.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 +157 -0
- data/README.md +65 -1
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +20 -7
- data/lib/pglpg/element.rb +16 -3
- data/lib/pglpg/graph.rb +8 -4
- data/lib/pglpg/model/base.rb +327 -0
- data/lib/pglpg/model/edge.rb +104 -0
- data/lib/pglpg/model/node.rb +202 -0
- data/lib/pglpg/model/relation.rb +327 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +47 -8
- data/lib/pglpg/reachability.rb +131 -0
- data/lib/pglpg/schema/edge_type.rb +14 -3
- data/lib/pglpg/schema/graph_type.rb +6 -5
- data/lib/pglpg/schema/node_type.rb +16 -7
- data/lib/pglpg/storage.rb +30 -16
- data/lib/pglpg/testing/leak_examples.rb +26 -5
- data/lib/pglpg/testing.rb +122 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +45 -10
- data/lib/pglpg.rb +1 -0
- metadata +7 -1
|
@@ -49,8 +49,8 @@ module Pglpg
|
|
|
49
49
|
@graph_type.node_types[type.name] = type
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
def edge(name, from:, to:, unique: nil, symmetric: false, temporal: false, &block)
|
|
53
|
-
type = EdgeType.new(name, from:, to:, unique:, symmetric:, temporal:)
|
|
52
|
+
def edge(name, from:, to:, unique: nil, symmetric: false, allow_self_loops: false, temporal: false, &block)
|
|
53
|
+
type = EdgeType.new(name, from:, to:, unique:, symmetric:, allow_self_loops:, temporal:)
|
|
54
54
|
raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
|
|
55
55
|
|
|
56
56
|
ElementBuilder.new(type).instance_eval(&block) if block
|
|
@@ -91,9 +91,10 @@ module Pglpg
|
|
|
91
91
|
|
|
92
92
|
class NodeBuilder < ElementBuilder
|
|
93
93
|
# Declares an exclusive key. Interactive inserts get a value from
|
|
94
|
-
# +generate+ (default: a random UUID)
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
# +generate+ (default: a random UUID). Scoped keys may be caller-chosen
|
|
95
|
+
# with an explicit key_scope_allowed grant.
|
|
96
|
+
def key(name, generate: nil, scope: nil)
|
|
97
|
+
@type.key_property(name, generate:, scope:)
|
|
97
98
|
end
|
|
98
99
|
end
|
|
99
100
|
end
|
|
@@ -7,10 +7,12 @@ module Pglpg
|
|
|
7
7
|
# A node type: a key label, inherited labels and property types
|
|
8
8
|
# (GQL node types, PG-Schema inheritance).
|
|
9
9
|
class NodeType < ElementType
|
|
10
|
-
# An exclusive, mandatory key
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
Key = Struct.new(:property, :declared_on, :generator, keyword_init: true) do
|
|
10
|
+
# An exclusive, mandatory string key, optionally scoped by another
|
|
11
|
+
# required string property. Caller-chosen scoped keys need an explicit
|
|
12
|
+
# occupancy grant; omitted keys use +generator+.
|
|
13
|
+
Key = Struct.new(:property, :declared_on, :generator, :scope, keyword_init: true) do
|
|
14
|
+
def properties = [scope, property].compact.freeze
|
|
15
|
+
|
|
14
16
|
def generate(type) = generator.call(type)
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -27,11 +29,11 @@ module Pglpg
|
|
|
27
29
|
|
|
28
30
|
def abstract? = @abstract
|
|
29
31
|
|
|
30
|
-
def key_property(name, generate: nil)
|
|
32
|
+
def key_property(name, generate: nil, scope: nil)
|
|
31
33
|
raise SchemaError, "#{self.name} declares more than one key" if @own_key
|
|
32
34
|
raise SchemaError, "key generator for #{self.name} must respond to call" if generate && !generate.respond_to?(:call)
|
|
33
35
|
|
|
34
|
-
@own_key = Key.new(property: name.to_sym, declared_on: self.name, generator: generate || DEFAULT_KEY_GENERATOR)
|
|
36
|
+
@own_key = Key.new(property: name.to_sym, declared_on: self.name, generator: generate || DEFAULT_KEY_GENERATOR, scope: scope && Schema.check_name!(scope, "key scope"))
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def own_key = @own_key
|
|
@@ -85,7 +87,14 @@ module Pglpg
|
|
|
85
87
|
raise SchemaError, "key #{key.declared_on}.#{key.property} must be required" unless prop.required?
|
|
86
88
|
raise SchemaError, "key #{key.declared_on}.#{key.property} must be a string" unless prop.type == :string
|
|
87
89
|
|
|
88
|
-
key
|
|
90
|
+
if key.scope
|
|
91
|
+
raise SchemaError, "scope must differ from key property" if key.scope == key.property
|
|
92
|
+
scope = @properties[key.scope]
|
|
93
|
+
unless scope && scope.required? && scope.type == :string
|
|
94
|
+
raise SchemaError, "key scope #{key.scope} must be a declared required string property"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
key.freeze
|
|
89
98
|
end
|
|
90
99
|
end
|
|
91
100
|
end
|
data/lib/pglpg/storage.rb
CHANGED
|
@@ -27,8 +27,10 @@ module Pglpg
|
|
|
27
27
|
raise ConfigurationError, "pglpg needs PostgreSQL 18 or later"
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
connection.transaction(requires_new: true) do
|
|
31
|
+
tables_sql(connection).each { connection.execute(_1) }
|
|
32
|
+
synchronize_constraints(connection)
|
|
33
|
+
end
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def drop!(connection = graph.connection)
|
|
@@ -81,8 +83,9 @@ module Pglpg
|
|
|
81
83
|
type.node_types.each_value do |t|
|
|
82
84
|
has_label = "labels @> ARRAY[#{connection.quote(t.name.to_s)}]::text[]"
|
|
83
85
|
if (key = t.own_key)
|
|
86
|
+
expressions = key.properties.map { "(properties->>#{connection.quote(_1.to_s)})" }.join(", ")
|
|
84
87
|
list << [:index, nodes_table, "key_#{t.name}_#{key.property}",
|
|
85
|
-
"ON #{n} (
|
|
88
|
+
"ON #{n} (#{expressions}) WHERE #{has_label}"]
|
|
86
89
|
end
|
|
87
90
|
t.own_properties.each_value.select(&:required?).each do |p|
|
|
88
91
|
list << [:check, nodes_table, "req_#{t.name}_#{p.name}", "NOT (#{has_label}) OR #{typed_check(connection, p)}"]
|
|
@@ -92,6 +95,11 @@ module Pglpg
|
|
|
92
95
|
type.edge_types.each_value do |t|
|
|
93
96
|
is_type = "type = #{connection.quote(t.name.to_s)}"
|
|
94
97
|
list << [:index, edges_table, "pair_#{t.name}", "ON #{e} (source_id, target_id) WHERE #{is_type}"] if t.unique_pair?
|
|
98
|
+
if t.symmetric?
|
|
99
|
+
# Canonical orientation, so a pair is stored one way only (ADR-0008).
|
|
100
|
+
list << [:check, edges_table, "sym_#{t.name}",
|
|
101
|
+
"NOT (#{is_type}) OR source_id #{t.allow_self_loops? ? "<=" : "<"} target_id"]
|
|
102
|
+
end
|
|
95
103
|
t.own_properties.each_value.select(&:required?).each do |p|
|
|
96
104
|
list << [:check, edges_table, "req_#{t.name}_#{p.name}", "NOT (#{is_type}) OR #{typed_check(connection, p)}"]
|
|
97
105
|
end
|
|
@@ -108,20 +116,11 @@ module Pglpg
|
|
|
108
116
|
end
|
|
109
117
|
end
|
|
110
118
|
|
|
111
|
-
#
|
|
119
|
+
# Atomic on the supplied connection: its own transaction or a savepoint
|
|
120
|
+
# within an ActiveRecord-managed caller transaction. Backfills belong in
|
|
121
|
+
# an explicit outer transaction on the same connection.
|
|
112
122
|
def sync_constraints!(connection = graph.connection)
|
|
113
|
-
|
|
114
|
-
existing = existing_constraints(connection)
|
|
115
|
-
(existing.keys - wanted.keys).each do |name|
|
|
116
|
-
info = existing[name]
|
|
117
|
-
if info[:kind] == :index
|
|
118
|
-
connection.execute("DROP INDEX #{q(connection, name)}")
|
|
119
|
-
else
|
|
120
|
-
connection.execute("ALTER TABLE #{q(connection, info[:table])} DROP CONSTRAINT #{q(connection, name)}")
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
(wanted.keys - existing.keys).each { connection.execute(wanted[_1][:sql]) }
|
|
124
|
-
nil
|
|
123
|
+
connection.transaction(requires_new: true) { synchronize_constraints(connection) }
|
|
125
124
|
end
|
|
126
125
|
|
|
127
126
|
# Rewrites stored label sets to match the graph type.
|
|
@@ -149,6 +148,21 @@ module Pglpg
|
|
|
149
148
|
|
|
150
149
|
private
|
|
151
150
|
|
|
151
|
+
def synchronize_constraints(connection)
|
|
152
|
+
wanted = constraints(connection)
|
|
153
|
+
existing = existing_constraints(connection)
|
|
154
|
+
(existing.keys - wanted.keys).each do |name|
|
|
155
|
+
info = existing[name]
|
|
156
|
+
if info[:kind] == :index
|
|
157
|
+
connection.execute("DROP INDEX #{q(connection, name)}")
|
|
158
|
+
else
|
|
159
|
+
connection.execute("ALTER TABLE #{q(connection, info[:table])} DROP CONSTRAINT #{q(connection, name)}")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
(wanted.keys - existing.keys).each { connection.execute(wanted[_1][:sql]) }
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
152
166
|
def type = graph.type
|
|
153
167
|
|
|
154
168
|
def q(connection, name) = connection.quote_table_name(name)
|
|
@@ -31,7 +31,8 @@ RSpec.shared_examples "a pglpg visibility policy" 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
37
|
node :Project do
|
|
37
38
|
property :uid, :string, required: true
|
|
@@ -47,6 +48,7 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
47
48
|
end
|
|
48
49
|
edge :BASED_AT, from: :Project, to: :Location
|
|
49
50
|
edge :KNOWS, from: :Person, to: :Person
|
|
51
|
+
edge :FRIEND, from: :Person, to: :Person, symmetric: true
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -65,10 +67,13 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
65
67
|
seed = pglpg_seed_graph.for(viewer: nil).trusted_writer
|
|
66
68
|
vis = pglpg_visible_attrs
|
|
67
69
|
hid = pglpg_hidden_attrs
|
|
68
|
-
person = ->(uid, name, rank, attrs) { seed.insert_node(:Person, properties: { uid:, name:, rank: }, attrs:) }
|
|
70
|
+
person = ->(uid, name, rank, attrs) { seed.insert_node(:Person, properties: { uid:, name:, rank:, namespace: attrs == hid ? "hidden" : "visible" }, attrs:) }
|
|
69
71
|
node = ->(type, uid, attrs, **props) { seed.insert_node(type, properties: { uid:, **props }, attrs:) }
|
|
70
72
|
edge = ->(type, from, to, attrs, **props) { seed.insert_edge(type, from:, to:, properties: props, attrs:) }
|
|
71
73
|
|
|
74
|
+
# Created first, so it has the smallest id: symmetric edges to it are
|
|
75
|
+
# stored with the hidden node in the source column.
|
|
76
|
+
h0 = person.("H0", "Abe", 0, hid)
|
|
72
77
|
p1 = person.("P1", "Ann", 1, vis)
|
|
73
78
|
p2 = person.("P2", "Bob", 2, vis)
|
|
74
79
|
p3 = person.("P3", "Cid", 3, vis)
|
|
@@ -81,12 +86,16 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
81
86
|
edge.(:WORKS_ON, p3, j2, vis, role: "lead"),
|
|
82
87
|
edge.(:BASED_AT, j1, l1, vis),
|
|
83
88
|
edge.(:KNOWS, p1, p2, vis),
|
|
84
|
-
edge.(:KNOWS, p2, p3, vis)
|
|
89
|
+
edge.(:KNOWS, p2, p3, vis),
|
|
90
|
+
edge.(:FRIEND, p2, p1, vis),
|
|
91
|
+
edge.(:FRIEND, p3, p2, vis)
|
|
85
92
|
]
|
|
86
93
|
visible_refs = [p1, p2, p3, j1, j2, l1, *visible_edges]
|
|
87
94
|
|
|
88
95
|
ctx = pglpg_leak_graph.for(viewer: pglpg_viewer)
|
|
89
96
|
before = Pglpg::Testing.observe(ctx, visible_refs)
|
|
97
|
+
# Three visible People plus two visible Projects in this fixture.
|
|
98
|
+
raise "lazy association observations are missing" unless before.keys.count { _1.to_s.start_with?("assoc_") } == 5
|
|
90
99
|
if before[:persons].size != 3 || before[:works_on].size != 3
|
|
91
100
|
raise "the visibility policy hides the visible fixture; check pglpg_visible_attrs"
|
|
92
101
|
end
|
|
@@ -96,7 +105,13 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
96
105
|
hj = node.(:Project, "HJ", hid, title: "Apollo")
|
|
97
106
|
hl = node.(:Location, "HL", hid)
|
|
98
107
|
hidden_refs = [
|
|
99
|
-
h1, h2, hj, hl,
|
|
108
|
+
h0, h1, h2, hj, hl,
|
|
109
|
+
# symmetric edges: hidden between visible nodes, and visible ones through
|
|
110
|
+
# hidden nodes stored at either end (a hidden bridge p1 ~ h0 ~ p3)
|
|
111
|
+
edge.(:FRIEND, p3, p1, hid),
|
|
112
|
+
edge.(:FRIEND, p1, h0, vis),
|
|
113
|
+
edge.(:FRIEND, h0, p3, vis),
|
|
114
|
+
edge.(:FRIEND, p2, h1, vis),
|
|
100
115
|
# hidden edges between visible nodes
|
|
101
116
|
edge.(:WORKS_ON, p3, j1, hid, role: "lead"),
|
|
102
117
|
edge.(:KNOWS, p3, p1, hid),
|
|
@@ -116,6 +131,12 @@ RSpec.shared_examples "a pglpg visibility policy" do
|
|
|
116
131
|
before.each_key { |name| expect(after[name]).to eq(before[name]), "observation #{name} changed" }
|
|
117
132
|
|
|
118
133
|
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
|
|
134
|
+
expect(ctx.find_node(:Person, namespace: "hidden", uid: "H1")).to be_nil
|
|
135
|
+
|
|
136
|
+
models = Pglpg::Testing.leak_models(pglpg_leak_graph)
|
|
137
|
+
[h0, h1, h2].each do |ref|
|
|
138
|
+
expect { models[:Person].in(ctx).find(ref) }.to raise_error(Pglpg::Model::RecordNotFound), "#{ref} is visible"
|
|
139
|
+
end
|
|
140
|
+
expect { models[:Project].in(ctx).find(hj) }.to raise_error(Pglpg::Model::RecordNotFound), "#{hj} is visible"
|
|
120
141
|
end
|
|
121
142
|
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,126 @@ 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.merge(observe_models(ctx, refs))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The same observations through the model layer (ADR-0010).
|
|
76
|
+
def observe_models(ctx, refs)
|
|
77
|
+
models = leak_models(ctx.graph)
|
|
78
|
+
relations = {
|
|
79
|
+
persons: -> { models[:Person].in(ctx).order(:name) },
|
|
80
|
+
ranked: -> { models[:Person].in(ctx).where(rank: Pglpg.gte(2)).order(rank: :desc) },
|
|
81
|
+
persons_page: -> { models[:Person].in(ctx).order(:name).limit(2).offset(1) },
|
|
82
|
+
projects: -> { models[:Project].in(ctx) },
|
|
83
|
+
works_on: -> { models[:WORKS_ON].in(ctx).order(:role) },
|
|
84
|
+
leads: -> { models[:WORKS_ON].in(ctx).where(role: "lead") },
|
|
85
|
+
knows: -> { models[:KNOWS].in(ctx) },
|
|
86
|
+
friends: -> { models[:FRIEND].in(ctx) },
|
|
87
|
+
friends_page: -> { models[:FRIEND].in(ctx).limit(1).offset(1) }
|
|
88
|
+
}
|
|
89
|
+
out = {}
|
|
90
|
+
relations.each do |name, r|
|
|
91
|
+
out[:"model_#{name}"] = r.().map { model_data(_1) }
|
|
92
|
+
out[:"model_#{name}_count"] = r.().count
|
|
93
|
+
out[:"model_#{name}_exists"] = r.().exists?
|
|
94
|
+
out[:"model_#{name}_first"] = model_data(r.().first)
|
|
95
|
+
out[:"model_#{name}_pluck"] = r.().pluck(:id, *r.().model.attribute_names)
|
|
96
|
+
end
|
|
97
|
+
out[:model_find_by] = %w[P1 P2 P3 H1].map { model_data(models[:Person].in(ctx).find_by(uid: _1)) }
|
|
98
|
+
out[:model_find] = refs.map do |ref|
|
|
99
|
+
model = ref.node? ? models[:Person] : models[:WORKS_ON]
|
|
100
|
+
model_data(model.in(ctx).find(ref))
|
|
101
|
+
rescue Pglpg::Model::RecordNotFound
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
out.merge!(observe_associations(ctx, models))
|
|
105
|
+
owners = models[:Person].in(ctx).with_associations(:known, :known_by, :friends, :projects).to_a +
|
|
106
|
+
models[:Project].in(ctx).with_associations(:people).to_a
|
|
107
|
+
eager = observe_associations(ctx, models, owners)
|
|
108
|
+
eager.each { |name, value| raise "preload differs from lazy #{name}" unless value == out.fetch(name) }
|
|
109
|
+
out[:eager_associations] = eager
|
|
110
|
+
nested = models[:Person].in(ctx).with_associations(friends: :friends, projects: :people)
|
|
111
|
+
out[:eager_nested] = nested.map do |owner|
|
|
112
|
+
[owner.uid, { friends: :friends, projects: :people }.map do |name, child|
|
|
113
|
+
owner.public_send(name).map do |target|
|
|
114
|
+
cached = target.public_send(child).map { model_data(_1) }
|
|
115
|
+
lazy = target.class.in(ctx).find(target.ref).public_send(child).map { model_data(_1) }
|
|
116
|
+
raise "nested preload differs from lazy #{name}.#{child}" unless cached == lazy
|
|
117
|
+
|
|
118
|
+
[model_data(target), cached]
|
|
119
|
+
end
|
|
120
|
+
end]
|
|
121
|
+
end
|
|
50
122
|
out
|
|
51
123
|
end
|
|
52
124
|
|
|
125
|
+
# Association target and binding queries from every visible owner.
|
|
126
|
+
def observe_associations(ctx, models, owners = nil)
|
|
127
|
+
pair = ->((node, edge)) { [model_data(node), model_data(edge)] }
|
|
128
|
+
owners ||= models[:Person].in(ctx).order(:uid).to_a + models[:Project].in(ctx).order(:uid).to_a
|
|
129
|
+
owners.to_h do |owner|
|
|
130
|
+
data = owner.class.associations.keys.to_h do |name|
|
|
131
|
+
targets = -> { owner.public_send(name) }
|
|
132
|
+
[name, {
|
|
133
|
+
rows: targets.().map { model_data(_1) },
|
|
134
|
+
count: targets.().count,
|
|
135
|
+
exists: targets.().exists?,
|
|
136
|
+
first: model_data(targets.().first),
|
|
137
|
+
page: targets.().limit(1).offset(1).map { model_data(_1) },
|
|
138
|
+
pairs: targets.().with_rel.map(&pair),
|
|
139
|
+
pairs_count: targets.().with_rel.count,
|
|
140
|
+
pairs_page: targets.().with_rel.limit(1).offset(1).map(&pair),
|
|
141
|
+
pluck: targets.().pluck(:id, *targets.().model.attribute_names),
|
|
142
|
+
pairs_pluck: targets.().with_rel.pluck(:id, rel: [:id, *models.fetch(owner.class.associations[name].edge_type).attribute_names])
|
|
143
|
+
}]
|
|
144
|
+
end
|
|
145
|
+
data[:leads] = owner.projects.rel_where(role: "lead").map { model_data(_1) } if owner.respond_to?(:projects)
|
|
146
|
+
[:"assoc_#{owner.uid}", data]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def model_data(record)
|
|
151
|
+
return nil if record.nil?
|
|
152
|
+
|
|
153
|
+
data = [record.class.schema_type.name, record.id, record.attributes, record.extra_properties]
|
|
154
|
+
data + (record.is_a?(Pglpg::Model::Edge) ? [record.source.id, record.target.id] : [record.labels])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# One model per type of the leak graph, built once per graph.
|
|
158
|
+
def leak_models(leak_graph)
|
|
159
|
+
(@leak_models ||= ObjectSpace::WeakKeyMap.new)[leak_graph] ||=
|
|
160
|
+
%i[Person Project WORKS_ON KNOWS FRIEND].to_h do |type|
|
|
161
|
+
base = leak_graph.type.node_types.key?(type) ? Pglpg::Model::Node : Pglpg::Model::Edge
|
|
162
|
+
[type, Class.new(base) { graph(leak_graph, type:) }]
|
|
163
|
+
end.tap do |models|
|
|
164
|
+
models[:Person].has_many :projects, :out, edge: :WORKS_ON
|
|
165
|
+
models[:Person].has_many :known, :out, edge: :KNOWS
|
|
166
|
+
models[:Person].has_many :known_by, :in, edge: :KNOWS
|
|
167
|
+
models[:Person].has_many :friends, :both, edge: :FRIEND
|
|
168
|
+
models[:Project].has_many :people, :in, edge: :WORKS_ON
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
53
172
|
def element_data(element)
|
|
54
173
|
case element
|
|
55
174
|
when nil then nil
|
|
@@ -61,4 +180,5 @@ module Pglpg
|
|
|
61
180
|
end
|
|
62
181
|
end
|
|
63
182
|
|
|
183
|
+
require_relative "model"
|
|
64
184
|
require_relative "testing/leak_examples" if defined?(RSpec)
|
data/lib/pglpg/version.rb
CHANGED
data/lib/pglpg/writer.rb
CHANGED
|
@@ -8,6 +8,9 @@ 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
|
+
# Scope-wide occupancy permission, distinct from write or read access.
|
|
12
|
+
KeyScopeRequest = Data.define(:context, :domain, :scope)
|
|
13
|
+
|
|
11
14
|
WriteRequest = Data.define(:viewer, :operation, :type, :target, :from, :to, :key, :properties, :attrs, :trusted)
|
|
12
15
|
|
|
13
16
|
# Compiles writes into single statements. Visibility of the existing row,
|
|
@@ -15,6 +18,8 @@ module Pglpg
|
|
|
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:)
|
|
19
24
|
@context = context
|
|
20
25
|
@graph = context.graph
|
|
@@ -26,7 +31,7 @@ module Pglpg
|
|
|
26
31
|
props = properties.transform_keys(&:to_sym)
|
|
27
32
|
if (key = node_type.key)
|
|
28
33
|
if props.key?(key.property)
|
|
29
|
-
raise ArgumentError, "#{node_type.name}.#{key.property} is a key and is generated" unless @trusted
|
|
34
|
+
raise ArgumentError, "#{node_type.name}.#{key.property} is a key and is generated" unless @trusted || key.scope
|
|
30
35
|
else
|
|
31
36
|
props[key.property] = key.generate(node_type.name)
|
|
32
37
|
end
|
|
@@ -34,6 +39,12 @@ module Pglpg
|
|
|
34
39
|
json = node_type.encode_properties(props)
|
|
35
40
|
cols = encode_attrs(@graph.type.node_columns, attrs, insert: true)
|
|
36
41
|
authorize!(:insert_node, node_type.name, properties: props, attrs:)
|
|
42
|
+
if key&.scope && !@trusted && properties.keys.map(&:to_sym).include?(key.property)
|
|
43
|
+
scope = { key.scope => props.fetch(key.scope).dup.freeze }.freeze
|
|
44
|
+
request = KeyScopeRequest.new(context:, domain: key.declared_on, scope:)
|
|
45
|
+
allowed = @graph.key_scope_allowed
|
|
46
|
+
raise WriteRejected unless allowed.respond_to?(:call) && allowed.call(request) == true
|
|
47
|
+
end
|
|
37
48
|
|
|
38
49
|
sql = new_sql
|
|
39
50
|
insert_node_values(sql, node_type, json, cols)
|
|
@@ -46,7 +57,7 @@ module Pglpg
|
|
|
46
57
|
node = @context.find(ref) or raise WriteRejected
|
|
47
58
|
node_type = @graph.type.node_type!(node.key_label)
|
|
48
59
|
props = properties.transform_keys(&:to_sym)
|
|
49
|
-
raise ArgumentError, "keys cannot be changed" if node_type.key && props.
|
|
60
|
+
raise ArgumentError, "keys cannot be changed" if node_type.key && (props.keys & node_type.key.properties).any?
|
|
50
61
|
|
|
51
62
|
json = node_type.encode_properties(props, partial: true)
|
|
52
63
|
cols = encode_attrs(@graph.type.node_columns, attrs, insert: false)
|
|
@@ -70,10 +81,10 @@ module Pglpg
|
|
|
70
81
|
node_type = concrete_node_type!(type)
|
|
71
82
|
node_key = node_type.key or raise ArgumentError, "#{node_type.name} has no key"
|
|
72
83
|
key = key.transform_keys(&:to_sym)
|
|
73
|
-
raise ArgumentError, "#{node_type.name} is upserted by #{node_key.
|
|
84
|
+
raise ArgumentError, "#{node_type.name} is upserted by #{node_key.properties.inspect}" unless key.keys.sort == node_key.properties.sort
|
|
74
85
|
|
|
75
86
|
props = properties.transform_keys(&:to_sym)
|
|
76
|
-
raise ArgumentError, "pass the key in key:, not properties:" if props.
|
|
87
|
+
raise ArgumentError, "pass the key in key:, not properties:" if (props.keys & node_key.properties).any?
|
|
77
88
|
|
|
78
89
|
props = props.merge(key)
|
|
79
90
|
json = node_type.encode_properties(props)
|
|
@@ -82,8 +93,13 @@ module Pglpg
|
|
|
82
93
|
|
|
83
94
|
sql = new_sql
|
|
84
95
|
insert_node_values(sql, node_type, json, cols)
|
|
85
|
-
sql << " ON CONFLICT (
|
|
86
|
-
|
|
96
|
+
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
|
|
102
|
+
sql << ") WHERE labels @> ARRAY["
|
|
87
103
|
sql.literal(node_key.declared_on) << "]::text[] DO UPDATE SET "
|
|
88
104
|
set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
|
|
89
105
|
set_columns(sql, cols, excluded: true)
|
|
@@ -166,6 +182,14 @@ module Pglpg
|
|
|
166
182
|
def write_edge(operation, edge_type, from, to, properties, attrs)
|
|
167
183
|
@graph.check_ref!(from, :node)
|
|
168
184
|
@graph.check_ref!(to, :node)
|
|
185
|
+
if edge_type.symmetric?
|
|
186
|
+
if from.id == to.id && !edge_type.allow_self_loops?
|
|
187
|
+
raise ArgumentError, "#{edge_type.name} does not allow self-loops"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Stored in canonical orientation, so either order names the same pair.
|
|
191
|
+
from, to = to, from if from.id > to.id
|
|
192
|
+
end
|
|
169
193
|
props = properties.transform_keys(&:to_sym)
|
|
170
194
|
json = edge_type.encode_properties(props)
|
|
171
195
|
cols = encode_attrs(@graph.type.edge_columns, attrs, insert: true)
|
|
@@ -183,9 +207,14 @@ module Pglpg
|
|
|
183
207
|
end
|
|
184
208
|
sql << " FROM " << table(@graph.nodes_table) << " AS s, " << table(@graph.nodes_table) << " AS t WHERE s.id = "
|
|
185
209
|
sql.bind(from.id, "bigint") << " AND t.id = "
|
|
186
|
-
sql.bind(to.id, "bigint") << " AND
|
|
187
|
-
sql
|
|
188
|
-
|
|
210
|
+
sql.bind(to.id, "bigint") << " AND ("
|
|
211
|
+
endpoint_labels(sql, edge_type.from, edge_type.to)
|
|
212
|
+
if edge_type.symmetric? && edge_type.from != edge_type.to
|
|
213
|
+
# Either endpoint may carry either label.
|
|
214
|
+
sql << " OR "
|
|
215
|
+
endpoint_labels(sql, edge_type.to, edge_type.from)
|
|
216
|
+
end
|
|
217
|
+
sql << ") AND "
|
|
189
218
|
@context.append_node_visible(sql, "s")
|
|
190
219
|
sql << " AND "
|
|
191
220
|
@context.append_node_visible(sql, "t")
|
|
@@ -203,6 +232,12 @@ module Pglpg
|
|
|
203
232
|
ref(:edge, write(sql))
|
|
204
233
|
end
|
|
205
234
|
|
|
235
|
+
def endpoint_labels(sql, source_label, target_label)
|
|
236
|
+
sql << "(s.labels @> "
|
|
237
|
+
sql.bind_text_array([source_label]) << " AND t.labels @> "
|
|
238
|
+
sql.bind_text_array([target_label]) << ")"
|
|
239
|
+
end
|
|
240
|
+
|
|
206
241
|
def insert_node_values(sql, node_type, json, cols)
|
|
207
242
|
sql << "INSERT INTO " << table(@graph.nodes_table) << " AS tgt (key_label, labels, properties"
|
|
208
243
|
cols.each { |c, _| sql << ", " << ident(c.name) }
|
|
@@ -312,7 +347,7 @@ module Pglpg
|
|
|
312
347
|
id.to_i
|
|
313
348
|
rescue ActiveRecord::StatementInvalid => e
|
|
314
349
|
case e.cause
|
|
315
|
-
when PG::UniqueViolation, PG::ForeignKeyViolation, PG::RestrictViolation then raise WriteRejected
|
|
350
|
+
when PG::UniqueViolation, PG::ForeignKeyViolation, PG::RestrictViolation then raise WriteRejected, cause: nil
|
|
316
351
|
when PG::NotNullViolation, PG::CheckViolation then raise ValidationError, e.message
|
|
317
352
|
else raise
|
|
318
353
|
end
|
data/lib/pglpg.rb
CHANGED
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton Fefilov
|
|
@@ -54,7 +54,13 @@ files:
|
|
|
54
54
|
- lib/pglpg/element.rb
|
|
55
55
|
- lib/pglpg/errors.rb
|
|
56
56
|
- lib/pglpg/graph.rb
|
|
57
|
+
- lib/pglpg/model.rb
|
|
58
|
+
- lib/pglpg/model/base.rb
|
|
59
|
+
- lib/pglpg/model/edge.rb
|
|
60
|
+
- lib/pglpg/model/node.rb
|
|
61
|
+
- lib/pglpg/model/relation.rb
|
|
57
62
|
- lib/pglpg/query.rb
|
|
63
|
+
- lib/pglpg/reachability.rb
|
|
58
64
|
- lib/pglpg/ref.rb
|
|
59
65
|
- lib/pglpg/schema/column.rb
|
|
60
66
|
- lib/pglpg/schema/edge_type.rb
|