lib/mongoid/interceptable.rb
# 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