pglpg 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -0
- data/README.md +65 -1
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +20 -7
- data/lib/pglpg/element.rb +16 -3
- data/lib/pglpg/graph.rb +8 -4
- data/lib/pglpg/model/base.rb +327 -0
- data/lib/pglpg/model/edge.rb +104 -0
- data/lib/pglpg/model/node.rb +202 -0
- data/lib/pglpg/model/relation.rb +327 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +47 -8
- data/lib/pglpg/reachability.rb +131 -0
- data/lib/pglpg/schema/edge_type.rb +14 -3
- data/lib/pglpg/schema/graph_type.rb +6 -5
- data/lib/pglpg/schema/node_type.rb +16 -7
- data/lib/pglpg/storage.rb +30 -16
- data/lib/pglpg/testing/leak_examples.rb +26 -5
- data/lib/pglpg/testing.rb +122 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +45 -10
- data/lib/pglpg.rb +1 -0
- metadata +7 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Model
|
|
5
|
+
# Shared by Model::Node and Model::Edge. A model class binds a graph and
|
|
6
|
+
# a schema type; its attributes are the type's properties and the
|
|
7
|
+
# graph's application columns. Models keep the Context they were read
|
|
8
|
+
# or built through, and write through its guarded write API (ADR-0010).
|
|
9
|
+
class Base
|
|
10
|
+
include ActiveModel::API
|
|
11
|
+
include ActiveModel::Attributes
|
|
12
|
+
include ActiveModel::Dirty
|
|
13
|
+
|
|
14
|
+
# Attributes are assigned as given: no form coercion ("42" stays a
|
|
15
|
+
# String). The schema checks values on validation.
|
|
16
|
+
#
|
|
17
|
+
# Every conversion copies Strings and Arrays, so the dirty baseline and
|
|
18
|
+
# the caller's object never share the record's value, and in-place
|
|
19
|
+
# edits (tags << "b", name << "e") count as changes.
|
|
20
|
+
class StrictType < ActiveModel::Type::Value
|
|
21
|
+
def cast(value) = copy(value)
|
|
22
|
+
|
|
23
|
+
def deserialize(value) = copy(value)
|
|
24
|
+
|
|
25
|
+
def serialize(value) = copy(value)
|
|
26
|
+
|
|
27
|
+
def changed_in_place?(raw_old_value, new_value) = raw_old_value != new_value
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def copy(value)
|
|
32
|
+
case value
|
|
33
|
+
when Array then value.map { copy(_1) }
|
|
34
|
+
when String then value.dup
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
STRICT = StrictType.new
|
|
41
|
+
|
|
42
|
+
CORE_OWNERS = [Kernel, Object, BasicObject].freeze
|
|
43
|
+
CORE_METHODS = %i[
|
|
44
|
+
class hash freeze frozen? send public_send object_id method methods respond_to? dup clone
|
|
45
|
+
equal? eql? is_a? kind_of? instance_of? nil? itself instance_variable_get instance_variable_set
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
validate :validate_schema
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Binds the model to a graph and a schema type:
|
|
52
|
+
# graph Kg, type: :Person
|
|
53
|
+
# Without arguments, returns the bound graph.
|
|
54
|
+
def graph(graph = nil, type: nil)
|
|
55
|
+
return @graph if graph.nil? && type.nil?
|
|
56
|
+
raise ConfigurationError, "#{self} is already bound to graph #{@graph.name}" if @graph
|
|
57
|
+
raise ArgumentError, "graph must be a Pglpg::Graph" unless graph.is_a?(Pglpg::Graph)
|
|
58
|
+
|
|
59
|
+
schema_type = resolve_type(graph, type)
|
|
60
|
+
check_parent!(graph, schema_type)
|
|
61
|
+
property_names = schema_type.properties.keys
|
|
62
|
+
column_names = element_columns(graph).keys
|
|
63
|
+
check_attribute_names!(schema_type, property_names, column_names)
|
|
64
|
+
|
|
65
|
+
property_names.each { attribute(_1, STRICT) }
|
|
66
|
+
column_names.each { attribute(_1, STRICT) }
|
|
67
|
+
@graph = graph
|
|
68
|
+
@schema_type = schema_type
|
|
69
|
+
@property_names = property_names.freeze
|
|
70
|
+
@column_names = column_names.freeze
|
|
71
|
+
Model.register(graph, kind, schema_type.name, self)
|
|
72
|
+
graph
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
attr_reader :schema_type, :property_names, :column_names
|
|
76
|
+
|
|
77
|
+
# The model's elements as seen through +context+: a Relation.
|
|
78
|
+
def in(context)
|
|
79
|
+
raise ConfigurationError, "#{self} is not bound to a graph; declare graph Kg, type: ..." unless @graph
|
|
80
|
+
raise ArgumentError, "expected a Pglpg::Context" unless context.is_a?(Pglpg::Context)
|
|
81
|
+
raise ArgumentError, "the context belongs to graph #{context.graph.name}, not #{@graph.name}" unless context.graph.equal?(@graph)
|
|
82
|
+
|
|
83
|
+
Relation.new(self, context)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Anonymous model classes are named after their schema type, so
|
|
87
|
+
# validation messages work for them too.
|
|
88
|
+
def model_name
|
|
89
|
+
return super if name
|
|
90
|
+
|
|
91
|
+
@model_name ||= ActiveModel::Name.new(self, nil, schema_type&.name&.to_s || base_class.name.split("::").last)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# -- internal ----------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
# A model of +element+, read through +context+: the model registered
|
|
97
|
+
# for its concrete type, else this one.
|
|
98
|
+
def instantiate(context, element)
|
|
99
|
+
klass = Model.lookup(graph, kind, element_type_name(element)) || self
|
|
100
|
+
klass.allocate.tap { _1.send(:load_element, context, element) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A new record bound to +context+.
|
|
104
|
+
def build(context, attributes)
|
|
105
|
+
new(attributes).tap { _1.instance_variable_set(:@context, context) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# A model may extend another model only along the schema's inheritance,
|
|
111
|
+
# checked against the nearest bound ancestor (unbound classes between
|
|
112
|
+
# them still inherit its attributes).
|
|
113
|
+
def check_parent!(graph, schema_type)
|
|
114
|
+
parent = superclass
|
|
115
|
+
parent = parent.superclass while parent < Base && parent.schema_type.nil?
|
|
116
|
+
return unless parent < Base
|
|
117
|
+
return if parent.graph.equal?(graph) && inherits_type?(schema_type, parent.schema_type)
|
|
118
|
+
|
|
119
|
+
raise SchemaError, "#{self} (#{schema_type.name}) cannot extend #{parent} (#{parent.schema_type.name})"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check_attribute_names!(schema_type, property_names, column_names)
|
|
123
|
+
clashes = property_names & column_names
|
|
124
|
+
raise SchemaError, "#{schema_type.name} has properties named like columns: #{clashes.join(", ")}" if clashes.any?
|
|
125
|
+
|
|
126
|
+
(property_names + column_names).each do |name|
|
|
127
|
+
if inherited_member_names.include?(name)
|
|
128
|
+
raise SchemaError, "#{schema_type.name}.#{name} clashes with the inherited association #{name}"
|
|
129
|
+
end
|
|
130
|
+
next unless reserved_name?(name)
|
|
131
|
+
|
|
132
|
+
raise SchemaError, "#{schema_type.name}.#{name} clashes with a method of #{base_class}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Names a model inherits that attributes may not take (associations).
|
|
137
|
+
def inherited_member_names = []
|
|
138
|
+
|
|
139
|
+
# Methods of the model classes and ActiveModel, public or private, plus
|
|
140
|
+
# the core Object methods an attribute must never shadow. Other
|
|
141
|
+
# Kernel and Object methods (format, display, ...) may be shadowed.
|
|
142
|
+
def reserved_name?(name)
|
|
143
|
+
[name, :"#{name}="].any? do |m|
|
|
144
|
+
next true if CORE_METHODS.include?(m)
|
|
145
|
+
next false unless base_class.method_defined?(m) || base_class.private_method_defined?(m)
|
|
146
|
+
|
|
147
|
+
!CORE_OWNERS.include?(base_class.instance_method(m).owner)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
attr_reader :context, :ref, :created_at, :updated_at
|
|
153
|
+
|
|
154
|
+
# Properties stored on the element that this model's type does not
|
|
155
|
+
# declare (a subtype read through a base-type model). Read-only.
|
|
156
|
+
def extra_properties = @extra_properties || {}.freeze
|
|
157
|
+
|
|
158
|
+
def id = ref&.id
|
|
159
|
+
|
|
160
|
+
def persisted? = !ref.nil? && !destroyed?
|
|
161
|
+
|
|
162
|
+
def new_record? = ref.nil?
|
|
163
|
+
|
|
164
|
+
def destroyed? = @destroyed == true
|
|
165
|
+
|
|
166
|
+
def to_key = persisted? ? [id] : nil
|
|
167
|
+
|
|
168
|
+
# The ref as a String; Relation#find accepts it back.
|
|
169
|
+
def to_param = ref&.to_s
|
|
170
|
+
|
|
171
|
+
# Validates, then writes through the context's guarded write API: an
|
|
172
|
+
# insert for a new record, else an update of the changed attributes
|
|
173
|
+
# only. Returns false when validation fails. Refusals (WriteRejected,
|
|
174
|
+
# NotTrusted), timeouts and database errors raise; changes are kept on
|
|
175
|
+
# any failure. +writer:+ hands over the context's trusted writer, for
|
|
176
|
+
# trusted-only operations; it never permits assigning a key.
|
|
177
|
+
#
|
|
178
|
+
# Success means the statement succeeded in the caller's transaction.
|
|
179
|
+
# Local state is what was assigned, not a stored snapshot (generated
|
|
180
|
+
# keys and timestamps are unknown until #reload), and a caller's
|
|
181
|
+
# rollback is not tracked: discard or reload the record after one.
|
|
182
|
+
def save(writer: nil)
|
|
183
|
+
target = write_target(writer)
|
|
184
|
+
return false unless valid?
|
|
185
|
+
|
|
186
|
+
persist(target)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# As #save, but raises RecordInvalid when validation fails.
|
|
190
|
+
def save!(writer: nil)
|
|
191
|
+
target = write_target(writer)
|
|
192
|
+
raise RecordInvalid, self unless valid?
|
|
193
|
+
|
|
194
|
+
persist(target)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Deletes the element through the guarded write API (a node only
|
|
198
|
+
# through the trusted writer). Returns true.
|
|
199
|
+
def destroy(writer: nil)
|
|
200
|
+
raise ArgumentError, "only a persisted record can be destroyed" unless persisted?
|
|
201
|
+
|
|
202
|
+
delete_element(write_target(writer))
|
|
203
|
+
@association_cache = nil
|
|
204
|
+
@destroyed = true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Replaces attributes, timestamps and the dirty baseline from the
|
|
208
|
+
# stored element. Raises RecordNotFound, leaving the record unchanged,
|
|
209
|
+
# when it is missing or no longer visible.
|
|
210
|
+
def reload
|
|
211
|
+
element = ref && context&.find(ref)
|
|
212
|
+
raise RecordNotFound unless element && self.class.matches?(element)
|
|
213
|
+
|
|
214
|
+
load_element(context, element)
|
|
215
|
+
self
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def ==(other) = other.is_a?(self.class.base_class) && !ref.nil? && other.ref == ref
|
|
219
|
+
|
|
220
|
+
alias eql? ==
|
|
221
|
+
|
|
222
|
+
def hash = ref ? ref.hash : super
|
|
223
|
+
|
|
224
|
+
def inspect
|
|
225
|
+
attrs = attributes.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
|
226
|
+
"#<#{self.class.name || self.class.base_class.name} #{ref || "new"} #{attrs}>"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# ActiveModel casts lazily, on first read; copy on assignment so a
|
|
232
|
+
# caller who changes the object afterwards doesn't change the record.
|
|
233
|
+
def _write_attribute(name, value) = super(name, STRICT.cast(value))
|
|
234
|
+
|
|
235
|
+
def load_element(context, element)
|
|
236
|
+
@association_cache = nil
|
|
237
|
+
@attributes = self.class._default_attributes.deep_dup
|
|
238
|
+
@context = context
|
|
239
|
+
@ref = element.ref
|
|
240
|
+
@created_at = element.created_at
|
|
241
|
+
@updated_at = element.updated_at
|
|
242
|
+
properties = element.properties
|
|
243
|
+
self.class.property_names.each { @attributes.write_from_database(_1.to_s, properties[_1]) }
|
|
244
|
+
self.class.column_names.each { @attributes.write_from_database(_1.to_s, element.attrs[_1]) }
|
|
245
|
+
@extra_properties = properties.except(*self.class.property_names).freeze
|
|
246
|
+
load_identity(element)
|
|
247
|
+
clear_changes_information
|
|
248
|
+
self
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The context's interactive writes, or the given writer, which must
|
|
252
|
+
# belong to this record's own context (identity).
|
|
253
|
+
def write_target(writer)
|
|
254
|
+
raise ArgumentError, "#{self.class} has no context; build it with Model.in(ctx).new" unless context
|
|
255
|
+
raise ArgumentError, "#{self.class} was destroyed" if destroyed?
|
|
256
|
+
return context if writer.nil?
|
|
257
|
+
raise ArgumentError, "writer: expects a Pglpg::Writer" unless writer.is_a?(Pglpg::Writer)
|
|
258
|
+
raise ArgumentError, "the writer belongs to another context" unless writer.context.equal?(context)
|
|
259
|
+
|
|
260
|
+
writer
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def persist(target)
|
|
264
|
+
if new_record?
|
|
265
|
+
create_element(target, assigned(self.class.property_names), assigned(self.class.column_names))
|
|
266
|
+
elsif changed?
|
|
267
|
+
update_element(target, changes_to(self.class.property_names), changes_to(self.class.column_names))
|
|
268
|
+
end
|
|
269
|
+
changes_applied
|
|
270
|
+
@association_cache = nil
|
|
271
|
+
true
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Non-nil values to insert (nil means absent).
|
|
275
|
+
def assigned(names)
|
|
276
|
+
names.each_with_object({}) do |name, out|
|
|
277
|
+
value = attribute(name.to_s)
|
|
278
|
+
out[name] = value unless value.nil?
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Changed values to update (nil removes a property).
|
|
283
|
+
def changes_to(names) = names.select { attribute_changed?(_1.to_s) }.to_h { [_1, attribute(_1.to_s)] }
|
|
284
|
+
|
|
285
|
+
def element_columns = self.class.send(:element_columns, self.class.graph)
|
|
286
|
+
|
|
287
|
+
# Schema checks: property values per the stored type, required
|
|
288
|
+
# properties and columns.
|
|
289
|
+
def validate_schema
|
|
290
|
+
type = schema_type
|
|
291
|
+
self.class.property_names.each do |name|
|
|
292
|
+
prop = type.property_named(name)
|
|
293
|
+
value = attribute(name.to_s)
|
|
294
|
+
if value.nil?
|
|
295
|
+
errors.add(name, :blank) if prop.required? && required_now?(name)
|
|
296
|
+
else
|
|
297
|
+
begin
|
|
298
|
+
prop.encode(value)
|
|
299
|
+
rescue ValidationError
|
|
300
|
+
errors.add(name, :invalid, message: "is not a valid #{prop.type == :list ? "list of #{prop.of}" : prop.type}")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
element_columns.each_value do |column|
|
|
305
|
+
next unless attribute(column.name.to_s).nil?
|
|
306
|
+
|
|
307
|
+
blank = new_record? ? column.required? : attribute_changed?(column.name.to_s) && !column.null?
|
|
308
|
+
errors.add(column.name, :blank) if blank
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# A missing required property is an error on insert, and when an
|
|
313
|
+
# update would remove it.
|
|
314
|
+
def required_now?(name) = new_record? || attribute_changed?(name.to_s)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# save! or create! found validation errors. #record has them.
|
|
318
|
+
class RecordInvalid < Error
|
|
319
|
+
attr_reader :record
|
|
320
|
+
|
|
321
|
+
def initialize(record)
|
|
322
|
+
@record = record
|
|
323
|
+
super("validation failed: #{record.errors.full_messages.join(", ")}")
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
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 << self
|
|
13
|
+
def base_class = Model::Edge
|
|
14
|
+
|
|
15
|
+
def kind = :edge
|
|
16
|
+
|
|
17
|
+
def query_var = :e
|
|
18
|
+
|
|
19
|
+
# Symmetric edges bind once per orientation, so the relation returns
|
|
20
|
+
# each edge once (distinct).
|
|
21
|
+
def pattern(context)
|
|
22
|
+
q = context.match.node(:s)
|
|
23
|
+
q = schema_type.symmetric? ? q.both(schema_type.name, :t, edge: :e) : q.out(schema_type.name, :t, edge: :e)
|
|
24
|
+
q.return(:e).distinct
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def matches?(element) = element.type == schema_type.name
|
|
28
|
+
|
|
29
|
+
def element_type_name(element) = element.type
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def resolve_type(graph, type) = graph.type.edge_type!(type)
|
|
34
|
+
|
|
35
|
+
def element_columns(graph) = graph.type.edge_columns
|
|
36
|
+
|
|
37
|
+
def inherits_type?(type, parent_type) = type == parent_type
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :source, :target
|
|
41
|
+
|
|
42
|
+
def schema_type = self.class.schema_type
|
|
43
|
+
|
|
44
|
+
def symmetric? = schema_type.symmetric?
|
|
45
|
+
|
|
46
|
+
# The endpoint opposite +ref+ (the same node for a self-loop).
|
|
47
|
+
def other(ref)
|
|
48
|
+
return target if ref == source
|
|
49
|
+
return source if ref == target
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "#{ref} is not an endpoint of #{self.ref}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Endpoints of a new record: a node Ref or a node model. They can't
|
|
55
|
+
# change once the edge is stored.
|
|
56
|
+
def source=(node)
|
|
57
|
+
@source = endpoint(node)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def target=(node)
|
|
61
|
+
@target = endpoint(node)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
validate :validate_endpoints
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def load_identity(element)
|
|
69
|
+
@source = element.source
|
|
70
|
+
@target = element.target
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def endpoint(node)
|
|
75
|
+
raise ArgumentError, "the endpoints of a stored edge can't change" unless new_record?
|
|
76
|
+
return nil if node.nil?
|
|
77
|
+
|
|
78
|
+
ref = node.is_a?(Model::Node) ? node.ref : node
|
|
79
|
+
raise ArgumentError, "the endpoint is not stored yet" if ref.nil?
|
|
80
|
+
|
|
81
|
+
self.class.graph.check_ref!(ref, :node)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A symmetric edge is stored in canonical order, so the record takes it.
|
|
85
|
+
def create_element(writer, properties, attrs)
|
|
86
|
+
@ref = writer.insert_edge(schema_type.name, from: source, to: target, properties:, attrs:)
|
|
87
|
+
@source, @target = @target, @source if symmetric? && @source.id > @target.id
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update_element(writer, properties, attrs)
|
|
91
|
+
writer.update_edge(ref, properties:, attrs:)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete_element(writer) = writer.delete_edge(ref)
|
|
95
|
+
|
|
96
|
+
def validate_endpoints
|
|
97
|
+
return unless new_record?
|
|
98
|
+
|
|
99
|
+
errors.add(:source, :blank) if source.nil?
|
|
100
|
+
errors.add(:target, :blank) if target.nil?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
# The value of the type's key property, or nil.
|
|
135
|
+
def key
|
|
136
|
+
key = schema_type.key or return nil
|
|
137
|
+
self.class.property_names.include?(key.property) ? attribute(key.property.to_s) : extra_properties[key.property]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
validate :validate_node
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# The association's targets as seen through this record's context,
|
|
145
|
+
# starting from this record (visibility applies to it too).
|
|
146
|
+
def association_relation(association)
|
|
147
|
+
raise ArgumentError, "#{association.name}: save the record first" unless persisted?
|
|
148
|
+
|
|
149
|
+
target = association_model(association)
|
|
150
|
+
query = context.match.node(:owner, ref:)
|
|
151
|
+
.public_send(association.direction, association.edge_type, :n,
|
|
152
|
+
association.label || target.schema_type.name, edge: :e)
|
|
153
|
+
edge_model = Model.lookup(self.class.graph, :edge, association.edge_type)
|
|
154
|
+
AssociationRelation.new(target, context, query, edge_model:, cached: (@association_cache || {})[association.name])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def association_model(association) = self.class.association_model(association)
|
|
158
|
+
|
|
159
|
+
def load_identity(element)
|
|
160
|
+
@schema_type = self.class.graph.type.node_type!(element.key_label)
|
|
161
|
+
@labels = element.labels
|
|
162
|
+
self
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Labels follow from the type, so they are known without a reload.
|
|
166
|
+
def create_element(target, properties, attrs)
|
|
167
|
+
@ref = target.insert_node(schema_type.name, properties:, attrs:)
|
|
168
|
+
@labels = schema_type.labels.map(&:to_sym).freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def update_element(target, properties, attrs)
|
|
172
|
+
target.update_node(ref, properties:, attrs:)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def delete_element(target)
|
|
176
|
+
raise NotTrusted, "destroying a node needs the trusted writer" unless target.is_a?(Pglpg::Writer)
|
|
177
|
+
|
|
178
|
+
target.delete_node(ref)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def key_property = schema_type.key&.property
|
|
182
|
+
|
|
183
|
+
# The generated key is not required on insert.
|
|
184
|
+
def required_now?(name) = new_record? ? name != key_property : super
|
|
185
|
+
|
|
186
|
+
# Keys are generated on insert and never change (ADR-0005, ADR-0010).
|
|
187
|
+
def validate_node
|
|
188
|
+
errors.add(:base, "#{schema_type.name} is abstract") if new_record? && schema_type.abstract?
|
|
189
|
+
key = key_property
|
|
190
|
+
return unless key && self.class.property_names.include?(key)
|
|
191
|
+
|
|
192
|
+
if new_record?
|
|
193
|
+
errors.add(key, "is generated and cannot be assigned") unless attribute(key.to_s).nil?
|
|
194
|
+
else
|
|
195
|
+
schema_type.key.properties.each do |property|
|
|
196
|
+
errors.add(property, "cannot be changed") if attribute_changed?(property.to_s)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|