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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/lib/pglpg/condition.rb +33 -0
- data/lib/pglpg/configuration.rb +48 -0
- data/lib/pglpg/context.rb +120 -0
- data/lib/pglpg/element.rb +62 -0
- data/lib/pglpg/errors.rb +40 -0
- data/lib/pglpg/graph.rb +132 -0
- data/lib/pglpg/query.rb +411 -0
- data/lib/pglpg/ref.rb +25 -0
- data/lib/pglpg/schema/column.rb +38 -0
- data/lib/pglpg/schema/edge_type.rb +35 -0
- data/lib/pglpg/schema/element_type.rb +65 -0
- data/lib/pglpg/schema/graph_type.rb +100 -0
- data/lib/pglpg/schema/node_type.rb +92 -0
- data/lib/pglpg/schema/property.rb +123 -0
- data/lib/pglpg/sql.rb +75 -0
- data/lib/pglpg/storage.rb +208 -0
- data/lib/pglpg/testing/leak_examples.rb +121 -0
- data/lib/pglpg/testing.rb +64 -0
- data/lib/pglpg/version.rb +5 -0
- data/lib/pglpg/writer.rb +329 -0
- data/lib/pglpg.rb +27 -0
- metadata +95 -0
|
@@ -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)
|