pglpg 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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