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.
@@ -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); callers never choose it.
95
- def key(name, generate: nil)
96
- @type.key_property(name, generate:)
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 on one property (PG-Keys). +generator+
11
- # produces values for interactive inserts, which never accept a
12
- # caller-chosen key.
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
- tables_sql(connection).each { connection.execute(_1) }
31
- sync_constraints!(connection)
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} ((properties->>#{connection.quote(key.property.to_s)})) WHERE #{has_label}"]
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
- # Adds missing generated constraints and drops stale ones.
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
- wanted = constraints(connection)
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
- key :uid
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pglpg
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.key?(node_type.key.property)
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.property.inspect}" unless key.keys == [node_key.property]
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.key?(node_key.property)
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 ((properties->>"
86
- sql.literal(node_key.property) << ")) WHERE labels @> ARRAY["
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 s.labels @> "
187
- sql.bind_text_array([edge_type.from]) << " AND t.labels @> "
188
- sql.bind_text_array([edge_type.to]) << " AND "
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
@@ -21,6 +21,7 @@ require_relative "pglpg/graph"
21
21
  require_relative "pglpg/context"
22
22
  require_relative "pglpg/writer"
23
23
  require_relative "pglpg/query"
24
+ require_relative "pglpg/reachability"
24
25
 
25
26
  # Labeled property graphs stored in plain PostgreSQL tables.
26
27
  module Pglpg
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.1.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