iknow_view_models 3.4.1 → 3.5.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.
- checksums.yaml +4 -4
- data/iknow_view_models.gemspec +2 -2
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model.rb +12 -0
- data/lib/view_model/active_record.rb +46 -5
- data/lib/view_model/active_record/association_data.rb +3 -1
- data/lib/view_model/active_record/association_manipulation.rb +186 -49
- data/lib/view_model/active_record/cloner.rb +13 -12
- data/lib/view_model/active_record/collection_nested_controller.rb +5 -2
- data/lib/view_model/active_record/controller_base.rb +45 -6
- data/lib/view_model/active_record/nested_controller_base.rb +126 -14
- data/lib/view_model/active_record/singular_nested_controller.rb +5 -2
- data/lib/view_model/callbacks.rb +1 -1
- data/lib/view_model/controller.rb +24 -2
- data/lib/view_model/record.rb +1 -1
- data/lib/view_model/schemas.rb +44 -0
- data/test/helpers/arvm_test_utilities.rb +65 -0
- data/test/helpers/controller_test_helpers.rb +65 -34
- data/test/unit/view_model/active_record/controller_nested_test.rb +599 -0
- data/test/unit/view_model/active_record/controller_test.rb +6 -362
- data/test/unit/view_model/active_record/has_many_test.rb +24 -7
- data/test/unit/view_model/active_record/has_many_through_test.rb +28 -12
- data/test/unit/view_model/traversal_context_test.rb +15 -1
- metadata +7 -5
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 0cb094f674e990ef9a67bbcb7b0ee260065356cdb6a56ee6e1b19582c320e96c
         | 
| 4 | 
            +
              data.tar.gz: 5bb5c175e7fd30442794c2ff87687eb5dec132fa7da0d0128a1b012bd50131d6
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: f196d4af404bcb91d88519e04ed2afcd7d73c590af4838e16b32b4f73171d20500a4a092cb76d693d7a683bf4ca6ad337d5e79a10a5233870f9c244acfffe969
         | 
| 7 | 
            +
              data.tar.gz: bfdd726357dc50f72732498d112274301089ab82719e46d7c5d7bfc5414a6bcf3dc12459bb9dee6ffa9c174dea1c81e2a0499649d9bbe1d1618702bfad18ef04
         | 
    
        data/iknow_view_models.gemspec
    CHANGED
    
    | @@ -8,7 +8,7 @@ Gem::Specification.new do |spec| | |
| 8 8 | 
             
              spec.name          = 'iknow_view_models'
         | 
| 9 9 | 
             
              spec.version       = IknowViewModels::VERSION
         | 
| 10 10 | 
             
              spec.authors       = ['iKnow Team']
         | 
| 11 | 
            -
              spec.email         = [' | 
| 11 | 
            +
              spec.email         = ['systems@iknow.jp']
         | 
| 12 12 | 
             
              spec.summary       = 'ViewModels provide a means of encapsulating a collection of related data and specifying its JSON serialization.'
         | 
| 13 13 | 
             
              spec.description   = ''
         | 
| 14 14 | 
             
              spec.homepage      = 'https://github.com/iknow/cerego_view_models'
         | 
| @@ -25,7 +25,7 @@ Gem::Specification.new do |spec| | |
| 25 25 | 
             
              spec.add_dependency 'activesupport', '>= 5.0'
         | 
| 26 26 |  | 
| 27 27 | 
             
              spec.add_dependency 'acts_as_manual_list'
         | 
| 28 | 
            -
              spec.add_dependency 'deep_preloader', '>= 1.0. | 
| 28 | 
            +
              spec.add_dependency 'deep_preloader', '>= 1.0.2'
         | 
| 29 29 | 
             
              spec.add_dependency 'iknow_cache'
         | 
| 30 30 | 
             
              spec.add_dependency 'iknow_params', '~> 2.2.0'
         | 
| 31 31 | 
             
              spec.add_dependency 'keyword_builder'
         | 
    
        data/lib/view_model.rb
    CHANGED
    
    | @@ -12,6 +12,10 @@ class ViewModel | |
| 12 12 | 
             
              VERSION_ATTRIBUTE   = '_version'
         | 
| 13 13 | 
             
              NEW_ATTRIBUTE       = '_new'
         | 
| 14 14 |  | 
| 15 | 
            +
              BULK_UPDATE_TYPE       = '_bulk_update'
         | 
| 16 | 
            +
              BULK_UPDATES_ATTRIBUTE = 'updates'
         | 
| 17 | 
            +
              BULK_UPDATE_ATTRIBUTE  = 'update'
         | 
| 18 | 
            +
             | 
| 15 19 | 
             
              # Migrations leave a metadata attribute _migrated on any views that they
         | 
| 16 20 | 
             
              # alter. This attribute is accessible as metadata when deserializing migrated
         | 
| 17 21 | 
             
              # input, and is included in the output serialization sent to clients.
         | 
| @@ -27,6 +31,14 @@ class ViewModel | |
| 27 31 | 
             
                attr_reader   :view_aliases
         | 
| 28 32 | 
             
                attr_writer   :view_name
         | 
| 29 33 |  | 
| 34 | 
            +
                # Boolean to indicate if the viewmodel is synthetic. Synthetic
         | 
| 35 | 
            +
                # viewmodels are nearly-invisible glue. They're full viewmodels,
         | 
| 36 | 
            +
                # but do not participate in hooks or registration. For example, a
         | 
| 37 | 
            +
                # join table connecting A and B through T has a synthetic
         | 
| 38 | 
            +
                # viewmodel T to represent the join model, but the external
         | 
| 39 | 
            +
                # interface is a relationship of A to a list of Bs.
         | 
| 40 | 
            +
                attr_accessor :synthetic
         | 
| 41 | 
            +
             | 
| 30 42 | 
             
                def inherited(subclass)
         | 
| 31 43 | 
             
                  super
         | 
| 32 44 | 
             
                  subclass.initialize_as_viewmodel
         | 
| @@ -35,14 +35,9 @@ class ViewModel::ActiveRecord < ViewModel::Record | |
| 35 35 |  | 
| 36 36 | 
             
              class << self
         | 
| 37 37 | 
             
                attr_reader   :_list_attribute_name
         | 
| 38 | 
            -
                attr_accessor :synthetic
         | 
| 39 38 |  | 
| 40 39 | 
             
                delegate :transaction, to: :model_class
         | 
| 41 40 |  | 
| 42 | 
            -
                def should_register?
         | 
| 43 | 
            -
                  super && !synthetic
         | 
| 44 | 
            -
                end
         | 
| 45 | 
            -
             | 
| 46 41 | 
             
                # Specifies that the model backing this viewmodel is a member of an
         | 
| 47 42 | 
             
                # `acts_as_manual_list` collection.
         | 
| 48 43 | 
             
                def acts_as_list(attr = :position)
         | 
| @@ -368,6 +363,52 @@ class ViewModel::ActiveRecord < ViewModel::Record | |
| 368 363 | 
             
                end
         | 
| 369 364 | 
             
              end
         | 
| 370 365 |  | 
| 366 | 
            +
              # Rails 6.1 introduced "previously_new_record?", but this library still
         | 
| 367 | 
            +
              # supports activerecord >= 5.0. This is an approximation.
         | 
| 368 | 
            +
              def self.model_previously_new?(model)
         | 
| 369 | 
            +
                if (id_changes = model.saved_change_to_id)
         | 
| 370 | 
            +
                  old_id, _new_id = id_changes
         | 
| 371 | 
            +
                  return true if old_id.nil?
         | 
| 372 | 
            +
                end
         | 
| 373 | 
            +
                false
         | 
| 374 | 
            +
              end
         | 
| 375 | 
            +
             | 
| 376 | 
            +
              # Helper to return entities that were part of the last deserialization. The
         | 
| 377 | 
            +
              # interface is complex due to the data requirements, and the implementation is
         | 
| 378 | 
            +
              # inefficient.
         | 
| 379 | 
            +
              #
         | 
| 380 | 
            +
              # Intended to be used by replace_associated style methods which may touch very
         | 
| 381 | 
            +
              # large collections that must not be returned fully. Since the collection is
         | 
| 382 | 
            +
              # not being returned, order is also ignored.
         | 
| 383 | 
            +
              def _read_association_touched(association_name, touched_ids:)
         | 
| 384 | 
            +
                association_data = self.class._association_data(association_name)
         | 
| 385 | 
            +
             | 
| 386 | 
            +
                associated = model.public_send(association_data.direct_reflection.name)
         | 
| 387 | 
            +
                return nil if associated.nil?
         | 
| 388 | 
            +
             | 
| 389 | 
            +
                case
         | 
| 390 | 
            +
                when association_data.through?
         | 
| 391 | 
            +
                  # associated here are join-table models; we need to get the far side out
         | 
| 392 | 
            +
                  associated.map do |through_model|
         | 
| 393 | 
            +
                    model = through_model.public_send(association_data.indirect_reflection.name)
         | 
| 394 | 
            +
             | 
| 395 | 
            +
                    next unless self.class.model_previously_new?(through_model) || touched_ids.include?(model.id)
         | 
| 396 | 
            +
             | 
| 397 | 
            +
                    association_data.viewmodel_class_for_model!(model.class).new(model)
         | 
| 398 | 
            +
                  end.reject(&:nil?)
         | 
| 399 | 
            +
                when association_data.collection?
         | 
| 400 | 
            +
                  associated.map do |model|
         | 
| 401 | 
            +
                    next unless self.class.model_previously_new?(model) || touched_ids.include?(model.id)
         | 
| 402 | 
            +
             | 
| 403 | 
            +
                    association_data.viewmodel_class_for_model!(model.class).new(model)
         | 
| 404 | 
            +
                  end.reject(&:nil?)
         | 
| 405 | 
            +
                else
         | 
| 406 | 
            +
                  # singleton always touched by definition
         | 
| 407 | 
            +
                  model = associated
         | 
| 408 | 
            +
                  association_data.viewmodel_class_for_model!(model.class).new(model)
         | 
| 409 | 
            +
                end
         | 
| 410 | 
            +
              end
         | 
| 411 | 
            +
             | 
| 371 412 | 
             
              def _serialize_association(association_name, json, serialize_context:)
         | 
| 372 413 | 
             
                associated = self.public_send(association_name)
         | 
| 373 414 | 
             
                association_data = self.class._association_data(association_name)
         | 
| @@ -127,7 +127,9 @@ class ViewModel::ActiveRecord::AssociationData | |
| 127 127 | 
             
              end
         | 
| 128 128 |  | 
| 129 129 | 
             
              def polymorphic?
         | 
| 130 | 
            -
                 | 
| 130 | 
            +
                # STI polymorphism isn't shown on the association reflection, so in that
         | 
| 131 | 
            +
                # case we have to infer it by having multiple target viewmodel types.
         | 
| 132 | 
            +
                target_reflection.polymorphic? || viewmodel_classes.size > 1
         | 
| 131 133 | 
             
              end
         | 
| 132 134 |  | 
| 133 135 | 
             
              # The side of the immediate association that holds the pointer.
         | 
| @@ -3,6 +3,8 @@ | |
| 3 3 | 
             
            # Mix-in for VM::ActiveRecord providing direct manipulation of
         | 
| 4 4 | 
             
            # directly-associated entities. Avoids loading entire collections.
         | 
| 5 5 | 
             
            module ViewModel::ActiveRecord::AssociationManipulation
         | 
| 6 | 
            +
              extend ActiveSupport::Concern
         | 
| 7 | 
            +
             | 
| 6 8 | 
             
              def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context)
         | 
| 7 9 | 
             
                association_data = self.class._association_data(association_name)
         | 
| 8 10 | 
             
                direct_reflection = association_data.direct_reflection
         | 
| @@ -16,7 +18,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 16 18 | 
             
                  associated_viewmodel = association_data.viewmodel_class
         | 
| 17 19 | 
             
                  direct_viewmodel     = association_data.direct_viewmodel
         | 
| 18 20 | 
             
                else
         | 
| 19 | 
            -
                  raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data. | 
| 21 | 
            +
                  raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?
         | 
| 20 22 |  | 
| 21 23 | 
             
                  associated_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
         | 
| 22 24 | 
             
                  direct_viewmodel     = associated_viewmodel
         | 
| @@ -49,49 +51,63 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 49 51 | 
             
                end
         | 
| 50 52 | 
             
              end
         | 
| 51 53 |  | 
| 52 | 
            -
              # Replace the current member(s) of an association with the provided | 
| 54 | 
            +
              # Replace the current member(s) of an association with the provided
         | 
| 55 | 
            +
              # hash(es).  Only mentioned member(s) will be returned.
         | 
| 56 | 
            +
              #
         | 
| 57 | 
            +
              # This interface deals with associations directly where reasonable,
         | 
| 58 | 
            +
              # with the notable exception of referenced+shared associations. That
         | 
| 59 | 
            +
              # is to say, that owned associations should be presented in the form
         | 
| 60 | 
            +
              # of direct update hashes, regardless of their
         | 
| 61 | 
            +
              # referencing. Reference and shared associations are excluded to
         | 
| 62 | 
            +
              # ensure that the update hash for a shared entity is unique, and
         | 
| 63 | 
            +
              # that edits may only be specified once.
         | 
| 53 64 | 
             
              def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
         | 
| 54 | 
            -
                 | 
| 55 | 
            -
             | 
| 56 | 
            -
             | 
| 57 | 
            -
             | 
| 58 | 
            -
                     | 
| 59 | 
            -
                     | 
| 60 | 
            -
             | 
| 61 | 
            -
             | 
| 62 | 
            -
             | 
| 63 | 
            -
             | 
| 64 | 
            -
                      action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
         | 
| 65 | 
            -
                      if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
         | 
| 66 | 
            -
                        # Remove actions are always type/id refs; others need to be translated to proper refs
         | 
| 67 | 
            -
                        next
         | 
| 68 | 
            -
                      end
         | 
| 65 | 
            +
                _updated_parent, changed_children =
         | 
| 66 | 
            +
                  self.class.replace_associated_bulk(
         | 
| 67 | 
            +
                    association_name,
         | 
| 68 | 
            +
                    { self.id => update_hash },
         | 
| 69 | 
            +
                    references: references,
         | 
| 70 | 
            +
                    deserialize_context: deserialize_context
         | 
| 71 | 
            +
                  ).first
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                changed_children
         | 
| 74 | 
            +
              end
         | 
| 69 75 |  | 
| 70 | 
            -
             | 
| 71 | 
            -
             | 
| 72 | 
            -
             | 
| 73 | 
            -
             | 
| 74 | 
            -
             | 
| 75 | 
            -
             | 
| 76 | 
            -
             | 
| 77 | 
            -
             | 
| 78 | 
            -
             | 
| 79 | 
            -
             | 
| 80 | 
            -
             | 
| 81 | 
            -
             | 
| 82 | 
            -
                     | 
| 76 | 
            +
              class_methods do
         | 
| 77 | 
            +
                # Replace the current member(s) of an association with the provided
         | 
| 78 | 
            +
                # hash(es) for many viewmodels.  Only mentioned members will be returned.
         | 
| 79 | 
            +
                #
         | 
| 80 | 
            +
                # This is an interim implementation that requires loading the contents of
         | 
| 81 | 
            +
                # all collections into memory and filtering for the mentioned entities,
         | 
| 82 | 
            +
                # even for functional updates.  This is in contrast to append_associated,
         | 
| 83 | 
            +
                # which only operates on the new entities.
         | 
| 84 | 
            +
                def replace_associated_bulk(association_name, updates_by_parent_id, references:, deserialize_context: self.class.new_deserialize_context)
         | 
| 85 | 
            +
                  association_data = _association_data(association_name)
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                  touched_ids = updates_by_parent_id.each_with_object({}) do |(parent_id, update_hash), acc|
         | 
| 88 | 
            +
                    acc[parent_id] =
         | 
| 89 | 
            +
                      mentioned_children(
         | 
| 90 | 
            +
                        update_hash,
         | 
| 91 | 
            +
                        references:       references,
         | 
| 92 | 
            +
                        association_data: association_data,
         | 
| 93 | 
            +
                      ).to_set
         | 
| 83 94 | 
             
                  end
         | 
| 84 | 
            -
                end
         | 
| 85 95 |  | 
| 86 | 
            -
             | 
| 87 | 
            -
             | 
| 88 | 
            -
             | 
| 89 | 
            -
             | 
| 90 | 
            -
             | 
| 96 | 
            +
                  root_update_hashes = updates_by_parent_id.map do |parent_id, update_hash|
         | 
| 97 | 
            +
                    {
         | 
| 98 | 
            +
                      ViewModel::ID_ATTRIBUTE   => parent_id,
         | 
| 99 | 
            +
                      ViewModel::TYPE_ATTRIBUTE => view_name,
         | 
| 100 | 
            +
                      association_name.to_s     => update_hash,
         | 
| 101 | 
            +
                    }
         | 
| 102 | 
            +
                  end
         | 
| 91 103 |  | 
| 92 | 
            -
             | 
| 104 | 
            +
                  root_update_viewmodels = deserialize_from_view(
         | 
| 105 | 
            +
                    root_update_hashes, references: references, deserialize_context: deserialize_context)
         | 
| 93 106 |  | 
| 94 | 
            -
             | 
| 107 | 
            +
                  root_update_viewmodels.each_with_object({}) do |updated, acc|
         | 
| 108 | 
            +
                    acc[updated] = updated._read_association_touched(association_name, touched_ids: touched_ids.fetch(updated.id))
         | 
| 109 | 
            +
                  end
         | 
| 110 | 
            +
                end
         | 
| 95 111 | 
             
              end
         | 
| 96 112 |  | 
| 97 113 | 
             
              # Create or update members of a associated collection. For an ordered
         | 
| @@ -118,7 +134,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 118 134 | 
             
                        direct_viewmodel_class = association_data.direct_viewmodel
         | 
| 119 135 | 
             
                        root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references)
         | 
| 120 136 | 
             
                      else
         | 
| 121 | 
            -
                        raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data. | 
| 137 | 
            +
                        raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?
         | 
| 122 138 |  | 
| 123 139 | 
             
                        direct_viewmodel_class = association_data.viewmodel_class
         | 
| 124 140 | 
             
                        root_update_data, referenced_update_data = construct_direct_append_updates(association_data, subtree_hashes, references)
         | 
| @@ -242,7 +258,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 242 258 | 
             
                      direct_viewmodel = association_data.direct_viewmodel
         | 
| 243 259 | 
             
                      association_scope = association_scope.where(association_data.indirect_reflection.foreign_key => associated_id)
         | 
| 244 260 | 
             
                    else
         | 
| 245 | 
            -
                      raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data. | 
| 261 | 
            +
                      raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?
         | 
| 246 262 |  | 
| 247 263 | 
             
                      # viewmodel type for current association: nil in case of empty polymorphic association
         | 
| 248 264 | 
             
                      direct_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
         | 
| @@ -313,7 +329,10 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 313 329 | 
             
                indirect_update_data, referenced_update_data = ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
         | 
| 314 330 |  | 
| 315 331 | 
             
                # Convert associated update data to references
         | 
| 316 | 
            -
                indirect_references = | 
| 332 | 
            +
                indirect_references =
         | 
| 333 | 
            +
                  self.class.convert_updates_to_references(
         | 
| 334 | 
            +
                    indirect_update_data, key: 'indirect_append')
         | 
| 335 | 
            +
             | 
| 317 336 | 
             
                referenced_update_data.merge!(indirect_references)
         | 
| 318 337 |  | 
| 319 338 | 
             
                # Find any existing models for the direct association: need to re-use any
         | 
| @@ -352,12 +371,6 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 352 371 | 
             
                return direct_update_data, referenced_update_data
         | 
| 353 372 | 
             
              end
         | 
| 354 373 |  | 
| 355 | 
            -
              def convert_updates_to_references(indirect_update_data, key:)
         | 
| 356 | 
            -
                indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
         | 
| 357 | 
            -
                  indirect_references["__#{key}_ref_#{i}"] = update
         | 
| 358 | 
            -
                end
         | 
| 359 | 
            -
              end
         | 
| 360 | 
            -
             | 
| 361 374 | 
             
              # TODO: this functionality could reasonably be extracted into `acts_as_manual_list`.
         | 
| 362 375 | 
             
              def select_append_positions(association_data, position_attr, append_count, before:, after:)
         | 
| 363 376 | 
             
                direct_reflection = association_data.direct_reflection
         | 
| @@ -395,10 +408,134 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 395 408 | 
             
              def check_association_type!(association_data, type)
         | 
| 396 409 | 
             
                if type && !association_data.accepts?(type)
         | 
| 397 410 | 
             
                  raise ViewModel::SerializationError.new(
         | 
| 398 | 
            -
                          "Type error: association '#{ | 
| 411 | 
            +
                          "Type error: association '#{association_data.association_name}' can't refer to viewmodel #{type.view_name}")
         | 
| 399 412 | 
             
                elsif association_data.polymorphic? && !type
         | 
| 400 413 | 
             
                  raise ViewModel::SerializationError.new(
         | 
| 401 | 
            -
                          "Need to specify target viewmodel type for polymorphic association '#{ | 
| 414 | 
            +
                          "Need to specify target viewmodel type for polymorphic association '#{association_data.association_name}'")
         | 
| 415 | 
            +
                end
         | 
| 416 | 
            +
              end
         | 
| 417 | 
            +
             | 
| 418 | 
            +
              class_methods do
         | 
| 419 | 
            +
                def convert_updates_to_references(indirect_update_data, key:)
         | 
| 420 | 
            +
                  indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
         | 
| 421 | 
            +
                    indirect_references["__#{key}_ref_#{i}"] = update
         | 
| 422 | 
            +
                  end
         | 
| 423 | 
            +
                end
         | 
| 424 | 
            +
             | 
| 425 | 
            +
                def add_reference_indirection(update_hash, association_data:, references:, key:)
         | 
| 426 | 
            +
                  raise ArgumentError.new('Not a referenced association') unless association_data.referenced?
         | 
| 427 | 
            +
             | 
| 428 | 
            +
                  is_fupdate =
         | 
| 429 | 
            +
                    association_data.collection? &&
         | 
| 430 | 
            +
                      update_hash.is_a?(Hash) &&
         | 
| 431 | 
            +
                      update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
         | 
| 432 | 
            +
             | 
| 433 | 
            +
                  if is_fupdate
         | 
| 434 | 
            +
                    update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
         | 
| 435 | 
            +
                      action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
         | 
| 436 | 
            +
                      if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
         | 
| 437 | 
            +
                        # Remove actions are always type/id refs; others need to be translated to proper refs
         | 
| 438 | 
            +
                        next
         | 
| 439 | 
            +
                      end
         | 
| 440 | 
            +
             | 
| 441 | 
            +
                      association_references = convert_updates_to_references(
         | 
| 442 | 
            +
                        action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
         | 
| 443 | 
            +
                        key: "#{key}_#{action_type_name}_#{i}")
         | 
| 444 | 
            +
                      references.merge!(association_references)
         | 
| 445 | 
            +
                      action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
         | 
| 446 | 
            +
                        association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
         | 
| 447 | 
            +
                    end
         | 
| 448 | 
            +
             | 
| 449 | 
            +
                    update_hash
         | 
| 450 | 
            +
                  else
         | 
| 451 | 
            +
                    ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
         | 
| 452 | 
            +
                      association_references = convert_updates_to_references(sh, key: "#{key}_replace")
         | 
| 453 | 
            +
                      references.merge!(association_references)
         | 
| 454 | 
            +
                      association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
         | 
| 455 | 
            +
                    end
         | 
| 456 | 
            +
                  end
         | 
| 457 | 
            +
                end
         | 
| 458 | 
            +
             | 
| 459 | 
            +
                # Traverses literals and fupdates to return referenced children.
         | 
| 460 | 
            +
                #
         | 
| 461 | 
            +
                # Runs before the main parser, so must be defensive
         | 
| 462 | 
            +
                def each_child_hash(assoc_update, association_data:)
         | 
| 463 | 
            +
                  return enum_for(__method__, assoc_update, association_data: association_data) unless block_given?
         | 
| 464 | 
            +
             | 
| 465 | 
            +
                  is_fupdate =
         | 
| 466 | 
            +
                    association_data.collection? &&
         | 
| 467 | 
            +
                      assoc_update.is_a?(Hash) &&
         | 
| 468 | 
            +
                      assoc_update[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
         | 
| 469 | 
            +
             | 
| 470 | 
            +
                  if is_fupdate
         | 
| 471 | 
            +
                    assoc_update.fetch(ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE).each do |action|
         | 
| 472 | 
            +
                      action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
         | 
| 473 | 
            +
                      if action_type_name.nil?
         | 
| 474 | 
            +
                        raise ViewModel::DeserializationError::InvalidSyntax.new(
         | 
| 475 | 
            +
                          "Functional update missing '#{ViewModel::ActiveRecord::TYPE_ATTRIBUTE}'"
         | 
| 476 | 
            +
                        )
         | 
| 477 | 
            +
                      end
         | 
| 478 | 
            +
             | 
| 479 | 
            +
                      if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
         | 
| 480 | 
            +
                        # Remove actions are not considered children of the action.
         | 
| 481 | 
            +
                        next
         | 
| 482 | 
            +
                      end
         | 
| 483 | 
            +
             | 
| 484 | 
            +
                      values = action.fetch(ViewModel::ActiveRecord::VALUES_ATTRIBUTE) {
         | 
| 485 | 
            +
                        raise ViewModel::DeserializationError::InvalidSyntax.new(
         | 
| 486 | 
            +
                          "Functional update missing '#{ViewModel::ActiveRecord::VALUES_ATTRIBUTE}'"
         | 
| 487 | 
            +
                        )
         | 
| 488 | 
            +
                      }
         | 
| 489 | 
            +
                      values.each { |x| yield x }
         | 
| 490 | 
            +
                    end
         | 
| 491 | 
            +
                  else
         | 
| 492 | 
            +
                    ViewModel::Utils.wrap_one_or_many(assoc_update) do |assoc_updates|
         | 
| 493 | 
            +
                      assoc_updates.each { |u| yield u }
         | 
| 494 | 
            +
                    end
         | 
| 495 | 
            +
                  end
         | 
| 496 | 
            +
                end
         | 
| 497 | 
            +
             | 
| 498 | 
            +
                # Collects the ids of children that are mentioned in the update data.
         | 
| 499 | 
            +
                #
         | 
| 500 | 
            +
                # Runs before the main parser, so must be defensive.
         | 
| 501 | 
            +
                def mentioned_children(assoc_update, references:, association_data:)
         | 
| 502 | 
            +
                  return enum_for(__method__, assoc_update, references: references, association_data: association_data) unless block_given?
         | 
| 503 | 
            +
             | 
| 504 | 
            +
                  each_child_hash(assoc_update, association_data: association_data).each do |child_hash|
         | 
| 505 | 
            +
                    unless child_hash.is_a?(Hash)
         | 
| 506 | 
            +
                      raise ViewModel::DeserializationError::InvalidSyntax.new(
         | 
| 507 | 
            +
                        "Expected update hash, received: #{child_hash}"
         | 
| 508 | 
            +
                      )
         | 
| 509 | 
            +
                    end
         | 
| 510 | 
            +
             | 
| 511 | 
            +
                    if association_data.referenced?
         | 
| 512 | 
            +
                      ref_handle = child_hash.fetch(ViewModel::REFERENCE_ATTRIBUTE) {
         | 
| 513 | 
            +
                        raise ViewModel::DeserializationError::InvalidSyntax.new(
         | 
| 514 | 
            +
                          "Reference hash missing '#{ViewModel::REFERENCE_ATTRIBUTE}'"
         | 
| 515 | 
            +
                        )
         | 
| 516 | 
            +
                      }
         | 
| 517 | 
            +
             | 
| 518 | 
            +
                      ref_update_hash = references.fetch(ref_handle) {
         | 
| 519 | 
            +
                        raise ViewModel::DeserializationError::InvalidSyntax.new(
         | 
| 520 | 
            +
                          "Reference '#{ref_handle}' does not exist in references"
         | 
| 521 | 
            +
                        )
         | 
| 522 | 
            +
                      }
         | 
| 523 | 
            +
             | 
| 524 | 
            +
                      unless ref_update_hash.is_a?(Hash)
         | 
| 525 | 
            +
                        raise ViewModel::DeserializationError::InvalidSyntax.new(
         | 
| 526 | 
            +
                          "Expected update hash, received: #{child_hash}"
         | 
| 527 | 
            +
                        )
         | 
| 528 | 
            +
                      end
         | 
| 529 | 
            +
             | 
| 530 | 
            +
                      if (id = ref_update_hash[ViewModel::ID_ATTRIBUTE])
         | 
| 531 | 
            +
                        yield id
         | 
| 532 | 
            +
                      end
         | 
| 533 | 
            +
                    else
         | 
| 534 | 
            +
                      if (id = child_hash[ViewModel::ID_ATTRIBUTE])
         | 
| 535 | 
            +
                        yield id
         | 
| 536 | 
            +
                      end
         | 
| 537 | 
            +
                    end
         | 
| 538 | 
            +
                  end
         | 
| 402 539 | 
             
                end
         | 
| 403 540 | 
             
              end
         | 
| 404 541 | 
             
            end
         | 
| @@ -52,22 +52,23 @@ class ViewModel::ActiveRecord::Cloner | |
| 52 52 | 
             
                      new_associated = associated
         | 
| 53 53 | 
             
                    else
         | 
| 54 54 | 
             
                      # Otherwise descend into the child, and attach the result
         | 
| 55 | 
            -
                       | 
| 56 | 
            -
                         | 
| 57 | 
            -
             | 
| 58 | 
            -
             | 
| 59 | 
            -
             | 
| 60 | 
            -
             | 
| 61 | 
            -
             | 
| 62 | 
            -
             | 
| 63 | 
            -
             | 
| 64 | 
            -
                         | 
| 55 | 
            +
                      build_vm = ->(model) do
         | 
| 56 | 
            +
                        vm_class =
         | 
| 57 | 
            +
                          if association_data.through?
         | 
| 58 | 
            +
                            # descend into the synthetic join table viewmodel
         | 
| 59 | 
            +
                            association_data.direct_viewmodel
         | 
| 60 | 
            +
                          else
         | 
| 61 | 
            +
                            association_data.viewmodel_class_for_model!(model.class)
         | 
| 62 | 
            +
                          end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                        vm_class.new(model)
         | 
| 65 | 
            +
                      end
         | 
| 65 66 |  | 
| 66 67 | 
             
                      new_associated =
         | 
| 67 68 | 
             
                        if ViewModel::Utils.array_like?(associated)
         | 
| 68 | 
            -
                          associated.map { |m| clone( | 
| 69 | 
            +
                          associated.map { |m| clone(build_vm.(m)) }.compact
         | 
| 69 70 | 
             
                        else
         | 
| 70 | 
            -
                          clone( | 
| 71 | 
            +
                          clone(build_vm.(associated))
         | 
| 71 72 | 
             
                        end
         | 
| 72 73 | 
             
                    end
         | 
| 73 74 | 
             
                  end
         |