iknow_view_models 2.9.0 → 3.0.0

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/iknow_view_models.gemspec +1 -1
  3. data/lib/iknow_view_models/version.rb +1 -1
  4. data/lib/view_model/active_record/association_data.rb +206 -92
  5. data/lib/view_model/active_record/association_manipulation.rb +22 -12
  6. data/lib/view_model/active_record/cache/cacheable_view.rb +3 -13
  7. data/lib/view_model/active_record/cache.rb +2 -2
  8. data/lib/view_model/active_record/cloner.rb +11 -11
  9. data/lib/view_model/active_record/controller.rb +0 -2
  10. data/lib/view_model/active_record/update_context.rb +21 -3
  11. data/lib/view_model/active_record/update_data.rb +43 -45
  12. data/lib/view_model/active_record/update_operation.rb +265 -153
  13. data/lib/view_model/active_record/visitor.rb +9 -6
  14. data/lib/view_model/active_record.rb +94 -74
  15. data/lib/view_model/after_transaction_runner.rb +3 -18
  16. data/lib/view_model/changes.rb +24 -16
  17. data/lib/view_model/config.rb +6 -2
  18. data/lib/view_model/deserialization_error.rb +31 -0
  19. data/lib/view_model/deserialize_context.rb +2 -6
  20. data/lib/view_model/error_view.rb +6 -5
  21. data/lib/view_model/record/attribute_data.rb +11 -6
  22. data/lib/view_model/record.rb +44 -24
  23. data/lib/view_model/serialize_context.rb +2 -63
  24. data/lib/view_model.rb +17 -8
  25. data/shell.nix +1 -1
  26. data/test/helpers/arvm_test_utilities.rb +6 -0
  27. data/test/helpers/controller_test_helpers.rb +5 -3
  28. data/test/helpers/viewmodel_spec_helpers.rb +63 -52
  29. data/test/unit/view_model/access_control_test.rb +88 -37
  30. data/test/unit/view_model/active_record/belongs_to_test.rb +110 -178
  31. data/test/unit/view_model/active_record/cache_test.rb +3 -2
  32. data/test/unit/view_model/active_record/cloner_test.rb +1 -1
  33. data/test/unit/view_model/active_record/controller_test.rb +12 -20
  34. data/test/unit/view_model/active_record/has_many_test.rb +540 -316
  35. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +12 -15
  36. data/test/unit/view_model/active_record/has_many_through_test.rb +15 -58
  37. data/test/unit/view_model/active_record/has_one_test.rb +288 -135
  38. data/test/unit/view_model/active_record/poly_test.rb +0 -1
  39. data/test/unit/view_model/active_record/shared_test.rb +21 -39
  40. data/test/unit/view_model/active_record/version_test.rb +3 -2
  41. data/test/unit/view_model/active_record_test.rb +5 -63
  42. data/test/unit/view_model/callbacks_test.rb +1 -0
  43. data/test/unit/view_model/record_test.rb +0 -32
  44. data/test/unit/view_model/traversal_context_test.rb +13 -12
  45. metadata +5 -8
  46. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +0 -58
@@ -40,10 +40,6 @@ class ViewModel::ActiveRecord
40
40
  end
41
41
  end
42
42
 
43
- def deferred?
44
- viewmodel.nil?
45
- end
46
-
47
43
  def built?
48
44
  @built
49
45
  end
@@ -112,9 +108,8 @@ class ViewModel::ActiveRecord
112
108
  if child_operation
113
109
  child_ctx = viewmodel.context_for_child(association_data.association_name, context: deserialize_context)
114
110
  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
111
+ propagate_tree_changes(association_data, child_viewmodel.previous_changes)
112
+
118
113
  child_viewmodel.model
119
114
  end
120
115
  association.writer(new_target)
@@ -153,9 +148,8 @@ class ViewModel::ActiveRecord
153
148
  if child_operation
154
149
  ViewModel::Utils.map_one_or_many(child_operation) do |op|
155
150
  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
151
+ propagate_tree_changes(association_data, child_viewmodel.previous_changes)
152
+
159
153
  child_viewmodel.model
160
154
  end
161
155
  end
@@ -183,7 +177,11 @@ class ViewModel::ActiveRecord
183
177
  child_hook_control.record_changes(changes)
184
178
  end
185
179
 
186
- viewmodel.children_changed! unless child_association_data.shared?
180
+ if child_association_data.nested?
181
+ viewmodel.nested_children_changed!
182
+ elsif child_association_data.owned?
183
+ viewmodel.referenced_children_changed!
184
+ end
187
185
  end
188
186
  debug "<- #{debug_name}: Finished checking released children permissions"
189
187
  end
@@ -208,9 +206,18 @@ class ViewModel::ActiveRecord
208
206
  raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex, blame_reference)
209
207
  end
210
208
 
209
+ def propagate_tree_changes(association_data, child_changes)
210
+ if association_data.nested?
211
+ viewmodel.nested_children_changed! if child_changes.changed_nested_tree?
212
+ viewmodel.referenced_children_changed! if child_changes.changed_referenced_children?
213
+ elsif association_data.owned?
214
+ viewmodel.referenced_children_changed! if child_changes.changed_owned_tree?
215
+ end
216
+ end
217
+
211
218
  # Recursively builds UpdateOperations for the associations in our UpdateData
212
219
  def build!(update_context)
213
- raise ViewModel::DeserializationError::Internal.new("Internal error: UpdateOperation cannot build a deferred update") if deferred?
220
+ raise ViewModel::DeserializationError::Internal.new("Internal error: UpdateOperation cannot build a deferred update") if viewmodel.nil?
214
221
  return self if built?
215
222
 
216
223
  update_data.associations.each do |association_name, association_update_data|
@@ -231,8 +238,10 @@ class ViewModel::ActiveRecord
231
238
  update =
232
239
  if association_data.through?
233
240
  build_updates_for_collection_referenced_association(association_data, reference_string, update_context)
241
+ elsif association_data.collection?
242
+ build_updates_for_collection_association(association_data, reference_string, update_context)
234
243
  else
235
- build_update_for_single_referenced_association(association_data, reference_string, update_context)
244
+ build_update_for_single_association(association_data, reference_string, update_context)
236
245
  end
237
246
 
238
247
  add_update(association_data, update)
@@ -254,38 +263,6 @@ class ViewModel::ActiveRecord
254
263
 
255
264
  private
256
265
 
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
266
  # Resolve or construct viewmodels for incoming update data. Where a child
290
267
  # hash references an existing model not currently attached to this parent,
291
268
  # it must be found before recursing into that child. If the model is
@@ -321,6 +298,60 @@ class ViewModel::ActiveRecord
321
298
  end
322
299
  end
323
300
 
301
+ def resolve_referenced_viewmodels(association_data, update_datas, previous_child_viewmodels, update_context)
302
+ previous_child_viewmodels = Array.wrap(previous_child_viewmodels).index_by(&:to_reference)
303
+
304
+ ViewModel::Utils.map_one_or_many(update_datas) do |update_data|
305
+ if update_data.is_a?(UpdateData)
306
+ # Dummy child update data for an unmodified previous child of a
307
+ # functional update; create it an empty update operation.
308
+ viewmodel = previous_child_viewmodels.fetch(update_data.viewmodel_reference)
309
+ update = update_context.new_update(viewmodel, update_data)
310
+ next [update, viewmodel]
311
+ end
312
+
313
+ reference_string = update_data
314
+ child_update = update_context.resolve_reference(reference_string, blame_reference)
315
+ child_viewmodel = child_update.viewmodel
316
+
317
+ unless association_data.accepts?(child_viewmodel.class)
318
+ raise ViewModel::DeserializationError::InvalidAssociationType.new(
319
+ association_data.association_name.to_s,
320
+ child_viewmodel.class.view_name,
321
+ blame_reference)
322
+ end
323
+
324
+ child_ref = child_viewmodel.to_reference
325
+
326
+ # The case of two potential owners trying to claim a new referenced
327
+ # child is covered by set_reference_update_parent.
328
+ claimed = !association_data.owned? ||
329
+ child_update.update_data.new? ||
330
+ previous_child_viewmodels.has_key?(child_ref) ||
331
+ update_context.try_take_released_viewmodel(child_ref).present?
332
+
333
+ if claimed
334
+ [child_update, child_viewmodel]
335
+ else
336
+ # Return the reference to indicate a deferred update
337
+ [child_update, child_ref]
338
+ end
339
+ end
340
+ end
341
+
342
+ def set_reference_update_parent(association_data, update, parent_data)
343
+ if update.reparent_to
344
+ # Another parent has already tried to take this (probably new)
345
+ # owned referenced view. It can only be claimed by one of them.
346
+ other_parent = update.reparent_to.viewmodel.to_reference
347
+ raise ViewModel::DeserializationError::DuplicateOwner.new(
348
+ association_data.association_name,
349
+ [blame_reference, other_parent])
350
+ end
351
+
352
+ update.reparent_to = parent_data
353
+ end
354
+
324
355
  def build_update_for_single_association(association_data, association_update_data, update_context)
325
356
  model = self.viewmodel.model
326
357
 
@@ -329,25 +360,63 @@ class ViewModel::ActiveRecord
329
360
  vm_class.new(previous_child_model)
330
361
  end
331
362
 
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)
363
+ if association_data.pointer_location == :remote
364
+ if previous_child_viewmodel.present?
365
+ # Clear the cached association so that AR's save behaviour doesn't
366
+ # conflict with our explicit parent updates. If we still have a child
367
+ # after the update, we'll either call `Association#writer` or manually
368
+ # fix the target cache after recursing in run!(). If we don't, we promise
369
+ # that the child will no longer be attached in the database, so the new
370
+ # cached data of nil will be correct.
371
+ clear_association_cache(model, association_data.direct_reflection)
372
+ end
373
+
374
+ reparent_data =
375
+ ParentData.new(association_data.direct_reflection.inverse_of, viewmodel)
340
376
  end
341
377
 
342
- child_viewmodel =
343
- if association_update_data.present?
344
- resolve_child_viewmodels(association_data, association_update_data, previous_child_viewmodel, update_context)
378
+ if association_update_data.present?
379
+ if association_data.referenced?
380
+ # resolve reference string
381
+ reference_string = association_update_data
382
+ child_update, child_viewmodel = resolve_referenced_viewmodels(association_data, reference_string,
383
+ previous_child_viewmodel, update_context)
384
+
385
+ if reparent_data
386
+ set_reference_update_parent(association_data, child_update, reparent_data)
387
+ end
388
+
389
+ if child_viewmodel.is_a?(ViewModel::Reference)
390
+ update_context.defer_update(child_viewmodel, child_update)
391
+ end
392
+ else
393
+ # Resolve direct children
394
+ child_viewmodel =
395
+ resolve_child_viewmodels(association_data, association_update_data, previous_child_viewmodel, update_context)
396
+
397
+ child_update =
398
+ if child_viewmodel.is_a?(ViewModel::Reference)
399
+ update_context.new_deferred_update(child_viewmodel, association_update_data, reparent_to: reparent_data)
400
+ else
401
+ update_context.new_update(child_viewmodel, association_update_data, reparent_to: reparent_data)
402
+ end
345
403
  end
346
404
 
405
+ # Build the update if we've claimed it
406
+ unless child_viewmodel.is_a?(ViewModel::Reference)
407
+ child_update.build!(update_context)
408
+ end
409
+ else
410
+ child_update = nil
411
+ child_viewmodel = nil
412
+ end
413
+
414
+ # Handle changes
347
415
  if previous_child_viewmodel != child_viewmodel
348
416
  viewmodel.association_changed!(association_data.association_name)
349
- # free previous child if present
350
- if previous_child_viewmodel.present?
417
+
418
+ # free previous child if present and owned
419
+ if previous_child_viewmodel.present? && association_data.owned?
351
420
  if association_data.pointer_location == :local
352
421
  # When we free a child that's pointed to from its old parent, we need to
353
422
  # clear the cached association to that old parent. If we don't do this,
@@ -358,12 +427,7 @@ class ViewModel::ActiveRecord
358
427
  # model.association(...).inverse_reflection_for(previous_child_model), but
359
428
  # that's private.
360
429
 
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
430
+ inverse_reflection = association_data.direct_reflection_inverse(previous_child_viewmodel.model.class)
367
431
 
368
432
  if inverse_reflection.present?
369
433
  clear_association_cache(previous_child_viewmodel.model, inverse_reflection)
@@ -374,25 +438,7 @@ class ViewModel::ActiveRecord
374
438
  end
375
439
  end
376
440
 
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
441
+ child_update
396
442
  end
397
443
 
398
444
  def build_updates_for_collection_association(association_data, association_update, update_context)
@@ -402,11 +448,13 @@ class ViewModel::ActiveRecord
402
448
  parent_data = ParentData.new(association_data.direct_reflection.inverse_of, viewmodel)
403
449
 
404
450
  # load children already attached to this model
405
- child_viewmodel_class = association_data.viewmodel_class
451
+ child_viewmodel_class = association_data.viewmodel_class
452
+
406
453
  previous_child_viewmodels =
407
454
  model.public_send(association_data.direct_reflection.name).map do |child_model|
408
455
  child_viewmodel_class.new(child_model)
409
456
  end
457
+
410
458
  if child_viewmodel_class._list_member?
411
459
  previous_child_viewmodels.sort_by!(&:_list_attribute)
412
460
  end
@@ -421,114 +469,178 @@ class ViewModel::ActiveRecord
421
469
  clear_association_cache(model, association_data.direct_reflection)
422
470
  end
423
471
 
424
- child_datas =
425
- case association_update
426
- when OwnedCollectionUpdate::Replace
427
- association_update.update_datas
472
+ # Update contents are either UpdateData in the case of a nested
473
+ # association, or reference strings in the case of a reference association.
474
+ # The former are resolved with resolve_child_viewmodels, the latter with
475
+ # resolve_referenced_viewmodels.
476
+ resolve_child_data_reference = ->(child_data) do
477
+ case child_data
478
+ when UpdateData
479
+ child_data.viewmodel_reference if child_data.id
480
+ when String
481
+ update_context.resolve_reference(child_data, nil).viewmodel_reference
482
+ else
483
+ raise ViewModel::DeserializationError::Internal.new(
484
+ "Unexpected child data type in collection update: #{child_data.class.name}")
485
+ end
486
+ end
428
487
 
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
488
+ case association_update
489
+ when AbstractCollectionUpdate::Replace
490
+ child_datas = association_update.contents
434
491
 
435
- association_update.check_for_duplicates!(update_context, self.viewmodel.blame_reference)
492
+ when AbstractCollectionUpdate::Functional
493
+ # A fupdate isn't permitted to edit the same model twice.
494
+ association_update.check_for_duplicates!(update_context, blame_reference)
436
495
 
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) }
496
+ # Construct empty updates for previous children
497
+ child_datas =
498
+ previous_child_viewmodels.map do |previous_child_viewmodel|
499
+ UpdateData.empty_update_for(previous_child_viewmodel)
500
+ end
443
501
 
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
502
+ # Insert or replace with either real UpdateData or reference strings
503
+ association_update.actions.each do |fupdate|
504
+ case fupdate
505
+ when FunctionalUpdate::Append
506
+ # If we're referring to existing members, ensure that they're removed before we append/insert
507
+ existing_refs = fupdate.contents
508
+ .map(&resolve_child_data_reference)
509
+ .to_set
510
+
511
+ child_datas.reject! do |child_data|
512
+ child_ref = resolve_child_data_reference.(child_data)
513
+ child_ref && existing_refs.include?(child_ref)
514
+ end
450
515
 
451
- index += 1 if fupdate.after
452
- child_datas.insert(index, *fupdate.contents)
516
+ if fupdate.before || fupdate.after
517
+ rel_ref = fupdate.before || fupdate.after
453
518
 
454
- else
455
- child_datas.concat(fupdate.contents)
519
+ # Find the relative insert location. This might be an empty
520
+ # UpdateData from a previous child or an already-fupdated
521
+ # reference string.
522
+ index = child_datas.find_index do |child_data|
523
+ rel_ref == resolve_child_data_reference.(child_data)
524
+ end
456
525
 
526
+ unless index
527
+ raise ViewModel::DeserializationError::AssociatedNotFound.new(
528
+ association_data.association_name.to_s, rel_ref, blame_reference)
457
529
  end
458
530
 
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) }
531
+ index += 1 if fupdate.after
532
+ child_datas.insert(index, *fupdate.contents)
533
+
534
+ else
535
+ child_datas.concat(fupdate.contents)
536
+ end
462
537
 
463
- when FunctionalUpdate::Update
464
- # Already guaranteed that each ref has a single data attached
465
- new_datas = fupdate.contents.index_by(&:viewmodel_reference)
538
+ when FunctionalUpdate::Remove
539
+ removed_refs = fupdate.removed_vm_refs.to_set
540
+ child_datas.reject! do |child_data|
541
+ child_ref = resolve_child_data_reference.(child_data)
542
+ removed_refs.include?(child_ref)
543
+ end
466
544
 
467
- child_datas = child_datas.map do |child_data|
468
- ref = child_data.viewmodel_reference
469
- new_datas.delete(ref) { child_data }
470
- end
545
+ when FunctionalUpdate::Update
546
+ # Already guaranteed that each ref has a single existing child attached
547
+ new_child_datas = fupdate.contents.index_by(&resolve_child_data_reference)
471
548
 
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)
549
+ # Replace matched child_datas with the update contents.
550
+ child_datas.map! do |child_data|
551
+ child_ref = resolve_child_data_reference.(child_data)
552
+ new_child_datas.delete(child_ref) { child_data }
481
553
  end
554
+
555
+ # Assertion that all values in the update were found in child_datas
556
+ unless new_child_datas.empty?
557
+ raise ViewModel::DeserializationError::AssociatedNotFound.new(
558
+ association_data.association_name.to_s, new_child_datas.keys, blame_reference)
559
+ end
560
+ else
561
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
562
+ "Unknown functional update type: '#{fupdate.type}'",
563
+ blame_reference)
482
564
  end
565
+ end
566
+ end
567
+
568
+ if association_data.referenced?
569
+ # child_datas are either pre-resolved UpdateData (for non-fupdated
570
+ # existing members) or reference strings. Resolve into pairs of
571
+ # [UpdateOperation, ViewModel] if we can create or claim the
572
+ # UpdateOperation or [UpdateOperation, ViewModelReference] otherwise.
573
+ resolved_children =
574
+ resolve_referenced_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context)
483
575
 
484
- child_datas
576
+ resolved_children.each do |child_update, child_viewmodel|
577
+ set_reference_update_parent(association_data, child_update, parent_data)
578
+
579
+ if child_viewmodel.is_a?(ViewModel::Reference)
580
+ update_context.defer_update(child_viewmodel, child_update)
581
+ end
485
582
  end
486
583
 
487
- child_viewmodels = resolve_child_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context)
584
+ else
585
+ # child datas are all UpdateData
586
+ child_viewmodels = resolve_child_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context)
488
587
 
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)
588
+ resolved_children = child_datas.zip(child_viewmodels).map do |child_data, child_viewmodel|
589
+ child_update =
590
+ if child_viewmodel.is_a?(ViewModel::Reference)
591
+ update_context.new_deferred_update(child_viewmodel, child_data, reparent_to: parent_data)
592
+ else
593
+ update_context.new_update(child_viewmodel, child_data, reparent_to: parent_data)
594
+ end
595
+
596
+ [child_update, child_viewmodel]
496
597
  end
497
598
  end
498
599
 
499
600
  # 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)
601
+ # positions (i.e. return nil) for unresolved references: they'll always
602
+ # need to be updated anyway since their parent pointer will change.
603
+ new_positions = Array.new(resolved_children.length)
503
604
 
504
605
  if association_data.viewmodel_class._list_member?
505
606
  set_position = ->(index, pos) { new_positions[index] = pos }
607
+
506
608
  get_previous_position = ->(index) do
507
- vm = child_viewmodels[index]
609
+ vm = resolved_children[index][1]
508
610
  vm._list_attribute unless vm.is_a?(ViewModel::Reference)
509
611
  end
510
612
 
511
613
  ActsAsManualList.update_positions(
512
- (0...child_viewmodels.size).to_a, # indexes
614
+ (0...resolved_children.size).to_a, # indexes
513
615
  position_getter: get_previous_position,
514
616
  position_setter: set_position)
515
617
  end
516
618
 
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)
619
+ resolved_children.zip(new_positions).each do |(child_update, child_viewmodel), new_position|
620
+ child_update.reposition_to = new_position
621
+
622
+ # Recurse into building child updates that we've claimed
623
+ unless child_viewmodel.is_a?(ViewModel::Reference)
624
+ child_update.build!(update_context)
625
+ end
626
+ end
627
+
628
+ child_updates, child_viewmodels = resolved_children.transpose.presence || [[], []]
629
+
630
+ # if the new children differ, including in order, mark that this
631
+ # association has changed and release any no-longer-attached children
632
+ if child_viewmodels != previous_child_viewmodels
633
+ viewmodel.association_changed!(association_data.association_name)
634
+
635
+ released_child_viewmodels = previous_child_viewmodels - child_viewmodels
636
+ released_child_viewmodels.each do |vm|
637
+ release_viewmodel(vm, association_data, update_context)
525
638
  end
526
639
  end
527
640
 
528
641
  child_updates
529
642
  end
530
643
 
531
-
532
644
  class ReferencedCollectionMember
533
645
  attr_reader :indirect_viewmodel_reference, :direct_viewmodel
534
646
  attr_accessor :ref_string, :position
@@ -694,7 +806,7 @@ class ViewModel::ActiveRecord
694
806
  target_collection = MutableReferencedCollection.new(
695
807
  association_data, update_context, previous_members, blame_reference)
696
808
 
697
- # All updates to shared collections produce a complete target list of
809
+ # All updates to referenced collections produce a complete target list of
698
810
  # ReferencedCollectionMembers including a ViewModel::Reference to the
699
811
  # indirect child, and an existing (from previous) or new ViewModel for the
700
812
  # direct child.
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ViewModel::ActiveRecord::Visitor
4
- attr_reader :visit_shared, :for_edit
4
+ attr_reader :visit_referenced, :visit_shared, :for_edit
5
5
 
6
- def initialize(visit_shared: true, for_edit: false)
7
- @visit_shared = visit_shared
8
- @for_edit = for_edit
6
+ def initialize(visit_referenced: true, visit_shared: true, for_edit: false)
7
+ @visit_referenced = visit_referenced
8
+ @visit_shared = visit_shared
9
+ @for_edit = for_edit
9
10
  end
10
11
 
11
12
  def visit(view, context: nil)
@@ -29,8 +30,10 @@ class ViewModel::ActiveRecord::Visitor
29
30
  # visit the underlying viewmodel for each association, ignoring any
30
31
  # customization
31
32
  view.class._members.each do |name, member_data|
32
- next unless member_data.is_a?(ViewModel::ActiveRecord::AssociationData)
33
- next if member_data.shared? && !visit_shared
33
+ next unless member_data.association?
34
+ next if member_data.referenced? && !visit_referenced
35
+ next if !member_data.owned? && !visit_shared
36
+
34
37
  children = Array.wrap(view._read_association(name))
35
38
  children.each do |child|
36
39
  if context