iknow_view_models 2.8.4

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.
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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'view_model/active_record/controller_base'
4
+
5
+ # Controller mixin defining machinery for accessing viewmodels nested under a
6
+ # parent. Used by Singular- and CollectionNestedControllers
7
+ module ViewModel::ActiveRecord::NestedControllerBase
8
+ extend ActiveSupport::Concern
9
+
10
+ protected
11
+
12
+ def show_association(scope: nil, serialize_context: new_serialize_context)
13
+ associated_views = nil
14
+ pre_rendered = owner_viewmodel.transaction do
15
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
16
+ ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
17
+ # Association manipulation methods construct child contexts internally
18
+ associated_views = owner_view.load_associated(association_name, scope: scope, serialize_context: serialize_context)
19
+
20
+ associated_views = yield(associated_views) if block_given?
21
+
22
+ child_context = owner_view.context_for_child(association_name, context: serialize_context)
23
+ prerender_viewmodel(associated_views, serialize_context: child_context)
24
+ end
25
+ end
26
+ render_json_string(pre_rendered)
27
+ associated_views
28
+ end
29
+
30
+ def write_association(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
31
+ association_view = nil
32
+ pre_rendered = owner_viewmodel.transaction do
33
+ update_hash, refs = parse_viewmodel_updates
34
+
35
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
36
+
37
+ association_view = owner_view.replace_associated(association_name, update_hash,
38
+ references: refs,
39
+ deserialize_context: deserialize_context)
40
+
41
+ ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
42
+ child_context = owner_view.context_for_child(association_name, context: serialize_context)
43
+ ViewModel.preload_for_serialization(association_view, serialize_context: child_context)
44
+ prerender_viewmodel(association_view, serialize_context: child_context)
45
+ end
46
+ end
47
+ render_json_string(pre_rendered)
48
+ association_view
49
+ end
50
+
51
+ def destroy_association(collection, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
52
+ empty_update = collection ? [] : nil
53
+ owner_viewmodel.deserialize_from_view(owner_update_hash(empty_update),
54
+ deserialize_context: deserialize_context)
55
+ render_viewmodel(empty_update, serialize_context: serialize_context)
56
+ end
57
+
58
+ def association_data
59
+ owner_viewmodel._association_data(association_name)
60
+ end
61
+
62
+ def owner_update_hash(update)
63
+ {
64
+ ViewModel::ID_ATTRIBUTE => owner_viewmodel_id,
65
+ ViewModel::TYPE_ATTRIBUTE => owner_viewmodel.view_name,
66
+ association_name.to_s => update,
67
+ }
68
+ end
69
+
70
+ def owner_viewmodel_id(required: true)
71
+ id_param_name = owner_viewmodel.view_name.downcase + '_id'
72
+ default = required ? {} : { default: nil }
73
+ parse_param(id_param_name, **default)
74
+ end
75
+
76
+ included do
77
+ delegate :owner_viewmodel, :association_name, to: 'self.class'
78
+ end
79
+
80
+ class_methods do
81
+ attr_accessor :owner_viewmodel, :association_name
82
+
83
+ def nested_in(owner, as:)
84
+ unless owner.is_a?(Class) && owner < ViewModel::Record
85
+ owner = ViewModel::Registry.for_view_name(owner.to_s.camelize)
86
+ end
87
+
88
+ self.owner_viewmodel = owner
89
+ raise ArgumentError.new("Could not find owner ViewModel class '#{owner_name}'") if owner_viewmodel.nil?
90
+ self.association_name = as
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Controller mixin for accessing a root ViewModel which can be accessed
4
+ # individually by a parent model. Enabled by calling `nested_in :parent, as:
5
+ # :child` on the viewmodel controller
6
+
7
+ # Contributes the following routes:
8
+ # POST /parents/:parent_id/child #create_associated -- deserialize (possibly existing) child, replacing existing child
9
+ # GET /parents/:parent_id/child #show_associated -- show child of parent
10
+ # DELETE /parents/:parent_id/child #destroy_associated -- delete relationship between parent and its child (possibly garbage-collecting child)
11
+
12
+ ## Inherits the following routes to manipulate children directly:
13
+ # POST /children #create -- create or update without parent
14
+ # GET /children #index -- list all child models regardless of parent
15
+ # GET /children/:id #show
16
+ # DELETE /children/:id #destroy
17
+
18
+ require 'view_model/active_record/nested_controller_base'
19
+ module ViewModel::ActiveRecord::SingularNestedController
20
+ extend ActiveSupport::Concern
21
+ include ViewModel::ActiveRecord::NestedControllerBase
22
+
23
+ def show_associated(scope: nil, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
24
+ show_association(scope: scope, serialize_context: serialize_context)
25
+ end
26
+
27
+ def create_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
28
+ write_association(serialize_context: serialize_context, deserialize_context: deserialize_context)
29
+ end
30
+
31
+ def destroy_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
32
+ destroy_association(false, serialize_context: serialize_context, deserialize_context: deserialize_context)
33
+ end
34
+ end
@@ -0,0 +1,252 @@
1
+ # Assembles an update operation tree from user input. Handles the interlinking
2
+ # and model of update operations, but does not handle the actual user data nor
3
+ # the mechanism by which it is applied to models.
4
+ class ViewModel::ActiveRecord
5
+ class UpdateContext
6
+ ReleaseEntry = Struct.new(:viewmodel, :association_data) do
7
+ def initialize(*)
8
+ super
9
+ @claimed = false
10
+ end
11
+
12
+ def release!
13
+ model = viewmodel.model
14
+ case association_data.direct_reflection.options[:dependent]
15
+ when :delete
16
+ model.delete
17
+ when :destroy
18
+ model.destroy
19
+ end
20
+ end
21
+
22
+ def claimed!
23
+ @claimed = true
24
+ end
25
+
26
+ def claimed?
27
+ @claimed
28
+ end
29
+ end
30
+
31
+ class ReleasePool
32
+ def initialize
33
+ # hash of { ViewModel::Reference => ReleaseEntry } for models
34
+ # that have been released by nodes we've already visited
35
+ @released_viewmodels = {}
36
+ end
37
+
38
+ def include?(key)
39
+ @released_viewmodels.has_key?(key)
40
+ end
41
+
42
+ def release_to_pool(viewmodel, association_data)
43
+ @released_viewmodels[viewmodel.to_reference] =
44
+ ReleaseEntry.new(viewmodel, association_data)
45
+ end
46
+
47
+ def claim_from_pool(key)
48
+ if (entry = @released_viewmodels.delete(key))
49
+ entry.claimed!
50
+ entry.viewmodel
51
+ end
52
+ end
53
+
54
+ def release_all!
55
+ @released_viewmodels.each_value(&:release!)
56
+ end
57
+ end
58
+
59
+ def self.build!(root_update_data, referenced_update_data, root_type: nil)
60
+ if root_type.present? && (bad_types = root_update_data.map(&:viewmodel_class).to_set.delete(root_type)).present?
61
+ raise ViewModel::DeserializationError::InvalidViewType.new(root_type.view_name, bad_types.map { |t| ViewModel::Reference.new(t, nil) })
62
+ end
63
+
64
+ self.new
65
+ .build_root_update_operations(root_update_data, referenced_update_data)
66
+ .assemble_update_tree
67
+ end
68
+
69
+ # TODO an unfortunate abstraction violation. The `append` case constructs an
70
+ # update tree and later injects the context of parent and position.
71
+ def root_updates
72
+ @root_update_operations
73
+ end
74
+
75
+ def initialize
76
+ @root_update_operations = [] # The subject(s) of this update
77
+ @referenced_update_operations = {} # Shared data updates, referred to by a ref hash
78
+
79
+ # Set of ViewModel::Reference used to assert only a single update is
80
+ # present for each viewmodel
81
+ @updated_viewmodel_references = Set.new
82
+
83
+ # hash of { ViewModel::Reference => deferred UpdateOperation }
84
+ # for linked partially-constructed node updates
85
+ @worklist = {}
86
+
87
+ @release_pool = ReleasePool.new
88
+ end
89
+
90
+ # Processes parsed (UpdateData) root updates and referenced updates into
91
+ # @root_update_operations and @referenced_update_operations.
92
+ def build_root_update_operations(root_updates, referenced_updates)
93
+ # Look up viewmodel classes for each tree with eager_includes. Note this
94
+ # won't yet include through a polymorphic boundary: for now we become
95
+ # lazy-loading and slow every time that happens.
96
+
97
+ # Combine our root and referenced updates, and separate by viewmodel type
98
+ updates_by_viewmodel_class =
99
+ root_updates.lazily
100
+ .map { |root_update| [nil, root_update] }
101
+ .concat(referenced_updates)
102
+ .group_by { |_, update_data| update_data.viewmodel_class }
103
+
104
+ # For each viewmodel type, look up referenced models and construct viewmodels to update
105
+ updates_by_viewmodel_class.each do |viewmodel_class, updates|
106
+ dependencies = updates.map { |_, upd| upd.preload_dependencies }
107
+ .inject { |acc, deps| acc.merge!(deps) }
108
+
109
+ model_ids = updates.map { |_, update_data| update_data.id unless update_data.new? }.compact
110
+
111
+ existing_models =
112
+ if model_ids.present?
113
+ model_class = viewmodel_class.model_class
114
+ models = model_class.where(model_class.primary_key => model_ids).to_a
115
+
116
+ if models.size < model_ids.size
117
+ missing_model_ids = model_ids - models.map(&:id)
118
+ missing_viewmodel_refs = missing_model_ids.map { |id| ViewModel::Reference.new(viewmodel_class, id) }
119
+ raise ViewModel::DeserializationError::NotFound.new(missing_viewmodel_refs)
120
+ end
121
+
122
+ DeepPreloader.preload(models, dependencies)
123
+ models.index_by(&:id)
124
+ else
125
+ {}
126
+ end
127
+
128
+ updates.each do |ref, update_data|
129
+ viewmodel =
130
+ if update_data.new?
131
+ viewmodel_class.for_new_model(id: update_data.id)
132
+ else
133
+ viewmodel_class.new(existing_models[update_data.id])
134
+ end
135
+
136
+ update_op = new_update(viewmodel, update_data)
137
+
138
+ if ref.nil?
139
+ @root_update_operations << update_op
140
+ else
141
+ # TODO make sure that referenced subtree hashes are unique and provide a decent error message
142
+ # not strictly necessary, but will save confusion
143
+ @referenced_update_operations[ref] = update_op
144
+ end
145
+ end
146
+ end
147
+
148
+ self
149
+ end
150
+
151
+ # Applies updates and subsequently releases. Returns the updated viewmodels.
152
+ def run!(deserialize_context:)
153
+ updated_viewmodels = @root_update_operations.map do |root_update|
154
+ root_update.run!(deserialize_context: deserialize_context)
155
+ end
156
+
157
+ @release_pool.release_all!
158
+
159
+ if updated_viewmodels.present?
160
+ # Deferred database constraints may have been violated by changes during
161
+ # deserialization. VM::AR promises that any errors during deserialization
162
+ # will be raised as a ViewModel::DeserializationError, so check constraints
163
+ # and raise before exit.
164
+ check_deferred_constraints!(updated_viewmodels.first.model.class)
165
+ end
166
+
167
+ updated_viewmodels
168
+ end
169
+
170
+ def assemble_update_tree
171
+ @root_update_operations.each do |root_update|
172
+ root_update.build!(self)
173
+ end
174
+
175
+ while @worklist.present?
176
+ key = @worklist.keys.detect { |k| @release_pool.include?(k) }
177
+ if key.nil?
178
+ raise ViewModel::DeserializationError::ParentNotFound.new(@worklist.keys)
179
+ end
180
+
181
+ deferred_update = @worklist.delete(key)
182
+ deferred_update.viewmodel = @release_pool.claim_from_pool(key)
183
+ deferred_update.build!(self)
184
+ end
185
+
186
+ dangling_references = @referenced_update_operations.reject { |ref, upd| upd.built? }.map { |ref, upd| upd.viewmodel.to_reference }
187
+ if dangling_references.present?
188
+ raise ViewModel::DeserializationError::InvalidStructure.new("References not referred to from roots", dangling_references)
189
+ end
190
+
191
+ self
192
+ end
193
+
194
+ ## Methods for objects being built in this context
195
+
196
+ # We require the updates to be recorded in the context so we can enforce the
197
+ # property that each viewmodel is in the tree at most once. To avoid mistakes,
198
+ # we require construction to go via methods that do this tracking.
199
+
200
+ def new_deferred_update(viewmodel_reference, update_data, reparent_to: nil, reposition_to: nil)
201
+ update_operation = ViewModel::ActiveRecord::UpdateOperation.new(
202
+ nil, update_data, reparent_to: reparent_to, reposition_to: reposition_to)
203
+ check_unique_update!(viewmodel_reference)
204
+ @worklist[viewmodel_reference] = update_operation
205
+ end
206
+
207
+ def new_update(viewmodel, update_data, reparent_to: nil, reposition_to: nil)
208
+ update = ViewModel::ActiveRecord::UpdateOperation.new(
209
+ viewmodel, update_data, reparent_to: reparent_to, reposition_to: reposition_to)
210
+
211
+ if (vm_ref = update.viewmodel_reference).present?
212
+ check_unique_update!(vm_ref)
213
+ end
214
+
215
+ update
216
+ end
217
+
218
+ def check_unique_update!(vm_ref)
219
+ unless @updated_viewmodel_references.add?(vm_ref)
220
+ raise ViewModel::DeserializationError::DuplicateNodes.new(vm_ref.viewmodel_class.view_name, vm_ref)
221
+ end
222
+ end
223
+
224
+ def resolve_reference(ref, blame_reference)
225
+ @referenced_update_operations.fetch(ref) do
226
+ raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference)
227
+ end
228
+ end
229
+
230
+ def try_take_released_viewmodel(vm_ref)
231
+ @release_pool.claim_from_pool(vm_ref)
232
+ end
233
+
234
+ def release_viewmodel(viewmodel, association_data)
235
+ @release_pool.release_to_pool(viewmodel, association_data)
236
+ end
237
+
238
+ # Immediately enforce any deferred database constraints (when using
239
+ # Postgres) and convert them to DeserializationErrors.
240
+ #
241
+ # Note that there's no effective way to tie such a failure back to the
242
+ # individual node that caused it, without attempting to parse Postgres'
243
+ # human-readable error details.
244
+ def check_deferred_constraints!(model_class)
245
+ if model_class.connection.adapter_name == "PostgreSQL"
246
+ model_class.connection.execute("SET CONSTRAINTS ALL IMMEDIATE")
247
+ end
248
+ rescue ::ActiveRecord::StatementInvalid => ex
249
+ raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex)
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,749 @@
1
+ require 'renum'
2
+ require 'view_model/schemas'
3
+
4
+ class ViewModel::ActiveRecord
5
+ using ViewModel::Utils::Collections
6
+
7
+ class FunctionalUpdate
8
+ def self.for_type(type)
9
+ case type
10
+ when Append::NAME
11
+ return Append
12
+ when Remove::NAME
13
+ return Remove
14
+ when Update::NAME
15
+ return Update
16
+ else
17
+ raise ArgumentError.new("invalid functional update type #{type}")
18
+ end
19
+ end
20
+
21
+ class Append
22
+ NAME = 'append'
23
+ attr_accessor :before, :after
24
+ attr_reader :contents
25
+ def initialize(contents)
26
+ @contents = contents
27
+ end
28
+ end
29
+
30
+ class Update
31
+ NAME = 'update'
32
+ attr_reader :contents
33
+ def initialize(contents)
34
+ @contents = contents
35
+ end
36
+ end
37
+
38
+ class Remove
39
+ NAME = 'remove'
40
+ attr_reader :removed_vm_refs
41
+
42
+ def initialize(removed_vm_refs)
43
+ @removed_vm_refs = removed_vm_refs
44
+ end
45
+ end
46
+ end
47
+
48
+ # Parser for collection updates. Collection updates have a regular
49
+ # structure, but vary based on the contents. Parsing a {direct,
50
+ # owned} collection recurses deeply and creates a tree of
51
+ # UpdateDatas, while parsing a {through, shared} collection collects
52
+ # reference strings.
53
+ class AbstractCollectionUpdate
54
+ # Wraps a complete collection of new data: either UpdateDatas for owned
55
+ # collections or reference strings for shared.
56
+ class Replace
57
+ attr_reader :contents
58
+ def initialize(contents)
59
+ @contents = contents
60
+ end
61
+ end
62
+
63
+ # Wraps an ordered list of FunctionalUpdates, each of whose `contents` are
64
+ # either UpdateData for owned collections or references for shared.
65
+ class Functional
66
+ attr_reader :actions
67
+ def initialize(actions)
68
+ @actions = actions
69
+ end
70
+
71
+ def contents
72
+ actions.lazy
73
+ .reject { |action| action.is_a?(FunctionalUpdate::Remove) }
74
+ .flat_map(&:contents)
75
+ .to_a
76
+ end
77
+
78
+ def vm_references(update_context)
79
+ used_vm_refs(update_context) + removed_vm_refs
80
+ end
81
+
82
+ # Resolve ViewModel::References used in the update's contents, whether by
83
+ # reference or value.
84
+ def used_vm_refs(update_context)
85
+ raise "abstract"
86
+ end
87
+
88
+ def removed_vm_refs
89
+ actions.lazy
90
+ .select { |action| action.is_a?(FunctionalUpdate::Remove) }
91
+ .flat_map(&:removed_vm_refs)
92
+ .to_a
93
+ end
94
+
95
+ def check_for_duplicates!(update_context, blame)
96
+ duplicate_vm_refs = vm_references(update_context).duplicates
97
+ if duplicate_vm_refs.present?
98
+ formatted_invalid_ids = duplicate_vm_refs.keys.map(&:to_s).join(', ')
99
+ raise ViewModel::DeserializationError::InvalidStructure.new("Duplicate functional update targets: [#{formatted_invalid_ids}]", blame)
100
+ end
101
+ end
102
+ end
103
+
104
+ class Parser
105
+ def initialize(association_data, blame_reference, valid_reference_keys)
106
+ @association_data = association_data
107
+ @blame_reference = blame_reference
108
+ @valid_reference_keys = valid_reference_keys
109
+ end
110
+
111
+ def parse(value)
112
+ case value
113
+ when Array
114
+ replace_update_type.new(parse_contents(value))
115
+
116
+ when Hash
117
+ ViewModel::Schemas.verify_schema!(functional_update_schema, value)
118
+ functional_updates = value[ACTIONS_ATTRIBUTE].map { |action| parse_action(action) }
119
+ functional_update_type.new(functional_updates)
120
+
121
+ else
122
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
123
+ "Could not parse non-array value for collection association '#{association_data}'",
124
+ blame_reference)
125
+ end
126
+ end
127
+
128
+ protected
129
+
130
+ attr_reader :association_data, :blame_reference, :valid_reference_keys
131
+
132
+ private
133
+
134
+ def parse_action(action)
135
+ type = action[ViewModel::TYPE_ATTRIBUTE]
136
+
137
+ case type
138
+ when FunctionalUpdate::Remove::NAME
139
+ parse_remove_action(action)
140
+ when FunctionalUpdate::Append::NAME
141
+ parse_append_action(action)
142
+ when FunctionalUpdate::Update::NAME
143
+ parse_update_action(action)
144
+ else
145
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
146
+ "Unknown action type '#{type}'",
147
+ blame_reference)
148
+ end
149
+ end
150
+
151
+ ## Action parsers
152
+ #
153
+ # The shape of the actions are always the same
154
+
155
+ # Parse an anchor for a functional_update, before/after
156
+ # May only contain type and id fields, is never a reference even for shared collections.
157
+ def parse_anchor(child_hash) # final
158
+ child_metadata = ViewModel.extract_reference_only_metadata(child_hash)
159
+
160
+ child_viewmodel_class =
161
+ association_data.viewmodel_class_for_name(child_metadata.view_name)
162
+
163
+ if child_viewmodel_class.nil?
164
+ raise ViewModel::DeserializationError::InvalidAssociationType.new(
165
+ association_data.association_name.to_s,
166
+ child_metadata.view_name,
167
+ blame_reference)
168
+ end
169
+
170
+ ViewModel::Reference.new(child_viewmodel_class, child_metadata.id)
171
+ end
172
+
173
+ def parse_append_action(action) # final
174
+ ViewModel::Schemas.verify_schema!(append_action_schema, action)
175
+
176
+ values = action[VALUES_ATTRIBUTE]
177
+ update = FunctionalUpdate::Append.new(parse_contents(values))
178
+
179
+ if (before = action[BEFORE_ATTRIBUTE])
180
+ update.before = parse_anchor(before)
181
+ end
182
+
183
+ if (after = action[AFTER_ATTRIBUTE])
184
+ update.after = parse_anchor(after)
185
+ end
186
+
187
+ if before && after
188
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
189
+ "Append may not specify both 'after' and 'before'",
190
+ blame_reference)
191
+ end
192
+
193
+ update
194
+ end
195
+
196
+ def parse_update_action(action) # final
197
+ ViewModel::Schemas.verify_schema!(update_action_schema, action)
198
+
199
+ values = action[VALUES_ATTRIBUTE]
200
+ FunctionalUpdate::Update.new(parse_contents(values))
201
+ end
202
+
203
+ def parse_remove_action(action) # final
204
+ ViewModel::Schemas.verify_schema!(remove_action_schema, action)
205
+
206
+ values = action[VALUES_ATTRIBUTE]
207
+ FunctionalUpdate::Remove.new(parse_remove_values(values))
208
+ end
209
+
210
+ ## Action contents
211
+ #
212
+ # The contents of the actions are determined by the subclasses
213
+
214
+ def functional_update_schema # abstract
215
+ raise "abstract"
216
+ end
217
+
218
+ def append_action_schema # abstract
219
+ raise "abstract"
220
+ end
221
+
222
+ def remove_action_schema # abstract
223
+ raise "abstract"
224
+ end
225
+
226
+ def update_action_schema # abstract
227
+ raise "abstract"
228
+ end
229
+
230
+ def parse_contents(values) # abstract
231
+ raise "abstract"
232
+ end
233
+
234
+ # Remove values are always anchors
235
+ def parse_remove_values(values)
236
+ # There's no reasonable interpretation of a remove update that includes data.
237
+ # Report it as soon as we detect it.
238
+ invalid_entries = values.reject { |h| UpdateData.reference_only_hash?(h) }
239
+ if invalid_entries.present?
240
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
241
+ "Removed entities must have only #{ViewModel::TYPE_ATTRIBUTE} and #{ViewModel::ID_ATTRIBUTE} fields. " \
242
+ "Invalid entries: #{invalid_entries}",
243
+ blame_reference)
244
+ end
245
+
246
+ values.map { |value| parse_anchor(value) }
247
+ end
248
+
249
+ ## Value constructors
250
+ #
251
+ # ReferencedCollectionUpdates and OwnedCollectionUpdates have different
252
+ # behaviour, so we parameterise the result type as well.
253
+
254
+ def replace_update_type # abstract
255
+ raise "abstract"
256
+ end
257
+
258
+ def functional_update_type # abstract
259
+ raise "abstract"
260
+ end
261
+ end
262
+ end
263
+
264
+ class OwnedCollectionUpdate < AbstractCollectionUpdate
265
+ class Replace < AbstractCollectionUpdate::Replace
266
+ alias update_datas contents # as UpdateDatas
267
+ end
268
+
269
+ class Functional < AbstractCollectionUpdate::Functional
270
+ alias update_datas contents # as UpdateDatas
271
+
272
+ def used_vm_refs(update_context)
273
+ update_datas
274
+ .map { |upd| upd.viewmodel_reference if upd.id }
275
+ .compact
276
+ end
277
+ end
278
+
279
+ class Parser < AbstractCollectionUpdate::Parser
280
+ def functional_update_schema
281
+ UpdateData::Schemas::COLLECTION_UPDATE
282
+ end
283
+
284
+ def append_action_schema
285
+ UpdateData::Schemas::APPEND_ACTION
286
+ end
287
+
288
+ def remove_action_schema
289
+ UpdateData::Schemas::REMOVE_ACTION
290
+ end
291
+
292
+ def update_action_schema
293
+ UpdateData::Schemas::UPDATE_ACTION
294
+ end
295
+
296
+ def parse_contents(values)
297
+ values.map do |value|
298
+ UpdateData.parse_associated(association_data, blame_reference, valid_reference_keys, value)
299
+ end
300
+ end
301
+
302
+ def replace_update_type
303
+ Replace
304
+ end
305
+
306
+ def functional_update_type
307
+ Functional
308
+ end
309
+ end
310
+ end
311
+
312
+ class ReferencedCollectionUpdate < AbstractCollectionUpdate
313
+ class Replace < AbstractCollectionUpdate::Replace
314
+ alias references contents # as reference strings
315
+ end
316
+
317
+ class Functional < AbstractCollectionUpdate::Functional
318
+ alias references contents
319
+
320
+ def used_vm_refs(update_context)
321
+ references.map do |ref|
322
+ update_context.resolve_reference(ref, nil).viewmodel_reference
323
+ end
324
+ end
325
+ end
326
+
327
+ class Parser < AbstractCollectionUpdate::Parser
328
+ def functional_update_schema
329
+ UpdateData::Schemas::REFERENCED_COLLECTION_UPDATE
330
+ end
331
+
332
+ def append_action_schema
333
+ UpdateData::Schemas::REFERENCED_APPEND_ACTION
334
+ end
335
+
336
+ def remove_action_schema
337
+ UpdateData::Schemas::REFERENCED_REMOVE_ACTION
338
+ end
339
+
340
+ def update_action_schema
341
+ UpdateData::Schemas::REFERENCED_UPDATE_ACTION
342
+ end
343
+
344
+ def parse_contents(values)
345
+ invalid_entries = values.reject { |h| ref_hash?(h) }
346
+
347
+ if invalid_entries.present?
348
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
349
+ "Appended/Updated entities must be specified as '#{ViewModel::REFERENCE_ATTRIBUTE}' style hashes." \
350
+ "Invalid entries: #{invalid_entries}",
351
+ blame_reference)
352
+ end
353
+
354
+ values.map do |x|
355
+ ref = ViewModel.extract_reference_metadata(x)
356
+ unless valid_reference_keys.include?(ref)
357
+ raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference)
358
+ end
359
+ ref
360
+ end
361
+ end
362
+
363
+ private
364
+
365
+ def replace_update_type
366
+ Replace
367
+ end
368
+
369
+ def functional_update_type
370
+ Functional
371
+ end
372
+
373
+ def ref_hash?(value)
374
+ value.size == 1 && value.has_key?(ViewModel::REFERENCE_ATTRIBUTE)
375
+ end
376
+ end
377
+ end
378
+
379
+ class UpdateData
380
+ attr_accessor :viewmodel_class, :metadata, :attributes, :associations, :referenced_associations
381
+ delegate :id, :view_name, :schema_version, to: :metadata
382
+
383
+ module Schemas
384
+ viewmodel_reference_only =
385
+ {
386
+ 'type' => 'object',
387
+ 'description' => 'viewmodel reference',
388
+ 'properties' => { ViewModel::TYPE_ATTRIBUTE => { 'type' => 'string' },
389
+ ViewModel::ID_ATTRIBUTE => ViewModel::Schemas::ID_SCHEMA },
390
+ 'additionalProperties' => false,
391
+ 'required' => [ViewModel::TYPE_ATTRIBUTE, ViewModel::ID_ATTRIBUTE]
392
+ }
393
+
394
+ VIEWMODEL_REFERENCE_ONLY = JsonSchema.parse!(viewmodel_reference_only)
395
+
396
+ fupdate_base = ->(value_schema) do
397
+ {
398
+ 'description' => 'functional update',
399
+ 'type' => 'object',
400
+ 'properties' => {
401
+ ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Append::NAME,
402
+ FunctionalUpdate::Update::NAME,
403
+ FunctionalUpdate::Remove::NAME] },
404
+ VALUES_ATTRIBUTE => { 'type' => 'array',
405
+ 'items' => value_schema }
406
+ },
407
+ 'required' => [ViewModel::TYPE_ATTRIBUTE, VALUES_ATTRIBUTE]
408
+ }
409
+ end
410
+
411
+ append_mixin = {
412
+ 'description' => 'collection append',
413
+ 'additionalProperties' => false,
414
+ 'properties' => {
415
+ ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Append::NAME] },
416
+ BEFORE_ATTRIBUTE => viewmodel_reference_only,
417
+ AFTER_ATTRIBUTE => viewmodel_reference_only
418
+ },
419
+ }
420
+
421
+ fupdate_owned =
422
+ fupdate_base.(ViewModel::Schemas::VIEWMODEL_UPDATE_SCHEMA)
423
+
424
+ fupdate_shared =
425
+ fupdate_base.({ 'oneOf' => [ViewModel::Schemas::VIEWMODEL_REFERENCE_SCHEMA,
426
+ viewmodel_reference_only] })
427
+
428
+ # Referenced updates are special:
429
+ # - Append requires `_ref` hashes
430
+ # - Update requires `_ref` hashes
431
+ # - Remove requires vm refs (type/id)
432
+ # Checked in code (ReferencedCollectionUpdate::Builder.parse_*_values)
433
+
434
+ APPEND_ACTION = JsonSchema.parse!(fupdate_owned.deep_merge(append_mixin))
435
+ REFERENCED_APPEND_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(append_mixin))
436
+
437
+ update_mixin = {
438
+ 'description' => 'collection update',
439
+ 'additionalProperties' => false,
440
+ 'properties' => {
441
+ ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Update::NAME] }
442
+ },
443
+ }
444
+
445
+ UPDATE_ACTION = JsonSchema.parse!(fupdate_owned.deep_merge(update_mixin))
446
+ REFERENCED_UPDATE_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(update_mixin))
447
+
448
+ remove_mixin = {
449
+ 'description' => 'collection remove',
450
+ 'additionalProperties' => false,
451
+ 'properties' => {
452
+ ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Remove::NAME] },
453
+ # The VALUES_ATTRIBUTE should be a viewmodel_reference, but in the
454
+ # name of error messages, we allow more keys and check the
455
+ # constraint in code.
456
+ },
457
+ }
458
+
459
+ REMOVE_ACTION = JsonSchema.parse!(fupdate_owned.deep_merge(remove_mixin))
460
+ REFERENCED_REMOVE_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(remove_mixin))
461
+
462
+
463
+ collection_update = ->(base_schema) do
464
+ {
465
+ 'type' => 'object',
466
+ 'description' => 'collection functional update',
467
+ 'additionalProperties' => false,
468
+ 'required' => [ViewModel::TYPE_ATTRIBUTE, ACTIONS_ATTRIBUTE],
469
+ 'properties' => {
470
+ ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FUNCTIONAL_UPDATE_TYPE] },
471
+ ACTIONS_ATTRIBUTE => { 'type' => 'array', 'items' => base_schema }
472
+ # The ACTIONS_ATTRIBUTE could be accurately expressed as
473
+ #
474
+ # { 'oneOf' => [append, update, remove] }
475
+ #
476
+ # but this produces completely unusable error messages. Instead we
477
+ # specify it must be an array, and defer checking to the code that
478
+ # can determine the schema by inspecting the type field.
479
+ }
480
+ }
481
+ end
482
+
483
+ COLLECTION_UPDATE = JsonSchema.parse!(collection_update.(fupdate_owned))
484
+ REFERENCED_COLLECTION_UPDATE = JsonSchema.parse!(collection_update.(fupdate_shared))
485
+ end
486
+
487
+ def [](name)
488
+ case name
489
+ when :id
490
+ id
491
+ when :_type
492
+ viewmodel_class.view_name
493
+ else
494
+ attributes.fetch(name) { associations.fetch(name) { referenced_associations.fetch(name) }}
495
+ end
496
+ end
497
+
498
+ def has_key?(name)
499
+ case name
500
+ when :id, :_type
501
+ true
502
+ else
503
+ attributes.has_key?(name) || associations.has_key?(name) || referenced_associations.has_key?(name)
504
+ end
505
+ end
506
+
507
+ def new?
508
+ id.nil? || metadata.new?
509
+ end
510
+
511
+ def self.parse_hashes(root_subtree_hashes, referenced_subtree_hashes = {})
512
+ valid_reference_keys = referenced_subtree_hashes.keys.to_set
513
+
514
+ valid_reference_keys.each do |ref|
515
+ unless ref.is_a?(String)
516
+ raise ViewModel::DeserializationError::InvalidSyntax.new("Invalid reference string: #{ref}")
517
+ end
518
+ end
519
+
520
+ # Construct root UpdateData
521
+ root_updates = root_subtree_hashes.map do |subtree_hash|
522
+ metadata = ViewModel.extract_viewmodel_metadata(subtree_hash)
523
+ viewmodel_class = ViewModel::Registry.for_view_name(metadata.view_name)
524
+ verify_schema_version!(viewmodel_class, metadata.schema_version, metadata.id) if metadata.schema_version
525
+ UpdateData.new(viewmodel_class, metadata, subtree_hash, valid_reference_keys)
526
+ end
527
+
528
+ # Ensure that no root is referred to more than once
529
+ check_duplicate_updates(root_updates, type: "root")
530
+
531
+ # Construct reference UpdateData
532
+ referenced_updates = referenced_subtree_hashes.transform_values do |subtree_hash|
533
+ metadata = ViewModel.extract_viewmodel_metadata(subtree_hash)
534
+ viewmodel_class = ViewModel::Registry.for_view_name(metadata.view_name)
535
+ verify_schema_version!(viewmodel_class, metadata.schema_version, metadata.id) if metadata.schema_version
536
+
537
+ UpdateData.new(viewmodel_class, metadata, subtree_hash, valid_reference_keys)
538
+ end
539
+
540
+ check_duplicate_updates(referenced_updates.values, type: "reference")
541
+
542
+ return root_updates, referenced_updates
543
+ end
544
+
545
+ def self.check_duplicate_updates(updates, type:)
546
+ # Ensure that no root is referred to more than once
547
+ duplicates = updates.duplicates_by { |upd| upd.viewmodel_reference if upd.id }
548
+ if duplicates.present?
549
+ raise ViewModel::DeserializationError::DuplicateNodes.new(type, duplicates.keys)
550
+ end
551
+ end
552
+
553
+ def initialize(viewmodel_class, metadata, hash_data, valid_reference_keys)
554
+ self.viewmodel_class = viewmodel_class
555
+ self.metadata = metadata
556
+ self.attributes = {}
557
+ self.associations = {}
558
+ self.referenced_associations = {}
559
+
560
+ parse(hash_data, valid_reference_keys)
561
+ end
562
+
563
+ def self.empty_update_for(viewmodel)
564
+ metadata = ViewModel::Metadata.new(viewmodel.id, viewmodel.view_name, viewmodel.class.schema_version, false)
565
+ self.new(viewmodel.class, metadata, {}, [])
566
+ end
567
+
568
+ # Produce a sequence of update datas for a given association update value, in the spirit of Array.wrap.
569
+ def to_sequence(name, value)
570
+ association_data = self.viewmodel_class._association_data(name)
571
+ case
572
+ when value.nil?
573
+ []
574
+ when association_data.shared?
575
+ []
576
+ when association_data.collection? # not shared, because of shared? check above
577
+ value.update_datas
578
+ else
579
+ [value]
580
+ end
581
+ end
582
+
583
+ # Updates in terms of viewmodel associations
584
+ def updated_associations
585
+ deps = {}
586
+
587
+ (associations.merge(referenced_associations)).each do |assoc_name, assoc_update|
588
+ deps[assoc_name] =
589
+ to_sequence(assoc_name, assoc_update)
590
+ .each_with_object({}) do |update_data, updated_associations|
591
+ updated_associations.deep_merge!(update_data.updated_associations)
592
+ end
593
+ end
594
+
595
+ deps
596
+ end
597
+
598
+ def build_preload_specs(association_data, updates)
599
+ if association_data.polymorphic?
600
+ updates.map do |update_data|
601
+ target_model = update_data.viewmodel_class.model_class
602
+ DeepPreloader::PolymorphicSpec.new(
603
+ target_model.name => update_data.preload_dependencies)
604
+ end
605
+ else
606
+ updates.map { |update_data| update_data.preload_dependencies }
607
+ end
608
+ end
609
+
610
+ def merge_preload_specs(association_data, specs)
611
+ empty = association_data.polymorphic? ? DeepPreloader::PolymorphicSpec.new : DeepPreloader::Spec.new
612
+ specs.inject(empty) { |acc, spec| acc.merge!(spec) }
613
+ end
614
+
615
+ # Updates in terms of activerecord associations: used for preloading subtree
616
+ # associations necessary to perform update.
617
+ def preload_dependencies
618
+ deps = {}
619
+
620
+ (associations.merge(referenced_associations)).each do |assoc_name, reference|
621
+ association_data = self.viewmodel_class._association_data(assoc_name)
622
+
623
+ preload_specs = build_preload_specs(association_data,
624
+ to_sequence(assoc_name, reference))
625
+
626
+ referenced_deps = merge_preload_specs(association_data, preload_specs)
627
+
628
+ if association_data.through?
629
+ referenced_deps = DeepPreloader::Spec.new(association_data.indirect_reflection.name.to_s => referenced_deps)
630
+ end
631
+
632
+ deps[association_data.direct_reflection.name.to_s] = referenced_deps
633
+ end
634
+
635
+ DeepPreloader::Spec.new(deps)
636
+ end
637
+
638
+ def viewmodel_reference
639
+ ViewModel::Reference.new(viewmodel_class, id)
640
+ end
641
+
642
+ def self.parse_associated(association_data, blame_reference, valid_reference_keys, child_hash)
643
+ child_metadata = ViewModel.extract_viewmodel_metadata(child_hash)
644
+
645
+ child_viewmodel_class =
646
+ association_data.viewmodel_class_for_name(child_metadata.view_name)
647
+
648
+ if child_viewmodel_class.nil?
649
+ raise ViewModel::DeserializationError::InvalidAssociationType.new(
650
+ association_data.association_name.to_s,
651
+ child_metadata.view_name,
652
+ blame_reference)
653
+ end
654
+
655
+ verify_schema_version!(child_viewmodel_class, child_metadata.schema_version, child_metadata.id) if child_metadata.schema_version
656
+
657
+ UpdateData.new(child_viewmodel_class, child_metadata, child_hash, valid_reference_keys)
658
+ end
659
+
660
+ private
661
+
662
+ def self.reference_only_hash?(hash)
663
+ hash.size == 2 && hash.has_key?(ViewModel::ID_ATTRIBUTE) && hash.has_key?(ViewModel::TYPE_ATTRIBUTE)
664
+ end
665
+
666
+ def parse(hash_data, valid_reference_keys)
667
+ hash_data = hash_data.dup
668
+
669
+ # handle view pre-parsing if defined
670
+ self.viewmodel_class.pre_parse(viewmodel_reference, metadata, hash_data) if self.viewmodel_class.respond_to?(:pre_parse)
671
+ hash_data.keys.each do |key|
672
+ if self.viewmodel_class.respond_to?(:"pre_parse_#{key}")
673
+ self.viewmodel_class.public_send("pre_parse_#{key}", viewmodel_reference, metadata, hash_data, hash_data.delete(key))
674
+ end
675
+ end
676
+
677
+ hash_data.each do |name, value|
678
+ member_data = self.viewmodel_class._members[name]
679
+ case member_data
680
+ when ViewModel::Record::AttributeData
681
+ attributes[name] = value
682
+
683
+ when AssociationData
684
+ association_data = member_data
685
+ case
686
+ when value.nil?
687
+ if association_data.collection?
688
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
689
+ "Invalid collection update value 'nil' for association '#{name}'",
690
+ blame_reference)
691
+ end
692
+ if association_data.shared?
693
+ referenced_associations[name] = nil
694
+ else
695
+ associations[name] = nil
696
+ end
697
+
698
+ when association_data.through?
699
+ referenced_associations[name] =
700
+ ReferencedCollectionUpdate::Parser
701
+ .new(association_data, blame_reference, valid_reference_keys)
702
+ .parse(value)
703
+
704
+ when association_data.shared?
705
+ # Extract and check reference
706
+ ref = ViewModel.extract_reference_metadata(value)
707
+
708
+ unless valid_reference_keys.include?(ref)
709
+ raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference)
710
+ end
711
+
712
+ referenced_associations[name] = ref
713
+
714
+ else
715
+ if association_data.collection?
716
+ associations[name] =
717
+ OwnedCollectionUpdate::Parser
718
+ .new(association_data, blame_reference, valid_reference_keys)
719
+ .parse(value)
720
+ else # not a collection
721
+ associations[name] =
722
+ if value.nil?
723
+ nil
724
+ else
725
+ self.class.parse_associated(association_data, blame_reference, valid_reference_keys, value)
726
+ end
727
+ end
728
+ end
729
+ else
730
+ raise ViewModel::DeserializationError::UnknownAttribute.new(name, blame_reference)
731
+ end
732
+ end
733
+ end
734
+
735
+
736
+ def blame_reference
737
+ ViewModel::Reference.new(self.viewmodel_class, self.id)
738
+ end
739
+
740
+ def self.verify_schema_version!(viewmodel_class, schema_version, id)
741
+ unless viewmodel_class.accepts_schema_version?(schema_version)
742
+ raise ViewModel::DeserializationError::SchemaVersionMismatch.new(
743
+ viewmodel_class,
744
+ schema_version,
745
+ ViewModel::Reference.new(viewmodel_class, id))
746
+ end
747
+ end
748
+ end
749
+ end