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.
@@ -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