iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. metadata +490 -0
@@ -0,0 +1,191 @@
1
+ # Abstract base for renderable errors in ViewModel-based APIs. Errors of this
2
+ # type will be caught by ViewModel controllers and rendered in a standard format
3
+ # by ViewModel::ErrorView, which loosely follows errors in JSON-API.
4
+ class ViewModel::AbstractError < StandardError
5
+ class << self
6
+ # Brief DSL for quickly defining constant attribute values in subclasses
7
+ [:detail, :status, :title, :code].each do |attribute|
8
+ define_method(attribute) do |x|
9
+ define_method(attribute){ x }
10
+ end
11
+ end
12
+ end
13
+
14
+ def initialize
15
+ # `detail` is used to provide the exception message. However, it's not safe
16
+ # to just override StandardError's `message` or `to_s` to call `detail`,
17
+ # since some of Ruby's C implementation of Exceptions internally ignores
18
+ # these methods and fetches the invisible internal `idMesg` attribute
19
+ # instead. (!)
20
+ #
21
+ # This means that all fields necessary to derive the detail message must be
22
+ # initialized before calling super().
23
+ super(detail)
24
+ end
25
+
26
+ # Human-readable reason for use displaying this error.
27
+ def detail
28
+ "ViewModel::AbstractError"
29
+ end
30
+
31
+ # HTTP status code most appropriate for this error
32
+ def status
33
+ 500
34
+ end
35
+
36
+ # Human-readable title for displaying this error
37
+ def title
38
+ nil
39
+ end
40
+
41
+ # Unique symbol identifying this error type
42
+ def code
43
+ "ViewModel.AbstractError"
44
+ end
45
+
46
+ # Additional information specific to this error type.
47
+ def meta
48
+ {}
49
+ end
50
+
51
+ # Some types of error may be aggregations over multiple causes
52
+ def aggregation?
53
+ false
54
+ end
55
+
56
+ # If so, the causes of this error (as AbstractErrors)
57
+ def causes
58
+ nil
59
+ end
60
+
61
+ # The exception responsible for this error. In most cases, that should be this
62
+ # object, but sometimes an Error may be used to wrap an external exception.
63
+ def exception
64
+ self
65
+ end
66
+
67
+ def view
68
+ ViewModel::ErrorView.new(self)
69
+ end
70
+
71
+ def to_s
72
+ detail
73
+ end
74
+
75
+ protected
76
+
77
+
78
+
79
+ def format_references(viewmodel_refs)
80
+ viewmodel_refs.map do |viewmodel_ref|
81
+ format_reference(viewmodel_ref)
82
+ end
83
+ end
84
+
85
+ def format_reference(viewmodel_ref)
86
+ {
87
+ ViewModel::TYPE_ATTRIBUTE => viewmodel_ref.viewmodel_class.view_name,
88
+ ViewModel::ID_ATTRIBUTE => viewmodel_ref.model_id
89
+ }
90
+ end
91
+ end
92
+
93
+ # For errors associated with specific viewmodel nodes, include metadata
94
+ # describing the node to blame.
95
+ class ViewModel::AbstractErrorWithBlame < ViewModel::AbstractError
96
+ attr_reader :nodes
97
+
98
+ def initialize(blame_nodes)
99
+ @nodes = Array.wrap(blame_nodes)
100
+ unless @nodes.all? { |n| n.is_a?(ViewModel::Reference) }
101
+ raise ArgumentError.new("#{self.class.name}: 'blame_nodes' must all be of type ViewModel::Reference")
102
+ end
103
+ super()
104
+ end
105
+
106
+ def meta
107
+ {
108
+ nodes: format_references(nodes)
109
+ }
110
+ end
111
+ end
112
+
113
+ # Abstract collection of errors
114
+ class ViewModel::AbstractErrorCollection < ViewModel::AbstractError
115
+ attr_reader :causes
116
+
117
+ def initialize(causes)
118
+ @causes = Array.wrap(causes)
119
+ unless @causes.present?
120
+ raise ArgumentError.new("A collection must have at least one cause")
121
+ end
122
+ super()
123
+ end
124
+
125
+ def status
126
+ causes.inject(causes.first.status) do |status, cause|
127
+ if status == cause.status
128
+ status
129
+ else
130
+ 400
131
+ end
132
+ end
133
+ end
134
+
135
+ def detail
136
+ "ViewModel::AbstractErrors: #{cause_details}"
137
+ end
138
+
139
+ def aggregation?
140
+ true
141
+ end
142
+
143
+ def self.for_errors(errors)
144
+ if errors.size == 1
145
+ errors.first
146
+ else
147
+ self.new(errors)
148
+ end
149
+ end
150
+
151
+ protected
152
+
153
+ def cause_details
154
+ causes.map(&:detail).join("; ")
155
+ end
156
+ end
157
+
158
+ # Error type to wrap an arbitrary exception as a renderable ViewModel::AbstractError
159
+ class ViewModel::WrappedExceptionError < ViewModel::AbstractError
160
+ attr_reader :exception, :status
161
+
162
+ def initialize(exception, status, code)
163
+ @exception = exception
164
+ @status = status
165
+ @code = code
166
+ super()
167
+ end
168
+
169
+ def detail
170
+ exception.message
171
+ end
172
+
173
+ def code
174
+ @code || "Exception.#{exception.class.name}"
175
+ end
176
+ end
177
+
178
+ # Implementation of ViewModel::AbstractError with constructor parameters for
179
+ # each error data field.
180
+ class ViewModel::Error < ViewModel::AbstractError
181
+ attr_reader :detail, :status, :title, :code, :meta
182
+
183
+ def initialize(status: 400, detail: "ViewModel Error", title: nil, code: nil, meta: {})
184
+ @detail = detail
185
+ @status = status
186
+ @title = title
187
+ @code = code
188
+ @meta = meta
189
+ super()
190
+ end
191
+ end
@@ -0,0 +1,35 @@
1
+ require 'view_model/record'
2
+
3
+ # ViewModel for rendering ViewModel::AbstractErrors
4
+ class ViewModel::ErrorView < ViewModel::Record
5
+ self.model_class = ViewModel::AbstractError
6
+ self.view_name = 'Error'
7
+
8
+ class ExceptionDetailView < ::ViewModel
9
+ attributes :exception
10
+ def serialize_view(json, serialize_context: nil)
11
+ json.set! :class, exception.class.name
12
+ json.backtrace exception.backtrace
13
+ if cause = exception.cause
14
+ json.cause do
15
+ json.set! :class, cause.class.name
16
+ json.backtrace cause.backtrace
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ attributes :status, :detail, :title, :code, :meta
23
+ attribute :causes, array: true, using: self
24
+ attribute :exception, using: ExceptionDetailView
25
+
26
+ # Ruby exceptions should never be serialized in production
27
+ def serialize_exception(json, serialize_context:)
28
+ super if ViewModel::Config.show_cause_in_error_view
29
+ end
30
+
31
+ # Only serialize causes for aggregation errors.
32
+ def serialize_causes(*)
33
+ super if model.aggregation?
34
+ end
35
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Abstract ViewModel type for serializing a subset of attributes from a record.
4
+ # A record viewmodel wraps a single underlying model, exposing a fixed set of
5
+ # real or calculated attributes.
6
+ class ViewModel::Record < ViewModel
7
+ # All ViewModel::Records have the same underlying ViewModel attribute: the
8
+ # record model they back on to. We want this to be inherited by subclasses, so
9
+ # we override ViewModel's :_attributes to close over it.
10
+ attr_accessor :model
11
+
12
+ require 'view_model/record/attribute_data'
13
+
14
+ class << self
15
+ attr_reader :_members
16
+ attr_accessor :abstract_class, :unregistered
17
+
18
+ def inherited(subclass)
19
+ super
20
+ subclass.initialize_as_viewmodel_record
21
+ ViewModel::Registry.register(subclass)
22
+ end
23
+
24
+ def initialize_as_viewmodel_record
25
+ @_members = {}
26
+ @abstract_class = false
27
+ @unregistered = false
28
+
29
+ @generated_accessor_module = Module.new
30
+ include @generated_accessor_module
31
+ end
32
+
33
+ # Should this class be registered in the viewmodel registry
34
+ def should_register?
35
+ !abstract_class && !unregistered
36
+ end
37
+
38
+ # Specifies an attribute from the model to be serialized in this view
39
+ def attribute(attr, as: nil, read_only: false, write_once: false, using: nil, format: nil, array: false, optional: false)
40
+ model_attribute_name = attr.to_s
41
+ vm_attribute_name = (as || attr).to_s
42
+
43
+ if using && format
44
+ raise ArgumentError.new("Only one of :using and :format may be specified")
45
+ end
46
+ if using && !(using.is_a?(Class) && using < ViewModel)
47
+ raise ArgumentError.new("Invalid 'using:' viewmodel: not a viewmodel class")
48
+ end
49
+ if format && !format.respond_to?(:dump) && !format.respond_to?(:load)
50
+ raise ArgumentError.new("Invalid 'format:' serializer: must respond to :dump and :load")
51
+ end
52
+
53
+ attr_data = AttributeData.new(vm_attribute_name, model_attribute_name, using, format,
54
+ array, optional, read_only, write_once)
55
+ _members[vm_attribute_name] = attr_data
56
+
57
+ @generated_accessor_module.module_eval do
58
+ define_method vm_attribute_name do
59
+ _get_attribute(attr_data)
60
+ end
61
+
62
+ define_method "serialize_#{vm_attribute_name}" do |json, serialize_context: self.class.new_serialize_context|
63
+ _serialize_attribute(attr_data, json, serialize_context: serialize_context)
64
+ end
65
+
66
+ define_method "deserialize_#{vm_attribute_name}" do |value, references: {}, deserialize_context: self.class.new_deserialize_context|
67
+ _deserialize_attribute(attr_data, value, references: references, deserialize_context: deserialize_context)
68
+ end
69
+ end
70
+ end
71
+
72
+ def deserialize_from_view(view_hashes, references: {}, deserialize_context: new_deserialize_context)
73
+ ViewModel::Utils.map_one_or_many(view_hashes) do |view_hash|
74
+ view_hash = view_hash.dup
75
+ metadata = ViewModel.extract_viewmodel_metadata(view_hash)
76
+
77
+ unless self.view_name == metadata.view_name || self.view_aliases.include?(metadata.view_name)
78
+ raise ViewModel::DeserializationError::InvalidViewType.new(
79
+ self.view_name,
80
+ ViewModel::Reference.new(self, metadata.id))
81
+ end
82
+
83
+ if metadata.schema_version && !self.accepts_schema_version?(metadata.schema_version)
84
+ raise ViewModel::DeserializationError::SchemaVersionMismatch.new(
85
+ self, version, ViewModel::Reference.new(self, metadata.id))
86
+ end
87
+
88
+ viewmodel = resolve_viewmodel(metadata, view_hash, deserialize_context: deserialize_context)
89
+
90
+ deserialize_members_from_view(viewmodel, view_hash, references: references, deserialize_context: deserialize_context)
91
+
92
+ viewmodel
93
+ end
94
+ end
95
+
96
+ def deserialize_members_from_view(viewmodel, view_hash, references:, deserialize_context:)
97
+ super do |hook_control|
98
+ final_changes = viewmodel.clear_changes!
99
+
100
+ if final_changes.changed?
101
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, viewmodel, changes: final_changes)
102
+ end
103
+
104
+ hook_control.record_changes(final_changes)
105
+ end
106
+ end
107
+
108
+ def resolve_viewmodel(metadata, view_hash, deserialize_context:)
109
+ self.for_new_model
110
+ end
111
+
112
+ def for_new_model(*model_args)
113
+ self.new(model_class.new(*model_args)).tap { |v| v.model_is_new! }
114
+ end
115
+
116
+ # Returns the AR model class wrapped by this viewmodel. If this has not been
117
+ # set via `model_class_name=`, attempt to automatically resolve based on the
118
+ # name of this viewmodel.
119
+ def model_class
120
+ unless instance_variable_defined?(:@model_class)
121
+ # try to auto-detect the model class based on our name
122
+ self.model_class_name =
123
+ ViewModel::Registry.infer_model_class_name(self.view_name)
124
+ end
125
+
126
+ @model_class
127
+ end
128
+
129
+ def member_names
130
+ self._members.keys
131
+ end
132
+
133
+ private
134
+
135
+ # Set the record type to be wrapped by this viewmodel
136
+ def model_class_name=(name)
137
+ name = name.to_s
138
+
139
+ type = name.safe_constantize
140
+
141
+ if type.nil?
142
+ raise ArgumentError.new("Could not find model class with name '#{name}'")
143
+ end
144
+
145
+ self.model_class = type
146
+ end
147
+
148
+ # Set the record type to be wrapped by this viewmodel
149
+ def model_class=(type)
150
+ if instance_variable_defined?(:@model_class)
151
+ raise ArgumentError.new("Model class for ViewModel '#{self.name}' already set")
152
+ end
153
+
154
+ @model_class = type
155
+ end
156
+ end
157
+
158
+ delegate :model_class, to: 'self.class'
159
+
160
+ attr_reader :changed_attributes, :previous_changes
161
+
162
+ def initialize(model)
163
+ unless model.is_a?(model_class)
164
+ raise ArgumentError.new("'#{model.inspect}' is not an instance of #{model_class.name}")
165
+ end
166
+
167
+ self.model = model
168
+
169
+ @new_model = false
170
+ @changed_attributes = []
171
+ @changed_children = false
172
+ end
173
+
174
+ # VM::Record identity matches the identity of its model. If the model has a
175
+ # stable identity, use it, otherwise fall back to its object_id.
176
+ def id
177
+ if stable_id?
178
+ model.id
179
+ else
180
+ model.object_id
181
+ end
182
+ end
183
+
184
+ def stable_id?
185
+ model.respond_to?(:id)
186
+ end
187
+
188
+ def new_model?
189
+ @new_model
190
+ end
191
+
192
+ def changed_children?
193
+ @changed_children
194
+ end
195
+
196
+ def serialize_view(json, serialize_context: self.class.new_serialize_context)
197
+ json.set!(ViewModel::ID_ATTRIBUTE, model.id) if model.respond_to?(:id)
198
+ json.set!(ViewModel::TYPE_ATTRIBUTE, self.view_name)
199
+ json.set!(ViewModel::VERSION_ATTRIBUTE, self.class.schema_version)
200
+
201
+ serialize_members(json, serialize_context: serialize_context)
202
+ end
203
+
204
+ def serialize_members(json, serialize_context:)
205
+ self.class._members.each do |member_name, member_data|
206
+ next unless serialize_context.includes_member?(member_name, !member_data.optional?)
207
+
208
+ self.public_send("serialize_#{member_name}", json, serialize_context: serialize_context)
209
+ end
210
+ end
211
+
212
+ # Check that the model backing this view is consistent, for example by calling
213
+ # AR validations. Default implementation handles ActiveModel::Validations, may
214
+ # be overridden by subclasses for other types of validation. Must raise
215
+ # DeserializationError::Validation if invalid.
216
+ def validate!
217
+ if model_class < ActiveModel::Validations && !model.valid?
218
+ raise ViewModel::DeserializationError::Validation.from_active_model(model.errors, self.blame_reference)
219
+ end
220
+ end
221
+
222
+ def model_is_new!
223
+ @new_model = true
224
+ end
225
+
226
+ def attribute_changed!(attr_name)
227
+ @changed_attributes << attr_name.to_s
228
+ end
229
+
230
+ def children_changed!
231
+ @changed_children = true
232
+ end
233
+
234
+ def changes
235
+ ViewModel::Changes.new(
236
+ new: new_model?,
237
+ changed_attributes: changed_attributes,
238
+ changed_children: changed_children?)
239
+ end
240
+
241
+ def clear_changes!
242
+ @previous_changes = changes
243
+ @new_model = false
244
+ @changed_attributes = []
245
+ @changed_children = false
246
+ previous_changes
247
+ end
248
+
249
+ # Use ActiveRecord style identity for viewmodels. This allows serialization to
250
+ # generate a references section by keying on the viewmodel itself.
251
+ def hash
252
+ [self.class, self.model].hash
253
+ end
254
+
255
+ def ==(other)
256
+ self.class == other.class && self.model == other.model
257
+ end
258
+
259
+ alias eql? ==
260
+
261
+ self.abstract_class = true
262
+
263
+ private
264
+
265
+ def _get_attribute(attr_data)
266
+ value = model.public_send(attr_data.model_attr_name)
267
+
268
+ if attr_data.using_viewmodel? && !value.nil?
269
+ # Where an attribute uses a viewmodel, the associated viewmodel type is
270
+ # significant and may have behaviour: like with VM::ActiveRecord
271
+ # associations it's useful to return the value wrapped in its viewmodel
272
+ # type even when not serializing.
273
+ value = attr_data.map_value(value) do |v|
274
+ attr_data.attribute_viewmodel.new(v)
275
+ end
276
+ end
277
+
278
+ value
279
+ end
280
+
281
+ def _serialize_attribute(attr_data, json, serialize_context:)
282
+ vm_attr_name = attr_data.name
283
+
284
+ value = self.public_send(vm_attr_name)
285
+
286
+ if attr_data.using_serializer? && !value.nil?
287
+ # Where an attribute uses a low level serializer (rather than another
288
+ # viewmodel), it's only desired for converting the value to and from wire
289
+ # format, so conversion is deferred to serialization time.
290
+ value = attr_data.map_value(value) do |v|
291
+ begin
292
+ attr_data.attribute_serializer.dump(v, json: true)
293
+ rescue IknowParams::Serializer::DumpError => ex
294
+ raise ViewModel::SerializationError.new(
295
+ "Could not serialize invalid value '#{vm_attr_name}': #{ex.message}")
296
+ end
297
+ end
298
+ end
299
+
300
+ json.set! vm_attr_name do
301
+ serialize_context = self.context_for_child(vm_attr_name, context: serialize_context) if attr_data.using_viewmodel?
302
+ self.class.serialize(value, json, serialize_context: serialize_context)
303
+ end
304
+ end
305
+
306
+ def _deserialize_attribute(attr_data, serialized_value, references:, deserialize_context:)
307
+ vm_attr_name = attr_data.name
308
+
309
+ if attr_data.array? && !serialized_value.nil?
310
+ expect_type!(vm_attr_name, Array, serialized_value)
311
+ end
312
+
313
+ value =
314
+ case
315
+ when serialized_value.nil?
316
+ serialized_value
317
+ when attr_data.using_viewmodel?
318
+ ctx = self.context_for_child(vm_attr_name, context: deserialize_context)
319
+ attr_data.map_value(serialized_value) do |sv|
320
+ attr_data.attribute_viewmodel.deserialize_from_view(sv, references: references, deserialize_context: ctx)
321
+ end
322
+ when attr_data.using_serializer?
323
+ attr_data.map_value(serialized_value) do |sv|
324
+ begin
325
+ attr_data.attribute_serializer.load(sv)
326
+ rescue IknowParams::Serializer::LoadError => ex
327
+ reason = "could not be deserialized because #{ex.message}"
328
+ raise ViewModel::DeserializationError::Validation.new(vm_attr_name, reason, {}, blame_reference)
329
+ end
330
+ end
331
+ else
332
+ serialized_value
333
+ end
334
+
335
+ # Detect changes with ==. In the case of `using_viewmodel?`, this compares viewmodels or arrays of viewmodels.
336
+ if value != self.public_send(vm_attr_name)
337
+ if attr_data.read_only? && !(attr_data.write_once? && new_model?)
338
+ raise ViewModel::DeserializationError::ReadOnlyAttribute.new(vm_attr_name, blame_reference)
339
+ end
340
+
341
+ attribute_changed!(vm_attr_name)
342
+
343
+ if attr_data.using_viewmodel? && !value.nil?
344
+ # Extract model from target viewmodel(s) to attach to our model
345
+ value = attr_data.map_value(value) { |vm| vm.model }
346
+ end
347
+
348
+ model.public_send("#{attr_data.model_attr_name}=", value)
349
+ end
350
+
351
+ if attr_data.using_viewmodel? &&
352
+ Array.wrap(value).any? { |v| v.respond_to?(:previous_changes) && v.previous_changes.changed_tree? }
353
+ self.children_changed!
354
+ end
355
+ end
356
+
357
+ # Helper for type-checking input in hand-rolled deserialization: raises
358
+ # DeserializationError unless the serialized value is of the provided type.
359
+ def expect_type!(attribute, type, serialized_value)
360
+ unless serialized_value.is_a?(type)
361
+ raise ViewModel::DeserializationError::InvalidAttributeType.new(attribute.to_s,
362
+ type.name,
363
+ serialized_value.class.name,
364
+ blame_reference)
365
+ end
366
+ end
367
+ end