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,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