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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Pglpg
6
+ module Schema
7
+ # A node type: a key label, inherited labels and property types
8
+ # (GQL node types, PG-Schema inheritance).
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
14
+ def generate(type) = generator.call(type)
15
+ end
16
+
17
+ DEFAULT_KEY_GENERATOR = ->(_type) { SecureRandom.uuid }
18
+
19
+ attr_reader :parents, :labels, :key, :ancestors
20
+
21
+ def initialize(name, extends: [], abstract: false)
22
+ super(name)
23
+ @parents = Array(extends).map { Schema.check_name!(_1, "type") }.freeze
24
+ @abstract = abstract ? true : false
25
+ @own_key = nil
26
+ end
27
+
28
+ def abstract? = @abstract
29
+
30
+ def key_property(name, generate: nil)
31
+ raise SchemaError, "#{self.name} declares more than one key" if @own_key
32
+ raise SchemaError, "key generator for #{self.name} must respond to call" if generate && !generate.respond_to?(:call)
33
+
34
+ @own_key = Key.new(property: name.to_sym, declared_on: self.name, generator: generate || DEFAULT_KEY_GENERATOR)
35
+ end
36
+
37
+ def own_key = @own_key
38
+
39
+ # Called once the graph type is complete.
40
+ def resolve!(graph_type, stack = [])
41
+ return if @resolved
42
+ raise SchemaError, "inheritance cycle: #{(stack + [name]).join(" < ")}" if stack.include?(name)
43
+
44
+ parent_types = parents.map do |p|
45
+ graph_type.node_types.fetch(p) { raise SchemaError, "#{name} extends unknown node type #{p}" }
46
+ end
47
+ parent_types.each { _1.resolve!(graph_type, stack + [name]) }
48
+
49
+ @ancestors = parent_types.flat_map { [_1.name, *_1.ancestors] }.uniq.freeze
50
+ @labels = ([name] + @ancestors).map(&:to_s).sort.freeze
51
+ @properties = merge_properties(parent_types)
52
+ @key = resolve_key(parent_types)
53
+ @resolved = true
54
+ freeze
55
+ end
56
+
57
+ # True when nodes of this type carry +label+.
58
+ def label?(label) = labels.include?(label.to_s)
59
+
60
+ private
61
+
62
+ def merge_properties(parent_types)
63
+ merged = {}
64
+ (parent_types.map(&:properties) + [own_properties]).each do |props|
65
+ props.each do |n, prop|
66
+ if merged.key?(n) && !merged[n].same_definition?(prop)
67
+ raise SchemaError, "#{name} inherits conflicting definitions of #{n}"
68
+ end
69
+
70
+ merged[n] = prop
71
+ end
72
+ end
73
+ merged.freeze
74
+ end
75
+
76
+ def resolve_key(parent_types)
77
+ keys = (parent_types.map(&:key) + [@own_key]).compact.uniq(&:declared_on)
78
+ raise SchemaError, "#{name} has more than one key (#{keys.map(&:declared_on).join(", ")})" if keys.size > 1
79
+
80
+ key = keys.first
81
+ return nil unless key
82
+
83
+ prop = @properties[key.property]
84
+ raise SchemaError, "key #{key.declared_on}.#{key.property} is not a declared property" unless prop
85
+ raise SchemaError, "key #{key.declared_on}.#{key.property} must be required" unless prop.required?
86
+ raise SchemaError, "key #{key.declared_on}.#{key.property} must be a string" unless prop.type == :string
87
+
88
+ key
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module Pglpg
8
+ module Schema
9
+ # A declared property: name, value type and whether it is required.
10
+ # Values are stored in the +properties+ jsonb column. A nil value means the
11
+ # property is absent, as in GQL.
12
+ class Property
13
+ SCALAR_TYPES = %i[string integer float decimal boolean date datetime enum].freeze
14
+ TYPES = (SCALAR_TYPES + %i[list]).freeze
15
+
16
+ # jsonb_typeof of a stored value, per type.
17
+ JSON_TYPES = {
18
+ string: "string", integer: "number", float: "number", decimal: "string",
19
+ boolean: "boolean", date: "string", datetime: "string", enum: "string", list: "array"
20
+ }.freeze
21
+
22
+ # SQL cast used to compare a stored value (extracted as text).
23
+ SQL_CASTS = {
24
+ string: "text", integer: "numeric", float: "double precision", decimal: "numeric",
25
+ boolean: "boolean", date: "date", datetime: "timestamptz", enum: "text"
26
+ }.freeze
27
+
28
+ attr_reader :name, :type, :values, :of
29
+
30
+ def initialize(name, type, required: false, values: nil, of: nil)
31
+ @name = name.to_sym
32
+ @type = type.to_sym
33
+ @required = required ? true : false
34
+ @values = values&.map(&:to_s)&.freeze
35
+ @of = of&.to_sym
36
+ check!
37
+ freeze
38
+ end
39
+
40
+ def required? = @required
41
+
42
+ def json_type = JSON_TYPES.fetch(type)
43
+
44
+ def sql_cast = SQL_CASTS.fetch(type)
45
+
46
+ def comparable? = type != :list
47
+
48
+ def same_definition?(other)
49
+ other.type == type && other.required? == required? && other.values == values && other.of == of
50
+ end
51
+
52
+ # Validates a Ruby value and returns its JSON form. nil stays nil.
53
+ def encode(value)
54
+ return nil if value.nil?
55
+ return encode_scalar(type, value) unless type == :list
56
+ raise ValidationError, "#{name} must be an Array" unless value.is_a?(Array)
57
+
58
+ value.map do |v|
59
+ raise ValidationError, "#{name} must not contain nil" if v.nil?
60
+
61
+ encode_scalar(of, v)
62
+ end
63
+ end
64
+
65
+ # Turns a stored JSON value back into Ruby.
66
+ def decode(json)
67
+ return nil if json.nil?
68
+ return decode_scalar(type, json) unless type == :list
69
+
70
+ json.map { |v| decode_scalar(of, v) }
71
+ end
72
+
73
+ private
74
+
75
+ def check!
76
+ raise SchemaError, "unknown property type #{type.inspect} for #{name}" unless TYPES.include?(type)
77
+ if type == :enum && (values.nil? || values.empty?)
78
+ raise SchemaError, "enum property #{name} needs values:"
79
+ end
80
+ return unless type == :list
81
+ return if SCALAR_TYPES.include?(of) && of != :enum
82
+
83
+ raise SchemaError, "list property #{name} needs of: with a scalar type"
84
+ end
85
+
86
+ def encode_scalar(t, v)
87
+ case t
88
+ when :string
89
+ return v if v.is_a?(String)
90
+ when :integer
91
+ return v if v.is_a?(Integer)
92
+ when :float
93
+ return v.to_f if v.is_a?(Numeric) && !v.is_a?(BigDecimal) && v.to_f.finite?
94
+ when :decimal
95
+ return v.to_s("F") if v.is_a?(BigDecimal) && v.finite?
96
+ return BigDecimal(v).to_s("F") if v.is_a?(Integer)
97
+ when :boolean
98
+ return v if [true, false].include?(v)
99
+ when :date
100
+ return v.iso8601 if v.instance_of?(Date)
101
+ when :datetime
102
+ return v.to_time.utc.iso8601(6) if v.is_a?(Time) || v.is_a?(DateTime) || time_with_zone?(v)
103
+ when :enum
104
+ return v.to_s if (v.is_a?(String) || v.is_a?(Symbol)) && values.include?(v.to_s)
105
+ end
106
+ raise ValidationError, "invalid #{t} value for #{name}: #{v.inspect}"
107
+ end
108
+
109
+ def time_with_zone?(v)
110
+ defined?(ActiveSupport::TimeWithZone) && v.is_a?(ActiveSupport::TimeWithZone)
111
+ end
112
+
113
+ def decode_scalar(t, v)
114
+ case t
115
+ when :decimal then BigDecimal(v)
116
+ when :date then Date.iso8601(v)
117
+ when :datetime then Time.iso8601(v)
118
+ else v
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
data/lib/pglpg/sql.rb ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg"
4
+
5
+ module Pglpg
6
+ # Builds one SQL statement with numbered bind parameters. It doubles as an
7
+ # Arel collector, so policy conditions (Arel nodes) compile straight into
8
+ # the statement and keep their own binds.
9
+ class Sql
10
+ ARRAY_ENCODER = PG::TextEncoder::Array.new
11
+
12
+ attr_reader :binds
13
+ attr_accessor :preparable, :retryable
14
+
15
+ def initialize(connection)
16
+ @connection = connection
17
+ @parts = []
18
+ @binds = []
19
+ end
20
+
21
+ def <<(str)
22
+ @parts << str
23
+ self
24
+ end
25
+
26
+ # Appends a bound value, optionally with a cast: "$3::jsonb".
27
+ def bind(value, cast = nil)
28
+ add_bind(ActiveRecord::Relation::QueryAttribute.new("", value, ActiveModel::Type::Value.new))
29
+ self << "::#{cast}" if cast
30
+ self
31
+ end
32
+
33
+ def bind_json(value) = bind(JSON.generate(value), "jsonb")
34
+
35
+ def bind_text_array(values) = bind(ARRAY_ENCODER.encode(values.map(&:to_s)), "text[]")
36
+
37
+ def ident(name) = self << @connection.quote_column_name(name.to_s)
38
+
39
+ def table(name) = self << @connection.quote_table_name(name.to_s)
40
+
41
+ # A schema-defined string (type or label name) as a SQL literal. Used where
42
+ # PostgreSQL needs a constant, such as ON CONFLICT index inference.
43
+ def literal(str) = self << @connection.quote(str.to_s)
44
+
45
+ # Compiles an Arel node into the statement.
46
+ def arel(node)
47
+ @connection.visitor.compile(node, self)
48
+ self
49
+ end
50
+
51
+ # Arel collector interface.
52
+ def add_bind(attribute, &)
53
+ @binds << attribute
54
+ self << "$#{@binds.size}"
55
+ end
56
+
57
+ def add_binds(attributes, _proc_for_binds = nil, &)
58
+ attributes.each_with_index do |attribute, i|
59
+ self << ", " if i.positive?
60
+ add_bind(attribute)
61
+ end
62
+ self
63
+ end
64
+
65
+ def value = self
66
+
67
+ # Puts +str+ before the statement built so far (binds keep their numbers).
68
+ def prepend(str)
69
+ @parts.unshift(str)
70
+ self
71
+ end
72
+
73
+ def to_s = @parts.join
74
+ end
75
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pglpg
6
+ # The two tables of a graph, their indexes and the constraints generated
7
+ # from the graph type.
8
+ #
9
+ # Kg.storage.create! # in a migration: tables, indexes, constraints
10
+ # Kg.storage.sync_constraints! # after a graph type change
11
+ # Kg.storage.relabel! # after inherited labels change
12
+ # Kg.storage.label_drift # => {} when stored labels match the schema
13
+ class Storage
14
+ NAME_LIMIT = 63
15
+ MIN_SERVER_VERSION = 180_000
16
+
17
+ attr_reader :graph, :nodes_table, :edges_table
18
+
19
+ def initialize(graph, nodes_table:, edges_table:)
20
+ @graph = graph
21
+ @nodes_table = nodes_table.to_s
22
+ @edges_table = edges_table.to_s
23
+ end
24
+
25
+ def create!(connection = graph.connection)
26
+ if connection.database_version < MIN_SERVER_VERSION
27
+ raise ConfigurationError, "pglpg needs PostgreSQL 18 or later"
28
+ end
29
+
30
+ tables_sql(connection).each { connection.execute(_1) }
31
+ sync_constraints!(connection)
32
+ end
33
+
34
+ def drop!(connection = graph.connection)
35
+ connection.execute("DROP TABLE IF EXISTS #{q(connection, edges_table)}, #{q(connection, nodes_table)}")
36
+ end
37
+
38
+ def tables_sql(connection = graph.connection)
39
+ n = q(connection, nodes_table)
40
+ e = q(connection, edges_table)
41
+ [
42
+ <<~SQL,
43
+ CREATE TABLE #{n} (
44
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
45
+ key_label text NOT NULL,
46
+ labels text[] NOT NULL,
47
+ properties jsonb NOT NULL DEFAULT '{}',
48
+ #{columns_sql(connection, type.node_columns)}created_at timestamptz NOT NULL DEFAULT now(),
49
+ updated_at timestamptz NOT NULL DEFAULT now(),
50
+ CHECK (labels @> ARRAY[key_label]),
51
+ CHECK (jsonb_typeof(properties) = 'object')
52
+ )
53
+ SQL
54
+ <<~SQL,
55
+ CREATE TABLE #{e} (
56
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
57
+ type text NOT NULL,
58
+ source_id bigint NOT NULL REFERENCES #{n} (id) ON DELETE RESTRICT,
59
+ target_id bigint NOT NULL REFERENCES #{n} (id) ON DELETE RESTRICT,
60
+ properties jsonb NOT NULL DEFAULT '{}',
61
+ #{columns_sql(connection, type.edge_columns)}created_at timestamptz NOT NULL DEFAULT now(),
62
+ updated_at timestamptz NOT NULL DEFAULT now(),
63
+ CHECK (jsonb_typeof(properties) = 'object')
64
+ )
65
+ SQL
66
+ "CREATE INDEX #{q(connection, "#{nodes_table}_labels")} ON #{n} USING gin (labels)",
67
+ "CREATE INDEX #{q(connection, "#{nodes_table}_properties")} ON #{n} USING gin (properties jsonb_path_ops)",
68
+ "CREATE INDEX #{q(connection, "#{edges_table}_source")} ON #{e} (source_id, type) INCLUDE (#{covering(connection, "target_id")})",
69
+ "CREATE INDEX #{q(connection, "#{edges_table}_target")} ON #{e} (target_id, type) INCLUDE (#{covering(connection, "source_id")})",
70
+ "CREATE INDEX #{q(connection, "#{edges_table}_properties")} ON #{e} USING gin (properties jsonb_path_ops)"
71
+ ]
72
+ end
73
+
74
+ # The constraints the graph type requires, by name. Names carry a digest
75
+ # of the definition, so a changed definition gets a new name.
76
+ def constraints(connection = graph.connection)
77
+ list = []
78
+ n = q(connection, nodes_table)
79
+ e = q(connection, edges_table)
80
+
81
+ type.node_types.each_value do |t|
82
+ has_label = "labels @> ARRAY[#{connection.quote(t.name.to_s)}]::text[]"
83
+ if (key = t.own_key)
84
+ list << [:index, nodes_table, "key_#{t.name}_#{key.property}",
85
+ "ON #{n} ((properties->>#{connection.quote(key.property.to_s)})) WHERE #{has_label}"]
86
+ end
87
+ t.own_properties.each_value.select(&:required?).each do |p|
88
+ list << [:check, nodes_table, "req_#{t.name}_#{p.name}", "NOT (#{has_label}) OR #{typed_check(connection, p)}"]
89
+ end
90
+ end
91
+
92
+ type.edge_types.each_value do |t|
93
+ is_type = "type = #{connection.quote(t.name.to_s)}"
94
+ list << [:index, edges_table, "pair_#{t.name}", "ON #{e} (source_id, target_id) WHERE #{is_type}"] if t.unique_pair?
95
+ t.own_properties.each_value.select(&:required?).each do |p|
96
+ list << [:check, edges_table, "req_#{t.name}_#{p.name}", "NOT (#{is_type}) OR #{typed_check(connection, p)}"]
97
+ end
98
+ end
99
+
100
+ list.to_h do |kind, table, label, body|
101
+ name = constraint_name(table, label, body)
102
+ sql = if kind == :index
103
+ "CREATE UNIQUE INDEX #{q(connection, name)} #{body}"
104
+ else
105
+ "ALTER TABLE #{q(connection, table)} ADD CONSTRAINT #{q(connection, name)} CHECK (#{body})"
106
+ end
107
+ [name, { kind:, table:, sql: }]
108
+ end
109
+ end
110
+
111
+ # Adds missing generated constraints and drops stale ones.
112
+ 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
125
+ end
126
+
127
+ # Rewrites stored label sets to match the graph type.
128
+ def relabel!(connection = graph.connection)
129
+ return 0 if concrete_types.empty?
130
+
131
+ connection.exec_update(<<~SQL, "Pglpg relabel")
132
+ UPDATE #{q(connection, nodes_table)} AS n SET labels = v.labels
133
+ FROM (VALUES #{label_values(connection)}) AS v(key_label, labels)
134
+ WHERE n.key_label = v.key_label AND n.labels IS DISTINCT FROM v.labels
135
+ SQL
136
+ end
137
+
138
+ # Stored nodes whose labels disagree with the graph type, counted by key
139
+ # label. Rows of unknown or abstract types count as drift too.
140
+ def label_drift(connection = graph.connection)
141
+ values = concrete_types.empty? ? "SELECT NULL::text, NULL::text[] WHERE false" : "VALUES #{label_values(connection)}"
142
+ connection.select_rows(<<~SQL, "Pglpg label drift").to_h { |label, count| [label.to_sym, count.to_i] }
143
+ SELECT n.key_label, count(*) FROM #{q(connection, nodes_table)} AS n
144
+ LEFT JOIN (#{values}) AS v(key_label, labels) ON v.key_label = n.key_label
145
+ WHERE v.labels IS NULL OR n.labels IS DISTINCT FROM v.labels
146
+ GROUP BY n.key_label ORDER BY n.key_label
147
+ SQL
148
+ end
149
+
150
+ private
151
+
152
+ def type = graph.type
153
+
154
+ def q(connection, name) = connection.quote_table_name(name)
155
+
156
+ def concrete_types = type.node_types.values.reject(&:abstract?)
157
+
158
+ def label_values(connection)
159
+ concrete_types.map do |t|
160
+ "(#{connection.quote(t.name.to_s)}, ARRAY[#{t.labels.map { connection.quote(_1) }.join(", ")}]::text[])"
161
+ end.join(", ")
162
+ end
163
+
164
+ # Edge columns declared covering: true (typically what the visibility
165
+ # policy filters on) ride along in the traversal indexes.
166
+ def covering(connection, endpoint)
167
+ [endpoint, *type.edge_columns.each_value.select(&:covering?).map { connection.quote_column_name(_1.name) }].join(", ")
168
+ end
169
+
170
+ def columns_sql(connection, columns)
171
+ columns.each_value.map do |c|
172
+ sql = +"#{connection.quote_column_name(c.name)} #{c.sql_type(connection)}"
173
+ sql << " NOT NULL" unless c.null?
174
+ sql << " DEFAULT #{connection.quote(c.options[:default])}" if c.default?
175
+ "#{sql},\n "
176
+ end.join
177
+ end
178
+
179
+ # Rejects a missing key, JSON null and the wrong JSON type: a CHECK passes
180
+ # on NULL, so the comparison is wrapped in IS TRUE.
181
+ def typed_check(connection, prop)
182
+ "(jsonb_typeof(properties->#{connection.quote(prop.name.to_s)}) = #{connection.quote(prop.json_type)}) IS TRUE"
183
+ end
184
+
185
+ def constraint_name(table, label, body)
186
+ digest = Digest::SHA256.hexdigest(body)[0, 8]
187
+ base = "pglpg_#{table}_#{label}"
188
+ base = base[0, NAME_LIMIT - digest.size - 1]
189
+ "#{base}_#{digest}"
190
+ end
191
+
192
+ def existing_constraints(connection)
193
+ prefixes = [nodes_table, edges_table].map { "pglpg\\_#{_1.gsub("_", "\\_")}\\_%" }
194
+ rows = connection.select_rows(<<~SQL, "Pglpg constraints")
195
+ SELECT c.conname, 'check', t.relname FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
196
+ WHERE t.relname IN (#{connection.quote(nodes_table)}, #{connection.quote(edges_table)})
197
+ AND t.relnamespace = to_regnamespace(current_schema())
198
+ AND (c.conname LIKE #{connection.quote(prefixes[0])} OR c.conname LIKE #{connection.quote(prefixes[1])})
199
+ UNION ALL
200
+ SELECT i.indexname, 'index', i.tablename FROM pg_indexes i
201
+ WHERE i.tablename IN (#{connection.quote(nodes_table)}, #{connection.quote(edges_table)})
202
+ AND i.schemaname = current_schema()
203
+ AND (i.indexname LIKE #{connection.quote(prefixes[0])} OR i.indexname LIKE #{connection.quote(prefixes[1])})
204
+ SQL
205
+ rows.to_h { |name, kind, table| [name, { kind: kind.to_sym, table: }] }
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared leak specs for an application's visibility policy.
4
+ #
5
+ # require "pglpg/testing"
6
+ #
7
+ # RSpec.describe MyApp::ClearancePolicy do
8
+ # it_behaves_like "a pglpg visibility policy" do
9
+ # let(:pglpg_visibility) { MyApp::ClearancePolicy }
10
+ # let(:pglpg_viewer) { MyApp::Viewer.new(clearance: 2) }
11
+ # let(:pglpg_columns) { { classification: [:integer, { null: false }] } }
12
+ # let(:pglpg_visible_attrs) { { classification: 1 } } # visible to the viewer
13
+ # let(:pglpg_hidden_attrs) { { classification: 4 } } # hidden from the viewer
14
+ # end
15
+ # end
16
+ #
17
+ # The suite builds its own small graph with the given application columns in
18
+ # temporary tables, inside a transaction that is rolled back. It is
19
+ # metamorphic: it records what the viewer can observe on a visible fixture,
20
+ # adds hidden nodes and edges (hidden bridges, hidden edges between visible
21
+ # nodes, visible edges to hidden nodes), and checks that every observation
22
+ # (rows, order, counts, exists?, first, pagination, lookups) is unchanged.
23
+ # Execution time and timeouts are outside this guarantee.
24
+ RSpec.shared_examples "a pglpg visibility policy" do
25
+ leak_graph = lambda do |visibility, columns|
26
+ Pglpg::Graph.define(:pglpg_leak, nodes_table: "pglpg_leak_nodes", edges_table: "pglpg_leak_edges",
27
+ visibility:, write_policy: Pglpg::Testing::AllowAll,
28
+ trusted_writer_allowed: ->(_context) { true }) do
29
+ columns.each { |name, (type, options)| column(name, type, **(options || {})) }
30
+ node :Person do
31
+ property :uid, :string, required: true
32
+ property :name, :string
33
+ property :rank, :integer
34
+ key :uid
35
+ end
36
+ node :Project do
37
+ property :uid, :string, required: true
38
+ property :title, :string
39
+ key :uid
40
+ end
41
+ node :Location do
42
+ property :uid, :string, required: true
43
+ key :uid
44
+ end
45
+ edge :WORKS_ON, from: :Person, to: :Project do
46
+ property :role, :string
47
+ end
48
+ edge :BASED_AT, from: :Project, to: :Location
49
+ edge :KNOWS, from: :Person, to: :Person
50
+ end
51
+ end
52
+
53
+ around do |example|
54
+ ActiveRecord::Base.transaction(requires_new: true) do
55
+ example.run
56
+ raise ActiveRecord::Rollback
57
+ end
58
+ end
59
+
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) }
62
+
63
+ it "keeps every observation unchanged when hidden data is added" do
64
+ pglpg_leak_graph.storage.create!
65
+ seed = pglpg_seed_graph.for(viewer: nil).trusted_writer
66
+ vis = pglpg_visible_attrs
67
+ 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:) }
71
+
72
+ p1 = person.("P1", "Ann", 1, vis)
73
+ p2 = person.("P2", "Bob", 2, vis)
74
+ p3 = person.("P3", "Cid", 3, vis)
75
+ j1 = node.(:Project, "J1", vis, title: "Apollo")
76
+ j2 = node.(:Project, "J2", vis, title: "Gemini")
77
+ l1 = node.(:Location, "L1", vis)
78
+ visible_edges = [
79
+ edge.(:WORKS_ON, p1, j1, vis, role: "lead"),
80
+ edge.(:WORKS_ON, p2, j1, vis, role: "dev"),
81
+ edge.(:WORKS_ON, p3, j2, vis, role: "lead"),
82
+ edge.(:BASED_AT, j1, l1, vis),
83
+ edge.(:KNOWS, p1, p2, vis),
84
+ edge.(:KNOWS, p2, p3, vis)
85
+ ]
86
+ visible_refs = [p1, p2, p3, j1, j2, l1, *visible_edges]
87
+
88
+ ctx = pglpg_leak_graph.for(viewer: pglpg_viewer)
89
+ before = Pglpg::Testing.observe(ctx, visible_refs)
90
+ if before[:persons].size != 3 || before[:works_on].size != 3
91
+ raise "the visibility policy hides the visible fixture; check pglpg_visible_attrs"
92
+ end
93
+
94
+ h1 = person.("H1", "Ann", 1, hid)
95
+ h2 = person.("H2", "Zed", 5, hid)
96
+ hj = node.(:Project, "HJ", hid, title: "Apollo")
97
+ hl = node.(:Location, "HL", hid)
98
+ hidden_refs = [
99
+ h1, h2, hj, hl,
100
+ # hidden edges between visible nodes
101
+ edge.(:WORKS_ON, p3, j1, hid, role: "lead"),
102
+ edge.(:KNOWS, p3, p1, hid),
103
+ edge.(:BASED_AT, j2, l1, hid),
104
+ # visible edges touching hidden nodes, including a hidden bridge p3 -> h1 -> p1
105
+ edge.(:WORKS_ON, p1, hj, vis, role: "lead"),
106
+ edge.(:BASED_AT, j2, hl, vis),
107
+ edge.(:KNOWS, p3, h1, vis),
108
+ edge.(:KNOWS, h1, p1, vis),
109
+ edge.(:KNOWS, h2, p2, vis),
110
+ edge.(:WORKS_ON, h2, j2, vis, role: "dev"),
111
+ # hidden edge between hidden nodes
112
+ edge.(:KNOWS, h1, h2, hid)
113
+ ]
114
+
115
+ after = Pglpg::Testing.observe(ctx, visible_refs)
116
+ before.each_key { |name| expect(after[name]).to eq(before[name]), "observation #{name} changed" }
117
+
118
+ 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
120
+ end
121
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ # Helpers for the shared leak specs (see testing/leak_examples.rb).
5
+ module Testing
6
+ # Sees everything; used only to seed fixtures.
7
+ module SeeAll
8
+ def self.node(_table, _viewer) = Arel.sql("TRUE")
9
+
10
+ def self.edge(_table, _viewer) = Arel.sql("TRUE")
11
+ end
12
+
13
+ # Allows every write; used only to seed fixtures.
14
+ module AllowAll
15
+ def self.authorize?(_request) = true
16
+ end
17
+
18
+ module_function
19
+
20
+ # Everything the viewer can observe through the query API on the leak
21
+ # graph, as plain data.
22
+ def observe(ctx, refs)
23
+ m = -> { ctx.match }
24
+ queries = {
25
+ persons: -> { m.().node(:p, :Person).order(p: :name) },
26
+ works_on: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project, edge: :w) },
27
+ two_hops: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project).out(:BASED_AT, :l, :Location) },
28
+ knows_chain: -> { m.().node(:a, :Person).out(:KNOWS, :b, :Person).out(:KNOWS, :c, :Person) },
29
+ inbound: -> { m.().node(:l, :Location).in(:BASED_AT, :j, :Project).in(:WORKS_ON, :p, :Person) },
30
+ branch: -> { m.().node(:j, :Project).in(:WORKS_ON, :p, :Person).out(:BASED_AT, :l, :Location, from: :j) },
31
+ unlabeled: -> { m.().node(:x).out(:KNOWS, :y) },
32
+ leads: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project, edge: :w).where(w: { role: "lead" }) },
33
+ ranked: -> { m.().node(:p, :Person).where(p: { rank: Pglpg.gte(2) }) },
34
+ by_title: -> { m.().node(:j, :Project, title: "Apollo").in(:WORKS_ON, :p, :Person) },
35
+ projects: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project).return(:j).distinct },
36
+ page: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project).limit(2).offset(1) },
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) }
39
+ }
40
+ out = {}
41
+ queries.each do |name, q|
42
+ out[name] = q.().to_a.map { |row| row.transform_values { element_data(_1) } }
43
+ out[:"#{name}_count"] = q.().count
44
+ out[:"#{name}_exists"] = q.().exists?
45
+ out[:"#{name}_first"] = q.().first&.transform_values { element_data(_1) }
46
+ end
47
+ out[:last_by_name] = element_data(m.().node(:p, :Person).order(p: { name: :desc }).first&.fetch(:p))
48
+ 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)) }
50
+ out
51
+ end
52
+
53
+ def element_data(element)
54
+ case element
55
+ when nil then nil
56
+ when Pglpg::Node then [:node, element.id, element.key_label, element.labels, element.properties, element.attrs]
57
+ when Pglpg::Edge
58
+ [:edge, element.id, element.type, element.source.id, element.target.id, element.properties, element.attrs]
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ require_relative "testing/leak_examples" if defined?(RSpec)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pglpg
4
+ VERSION = "0.1.0"
5
+ end