mongodb/mongoid

View on GitHub
lib/mongoid/interceptable.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true
# rubocop:todo all

module Mongoid

  # This module contains all the callback hooks for Mongoid.
  module Interceptable
    extend ActiveSupport::Concern

    CALLBACKS = [
      :after_build,
      :after_create,
      :after_destroy,
      :after_find,
      :after_initialize,
      :after_save,
      :after_touch,
      :after_update,
      :after_upsert,
      :after_validation,
      :around_create,
      :around_destroy,
      :around_save,
      :around_update,
      :around_upsert,
      :before_create,
      :before_destroy,
      :before_save,
      :before_update,
      :before_upsert,
      :before_validation,
    ].freeze

    included do
      extend ActiveModel::Callbacks
      include ActiveModel::Validations::Callbacks

      define_model_callbacks :build, :find, :initialize, :touch, only: :after
      define_model_callbacks :create, :destroy, :save, :update, :upsert

      # This callback is used internally by Mongoid to save association
      # targets for referenced associations after the parent model is persisted.
      #
      # @api private
      define_model_callbacks :persist_parent

      define_callbacks :commit, :rollback,
                       only: :after,
                       scope: [:kind, :name]

      attr_accessor :before_callback_halted
    end

    # Is the provided type of callback executable by this document?
    #
    # @example Is the callback executable?
    #   document.callback_executable?(:save)
    #
    # @param [ Symbol ] kind The type of callback.
    #
    # @return [ true | false ] If the callback can be executed.
    def callback_executable?(kind)
      respond_to?("_#{kind}_callbacks")
    end

    # Is the document currently in a state that could potentially require
    # callbacks to be executed?
    #
    # @example Is the document in a callback state?
    #   document.in_callback_state?(:update)
    #
    # @param [ Symbol ] kind The callback kind.
    #
    # @return [ true | false ] If the document is in a callback state.
    def in_callback_state?(kind)
      [ :create, :destroy ].include?(kind) || new_record? || flagged_for_destroy? || changed?
    end

    # Run only the after callbacks for the specific event.
    #
    # @note ActiveSupport does not allow this type of behavior by default, so
    #   Mongoid has to get around it and implement itself.
    #
    # @example Run only the after save callbacks.
    #   model.run_after_callbacks(:save)
    #
    # @param [ Symbol... ] *kinds The events that are occurring.
    #
    # @return [ Object ] The result of the chain executing.
    def run_after_callbacks(*kinds)
      kinds.each do |kind|
        run_targeted_callbacks(:after, kind)
      end
    end

    # Run only the before callbacks for the specific event.
    #
    # @note ActiveSupport does not allow this type of behavior by default, so
    #   Mongoid has to get around it and implement itself.
    #
    # @example Run only the before save callbacks.
    #   model.run_before_callbacks(:save, :create)
    #
    # @param [ Symbol... ] *kinds The events that are occurring.
    #
    # @return [ Object ] The result of the chain executing.
    def run_before_callbacks(*kinds)
      kinds.each do |kind|
        run_targeted_callbacks(:before, kind)
      end
    end

    # Run the callbacks for the document. This overrides active support's
    # functionality to cascade callbacks to embedded documents that have been
    # flagged as such.
    #
    # @example Run the callbacks.
    #   run_callbacks :save do
    #     save!
    #   end
    #
    # @param [ Symbol ] kind The type of callback to execute.
    # @param [ true | false ] with_children Flag specifies whether callbacks
    #   of embedded document should be run.
    # @param [ Proc | nil ] skip_if If this proc returns true, the callbacks
    #   will not be triggered, while the given block will be still called.
    def run_callbacks(kind, with_children: true, skip_if: nil, &block)
      if skip_if&.call
        return block&.call
      end
      if with_children
        cascadable_children(kind).each do |child|
          if child.run_callbacks(child_callback_type(kind, child), with_children: with_children) == false
            return false
          end
        end
      end
      if callback_executable?(kind)
        super(kind, &block)
      else
        true
      end
    end

    # Run the callbacks for embedded documents.
    #
    # @param [ Symbol ] kind The type of callback to execute.
    # @param [ Array<Document> ] children Children to execute callbacks on. If
    #   nil, callbacks will be executed on all cascadable children of
    #   the document.
    #
    # @api private
    def _mongoid_run_child_callbacks(kind, children: nil, &block)
      if Mongoid::Config.around_callbacks_for_embeds
        _mongoid_run_child_callbacks_with_around(kind, children: children, &block)
      else
        _mongoid_run_child_callbacks_without_around(kind, children: children, &block)
      end
    end

    # Execute the callbacks of given kind for embedded documents including
    # around callbacks.
    #
    # @note This method is prone to stack overflow errors if the document
    #   has a large number of embedded documents. It is recommended to avoid
    #   using around callbacks for embedded documents until a proper solution
    #   is implemented.
    #
    # @param [ Symbol ] kind The type of callback to execute.
    # @param [ Array<Document> ] children Children to execute callbacks on. If
    #  nil, callbacks will be executed on all cascadable children of
    #  the document.
    #
    #  @api private
    def _mongoid_run_child_callbacks_with_around(kind, children: nil, &block)
      child, *tail = (children || cascadable_children(kind))
      with_children = !Mongoid::Config.prevent_multiple_calls_of_embedded_callbacks
      if child.nil?
        block&.call
      elsif tail.empty?
        child.run_callbacks(child_callback_type(kind, child), with_children: with_children, &block)
      else
        child.run_callbacks(child_callback_type(kind, child), with_children: with_children) do
          _mongoid_run_child_callbacks_with_around(kind, children: tail, &block)
        end
      end
    end

    # Execute the callbacks of given kind for embedded documents without
    # around callbacks.
    #
    # @param [ Symbol ] kind The type of callback to execute.
    # @param [ Array<Document> ] children Children to execute callbacks on. If
    #   nil, callbacks will be executed on all cascadable children of
    #   the document.
    #
    # @api private
    def _mongoid_run_child_callbacks_without_around(kind, children: nil, &block)
      children = (children || cascadable_children(kind))
      callback_list = _mongoid_run_child_before_callbacks(kind, children: children)
      return false if callback_list == false
      value = block&.call
      callback_list.each do |_next_sequence, env|
        env.value &&= value
      end
      return false if _mongoid_run_child_after_callbacks(callback_list: callback_list) == false

      value
    end

    # Execute the before callbacks of given kind for embedded documents.
    #
    # @param [ Symbol ] kind The type of callback to execute.
    # @param [ Array<Document> ] children Children to execute callbacks on.
    # @param [ Array<ActiveSupport::Callbacks::CallbackSequence, ActiveSupport::Callbacks::Filters::Environment> ] callback_list List of
    #   pairs of callback sequence and environment. This list will be later used
    #   to execute after callbacks in reverse order.
    #
    # @api private
    def _mongoid_run_child_before_callbacks(kind, children: [], callback_list: [])
      children.each do |child|
        chain = child.__callbacks[child_callback_type(kind, child)]
        env = ActiveSupport::Callbacks::Filters::Environment.new(child, false, nil)
        next_sequence = compile_callbacks(chain)
        unless next_sequence.final?
          Mongoid.logger.warn("Around callbacks are disabled for embedded documents. Skipping around callbacks for #{child.class.name}.")
          Mongoid.logger.warn("To enable around callbacks for embedded documents, set Mongoid::Config.around_callbacks_for_embeds to true.")
        end
        next_sequence.invoke_before(env)
        return false if env.halted
        env.value = !env.halted
        callback_list << [next_sequence, env]
        if (grandchildren = child.send(:cascadable_children, kind))
          _mongoid_run_child_before_callbacks(kind, children: grandchildren, callback_list: callback_list)
        end
      end
      callback_list
    end

    # Execute the after callbacks.
    #
    # @param [ Array<ActiveSupport::Callbacks::CallbackSequence, ActiveSupport::Callbacks::Filters::Environment> ] callback_list List of
    #   pairs of callback sequence and environment.
    def _mongoid_run_child_after_callbacks(callback_list: [])
      callback_list.reverse_each do |next_sequence, env|
        next_sequence.invoke_after(env)
        return false if env.halted
      end
    end

    # Returns the stored callbacks to be executed later.
    #
    # @return [ Array<Symbol> ] Method symbols of the stored pending callbacks.
    #
    # @api private
    def pending_callbacks
      @pending_callbacks ||= [].to_set
    end

    # Stores callbacks to be executed later. A good use case for
    # this is delaying the after_find and after_initialize callbacks until the
    # associations are set on the document. This can also be used to delay
    # applying the defaults on a document.
    #
    # @param [ Array<Symbol> ] value Method symbols of the pending callbacks to store.
    #
    # @return [ Array<Symbol> ] Method symbols of the stored pending callbacks.
    #
    # @api private
    def pending_callbacks=(value)
      @pending_callbacks = value
    end

    # Run the pending callbacks. If the callback is :apply_defaults, we will apply
    # the defaults for this document. Otherwise, the callback is passed to the
    # run_callbacks function.
    #
    # @api private
    def run_pending_callbacks
      pending_callbacks.each do |cb|
        if [:apply_defaults, :apply_post_processed_defaults].include?(cb)
          send(cb)
        else
          self.run_callbacks(cb, with_children: false)
        end
      end
      pending_callbacks.clear
    end

    private

    # We need to hook into this for autosave, since we don't want it firing if
    # the before callbacks were halted.
    #
    # @api private
    #
    # @example Was a before callback halted?
    #   document.before_callback_halted?
    #
    # @return [ true | false ] If a before callback was halted.
    def before_callback_halted?
      !!@before_callback_halted
    end

    # Get all the child embedded documents that are flagged as cascadable.
    #
    # @example Get all the cascading children.
    #   document.cascadable_children(:update)
    #
    # @param [ Symbol ] kind The type of callback.
    #
    # @return [ Array<Document> ] The children.
    def cascadable_children(kind, children = Set.new)
      embedded_relations.each_pair do |name, association|
        next unless association.cascading_callbacks?
        without_autobuild do
          delayed_pulls = delayed_atomic_pulls[name]
          delayed_unsets = delayed_atomic_unsets[name]
          children.merge(delayed_pulls) if delayed_pulls
          children.merge(delayed_unsets) if delayed_unsets
          relation = send(name)
          Array.wrap(relation).each do |child|
            next if children.include?(child)
            children.add(child) if cascadable_child?(kind, child, association)
            child.send(:cascadable_children, kind, children)
          end
        end
      end
      children.to_a
    end

    # Determine if the child should fire the callback.
    #
    # @example Should the child fire the callback?
    #   document.cascadable_child?(:update, doc)
    #
    # @param [ Symbol ] kind The type of callback.
    # @param [ Document ] child The child document.
    #
    # @return [ true | false ] If the child should fire the callback.
    def cascadable_child?(kind, child, association)
      return false if kind == :initialize || kind == :find || kind == :touch
      return false if kind == :validate && association.validate?
      child.callback_executable?(kind) ? child.in_callback_state?(kind) : false
    end

    # Get the name of the callback that the child should fire. This changes
    # depending on whether or not the child is new. A persisted parent with a
    # new child would fire :update from the parent, but needs to fire :create
    # on the child.
    #
    # @example Get the callback type.
    #   document.child_callback_type(:update, doc)
    #
    # @param [ Symbol ] kind The type of callback.
    # @param [ Document ] child The child document
    #
    # @return [ Symbol ] The name of the callback.
    def child_callback_type(kind, child)
      if kind == :update
        return :create if child.new_record?
        return :destroy if child.flagged_for_destroy?
        kind
      else
        kind
      end
    end

    # We need to hook into this for autosave, since we don't want it firing if
    # the before callbacks were halted.
    #
    # @api private
    #
    # @example Hook into the halt.
    #   document.halted_callback_hook(filter)
    #
    # @param [ Symbol ] filter The callback that halted.
    # @param [ Symbol ] name The name of the callback that was halted
    #   (requires Rails 6.1+)
    def halted_callback_hook(filter, name = nil)
      @before_callback_halted = true
    end

    # Run only the callbacks for the target location (before, after, around)
    # and kind (save, update, create).
    #
    # @example Run the targeted callbacks.
    #   model.run_targeted_callbacks(:before, :save)
    #
    # @param [ Symbol ] place The time to run, :before, :after, :around.
    # @param [ Symbol ] kind The type of callback, :save, :create, :update.
    #
    # @return [ Object ] The result of the chain execution.
    def run_targeted_callbacks(place, kind)
      name = "_run__#{place}__#{kind}__callbacks"
      unless respond_to?(name)
        chain = ActiveSupport::Callbacks::CallbackChain.new(name, {})
        send("_#{kind}_callbacks").each do |callback|
          chain.append(callback) if callback.kind == place
        end
        self.class.send :define_method, name do
          env = ActiveSupport::Callbacks::Filters::Environment.new(self, false, nil)
          sequence = compile_callbacks(chain)
          sequence.invoke_before(env)
          env.value = !env.halted
          sequence.invoke_after(env)
          env.value
        end
        self.class.send :protected, name
      end
      send(name)
    end

    # Compile the callback chain.
    #
    # This method hides the differences between ActiveSupport implementations
    # before and after 7.1.
    #
    # @param [ ActiveSupport::Callbacks::CallbackChain ] chain The callback chain.
    # @param [ Symbol | nil ] type The type of callback chain to compile.
    #
    # @return [ ActiveSupport::Callbacks::CallbackSequence ] The compiled callback sequence.
    def compile_callbacks(chain, type = nil)
      if chain.method(:compile).arity == 0
        # ActiveSupport < 7.1
        chain.compile
      else
        # ActiveSupport >= 7.1
        chain.compile(type)
      end
    end
  end
end