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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +249 -0
- data/README.md +269 -3
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +71 -13
- data/lib/pglpg/element.rb +23 -3
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +13 -6
- data/lib/pglpg/model/base.rb +337 -0
- data/lib/pglpg/model/edge.rb +169 -0
- data/lib/pglpg/model/node.rb +207 -0
- data/lib/pglpg/model/relation.rb +334 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +68 -15
- data/lib/pglpg/reachability.rb +133 -0
- data/lib/pglpg/schema/edge_type.rb +41 -8
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +23 -8
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -22
- data/lib/pglpg/storage.rb +147 -20
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +67 -18
- data/lib/pglpg/testing.rb +131 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +127 -43
- data/lib/pglpg.rb +3 -0
- metadata +9 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Model
|
|
5
|
+
# A model of an edge type. For a symmetric type, source and target are
|
|
6
|
+
# the stored (canonical) endpoints and carry no direction.
|
|
7
|
+
#
|
|
8
|
+
# class WorksOn < Pglpg::Model::Edge
|
|
9
|
+
# graph Kg, type: :WORKS_ON
|
|
10
|
+
# end
|
|
11
|
+
class Edge < Base
|
|
12
|
+
class BoundType < StrictType
|
|
13
|
+
def cast(value) = Temporal.date(value)
|
|
14
|
+
alias deserialize cast
|
|
15
|
+
alias serialize cast
|
|
16
|
+
end
|
|
17
|
+
BOUND = BoundType.new.freeze
|
|
18
|
+
class << self
|
|
19
|
+
def base_class = Model::Edge
|
|
20
|
+
|
|
21
|
+
def kind = :edge
|
|
22
|
+
|
|
23
|
+
def query_var = :e
|
|
24
|
+
|
|
25
|
+
# Symmetric edges bind once per orientation, so the relation returns
|
|
26
|
+
# each edge once (distinct).
|
|
27
|
+
def pattern(context)
|
|
28
|
+
q = context.match.node(:s)
|
|
29
|
+
q = schema_type.symmetric? ? q.both(schema_type.name, :t, edge: :e) : q.out(schema_type.name, :t, edge: :e)
|
|
30
|
+
q.return(:e).distinct
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def matches?(element) = element.type == schema_type.name
|
|
34
|
+
|
|
35
|
+
def element_type_name(element) = element.type
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def resolve_type(graph, type) = graph.type.edge_type!(type)
|
|
40
|
+
|
|
41
|
+
def element_columns(graph) = graph.type.edge_columns
|
|
42
|
+
|
|
43
|
+
def inherits_type?(type, parent_type) = type == parent_type
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :source, :target
|
|
47
|
+
|
|
48
|
+
def schema_type = self.class.schema_type
|
|
49
|
+
|
|
50
|
+
def symmetric? = schema_type.symmetric?
|
|
51
|
+
|
|
52
|
+
def temporal? = schema_type.temporal?
|
|
53
|
+
|
|
54
|
+
def key
|
|
55
|
+
descriptor = schema_type.key or return nil
|
|
56
|
+
values = descriptor.properties.to_h { [_1, attribute(_1.to_s)] }
|
|
57
|
+
descriptor.property ? values[descriptor.property] : values.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The endpoint opposite +ref+ (the same node for a self-loop).
|
|
61
|
+
def other(ref)
|
|
62
|
+
return target if ref == source
|
|
63
|
+
return source if ref == target
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "#{ref} is not an endpoint of #{self.ref}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Endpoints of a new record: a node Ref or a node model. They can't
|
|
69
|
+
# change once the edge is stored.
|
|
70
|
+
def source=(node)
|
|
71
|
+
@source = endpoint(node)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def target=(node)
|
|
75
|
+
@target = endpoint(node)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
validate :validate_endpoints
|
|
79
|
+
validate :validate_validity
|
|
80
|
+
validate :validate_keys
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def _write_attribute(name, value)
|
|
85
|
+
if Temporal::NAMES.include?(name.to_sym) && temporal?
|
|
86
|
+
value = BOUND.cast(value)
|
|
87
|
+
(@validity_assigned ||= {})[name.to_sym] = true
|
|
88
|
+
end
|
|
89
|
+
super(name, value)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def load_identity(element)
|
|
93
|
+
@validity_assigned = {}
|
|
94
|
+
if temporal?
|
|
95
|
+
Temporal::NAMES.each { @attributes.write_from_database(_1.to_s, element.public_send(_1)) }
|
|
96
|
+
end
|
|
97
|
+
@source = element.source
|
|
98
|
+
@target = element.target
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def endpoint(node)
|
|
103
|
+
raise ArgumentError, "the endpoints of a stored edge can't change" unless new_record?
|
|
104
|
+
return nil if node.nil?
|
|
105
|
+
|
|
106
|
+
ref = node.is_a?(Model::Node) ? node.ref : node
|
|
107
|
+
raise ArgumentError, "the endpoint is not stored yet" if ref.nil?
|
|
108
|
+
|
|
109
|
+
self.class.graph.check_ref!(ref, :node)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A symmetric edge is stored in canonical order, so the record takes it.
|
|
113
|
+
def create_element(writer, properties, attrs)
|
|
114
|
+
@ref = writer.insert_edge(schema_type.name, from: source, to: target, properties:, attrs:, **validity_changes)
|
|
115
|
+
@source, @target = @target, @source if symmetric? && @source.id > @target.id
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def update_element(writer, properties, attrs)
|
|
119
|
+
writer.update_edge(ref, properties:, attrs:, **validity_changes)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def delete_element(writer) = writer.delete_edge(ref)
|
|
123
|
+
|
|
124
|
+
def validity_changes
|
|
125
|
+
return {} unless temporal?
|
|
126
|
+
|
|
127
|
+
names = new_record? ? (@validity_assigned || {}).keys : Temporal::NAMES.select { attribute_changed?(_1.to_s) }
|
|
128
|
+
names.to_h { [_1, public_send(_1)] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_validity
|
|
132
|
+
return unless temporal?
|
|
133
|
+
|
|
134
|
+
if new_record? && !(@validity_assigned || {}).key?(:valid_from)
|
|
135
|
+
errors.add(:valid_from, "must be explicitly assigned (nil means unbounded)")
|
|
136
|
+
end
|
|
137
|
+
Temporal.validate_interval!(valid_from, valid_to)
|
|
138
|
+
rescue ValidationError => e
|
|
139
|
+
errors.add(:valid_from, e.message)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def required_now?(name)
|
|
143
|
+
return false if new_record? && schema_type.key&.generated? && schema_type.key.property == name
|
|
144
|
+
|
|
145
|
+
super
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_keys
|
|
149
|
+
key = schema_type.key
|
|
150
|
+
if new_record?
|
|
151
|
+
if key&.generated? && !attribute(key.property.to_s).nil?
|
|
152
|
+
errors.add(key.property, "is generated and cannot be assigned")
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
schema_type.immutable_properties.each do |property|
|
|
156
|
+
errors.add(property, "cannot be changed") if attribute_changed?(property.to_s)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_endpoints
|
|
162
|
+
return unless new_record?
|
|
163
|
+
|
|
164
|
+
errors.add(:source, :blank) if source.nil?
|
|
165
|
+
errors.add(:target, :blank) if target.nil?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Model
|
|
5
|
+
# A model of a node type. A query on it returns nodes of the type and its
|
|
6
|
+
# subtypes; each is built as the model registered for its concrete type,
|
|
7
|
+
# else as the queried model.
|
|
8
|
+
#
|
|
9
|
+
# class Person < Pglpg::Model::Node
|
|
10
|
+
# graph Kg, type: :Person
|
|
11
|
+
# has_many :projects, :out, edge: :WORKS_ON
|
|
12
|
+
# end
|
|
13
|
+
class Node < Base
|
|
14
|
+
# A declared association: follow +edge_type+ in +direction+ to nodes of
|
|
15
|
+
# +label+, loaded as +model+ (a class, a class name, or nil for the
|
|
16
|
+
# model registered for +label+).
|
|
17
|
+
Association = Data.define(:name, :direction, :edge_type, :label, :model)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def base_class = Model::Node
|
|
21
|
+
|
|
22
|
+
# Declares an association, read as a method returning an
|
|
23
|
+
# AssociationRelation:
|
|
24
|
+
# has_many :projects, :out, edge: :WORKS_ON
|
|
25
|
+
# has_many :friends, :both, edge: :FRIEND
|
|
26
|
+
# has_many :team, :in, edge: :WORKS_ON, label: :Employee, model: "Employee"
|
|
27
|
+
# +direction+ is :out or :in for a directed edge type and :both for a
|
|
28
|
+
# symmetric one. The target label defaults to the edge type's other
|
|
29
|
+
# endpoint; +model+ defaults to the model registered for that label.
|
|
30
|
+
def has_many(name, direction, edge:, label: nil, model: nil)
|
|
31
|
+
raise ConfigurationError, "#{self}: declare graph before has_many" unless graph
|
|
32
|
+
|
|
33
|
+
name = Schema.check_name!(name, "association")
|
|
34
|
+
edge_type = graph.type.edge_type!(edge)
|
|
35
|
+
owner_labels, target_label = association_ends(edge_type, direction)
|
|
36
|
+
unless owner_labels.any? { |l| graph.type.node_types_with_label(l).any? { _1.label?(schema_type.name) } }
|
|
37
|
+
raise SchemaError, "#{schema_type.name} is not an endpoint of #{edge_type.name} (#{direction})"
|
|
38
|
+
end
|
|
39
|
+
target_label = graph.type.node_type!(label).name if label
|
|
40
|
+
if target_label.nil? && model.nil?
|
|
41
|
+
raise SchemaError, "#{name}: #{edge_type.name} joins two labels; give label: or model:"
|
|
42
|
+
end
|
|
43
|
+
if method_defined?(name) || reserved_name?(name)
|
|
44
|
+
raise SchemaError, "association #{name} clashes with a method of #{self}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
association = Association.new(name:, direction:, edge_type: edge_type.name, label: target_label, model:)
|
|
48
|
+
own_associations[name] = association
|
|
49
|
+
define_method(name) { association_relation(association) }
|
|
50
|
+
association
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Associations declared on this model and the models it extends.
|
|
54
|
+
def associations
|
|
55
|
+
inherited = superclass.respond_to?(:associations) ? superclass.associations : {}
|
|
56
|
+
inherited.merge(own_associations)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The query variable a relation binds the node to.
|
|
60
|
+
|
|
61
|
+
def kind = :node
|
|
62
|
+
|
|
63
|
+
# The query variable a relation binds the node to.
|
|
64
|
+
def query_var = :n
|
|
65
|
+
|
|
66
|
+
# The pattern a relation starts from.
|
|
67
|
+
def pattern(context) = context.match.node(:n, schema_type.name)
|
|
68
|
+
|
|
69
|
+
def matches?(element) = element.label?(schema_type.name)
|
|
70
|
+
|
|
71
|
+
def element_type_name(element) = element.key_label
|
|
72
|
+
|
|
73
|
+
# Internal association target resolution, shared by lazy and eager reads.
|
|
74
|
+
def association_model(association)
|
|
75
|
+
model = case association.model
|
|
76
|
+
when nil then Model.lookup(graph, :node, association.label)
|
|
77
|
+
when String, Symbol then resolve_model_name(association.model.to_s)
|
|
78
|
+
else association.model
|
|
79
|
+
end
|
|
80
|
+
unless model.is_a?(Class) && model < Model::Node && model.graph.equal?(graph)
|
|
81
|
+
raise ConfigurationError, "#{self}##{association.name}: no node model of graph #{graph.name} " \
|
|
82
|
+
"for #{association.label}; give model:"
|
|
83
|
+
end
|
|
84
|
+
if association.label && !graph.type.node_type!(association.label).label?(model.schema_type.name)
|
|
85
|
+
raise ConfigurationError, "#{self}##{association.name}: #{association.label} nodes are not #{model}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
model
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# nil for a name that doesn't resolve, so it reports as a missing model.
|
|
92
|
+
def resolve_model_name(name)
|
|
93
|
+
Object.const_get(name)
|
|
94
|
+
rescue NameError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def own_associations = (@own_associations ||= {})
|
|
101
|
+
|
|
102
|
+
def inherited_member_names = associations.keys
|
|
103
|
+
|
|
104
|
+
# The owner's possible labels and the target label for +direction+,
|
|
105
|
+
# following the query DSL's rule for symmetric types (ADR-0008).
|
|
106
|
+
def association_ends(edge_type, direction)
|
|
107
|
+
case direction
|
|
108
|
+
when :out, :in
|
|
109
|
+
raise SchemaError, "#{edge_type.name} is symmetric; use :both" if edge_type.symmetric?
|
|
110
|
+
|
|
111
|
+
direction == :out ? [[edge_type.from], edge_type.to] : [[edge_type.to], edge_type.from]
|
|
112
|
+
when :both
|
|
113
|
+
raise SchemaError, "#{edge_type.name} is directed; use :out or :in" unless edge_type.symmetric?
|
|
114
|
+
|
|
115
|
+
[[edge_type.from, edge_type.to].uniq, (edge_type.from if edge_type.from == edge_type.to)]
|
|
116
|
+
else
|
|
117
|
+
raise ArgumentError, "direction must be :out, :in or :both"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_type(graph, type) = graph.type.node_type!(type)
|
|
122
|
+
|
|
123
|
+
def element_columns(graph) = graph.type.node_columns
|
|
124
|
+
|
|
125
|
+
def inherits_type?(type, parent_type) = type == parent_type || type.ancestors.include?(parent_type.name)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
attr_reader :labels
|
|
129
|
+
|
|
130
|
+
# The concrete schema type of the stored node, which may be a subtype
|
|
131
|
+
# of the model's type.
|
|
132
|
+
def schema_type = @schema_type || self.class.schema_type
|
|
133
|
+
|
|
134
|
+
# A legacy scalar key, a composite property/value map, or nil.
|
|
135
|
+
def key
|
|
136
|
+
key = schema_type.key or return nil
|
|
137
|
+
values = key.properties.to_h do |name|
|
|
138
|
+
[name, self.class.property_names.include?(name) ? attribute(name.to_s) : extra_properties[name]]
|
|
139
|
+
end
|
|
140
|
+
key.property ? values[key.property] : values.freeze
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
validate :validate_node
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# The association's targets as seen through this record's context,
|
|
148
|
+
# starting from this record (visibility applies to it too).
|
|
149
|
+
def association_relation(association)
|
|
150
|
+
raise ArgumentError, "#{association.name}: save the record first" unless persisted?
|
|
151
|
+
|
|
152
|
+
target = association_model(association)
|
|
153
|
+
query = context.match.node(:owner, ref:)
|
|
154
|
+
.public_send(association.direction, association.edge_type, :n,
|
|
155
|
+
association.label || target.schema_type.name, edge: :e)
|
|
156
|
+
edge_model = Model.lookup(self.class.graph, :edge, association.edge_type)
|
|
157
|
+
AssociationRelation.new(target, context, query, edge_model:, cached: (@association_cache || {})[association.name])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def association_model(association) = self.class.association_model(association)
|
|
161
|
+
|
|
162
|
+
def load_identity(element)
|
|
163
|
+
@schema_type = self.class.graph.type.node_type!(element.key_label)
|
|
164
|
+
@labels = element.labels
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Labels follow from the type, so they are known without a reload.
|
|
169
|
+
def create_element(target, properties, attrs)
|
|
170
|
+
@ref = target.insert_node(schema_type.name, properties:, attrs:)
|
|
171
|
+
@labels = schema_type.labels.map(&:to_sym).freeze
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def update_element(target, properties, attrs)
|
|
175
|
+
target.update_node(ref, properties:, attrs:)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def delete_element(target)
|
|
179
|
+
raise NotTrusted, "destroying a node needs the trusted writer" unless target.is_a?(Pglpg::Writer)
|
|
180
|
+
|
|
181
|
+
target.delete_node(ref)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def key_property = schema_type.key&.generated? ? schema_type.key.property : nil
|
|
185
|
+
|
|
186
|
+
# The generated key is not required on insert.
|
|
187
|
+
def required_now?(name) = new_record? ? name != key_property : super
|
|
188
|
+
|
|
189
|
+
# Generated keys cannot be assigned; every stored key is immutable.
|
|
190
|
+
def validate_node
|
|
191
|
+
errors.add(:base, "#{schema_type.name} is abstract") if new_record? && schema_type.abstract?
|
|
192
|
+
key = schema_type.key
|
|
193
|
+
return unless key
|
|
194
|
+
|
|
195
|
+
if new_record?
|
|
196
|
+
if key.generated? && self.class.property_names.include?(key.property) && !attribute(key.property.to_s).nil?
|
|
197
|
+
errors.add(key.property, "is generated and cannot be assigned")
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
key.properties.each do |property|
|
|
201
|
+
errors.add(property, "cannot be changed") if attribute_changed?(property.to_s)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|