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.
@@ -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
- key :uid
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
- let(:pglpg_leak_graph) { leak_graph.call(pglpg_visibility, pglpg_columns) }
61
- let(:pglpg_seed_graph) { leak_graph.call(Pglpg::Testing::SeeAll, pglpg_columns) }
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 = ->(uid, name, rank, attrs) { seed.insert_node(:Person, properties: { uid:, name:, rank: }, attrs:) }
69
- node = ->(type, uid, attrs, **props) { seed.insert_node(type, properties: { uid:, **props }, attrs:) }
70
- edge = ->(type, from, to, attrs, **props) { seed.insert_edge(type, from:, to:, properties: props, attrs:) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pglpg
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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
- WriteRequest = Data.define(:viewer, :operation, :type, :target, :from, :to, :key, :properties, :attrs, :trusted)
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.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
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 = properties.transform_keys(&:to_sym)
49
- raise ArgumentError, "keys cannot be changed" if node_type.key && props.key?(node_type.key.property)
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
- raise ArgumentError, "#{node_type.name} is upserted by #{node_key.property.inspect}" unless key.keys == [node_key.property]
79
+ node_key.validate_values!(node_type, key)
74
80
 
75
- props = properties.transform_keys(&:to_sym)
76
- raise ArgumentError, "pass the key in key:, not properties:" if props.key?(node_key.property)
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 ((properties->>"
86
- sql.literal(node_key.property) << ")) WHERE labels @> ARRAY["
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.unique_pair? && !@trusted
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, for edge types declared unique: :pair.
123
- def upsert_edge(type, from:, to:, properties: {}, attrs: {})
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
- 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)
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
- props = properties.transform_keys(&:to_sym)
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
- request = authorize!(:update_edge, edge_type.name, target: ref, properties: props, attrs:)
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
- props = properties.transform_keys(&:to_sym)
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
- request = authorize!(operation, edge_type.name, from:, to:, properties: props, attrs:)
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 s.labels @> "
187
- sql.bind_text_array([edge_type.from]) << " AND t.labels @> "
188
- sql.bind_text_array([edge_type.to]) << " AND "
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
- sql << " ON CONFLICT (source_id, target_id) WHERE type = "
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, cols, excluded: true)
199
- sql << " WHERE "
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, e.message
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