pglpg 0.1.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.
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ # What a write asks for, as passed to the write policy.
5
+ # operation: :insert_node, :update_node, :upsert_node, :delete_node,
6
+ # :insert_edge, :update_edge, :upsert_edge or :delete_edge
7
+ # type: the node or edge type
8
+ # target: the Ref being changed (update and delete), else nil
9
+ # from, to: endpoint Refs for edge inserts and upserts
10
+ # key: the key for upserts
11
+ WriteRequest = Data.define(:viewer, :operation, :type, :target, :from, :to, :key, :properties, :attrs, :trusted)
12
+
13
+ # Compiles writes into single statements. Visibility of the existing row,
14
+ # the policy's conditions on it and endpoint checks all run inside the
15
+ # statement, so they hold against the row the statement actually touches,
16
+ # even under concurrent writes.
17
+ class Writer
18
+ def initialize(context, trusted:)
19
+ @context = context
20
+ @graph = context.graph
21
+ @trusted = trusted
22
+ end
23
+
24
+ def insert_node(type, properties: {}, attrs: {})
25
+ node_type = concrete_node_type!(type)
26
+ props = properties.transform_keys(&:to_sym)
27
+ if (key = node_type.key)
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
34
+ json = node_type.encode_properties(props)
35
+ cols = encode_attrs(@graph.type.node_columns, attrs, insert: true)
36
+ authorize!(:insert_node, node_type.name, properties: props, attrs:)
37
+
38
+ sql = new_sql
39
+ insert_node_values(sql, node_type, json, cols)
40
+ sql << " RETURNING id"
41
+ ref(:node, write(sql))
42
+ end
43
+
44
+ def update_node(ref, properties: {}, attrs: {})
45
+ @graph.check_ref!(ref, :node)
46
+ node = @context.find(ref) or raise WriteRejected
47
+ node_type = @graph.type.node_type!(node.key_label)
48
+ props = properties.transform_keys(&:to_sym)
49
+ raise ArgumentError, "keys cannot be changed" if node_type.key && props.key?(node_type.key.property)
50
+
51
+ json = node_type.encode_properties(props, partial: true)
52
+ cols = encode_attrs(@graph.type.node_columns, attrs, insert: false)
53
+ request = authorize!(:update_node, node_type.name, target: ref, properties: props, attrs:)
54
+
55
+ sql = new_sql
56
+ sql << "UPDATE " << table(@graph.nodes_table) << " AS tgt SET "
57
+ set_properties(sql, "tgt.properties", json, null_keys(props))
58
+ set_columns(sql, cols)
59
+ sql << " WHERE tgt.id = "
60
+ sql.bind(ref.id, "bigint") << " AND tgt.key_label = "
61
+ sql.bind(node_type.name.to_s, "text") << " AND "
62
+ existing_row(sql, :node, request)
63
+ sql << " RETURNING tgt.id"
64
+ self.ref(:node, write(sql))
65
+ end
66
+
67
+ # Trusted only. Inserts, or updates the node of the same type with this key.
68
+ def upsert_node(type, key:, properties: {}, attrs: {})
69
+ trusted!(:upsert_node)
70
+ node_type = concrete_node_type!(type)
71
+ node_key = node_type.key or raise ArgumentError, "#{node_type.name} has no key"
72
+ key = key.transform_keys(&:to_sym)
73
+ raise ArgumentError, "#{node_type.name} is upserted by #{node_key.property.inspect}" unless key.keys == [node_key.property]
74
+
75
+ props = properties.transform_keys(&:to_sym)
76
+ raise ArgumentError, "pass the key in key:, not properties:" if props.key?(node_key.property)
77
+
78
+ props = props.merge(key)
79
+ json = node_type.encode_properties(props)
80
+ cols = encode_attrs(@graph.type.node_columns, attrs, insert: true)
81
+ request = authorize!(:upsert_node, node_type.name, key:, properties: props, attrs:)
82
+
83
+ sql = new_sql
84
+ insert_node_values(sql, node_type, json, cols)
85
+ sql << " ON CONFLICT ((properties->>"
86
+ sql.literal(node_key.property) << ")) WHERE labels @> ARRAY["
87
+ sql.literal(node_key.declared_on) << "]::text[] DO UPDATE SET "
88
+ set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
89
+ set_columns(sql, cols, excluded: true)
90
+ sql << " WHERE tgt.key_label = EXCLUDED.key_label AND "
91
+ existing_row(sql, :node, request)
92
+ sql << " RETURNING id"
93
+ ref(:node, write(sql))
94
+ end
95
+
96
+ # Trusted only. Nodes that still have edges cannot be deleted.
97
+ def delete_node(ref)
98
+ trusted!(:delete_node)
99
+ @graph.check_ref!(ref, :node)
100
+ node = @context.find(ref) or raise WriteRejected
101
+ request = authorize!(:delete_node, node.key_label, target: ref)
102
+
103
+ sql = new_sql
104
+ sql << "DELETE FROM " << table(@graph.nodes_table) << " AS tgt WHERE tgt.id = "
105
+ sql.bind(ref.id, "bigint") << " AND tgt.key_label = "
106
+ sql.bind(node.key_label.to_s, "text") << " AND "
107
+ existing_row(sql, :node, request)
108
+ sql << " RETURNING tgt.id"
109
+ write(sql)
110
+ true
111
+ end
112
+
113
+ def insert_edge(type, from:, to:, properties: {}, attrs: {})
114
+ edge_type = @graph.type.edge_type!(type)
115
+ if edge_type.unique_pair? && !@trusted
116
+ raise ArgumentError, "#{edge_type.name} is unique per pair; write it through the trusted writer"
117
+ end
118
+
119
+ write_edge(:insert_edge, edge_type, from, to, properties, attrs)
120
+ end
121
+
122
+ # Trusted only, for edge types declared unique: :pair.
123
+ def upsert_edge(type, from:, to:, properties: {}, attrs: {})
124
+ trusted!(:upsert_edge)
125
+ edge_type = @graph.type.edge_type!(type)
126
+ raise ArgumentError, "#{edge_type.name} is not unique per pair; use insert_edge" unless edge_type.unique_pair?
127
+
128
+ write_edge(:upsert_edge, edge_type, from, to, properties, attrs)
129
+ end
130
+
131
+ def update_edge(ref, properties: {}, attrs: {})
132
+ @graph.check_ref!(ref, :edge)
133
+ edge = @context.find(ref) or raise WriteRejected
134
+ edge_type = @graph.type.edge_type!(edge.type)
135
+ props = properties.transform_keys(&:to_sym)
136
+ json = edge_type.encode_properties(props, partial: true)
137
+ cols = encode_attrs(@graph.type.edge_columns, attrs, insert: false)
138
+ request = authorize!(:update_edge, edge_type.name, target: ref, properties: props, attrs:)
139
+
140
+ sql = new_sql
141
+ sql << "UPDATE " << table(@graph.edges_table) << " AS tgt SET "
142
+ set_properties(sql, "tgt.properties", json, null_keys(props))
143
+ set_columns(sql, cols)
144
+ sql << " FROM " << table(@graph.nodes_table) << " AS s, " << table(@graph.nodes_table) << " AS t"
145
+ edge_row_condition(sql, ref, edge_type, request)
146
+ sql << " RETURNING tgt.id"
147
+ self.ref(:edge, write(sql))
148
+ end
149
+
150
+ def delete_edge(ref)
151
+ @graph.check_ref!(ref, :edge)
152
+ edge = @context.find(ref) or raise WriteRejected
153
+ request = authorize!(:delete_edge, edge.type, target: ref)
154
+
155
+ sql = new_sql
156
+ sql << "DELETE FROM " << table(@graph.edges_table) << " AS tgt USING "
157
+ sql << table(@graph.nodes_table) << " AS s, " << table(@graph.nodes_table) << " AS t"
158
+ edge_row_condition(sql, ref, @graph.type.edge_type!(edge.type), request)
159
+ sql << " RETURNING tgt.id"
160
+ write(sql)
161
+ true
162
+ end
163
+
164
+ private
165
+
166
+ def write_edge(operation, edge_type, from, to, properties, attrs)
167
+ @graph.check_ref!(from, :node)
168
+ @graph.check_ref!(to, :node)
169
+ props = properties.transform_keys(&:to_sym)
170
+ json = edge_type.encode_properties(props)
171
+ cols = encode_attrs(@graph.type.edge_columns, attrs, insert: true)
172
+ request = authorize!(operation, edge_type.name, from:, to:, properties: props, attrs:)
173
+
174
+ sql = new_sql
175
+ sql << "INSERT INTO " << table(@graph.edges_table) << " AS tgt (type, source_id, target_id, properties"
176
+ cols.each { |c, _| sql << ", " << ident(c.name) }
177
+ sql << ") SELECT "
178
+ sql.bind(edge_type.name.to_s, "text") << ", s.id, t.id, "
179
+ sql.bind_json(json)
180
+ cols.each do |c, v|
181
+ sql << ", "
182
+ sql.bind(v, c.sql_type(@graph.connection))
183
+ end
184
+ sql << " FROM " << table(@graph.nodes_table) << " AS s, " << table(@graph.nodes_table) << " AS t WHERE s.id = "
185
+ sql.bind(from.id, "bigint") << " AND t.id = "
186
+ sql.bind(to.id, "bigint") << " AND s.labels @> "
187
+ sql.bind_text_array([edge_type.from]) << " AND t.labels @> "
188
+ sql.bind_text_array([edge_type.to]) << " AND "
189
+ @context.append_node_visible(sql, "s")
190
+ sql << " AND "
191
+ @context.append_node_visible(sql, "t")
192
+ # Endpoints stay locked against concurrent change until commit.
193
+ sql << " FOR SHARE OF s, t"
194
+ if operation == :upsert_edge
195
+ sql << " ON CONFLICT (source_id, target_id) WHERE type = "
196
+ sql.literal(edge_type.name) << " DO UPDATE SET "
197
+ set_properties(sql, "tgt.properties", json, null_keys(props), excluded: true)
198
+ set_columns(sql, cols, excluded: true)
199
+ sql << " WHERE "
200
+ existing_row(sql, :edge, request)
201
+ end
202
+ sql << " RETURNING id"
203
+ ref(:edge, write(sql))
204
+ end
205
+
206
+ def insert_node_values(sql, node_type, json, cols)
207
+ sql << "INSERT INTO " << table(@graph.nodes_table) << " AS tgt (key_label, labels, properties"
208
+ cols.each { |c, _| sql << ", " << ident(c.name) }
209
+ sql << ") VALUES ("
210
+ sql.bind(node_type.name.to_s, "text") << ", "
211
+ sql.bind_text_array(node_type.labels) << ", "
212
+ sql.bind_json(json)
213
+ cols.each do |c, v|
214
+ sql << ", "
215
+ sql.bind(v, c.sql_type(@graph.connection))
216
+ end
217
+ sql << ")"
218
+ end
219
+
220
+ # The edge row and both endpoints must be visible, and the policy's
221
+ # conditions on the stored edge must hold.
222
+ def edge_row_condition(sql, ref, edge_type, request)
223
+ sql << " WHERE tgt.id = "
224
+ sql.bind(ref.id, "bigint") << " AND tgt.type = "
225
+ sql.bind(edge_type.name.to_s, "text")
226
+ sql << " AND s.id = tgt.source_id AND t.id = tgt.target_id AND "
227
+ @context.append_node_visible(sql, "s")
228
+ sql << " AND "
229
+ @context.append_node_visible(sql, "t")
230
+ sql << " AND "
231
+ existing_row(sql, :edge, request)
232
+ end
233
+
234
+ # Visibility of the stored row "tgt" plus the policy's optional
235
+ # conditions on it (allowed old-to-new transitions).
236
+ def existing_row(sql, kind, request)
237
+ kind == :node ? @context.append_node_visible(sql, "tgt") : @context.append_edge_visible(sql, "tgt")
238
+ policy = @graph.write_policy
239
+ return unless policy.respond_to?(:existing)
240
+
241
+ condition = policy.existing(Arel::Table.new("tgt"), request)
242
+ return if condition.nil?
243
+ unless condition.is_a?(Arel::Nodes::Node) || condition.is_a?(Arel::Nodes::SqlLiteral)
244
+ raise ConfigurationError, "write policy returned #{condition.class}, expected an Arel condition"
245
+ end
246
+
247
+ sql << " AND ("
248
+ sql.arel(condition)
249
+ sql << ")"
250
+ end
251
+
252
+ # properties = (old || new) - removed keys
253
+ def set_properties(sql, current, json, removed, excluded: false)
254
+ sql << "properties = (" << current << " || "
255
+ excluded ? sql << "EXCLUDED.properties" : sql.bind_json(json)
256
+ sql << ") - "
257
+ sql.bind_text_array(removed)
258
+ sql << ", updated_at = now()"
259
+ end
260
+
261
+ def set_columns(sql, cols, excluded: false)
262
+ cols.each do |c, v|
263
+ sql << ", " << ident(c.name) << " = "
264
+ excluded ? sql << "EXCLUDED." << ident(c.name) : sql.bind(v, c.sql_type(@graph.connection))
265
+ end
266
+ end
267
+
268
+ def null_keys(props) = props.select { |_, v| v.nil? }.keys.map(&:to_s)
269
+
270
+ def authorize!(operation, type, target: nil, from: nil, to: nil, key: nil, properties: {}, attrs: {})
271
+ request = WriteRequest.new(viewer: @context.viewer, operation:, type: type.to_sym, target:, from:, to:, key:,
272
+ properties: properties.freeze, attrs: attrs.transform_keys(&:to_sym).freeze,
273
+ trusted: @trusted)
274
+ raise WriteRejected unless @graph.write_policy.authorize?(request) == true
275
+
276
+ request
277
+ end
278
+
279
+ def trusted!(operation)
280
+ raise NotTrusted, "#{operation} needs the trusted writer" unless @trusted
281
+ end
282
+
283
+ def concrete_node_type!(type)
284
+ node_type = @graph.type.node_type!(type)
285
+ raise ValidationError, "#{node_type.name} is abstract" if node_type.abstract?
286
+
287
+ node_type
288
+ end
289
+
290
+ def encode_attrs(columns, attrs, insert:)
291
+ attrs = (attrs || {}).transform_keys(&:to_sym)
292
+ unknown = attrs.keys - columns.keys
293
+ raise ValidationError, "unknown column#{"s" if unknown.size > 1} #{unknown.join(", ")}" if unknown.any?
294
+
295
+ columns.each_value do |c|
296
+ raise ValidationError, "#{c.name} is required" if insert && c.required? && attrs[c.name].nil?
297
+ raise ValidationError, "#{c.name} cannot be null" if attrs.key?(c.name) && attrs[c.name].nil? && !c.null?
298
+ end
299
+ attrs.map { |name, v| [columns.fetch(name), v] }
300
+ end
301
+
302
+ # Runs a write in a savepoint, so a refusal never aborts the caller's
303
+ # transaction. Zero rows means the statement's own checks refused it.
304
+ def write(sql)
305
+ id = @graph.with_connection do |c|
306
+ c.transaction(requires_new: true) do
307
+ c.exec_query(sql.to_s, "Pglpg", sql.binds).rows.first&.first
308
+ end
309
+ end
310
+ raise WriteRejected if id.nil?
311
+
312
+ id.to_i
313
+ rescue ActiveRecord::StatementInvalid => e
314
+ case e.cause
315
+ when PG::UniqueViolation, PG::ForeignKeyViolation, PG::RestrictViolation then raise WriteRejected
316
+ when PG::NotNullViolation, PG::CheckViolation then raise ValidationError, e.message
317
+ else raise
318
+ end
319
+ end
320
+
321
+ def ref(kind, id) = @graph.ref(kind, id)
322
+
323
+ def new_sql = Sql.new(@graph.connection)
324
+
325
+ def table(name) = @graph.connection.quote_table_name(name)
326
+
327
+ def ident(name) = @graph.connection.quote_column_name(name)
328
+ end
329
+ end
data/lib/pglpg.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "json"
5
+
6
+ require_relative "pglpg/version"
7
+ require_relative "pglpg/errors"
8
+ require_relative "pglpg/configuration"
9
+ require_relative "pglpg/schema/property"
10
+ require_relative "pglpg/schema/element_type"
11
+ require_relative "pglpg/schema/node_type"
12
+ require_relative "pglpg/schema/edge_type"
13
+ require_relative "pglpg/schema/column"
14
+ require_relative "pglpg/schema/graph_type"
15
+ require_relative "pglpg/ref"
16
+ require_relative "pglpg/element"
17
+ require_relative "pglpg/condition"
18
+ require_relative "pglpg/sql"
19
+ require_relative "pglpg/storage"
20
+ require_relative "pglpg/graph"
21
+ require_relative "pglpg/context"
22
+ require_relative "pglpg/writer"
23
+ require_relative "pglpg/query"
24
+
25
+ # Labeled property graphs stored in plain PostgreSQL tables.
26
+ module Pglpg
27
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pglpg
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anton Fefilov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pg
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.5'
40
+ description: A Ruby library for labeled property graphs (ISO GQL model, PG-Schema
41
+ / PG-Keys) stored in plain PostgreSQL tables, with a visibility policy applied to
42
+ every node and edge on every hop.
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - CHANGELOG.md
48
+ - LICENSE
49
+ - README.md
50
+ - lib/pglpg.rb
51
+ - lib/pglpg/condition.rb
52
+ - lib/pglpg/configuration.rb
53
+ - lib/pglpg/context.rb
54
+ - lib/pglpg/element.rb
55
+ - lib/pglpg/errors.rb
56
+ - lib/pglpg/graph.rb
57
+ - lib/pglpg/query.rb
58
+ - lib/pglpg/ref.rb
59
+ - lib/pglpg/schema/column.rb
60
+ - lib/pglpg/schema/edge_type.rb
61
+ - lib/pglpg/schema/element_type.rb
62
+ - lib/pglpg/schema/graph_type.rb
63
+ - lib/pglpg/schema/node_type.rb
64
+ - lib/pglpg/schema/property.rb
65
+ - lib/pglpg/sql.rb
66
+ - lib/pglpg/storage.rb
67
+ - lib/pglpg/testing.rb
68
+ - lib/pglpg/testing/leak_examples.rb
69
+ - lib/pglpg/version.rb
70
+ - lib/pglpg/writer.rb
71
+ homepage: https://github.com/valamonai/pglpg
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ source_code_uri: https://github.com/valamonai/pglpg
76
+ changelog_uri: https://github.com/valamonai/pglpg/blob/master/CHANGELOG.md
77
+ rubygems_mfa_required: 'true'
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '4.0'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 4.0.17
93
+ specification_version: 4
94
+ summary: Labeled property graphs stored in plain PostgreSQL tables
95
+ test_files: []