lib/view_model/active_record/association_manipulation.rb



# frozen_string_literal: true

# Mix-in for VM::ActiveRecord providing direct manipulation of
# directly-associated entities. Avoids loading entire collections.
module ViewModel::ActiveRecord::AssociationManipulation
  extend ActiveSupport::Concern

  def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context)
    association_data = self.class._association_data(association_name)
    direct_reflection = association_data.direct_reflection

    association = self.model.association(direct_reflection.name)
    association_scope = association.scope

    if association_data.through?
      raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic?

      associated_viewmodel = association_data.viewmodel_class
      direct_viewmodel     = association_data.direct_viewmodel
    else
      raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?

      associated_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
      direct_viewmodel     = associated_viewmodel
    end

    if association_data.ordered?
      association_scope = association_scope.order(direct_viewmodel._list_attribute_name)
    end

    if association_data.through?
      association_scope = associated_viewmodel.model_class
                            .joins(association_data.indirect_reflection.inverse_of.name)
                            .merge(association_scope)
    end

    association_scope = association_scope.merge(scope) if scope

    vms = association_scope.map { |model| associated_viewmodel.new(model) }

    ViewModel.preload_for_serialization(vms) if eager_include

    if association_data.collection?
      vms
    else
      if vms.size > 1
        raise ViewModel::DeserializationError::Internal.new("Internal error: encountered multiple records for single association #{association_name}", self.blame_reference)
      end

      vms.first
    end
  end

  # Replace the current member(s) of an association with the provided
  # hash(es).  Only mentioned member(s) will be returned.
  #
  # This interface deals with associations directly where reasonable,
  # with the notable exception of referenced+shared associations. That
  # is to say, that owned associations should be presented in the form
  # of direct update hashes, regardless of their
  # referencing. Reference and shared associations are excluded to
  # ensure that the update hash for a shared entity is unique, and
  # that edits may only be specified once.
  def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
    _updated_parent, changed_children =
      self.class.replace_associated_bulk(
        association_name,
        { self.id => update_hash },
        references: references,
        deserialize_context: deserialize_context
      ).first

    changed_children
  end

  class_methods do
    # Replace the current member(s) of an association with the provided
    # hash(es) for many viewmodels.  Only mentioned members will be returned.
    #
    # This is an interim implementation that requires loading the contents of
    # all collections into memory and filtering for the mentioned entities,
    # even for functional updates.  This is in contrast to append_associated,
    # which only operates on the new entities.
    def replace_associated_bulk(association_name, updates_by_parent_id, references:, deserialize_context: self.class.new_deserialize_context)
      association_data = _association_data(association_name)

      touched_ids = updates_by_parent_id.each_with_object({}) do |(parent_id, update_hash), acc|
        acc[parent_id] =
          mentioned_children(
            update_hash,
            references:       references,
            association_data: association_data,
          ).to_set
      end

      root_update_hashes = updates_by_parent_id.map do |parent_id, update_hash|
        {
          ViewModel::ID_ATTRIBUTE   => parent_id,
          ViewModel::TYPE_ATTRIBUTE => view_name,
          association_name.to_s     => update_hash,
        }
      end

      root_update_viewmodels = deserialize_from_view(
        root_update_hashes, references: references, deserialize_context: deserialize_context)

      root_update_viewmodels.each_with_object({}) do |updated, acc|
        acc[updated] = updated._read_association_touched(association_name, touched_ids: touched_ids.fetch(updated.id))
      end
    end
  end

  # Create or update members of a associated collection. For an ordered
  # collection, the items are inserted either before `before`, after `after`, or
  # at the end.
  def append_associated(association_name, subtree_hash_or_hashes, references: {}, before: nil, after: nil, deserialize_context: self.class.new_deserialize_context)
    if self.changes.changed?
      raise ArgumentError.new('Invalid call to append_associated on viewmodel with pending changes')
    end

    association_data = self.class._association_data(association_name)
    direct_reflection = association_data.direct_reflection
    raise ArgumentError.new("Cannot append to single association '#{association_name}'") unless association_data.collection?

    ViewModel::Utils.wrap_one_or_many(subtree_hash_or_hashes) do |subtree_hashes|
      model_class.transaction do
        ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
          association_changed!(association_name)
          deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self)

          if association_data.through?
            raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic?

            direct_viewmodel_class = association_data.direct_viewmodel
            root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references)
          else
            raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?

            direct_viewmodel_class = association_data.viewmodel_class
            root_update_data, referenced_update_data = construct_direct_append_updates(association_data, subtree_hashes, references)
          end

          update_context = ViewModel::ActiveRecord::UpdateContext.build!(root_update_data, referenced_update_data, root_type: direct_viewmodel_class)

          # Set new parent
          new_parent = ViewModel::ActiveRecord::UpdateOperation::ParentData.new(direct_reflection.inverse_of, self)
          update_context.root_updates.each { |update| update.reparent_to = new_parent }

          # Set place in list.
          if association_data.ordered?
            new_positions = select_append_positions(association_data,
                                                    direct_viewmodel_class._list_attribute_name,
                                                    update_context.root_updates.count,
                                                    before: before, after: after)

            update_context.root_updates.zip(new_positions).each do |update, new_pos|
              update.reposition_to = new_pos
            end
          end

          # Because append_associated can take from other parents, edit-check previous parents (other than this model)
          unless association_data.through?
            inverse_assoc_name = direct_reflection.inverse_of.name

            previous_parent_ids = Set.new
            update_context.root_updates.each do |update|
              update_model    = update.viewmodel.model
              parent_model_id = update_model.read_attribute(update_model
                                                              .association(inverse_assoc_name)
                                                              .reflection.foreign_key)

              if parent_model_id && parent_model_id != self.id
                previous_parent_ids << parent_model_id
              end
            end

            if previous_parent_ids.present?
              previous_parents = self.class.find(previous_parent_ids.to_a, eager_include: false)

              previous_parents.each do |parent_view|
                ViewModel::Callbacks.wrap_deserialize(parent_view, deserialize_context: deserialize_context) do |pp_hook_control|
                  changes = ViewModel::Changes.new(changed_associations: [association_name])
                  deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, parent_view, changes: changes)
                  pp_hook_control.record_changes(changes)
                end
              end
            end
          end

          child_context = self.context_for_child(association_name, context: deserialize_context)
          updated_viewmodels = update_context.run!(deserialize_context: child_context)

          # Propagate changes and finalize the parent
          updated_viewmodels.each do |child|
            child_changes = child.previous_changes

            if association_data.nested?
              nested_children_changed!     if child_changes.changed_nested_tree?
              referenced_children_changed! if child_changes.changed_referenced_children?
            elsif association_data.owned?
              referenced_children_changed! if child_changes.changed_owned_tree?
            end
          end

          final_changes = self.clear_changes!

          if association_data.through?
            updated_viewmodels.map! do |direct_vm|
              direct_vm._read_association(association_data.indirect_reflection.name)
            end
          end

          # Could happen if hooks attempted to change the parent, which aren't
          # valid since we're only editing children here.
          unless final_changes.contained_to?(associations: [association_name.to_s])
            raise ViewModel::DeserializationError::InvalidParentEdit.new(final_changes, blame_reference)
          end

          deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: final_changes)
          hook_control.record_changes(final_changes)

          updated_viewmodels
        end
      end
    end
  end

  # Removes the association between the models represented by this viewmodel and
  # the provided associated viewmodel. The associated model will be
  # garbage-collected if the assocation is specified with `dependent: :destroy`
  # or `:delete_all`
  def delete_associated(association_name, associated_id, type: nil, deserialize_context: self.class.new_deserialize_context)
    if self.changes.changed?
      raise ArgumentError.new('Invalid call to delete_associated on viewmodel with pending changes')
    end

    association_data = self.class._association_data(association_name)
    direct_reflection = association_data.direct_reflection

    unless association_data.collection?
      raise ArgumentError.new("Cannot remove element from single association '#{association_name}'")
    end

    check_association_type!(association_data, type)
    target_ref = ViewModel::Reference.new(type || association_data.viewmodel_class, associated_id)

    model_class.transaction do
      ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
        association_changed!(association_name)
        deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self)

        association = self.model.association(direct_reflection.name)
        association_scope = association.scope

        if association_data.through?
          raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic?

          direct_viewmodel = association_data.direct_viewmodel
          association_scope = association_scope.where(association_data.indirect_reflection.foreign_key => associated_id)
        else
          raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?

          # viewmodel type for current association: nil in case of empty polymorphic association
          direct_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }

          if association_data.pointer_location == :local
            # If we hold the pointer, we can immediately check if the type and id match.
            if target_ref != ViewModel::Reference.new(direct_viewmodel, model.read_attribute(direct_reflection.foreign_key))
              raise ViewModel::DeserializationError::AssociatedNotFound.new(association_name.to_s, target_ref, blame_reference)
            end
          else
            # otherwise add the target constraint to the association scope
            association_scope = association_scope.where(id: associated_id)
          end
        end

        models = association_scope.to_a

        if models.blank?
          raise ViewModel::DeserializationError::AssociatedNotFound.new(association_name.to_s, target_ref, blame_reference)
        elsif models.size > 1
          raise ViewModel::DeserializationError::Internal.new(
                  "Internal error: encountered multiple records for #{target_ref} in association #{association_name}",
                  blame_reference)
        end

        child_context = self.context_for_child(association_name, context: deserialize_context)
        child_vm = direct_viewmodel.new(models.first)

        ViewModel::Callbacks.wrap_deserialize(child_vm, deserialize_context: child_context) do |child_hook_control|
          changes = ViewModel::Changes.new(deleted: true)
          child_context.run_callback(ViewModel::Callbacks::Hook::OnChange, child_vm, changes: changes)
          child_hook_control.record_changes(changes)

          association.delete(child_vm.model)
        end

        if association_data.nested?
          nested_children_changed!
        elsif association_data.owned?
          referenced_children_changed!
        end

        final_changes = self.clear_changes!

        unless final_changes.contained_to?(associations: [association_name.to_s])
          raise ViewModel::DeserializationError::InvalidParentEdit.new(final_changes, blame_reference)
        end

        deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: final_changes)
        hook_control.record_changes(final_changes)

        child_vm
      end
    end
  end

  private

  def construct_direct_append_updates(_association_data, subtree_hashes, references)
    ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
  end

  def construct_indirect_append_updates(association_data, subtree_hashes, references)
    indirect_reflection = association_data.indirect_reflection
    direct_viewmodel_class = association_data.direct_viewmodel

    # Construct updates for the provided indirectly-associated hashes
    indirect_update_data, referenced_update_data = ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)

    # Convert associated update data to references
    indirect_references =
      self.class.convert_updates_to_references(
        indirect_update_data, key: 'indirect_append')

    referenced_update_data.merge!(indirect_references)

    # Find any existing models for the direct association: need to re-use any
    # existing join-table entries, to maintain single membership of each
    # associate.
    # TODO: this won't handle polymorphic associations! In the case of polymorphism,
    #       need to join on (type, id) pairs instead.
    if association_data.polymorphic?
      raise ArgumentError.new('Internal error: append_association is not yet supported for polymorphic indirect associations')
    end

    existing_indirect_associates = indirect_update_data.map { |upd| upd.id unless upd.new? }.compact

    direct_association_scope = model.association(association_data.direct_reflection.name).scope

    existing_direct_ids = direct_association_scope
                            .where(indirect_reflection.foreign_key => existing_indirect_associates)
                            .pluck(indirect_reflection.foreign_key, :id)
                            .to_h

    direct_update_data = indirect_references.map do |ref_name, update|
      existing_id = existing_direct_ids[update.id] unless update.new?

      metadata = ViewModel::Metadata.new(existing_id,
                                         direct_viewmodel_class.view_name,
                                         direct_viewmodel_class.schema_version,
                                         existing_id.nil?)

      ViewModel::ActiveRecord::UpdateData.new(
        direct_viewmodel_class,
        metadata,
        { indirect_reflection.name.to_s => { ViewModel::REFERENCE_ATTRIBUTE => ref_name } },
        [ref_name])
    end

    return direct_update_data, referenced_update_data
  end

  # TODO: this functionality could reasonably be extracted into `acts_as_manual_list`.
  def select_append_positions(association_data, position_attr, append_count, before:, after:)
    direct_reflection = association_data.direct_reflection
    association_scope = model.association(direct_reflection.name).scope

    search_key =
      if association_data.through?
        association_data.indirect_reflection.foreign_key
      else
        :id
      end

    if (relative_ref = (before || after))
      relative_target = association_scope.where(search_key => relative_ref.model_id).select(:position)
      if before
        end_pos, start_pos = association_scope.where("#{position_attr} <= (?)", relative_target).order("#{position_attr} DESC").limit(2).pluck(:position)
      else
        start_pos, end_pos = association_scope.where("#{position_attr} >= (?)", relative_target).order("#{position_attr} ASC").limit(2).pluck(:position)
      end

      if start_pos.nil? && end_pos.nil?
        # Attempted to insert relative to ref that's not in the association
        raise ViewModel::DeserializationError::AssociatedNotFound.new(association_data.association_name.to_s,
                                                                      relative_ref,
                                                                      blame_reference)
      end
    else
      start_pos = association_scope.maximum(position_attr)
      end_pos   = nil
    end

    ActsAsManualList.select_positions(start_pos, end_pos, append_count)
  end

  def check_association_type!(association_data, type)
    if type && !association_data.accepts?(type)
      raise ViewModel::SerializationError.new(
              "Type error: association '#{association_data.association_name}' can't refer to viewmodel #{type.view_name}")
    elsif association_data.polymorphic? && !type
      raise ViewModel::SerializationError.new(
              "Need to specify target viewmodel type for polymorphic association '#{association_data.association_name}'")
    end
  end

  class_methods do
    def convert_updates_to_references(indirect_update_data, key:)
      indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
        indirect_references["__#{key}_ref_#{i}"] = update
      end
    end

    def add_reference_indirection(update_hash, association_data:, references:, key:)
      raise ArgumentError.new('Not a referenced association') unless association_data.referenced?

      is_fupdate =
        association_data.collection? &&
          update_hash.is_a?(Hash) &&
          update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE

      if is_fupdate
        update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
          action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
          if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
            # Remove actions are always type/id refs; others need to be translated to proper refs
            next
          end

          association_references = convert_updates_to_references(
            action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
            key: "#{key}_#{action_type_name}_#{i}")
          references.merge!(association_references)
          action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
            association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
        end

        update_hash
      else
        ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
          association_references = convert_updates_to_references(sh, key: "#{key}_replace")
          references.merge!(association_references)
          association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
        end
      end
    end

    # Traverses literals and fupdates to return referenced children.
    #
    # Runs before the main parser, so must be defensive
    def each_child_hash(assoc_update, association_data:)
      return enum_for(__method__, assoc_update, association_data: association_data) unless block_given?

      is_fupdate =
        association_data.collection? &&
          assoc_update.is_a?(Hash) &&
          assoc_update[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE

      if is_fupdate
        assoc_update.fetch(ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE).each do |action|
          action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
          if action_type_name.nil?
            raise ViewModel::DeserializationError::InvalidSyntax.new(
              "Functional update missing '#{ViewModel::ActiveRecord::TYPE_ATTRIBUTE}'"
            )
          end

          if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
            # Remove actions are not considered children of the action.
            next
          end

          values = action.fetch(ViewModel::ActiveRecord::VALUES_ATTRIBUTE) {
            raise ViewModel::DeserializationError::InvalidSyntax.new(
              "Functional update missing '#{ViewModel::ActiveRecord::VALUES_ATTRIBUTE}'"
            )
          }
          values.each { |x| yield x }
        end
      else
        ViewModel::Utils.wrap_one_or_many(assoc_update) do |assoc_updates|
          assoc_updates.each { |u| yield u }
        end
      end
    end

    # Collects the ids of children that are mentioned in the update data.
    #
    # Runs before the main parser, so must be defensive.
    def mentioned_children(assoc_update, references:, association_data:)
      return enum_for(__method__, assoc_update, references: references, association_data: association_data) unless block_given?

      each_child_hash(assoc_update, association_data: association_data).each do |child_hash|
        unless child_hash.is_a?(Hash)
          raise ViewModel::DeserializationError::InvalidSyntax.new(
            "Expected update hash, received: #{child_hash}"
          )
        end

        if association_data.referenced?
          ref_handle = child_hash.fetch(ViewModel::REFERENCE_ATTRIBUTE) {
            raise ViewModel::DeserializationError::InvalidSyntax.new(
              "Reference hash missing '#{ViewModel::REFERENCE_ATTRIBUTE}'"
            )
          }

          ref_update_hash = references.fetch(ref_handle) {
            raise ViewModel::DeserializationError::InvalidSyntax.new(
              "Reference '#{ref_handle}' does not exist in references"
            )
          }

          unless ref_update_hash.is_a?(Hash)
            raise ViewModel::DeserializationError::InvalidSyntax.new(
              "Expected update hash, received: #{child_hash}"
            )
          end

          if (id = ref_update_hash[ViewModel::ID_ATTRIBUTE])
            yield id
          end
        else
          if (id = child_hash[ViewModel::ID_ATTRIBUTE])
            yield id
          end
        end
      end
    end
  end
end