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