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,810 @@
1
+ require "renum"
2
+
3
+ # Partially parsed tree of user-specified update hashes, created during deserialization.
4
+ class ViewModel::ActiveRecord
5
+ using ViewModel::Utils::Collections
6
+
7
+ class UpdateOperation
8
+ # inverse association and record to update a change in parent from a child
9
+ ParentData = Struct.new(:association_reflection, :viewmodel)
10
+
11
+ enum :RunState, [:Pending, :Running, :Run]
12
+
13
+ attr_accessor :viewmodel,
14
+ :update_data,
15
+ :points_to, # AssociationData => UpdateOperation (returns single new viewmodel to update fkey)
16
+ :pointed_to, # AssociationData => UpdateOperation(s) (returns viewmodel(s) with which to update assoc cache)
17
+ :reparent_to, # If node needs to update its pointer to a new parent, ParentData for the parent
18
+ :reposition_to, # if this node participates in a list under its parent, what should its position be?
19
+ :released_children # Set of children that have been released
20
+
21
+ delegate :attributes, to: :update_data
22
+
23
+ def initialize(viewmodel, update_data, reparent_to: nil, reposition_to: nil)
24
+ self.viewmodel = viewmodel
25
+ self.update_data = update_data
26
+ self.points_to = {}
27
+ self.pointed_to = {}
28
+ self.reparent_to = reparent_to
29
+ self.reposition_to = reposition_to
30
+ self.released_children = []
31
+
32
+ @run_state = RunState::Pending
33
+ @changed_associations = []
34
+ @built = false
35
+ end
36
+
37
+ def viewmodel_reference
38
+ unless viewmodel.model.new_record?
39
+ viewmodel.to_reference
40
+ end
41
+ end
42
+
43
+ def deferred?
44
+ viewmodel.nil?
45
+ end
46
+
47
+ def built?
48
+ @built
49
+ end
50
+
51
+ # Evaluate a built update tree, applying and saving changes to the models.
52
+ def run!(deserialize_context:)
53
+ raise ViewModel::DeserializationError::Internal.new("Internal error: UpdateOperation run before build") unless built?
54
+
55
+ case @run_state
56
+ when RunState::Running
57
+ raise ViewModel::DeserializationError::Internal.new("Internal error: Cycle found in running UpdateOperation")
58
+ when RunState::Run
59
+ return viewmodel
60
+ end
61
+
62
+ @run_state = RunState::Running
63
+
64
+ model = viewmodel.model
65
+
66
+ debug_name = "#{model.class.name}:#{model.id || '<new>'}"
67
+ debug "-> #{debug_name}: Entering"
68
+
69
+ model.class.transaction do
70
+ # Run context and viewmodel hooks
71
+ ViewModel::Callbacks.wrap_deserialize(viewmodel, deserialize_context: deserialize_context) do |hook_control|
72
+ # update parent association
73
+ if reparent_to.present?
74
+ debug "-> #{debug_name}: Updating parent pointer to '#{reparent_to.viewmodel.class.view_name}:#{reparent_to.viewmodel.id}'"
75
+ association = model.association(reparent_to.association_reflection.name)
76
+ association.writer(reparent_to.viewmodel.model)
77
+ debug "<- #{debug_name}: Updated parent pointer"
78
+ end
79
+
80
+ # update position
81
+ if reposition_to.present?
82
+ debug "-> #{debug_name}: Updating position to #{reposition_to}"
83
+ viewmodel._list_attribute = reposition_to
84
+ end
85
+
86
+ # update user-specified attributes
87
+ valid_members = viewmodel.class._members.keys.map(&:to_s).to_set
88
+ bad_keys = attributes.keys.reject { |k| valid_members.include?(k) }
89
+ if bad_keys.present?
90
+ causes = bad_keys.map { |k| ViewModel::DeserializationError::UnknownAttribute.new(k, blame_reference) }
91
+ raise ViewModel::DeserializationError::Collection.for_errors(causes)
92
+ end
93
+
94
+ attributes.each do |attr_name, serialized_value|
95
+ # Note that the VM::AR deserialization tree asserts ownership over any
96
+ # references it's provided, and so they're intentionally not passed on
97
+ # to attribute deserialization for use by their `using:` viewmodels. A
98
+ # (better?) alternative would be to provide them as reference-only
99
+ # hashes, to indicate that no modification can be permitted.
100
+ viewmodel.public_send("deserialize_#{attr_name}", serialized_value,
101
+ references: {},
102
+ deserialize_context: deserialize_context)
103
+ end
104
+
105
+ # Update points-to associations before save
106
+ points_to.each do |association_data, child_operation|
107
+ reflection = association_data.direct_reflection
108
+ debug "-> #{debug_name}: Updating points-to association '#{reflection.name}'"
109
+
110
+ association = model.association(reflection.name)
111
+ new_target =
112
+ if child_operation
113
+ child_ctx = viewmodel.context_for_child(association_data.association_name, context: deserialize_context)
114
+ child_viewmodel = child_operation.run!(deserialize_context: child_ctx)
115
+ if !association_data.shared? && child_viewmodel.previous_changes.changed_tree?
116
+ viewmodel.children_changed!
117
+ end
118
+ child_viewmodel.model
119
+ end
120
+ association.writer(new_target)
121
+ debug "<- #{debug_name}: Updated points-to association '#{reflection.name}'"
122
+ end
123
+
124
+ # validate
125
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, viewmodel)
126
+ viewmodel.validate!
127
+
128
+ # Save if the model has been altered. Covers not only models with
129
+ # view changes but also lock version assertions.
130
+ if viewmodel.model.changed?
131
+ debug "-> #{debug_name}: Saving"
132
+ begin
133
+ model.save!
134
+ rescue ::ActiveRecord::RecordInvalid => ex
135
+ raise ViewModel::DeserializationError::Validation.from_active_model(ex.errors, blame_reference)
136
+ rescue ::ActiveRecord::StaleObjectError => _ex
137
+ raise ViewModel::DeserializationError::LockFailure.new(blame_reference)
138
+ end
139
+ debug "<- #{debug_name}: Saved"
140
+ end
141
+
142
+ # Update association cache of pointed-from associations after save: the
143
+ # child update will have saved the pointer.
144
+ pointed_to.each do |association_data, child_operation|
145
+ reflection = association_data.direct_reflection
146
+
147
+ debug "-> #{debug_name}: Updating pointed-to association '#{reflection.name}'"
148
+
149
+ association = model.association(reflection.name)
150
+ child_ctx = viewmodel.context_for_child(association_data.association_name, context: deserialize_context)
151
+
152
+ new_target =
153
+ if child_operation
154
+ ViewModel::Utils.map_one_or_many(child_operation) do |op|
155
+ child_viewmodel = op.run!(deserialize_context: child_ctx)
156
+ if !association_data.shared? && child_viewmodel.previous_changes.changed_tree?
157
+ viewmodel.children_changed!
158
+ end
159
+ child_viewmodel.model
160
+ end
161
+ end
162
+
163
+ association.target = new_target
164
+
165
+ debug "<- #{debug_name}: Updated pointed-to association '#{reflection.name}'"
166
+ end
167
+
168
+ if self.released_children.present?
169
+ # Released children that were not reclaimed by other parents during the
170
+ # build phase will be deleted: check access control.
171
+ debug "-> #{debug_name}: Checking released children permissions"
172
+ self.released_children.reject(&:claimed?).each do |released_child|
173
+ debug "-> #{debug_name}: Checking #{released_child.viewmodel.to_reference}"
174
+ child_vm = released_child.viewmodel
175
+ child_association_data = released_child.association_data
176
+ child_ctx = viewmodel.context_for_child(child_association_data.association_name, context: deserialize_context)
177
+
178
+ ViewModel::Callbacks.wrap_deserialize(child_vm, deserialize_context: child_ctx) do |child_hook_control|
179
+ changes = ViewModel::Changes.new(deleted: true)
180
+ child_ctx.run_callback(ViewModel::Callbacks::Hook::OnChange,
181
+ child_vm,
182
+ changes: changes)
183
+ child_hook_control.record_changes(changes)
184
+ end
185
+
186
+ viewmodel.children_changed! unless child_association_data.shared?
187
+ end
188
+ debug "<- #{debug_name}: Finished checking released children permissions"
189
+ end
190
+
191
+ final_changes = viewmodel.clear_changes!
192
+
193
+ if final_changes.changed?
194
+ # Now that the change has been fully attempted, call the OnChange
195
+ # hook if local changes were made
196
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, viewmodel, changes: final_changes)
197
+ end
198
+
199
+ hook_control.record_changes(final_changes)
200
+ end
201
+ end
202
+
203
+ debug "<- #{debug_name}: Leaving"
204
+
205
+ @run_state = RunState::Run
206
+ viewmodel
207
+ rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::InvalidForeignKey, ::ActiveRecord::RecordNotSaved => ex
208
+ raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex, blame_reference)
209
+ end
210
+
211
+ # Recursively builds UpdateOperations for the associations in our UpdateData
212
+ def build!(update_context)
213
+ raise ViewModel::DeserializationError::Internal.new("Internal error: UpdateOperation cannot build a deferred update") if deferred?
214
+ return self if built?
215
+
216
+ update_data.associations.each do |association_name, association_update_data|
217
+ association_data = self.viewmodel.class._association_data(association_name)
218
+ update =
219
+ if association_data.collection?
220
+ build_updates_for_collection_association(association_data, association_update_data, update_context)
221
+ else
222
+ build_update_for_single_association(association_data, association_update_data, update_context)
223
+ end
224
+
225
+ add_update(association_data, update)
226
+ end
227
+
228
+ update_data.referenced_associations.each do |association_name, reference_string|
229
+ association_data = self.viewmodel.class._association_data(association_name)
230
+
231
+ update =
232
+ if association_data.through?
233
+ build_updates_for_collection_referenced_association(association_data, reference_string, update_context)
234
+ else
235
+ build_update_for_single_referenced_association(association_data, reference_string, update_context)
236
+ end
237
+
238
+ add_update(association_data, update)
239
+ end
240
+
241
+ @built = true
242
+ self
243
+ end
244
+
245
+ def add_update(association_data, update)
246
+ target =
247
+ case association_data.pointer_location
248
+ when :remote; pointed_to
249
+ when :local; points_to
250
+ end
251
+
252
+ target[association_data] = update
253
+ end
254
+
255
+ private
256
+
257
+ def build_update_for_single_referenced_association(association_data, reference_string, update_context)
258
+ # TODO intern loads for shared items so we only load them once
259
+ model = self.viewmodel.model
260
+ previous_child_viewmodel = model.public_send(association_data.direct_reflection.name).try do |previous_child_model|
261
+ vm_class = association_data.viewmodel_class_for_model!(previous_child_model.class)
262
+ vm_class.new(previous_child_model)
263
+ end
264
+
265
+ if reference_string.nil?
266
+ referred_update = nil
267
+ referred_viewmodel = nil
268
+ else
269
+ referred_update = update_context.resolve_reference(reference_string, blame_reference)
270
+ referred_viewmodel = referred_update.viewmodel
271
+
272
+ unless association_data.accepts?(referred_viewmodel.class)
273
+ raise ViewModel::DeserializationError::InvalidAssociationType.new(
274
+ association_data.association_name.to_s,
275
+ referred_viewmodel.class.view_name,
276
+ blame_reference)
277
+ end
278
+
279
+ referred_update.build!(update_context)
280
+ end
281
+
282
+ if previous_child_viewmodel != referred_viewmodel
283
+ viewmodel.association_changed!(association_data.association_name)
284
+ end
285
+
286
+ referred_update
287
+ end
288
+
289
+ # Resolve or construct viewmodels for incoming update data. Where a child
290
+ # hash references an existing model not currently attached to this parent,
291
+ # it must be found before recursing into that child. If the model is
292
+ # available in released models we can take it and recurse, otherwise we must
293
+ # return a ViewModel::Reference to be added to the worklist for deferred
294
+ # resolution.
295
+ def resolve_child_viewmodels(association_data, update_datas, previous_child_viewmodels, update_context)
296
+ if self.viewmodel.respond_to?(:"resolve_#{association_data.direct_reflection.name}")
297
+ return self.viewmodel.public_send(:"resolve_#{association_data.direct_reflection.name}", update_datas, previous_child_viewmodels)
298
+ end
299
+
300
+ previous_child_viewmodels = Array.wrap(previous_child_viewmodels)
301
+
302
+ previous_by_key = previous_child_viewmodels.index_by do |vm|
303
+ vm.to_reference
304
+ end
305
+
306
+ ViewModel::Utils.map_one_or_many(update_datas) do |update_data|
307
+ child_viewmodel_class = update_data.viewmodel_class
308
+ key = ViewModel::Reference.new(child_viewmodel_class, update_data.id)
309
+
310
+ case
311
+ when update_data.new?
312
+ child_viewmodel_class.for_new_model(id: update_data.id)
313
+ when existing_child = previous_by_key[key]
314
+ existing_child
315
+ when taken_child = update_context.try_take_released_viewmodel(key)
316
+ taken_child
317
+ else
318
+ # Refers to child that hasn't yet been seen: create a deferred update.
319
+ key
320
+ end
321
+ end
322
+ end
323
+
324
+ def build_update_for_single_association(association_data, association_update_data, update_context)
325
+ model = self.viewmodel.model
326
+
327
+ previous_child_viewmodel = model.public_send(association_data.direct_reflection.name).try do |previous_child_model|
328
+ vm_class = association_data.viewmodel_class_for_model!(previous_child_model.class)
329
+ vm_class.new(previous_child_model)
330
+ end
331
+
332
+ if previous_child_viewmodel.present?
333
+ # Clear the cached association so that AR's save behaviour doesn't
334
+ # conflict with our explicit parent updates. If we still have a child
335
+ # after the update, we'll either call `Association#writer` or manually
336
+ # fix the target cache after recursing in run!(). If we don't, we promise
337
+ # that the child will no longer be attached in the database, so the new
338
+ # cached data of nil will be correct.
339
+ clear_association_cache(model, association_data.direct_reflection)
340
+ end
341
+
342
+ child_viewmodel =
343
+ if association_update_data.present?
344
+ resolve_child_viewmodels(association_data, association_update_data, previous_child_viewmodel, update_context)
345
+ end
346
+
347
+ if previous_child_viewmodel != child_viewmodel
348
+ viewmodel.association_changed!(association_data.association_name)
349
+ # free previous child if present
350
+ if previous_child_viewmodel.present?
351
+ if association_data.pointer_location == :local
352
+ # When we free a child that's pointed to from its old parent, we need to
353
+ # clear the cached association to that old parent. If we don't do this,
354
+ # then if the child gets claimed by a new parent and `save!`ed, AR will
355
+ # re-establish the link from the old parent in the cache.
356
+
357
+ # Ideally we want
358
+ # model.association(...).inverse_reflection_for(previous_child_model), but
359
+ # that's private.
360
+
361
+ inverse_reflection =
362
+ if association_data.direct_reflection.polymorphic?
363
+ association_data.direct_reflection.polymorphic_inverse_of(previous_child_viewmodel.model.class)
364
+ else
365
+ association_data.direct_reflection.inverse_of
366
+ end
367
+
368
+ if inverse_reflection.present?
369
+ clear_association_cache(previous_child_viewmodel.model, inverse_reflection)
370
+ end
371
+ end
372
+
373
+ release_viewmodel(previous_child_viewmodel, association_data, update_context)
374
+ end
375
+ end
376
+
377
+ # Construct and return update for new child viewmodel
378
+ if child_viewmodel.present?
379
+ # If the association's pointer is in the child, need to provide it with a
380
+ # ParentData to update
381
+ parent_data =
382
+ if association_data.pointer_location == :remote
383
+ ParentData.new(association_data.direct_reflection.inverse_of, viewmodel)
384
+ else
385
+ nil
386
+ end
387
+
388
+ case child_viewmodel
389
+ when ViewModel::Reference # deferred
390
+ vm_ref = child_viewmodel
391
+ update_context.new_deferred_update(vm_ref, association_update_data, reparent_to: parent_data)
392
+ else
393
+ update_context.new_update(child_viewmodel, association_update_data, reparent_to: parent_data).build!(update_context)
394
+ end
395
+ end
396
+ end
397
+
398
+ def build_updates_for_collection_association(association_data, association_update, update_context)
399
+ model = self.viewmodel.model
400
+
401
+ # reference back to this model, so we can set the link while updating the children
402
+ parent_data = ParentData.new(association_data.direct_reflection.inverse_of, viewmodel)
403
+
404
+ # load children already attached to this model
405
+ child_viewmodel_class = association_data.viewmodel_class
406
+ previous_child_viewmodels =
407
+ model.public_send(association_data.direct_reflection.name).map do |child_model|
408
+ child_viewmodel_class.new(child_model)
409
+ end
410
+ if child_viewmodel_class._list_member?
411
+ previous_child_viewmodels.sort_by!(&:_list_attribute)
412
+ end
413
+
414
+ if previous_child_viewmodels.present?
415
+ # Clear the cached association so that AR's save behaviour doesn't
416
+ # conflict with our explicit parent updates. If we still have children
417
+ # after the update, we'll reset the target cache after recursing in
418
+ # run(). If not, the empty array we cache here will be correct, because
419
+ # previous children will be deleted or have had their parent pointers
420
+ # updated.
421
+ clear_association_cache(model, association_data.direct_reflection)
422
+ end
423
+
424
+ child_datas =
425
+ case association_update
426
+ when OwnedCollectionUpdate::Replace
427
+ association_update.update_datas
428
+
429
+ when OwnedCollectionUpdate::Functional
430
+ child_datas =
431
+ previous_child_viewmodels.map do |previous_child_viewmodel|
432
+ UpdateData.empty_update_for(previous_child_viewmodel)
433
+ end
434
+
435
+ association_update.check_for_duplicates!(update_context, self.viewmodel.blame_reference)
436
+
437
+ association_update.actions.each do |fupdate|
438
+ case fupdate
439
+ when FunctionalUpdate::Append
440
+ if fupdate.before || fupdate.after
441
+ moved_refs = fupdate.contents.map(&:viewmodel_reference).to_set
442
+ child_datas = child_datas.reject { |child| moved_refs.include?(child.viewmodel_reference) }
443
+
444
+ ref = fupdate.before || fupdate.after
445
+ index = child_datas.find_index { |cd| cd.viewmodel_reference == ref }
446
+ unless index
447
+ raise ViewModel::DeserializationError::AssociatedNotFound.new(
448
+ association_data.association_name.to_s, ref, blame_reference)
449
+ end
450
+
451
+ index += 1 if fupdate.after
452
+ child_datas.insert(index, *fupdate.contents)
453
+
454
+ else
455
+ child_datas.concat(fupdate.contents)
456
+
457
+ end
458
+
459
+ when FunctionalUpdate::Remove
460
+ removed_refs = fupdate.removed_vm_refs.to_set
461
+ child_datas.reject! { |child_data| removed_refs.include?(child_data.viewmodel_reference) }
462
+
463
+ when FunctionalUpdate::Update
464
+ # Already guaranteed that each ref has a single data attached
465
+ new_datas = fupdate.contents.index_by(&:viewmodel_reference)
466
+
467
+ child_datas = child_datas.map do |child_data|
468
+ ref = child_data.viewmodel_reference
469
+ new_datas.delete(ref) { child_data }
470
+ end
471
+
472
+ # Assertion that all values in update_op.values are present in the collection
473
+ unless new_datas.empty?
474
+ raise ViewModel::DeserializationError::AssociatedNotFound.new(
475
+ association_data.association_name.to_s, new_datas.keys, blame_reference)
476
+ end
477
+ else
478
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
479
+ "Unknown functional update type: '#{fupdate.type}'",
480
+ blame_reference)
481
+ end
482
+ end
483
+
484
+ child_datas
485
+ end
486
+
487
+ child_viewmodels = resolve_child_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context)
488
+
489
+ # if the new children differ, including in order, mark that one of our
490
+ # associations has changed and release any no-longer-attached children
491
+ if child_viewmodels != previous_child_viewmodels
492
+ viewmodel.association_changed!(association_data.association_name)
493
+ released_child_viewmodels = previous_child_viewmodels - child_viewmodels
494
+ released_child_viewmodels.each do |vm|
495
+ release_viewmodel(vm, association_data, update_context)
496
+ end
497
+ end
498
+
499
+ # Calculate new positions for children if in a list. Ignore previous
500
+ # positions for unresolved references: they'll always need to be updated
501
+ # anyway since their parent pointer will change.
502
+ new_positions = Array.new(child_viewmodels.length)
503
+
504
+ if association_data.viewmodel_class._list_member?
505
+ set_position = ->(index, pos) { new_positions[index] = pos }
506
+ get_previous_position = ->(index) do
507
+ vm = child_viewmodels[index]
508
+ vm._list_attribute unless vm.is_a?(ViewModel::Reference)
509
+ end
510
+
511
+ ActsAsManualList.update_positions(
512
+ (0...child_viewmodels.size).to_a, # indexes
513
+ position_getter: get_previous_position,
514
+ position_setter: set_position)
515
+ end
516
+
517
+ # Recursively build update operations for children
518
+ child_updates = child_viewmodels.zip(child_datas, new_positions).map do |child_viewmodel, association_update_data, position|
519
+ case child_viewmodel
520
+ when ViewModel::Reference # deferred
521
+ reference = child_viewmodel
522
+ update_context.new_deferred_update(reference, association_update_data, reparent_to: parent_data, reposition_to: position)
523
+ else
524
+ update_context.new_update(child_viewmodel, association_update_data, reparent_to: parent_data, reposition_to: position).build!(update_context)
525
+ end
526
+ end
527
+
528
+ child_updates
529
+ end
530
+
531
+
532
+ class ReferencedCollectionMember
533
+ attr_reader :indirect_viewmodel_reference, :direct_viewmodel
534
+ attr_accessor :ref_string, :position
535
+
536
+ def initialize(indirect_viewmodel_reference, direct_viewmodel)
537
+ @indirect_viewmodel_reference = indirect_viewmodel_reference
538
+ @direct_viewmodel = direct_viewmodel
539
+ if direct_viewmodel.class._list_member?
540
+ @position = direct_viewmodel._list_attribute
541
+ end
542
+ end
543
+
544
+ def ==(other)
545
+ other.class == self.class &&
546
+ other.indirect_viewmodel_reference == self.indirect_viewmodel_reference
547
+ end
548
+
549
+ alias :eql? :==
550
+ end
551
+
552
+ # Helper class to wrap the previous members of a referenced collection and
553
+ # provide update operations. No one member may be affected by more than one
554
+ # update operation. Elements removed from the collection are collected as
555
+ # `orphaned_members`."
556
+ class MutableReferencedCollection
557
+ attr_reader :members, :orphaned_members, :blame_reference
558
+
559
+ def initialize(association_data, update_context, members, blame_reference)
560
+ @association_data = association_data
561
+ @update_context = update_context
562
+ @members = members.dup
563
+ @blame_reference = blame_reference
564
+
565
+ @orphaned_members = []
566
+
567
+ @free_members_by_indirect_ref = @members.index_by(&:indirect_viewmodel_reference)
568
+ end
569
+
570
+ def replace(references)
571
+ members.replace(claim_or_create_references(references))
572
+
573
+ # Any unclaimed free members after building the update target are now
574
+ # orphaned and their direct viewmodels can be released.
575
+ orphaned_members.concat(free_members_by_indirect_ref.values)
576
+ free_members_by_indirect_ref.clear
577
+ end
578
+
579
+ def insert_before(relative_to, references)
580
+ insert_relative(relative_to, 0, references)
581
+ end
582
+
583
+ def insert_after(relative_to, references)
584
+ insert_relative(relative_to, 1, references)
585
+ end
586
+
587
+ def concat(references)
588
+ new_members = claim_or_create_references(references)
589
+ remove_from_members(new_members)
590
+ members.concat(new_members)
591
+ end
592
+
593
+ def remove(vm_references)
594
+ removed_members = vm_references.map do |vm_ref|
595
+ claim_existing_member(vm_ref)
596
+ end
597
+ remove_from_members(removed_members)
598
+ orphaned_members.concat(removed_members)
599
+ end
600
+
601
+ def update(references)
602
+ claim_existing_references(references)
603
+ end
604
+
605
+ private
606
+
607
+ attr_reader :free_members_by_indirect_ref
608
+ attr_reader :association_data, :update_context
609
+
610
+ def insert_relative(relative_vm_ref, offset, references)
611
+ new_members = claim_or_create_references(references)
612
+ remove_from_members(new_members)
613
+
614
+ index = members.find_index { |m| m.indirect_viewmodel_reference == relative_vm_ref }
615
+
616
+ unless index
617
+ raise ViewModel::DeserializationError::AssociatedNotFound.new(
618
+ association_data.association_name.to_s, relative_vm_ref, blame_reference)
619
+ end
620
+
621
+ members.insert(index + offset, *new_members)
622
+ end
623
+
624
+ # Reclaim existing members corresponding to the specified references, or create new ones if not found.
625
+ def claim_or_create_references(references)
626
+ references.map do |ref_string|
627
+ indirect_vm_ref = update_context.resolve_reference(ref_string, blame_reference).viewmodel_reference
628
+ claim_or_create_member(indirect_vm_ref, ref_string)
629
+ end
630
+ end
631
+
632
+ # Reclaim an existing member for an update and set its ref, or create a new one if not found.
633
+ def claim_or_create_member(indirect_vm_ref, ref_string)
634
+ member = free_members_by_indirect_ref.delete(indirect_vm_ref) do
635
+ ReferencedCollectionMember.new(indirect_vm_ref, association_data.direct_viewmodel.for_new_model)
636
+ end
637
+ member.ref_string = ref_string
638
+ member
639
+ end
640
+
641
+ # Reclaim existing members corresponding to the specified references or raise if not found.
642
+ def claim_existing_references(references)
643
+ references.each do |ref_string|
644
+ indirect_vm_ref = update_context.resolve_reference(ref_string, blame_reference).viewmodel_reference
645
+ claim_existing_member(indirect_vm_ref, ref_string)
646
+ end
647
+ end
648
+
649
+ # Claim an existing collection member for the update and optionally set its ref.
650
+ def claim_existing_member(indirect_vm_ref, ref_string = nil)
651
+ member = free_members_by_indirect_ref.delete(indirect_vm_ref) do
652
+ raise ViewModel::DeserializationError::AssociatedNotFound.new(
653
+ association_data.association_name.to_s, indirect_vm_ref, blame_reference)
654
+ end
655
+ member.ref_string = ref_string if ref_string
656
+ member
657
+ end
658
+ def remove_from_members(removed_members)
659
+ s = removed_members.to_set
660
+ members.reject! { |m| s.include?(m) }
661
+ end
662
+ end
663
+
664
+ def build_updates_for_collection_referenced_association(association_data, association_update, update_context)
665
+ model = self.viewmodel.model
666
+
667
+ # We have two relationships here.
668
+ # - the relationship from us to the join table models: direct
669
+ # - the relationship from the join table to the children: indirect
670
+
671
+ direct_reflection = association_data.direct_reflection
672
+ indirect_reflection = association_data.indirect_reflection
673
+ direct_viewmodel_class = association_data.direct_viewmodel
674
+ indirect_association_data = association_data.indirect_association_data
675
+
676
+ indirect_ref_for_direct_viewmodel = ->(direct_viewmodel) do
677
+ direct_model = direct_viewmodel.model
678
+ model_class = direct_model.association(indirect_reflection.name).klass
679
+ model_id = direct_model.public_send(indirect_reflection.foreign_key)
680
+ viewmodel_class = indirect_association_data.viewmodel_class_for_model!(model_class)
681
+ ViewModel::Reference.new(viewmodel_class, model_id)
682
+ end
683
+
684
+ previous_members = model.public_send(direct_reflection.name).map do |m|
685
+ direct_vm = direct_viewmodel_class.new(m)
686
+ indirect_viewmodel_ref = indirect_ref_for_direct_viewmodel.(direct_vm)
687
+ ReferencedCollectionMember.new(indirect_viewmodel_ref, direct_vm)
688
+ end
689
+
690
+ if direct_viewmodel_class._list_member?
691
+ previous_members.sort_by!(&:position)
692
+ end
693
+
694
+ target_collection = MutableReferencedCollection.new(
695
+ association_data, update_context, previous_members, blame_reference)
696
+
697
+ # All updates to shared collections produce a complete target list of
698
+ # ReferencedCollectionMembers including a ViewModel::Reference to the
699
+ # indirect child, and an existing (from previous) or new ViewModel for the
700
+ # direct child.
701
+ #
702
+ # Members participating in the update (all members in the case of Replace,
703
+ # specified append or update members in the case of Functional) will also
704
+ # include a reference string for the update operation for the indirect
705
+ # child, which will be subsequently added to the new UpdateOperation for
706
+ # the direct child.
707
+ case association_update
708
+ when ReferencedCollectionUpdate::Replace
709
+ target_collection.replace(association_update.references)
710
+
711
+ when ReferencedCollectionUpdate::Functional
712
+ # Collection updates are a list of actions modifying the list
713
+ # of indirect children.
714
+ #
715
+ # The target collection starts out as a copy of the previous collection
716
+ # members, and is then mutated based on the actions specified. All
717
+ # members added or modified by actions will have their `ref` set.
718
+
719
+ association_update.check_for_duplicates!(update_context, self.viewmodel.blame_reference)
720
+
721
+ association_update.actions.each do |fupdate|
722
+ case fupdate
723
+ when FunctionalUpdate::Append # Append new members, possibly relative to another member
724
+ case
725
+ when fupdate.before
726
+ target_collection.insert_before(fupdate.before, fupdate.contents)
727
+ when fupdate.after
728
+ target_collection.insert_after(fupdate.after, fupdate.contents)
729
+ else
730
+ target_collection.concat(fupdate.contents)
731
+ end
732
+
733
+ when FunctionalUpdate::Remove
734
+ target_collection.remove(fupdate.removed_vm_refs)
735
+
736
+ when FunctionalUpdate::Update # Update contents of members already in the collection
737
+ target_collection.update(fupdate.contents)
738
+
739
+ else
740
+ raise ArgumentError.new("Unknown functional update: '#{fupdate.class}'")
741
+ end
742
+ end
743
+
744
+ else
745
+ raise ViewModel::DeserializationError::InvalidSyntax.new("Unknown association_update type '#{association_update.class.name}'", blame_reference)
746
+ end
747
+
748
+ # We should now have an updated list `target_collection.members`,
749
+ # containing members for the desired new collection in the order that we
750
+ # want them, each of which has a `direct_viewmodel` set, and additionally
751
+ # a `ref_string` set for those that participated in the update.
752
+ if target_collection.members != previous_members
753
+ viewmodel.association_changed!(association_data.association_name)
754
+ end
755
+
756
+ if direct_viewmodel_class._list_member?
757
+ ActsAsManualList.update_positions(target_collection.members)
758
+ end
759
+
760
+ parent_data = ParentData.new(direct_reflection.inverse_of, self.viewmodel)
761
+
762
+ new_direct_updates = target_collection.members.map do |member|
763
+ update_data = UpdateData.empty_update_for(member.direct_viewmodel)
764
+
765
+ if (ref = member.ref_string)
766
+ update_data.referenced_associations[indirect_reflection.name] = ref
767
+ end
768
+
769
+ update_context.new_update(member.direct_viewmodel, update_data,
770
+ reparent_to: parent_data,
771
+ reposition_to: member.position)
772
+ .build!(update_context)
773
+ end
774
+
775
+ # Members removed from the collection, either by `Remove` or by
776
+ # not being included in the new Replace list can now be
777
+ # released.
778
+ target_collection.orphaned_members.each do |member|
779
+ release_viewmodel(member.direct_viewmodel, association_data, update_context)
780
+ end
781
+
782
+ new_direct_updates
783
+ end
784
+
785
+ def release_viewmodel(viewmodel, association_data, update_context)
786
+ self.released_children << update_context.release_viewmodel(viewmodel, association_data)
787
+ end
788
+
789
+ def clear_association_cache(model, reflection)
790
+ association = model.association(reflection.name)
791
+ if reflection.collection?
792
+ association.target = []
793
+ else
794
+ association.target = nil
795
+ end
796
+ end
797
+
798
+ def blame_reference
799
+ self.viewmodel.blame_reference
800
+ end
801
+
802
+ def debug(msg)
803
+ return unless ViewModel::Config.debug_deserialization
804
+
805
+ ::ActiveRecord::Base.logger.try do |logger|
806
+ logger.debug(msg)
807
+ end
808
+ end
809
+ end
810
+ end