# frozen_string_literal: true
require "active_record/associations/nested_error"
module ActiveRecord
# = Active Record Autosave Association
#
# AutosaveAssociation is a module that takes care of automatically saving
# associated records when their parent is saved. In addition to saving, it
# also destroys any associated records that were marked for destruction.
# (See #mark_for_destruction and #marked_for_destruction?).
#
# Saving of the parent, its associations, and the destruction of marked
# associations, all happen inside a transaction. This should never leave the
# database in an inconsistent state.
#
# If validations for any of the associations fail, their error messages will
# be applied to the parent.
#
# Note that it also means that associations marked for destruction won't
# be destroyed directly. They will however still be marked for destruction.
#
# Note that autosave: false is not same as not declaring :autosave.
# When the :autosave option is not present then new association records are
# saved but the updated association records are not saved.
#
# == Validation
#
# Child records are validated unless :validate is +false+.
#
# == \Callbacks
#
# Association with autosave option defines several callbacks on your
# model (around_save, before_save, after_create, after_update). Please note that
# callbacks are executed in the order they were defined in
# model. You should avoid modifying the association content before
# autosave callbacks are executed. Placing your callbacks after
# associations is usually a good practice.
#
# === One-to-one Example
#
# class Post < ActiveRecord::Base
# has_one :author, autosave: true
# end
#
# Saving changes to the parent and its associated model can now be performed
# automatically _and_ atomically:
#
# post = Post.find(1)
# post.title # => "The current global position of migrating ducks"
# post.author.name # => "alloy"
#
# post.title = "On the migration of ducks"
# post.author.name = "Eloy Duran"
#
# post.save
# post.reload
# post.title # => "On the migration of ducks"
# post.author.name # => "Eloy Duran"
#
# Destroying an associated model, as part of the parent's save action, is as
# simple as marking it for destruction:
#
# post.author.mark_for_destruction
# post.author.marked_for_destruction? # => true
#
# Note that the model is _not_ yet removed from the database:
#
# id = post.author.id
# Author.find_by(id: id).nil? # => false
#
# post.save
# post.reload.author # => nil
#
# Now it _is_ removed from the database:
#
# Author.find_by(id: id).nil? # => true
#
# === One-to-many Example
#
# When :autosave is not declared new children are saved when their parent is saved:
#
# class Post < ActiveRecord::Base
# has_many :comments # :autosave option is not declared
# end
#
# post = Post.new(title: 'ruby rocks')
# post.comments.build(body: 'hello world')
# post.save # => saves both post and comment
#
# post = Post.create(title: 'ruby rocks')
# post.comments.build(body: 'hello world')
# post.save # => saves both post and comment
#
# post = Post.create(title: 'ruby rocks')
# comment = post.comments.create(body: 'hello world')
# comment.body = 'hi everyone'
# post.save # => saves post, but not comment
#
# When :autosave is true all children are saved, no matter whether they
# are new records or not:
#
# class Post < ActiveRecord::Base
# has_many :comments, autosave: true
# end
#
# post = Post.create(title: 'ruby rocks')
# comment = post.comments.create(body: 'hello world')
# comment.body = 'hi everyone'
# post.comments.build(body: "good morning.")
# post.save # => saves post and both comments.
#
# Destroying one of the associated models as part of the parent's save action
# is as simple as marking it for destruction:
#
# post.comments # => [#, #
# post.comments[1].mark_for_destruction
# post.comments[1].marked_for_destruction? # => true
# post.comments.length # => 2
#
# Note that the model is _not_ yet removed from the database:
#
# id = post.comments.last.id
# Comment.find_by(id: id).nil? # => false
#
# post.save
# post.reload.comments.length # => 1
#
# Now it _is_ removed from the database:
#
# Comment.find_by(id: id).nil? # => true
#
# === Caveats
#
# Note that autosave will only trigger for already-persisted association records
# if the records themselves have been changed. This is to protect against
# SystemStackError caused by circular association validations. The one
# exception is if a custom validation context is used, in which case the validations
# will always fire on the associated records.
module AutosaveAssociation
extend ActiveSupport::Concern
module AssociationBuilderExtension # :nodoc:
def self.build(model, reflection)
model.send(:add_autosave_association_callbacks, reflection)
end
def self.valid_options
[ :autosave ]
end
end
included do
Associations::Builder::Association.extensions << AssociationBuilderExtension
end
module ClassMethods # :nodoc:
private
def define_non_cyclic_method(name, &block)
return if method_defined?(name, false)
define_method(name) do |*args|
result = true; @_already_called ||= {}
# Loop prevention for validation of associations
unless @_already_called[name]
begin
@_already_called[name] = true
result = instance_eval(&block)
ensure
@_already_called[name] = false
end
end
result
end
end
# Adds validation and save callbacks for the association as specified by
# the +reflection+.
#
# For performance reasons, we don't check whether to validate at runtime.
# However the validation and callback methods are lazy and those methods
# get created when they are invoked for the very first time. However,
# this can change, for instance, when using nested attributes, which is
# called _after_ the association has been defined. Since we don't want
# the callbacks to get defined multiple times, there are guards that
# check if the save or validation methods have already been defined
# before actually defining them.
def add_autosave_association_callbacks(reflection)
save_method = :"autosave_associated_records_for_#{reflection.name}"
if reflection.collection?
around_save :around_save_collection_association
define_non_cyclic_method(save_method) { save_collection_association(reflection) }
# Doesn't use after_save as that would save associations added in after_create/after_update twice
after_create save_method
after_update save_method
elsif reflection.has_one?
define_non_cyclic_method(save_method) { save_has_one_association(reflection) }
# Configures two callbacks instead of a single after_save so that
# the model may rely on their execution order relative to its
# own callbacks.
#
# For example, given that after_creates run before after_saves, if
# we configured instead an after_save there would be no way to fire
# a custom after_create callback after the child association gets
# created.
after_create save_method
after_update save_method
else
define_non_cyclic_method(save_method) { throw(:abort) if save_belongs_to_association(reflection) == false }
before_save save_method
end
define_autosave_validation_callbacks(reflection)
end
def define_autosave_validation_callbacks(reflection)
validation_method = :"validate_associated_records_for_#{reflection.name}"
if reflection.validate? && !method_defined?(validation_method)
if reflection.collection?
method = :validate_collection_association
elsif reflection.has_one?
method = :validate_has_one_association
else
method = :validate_belongs_to_association
end
define_non_cyclic_method(validation_method) { send(method, reflection) }
validate validation_method
after_validation :_ensure_no_duplicate_errors
end
end
end
# Reloads the attributes of the object as usual and clears marked_for_destruction flag.
def reload(options = nil)
@marked_for_destruction = false
@destroyed_by_association = nil
super
end
# Marks this record to be destroyed as part of the parent's save transaction.
# This does _not_ actually destroy the record instantly, rather child record will be destroyed
# when parent.save is called.
#
# Only useful if the :autosave option on the parent is enabled for this associated model.
def mark_for_destruction
@marked_for_destruction = true
end
# Returns whether or not this record will be destroyed as part of the parent's save transaction.
#
# Only useful if the :autosave option on the parent is enabled for this associated model.
def marked_for_destruction?
@marked_for_destruction
end
# Records the association that is being destroyed and destroying this
# record in the process.
def destroyed_by_association=(reflection)
@destroyed_by_association = reflection
end
# Returns the association for the parent being destroyed.
#
# Used to avoid updating the counter cache unnecessarily.
def destroyed_by_association
@destroyed_by_association
end
# Returns whether or not this record has been changed in any way (including whether
# any of its nested autosave associations are likewise changed)
def changed_for_autosave?
new_record? || has_changes_to_save? || marked_for_destruction? || nested_records_changed_for_autosave?
end
def validating_belongs_to_for?(association)
@validating_belongs_to_for ||= {}
@validating_belongs_to_for[association]
end
def autosaving_belongs_to_for?(association)
@autosaving_belongs_to_for ||= {}
@autosaving_belongs_to_for[association]
end
private
def init_internals
super
@_already_called = nil
end
# Returns the record for an association collection that should be validated
# or saved. If +autosave+ is +false+ only new records will be returned,
# unless the parent is/was a new record itself.
def associated_records_to_validate_or_save(association, new_record, autosave)
if new_record || custom_validation_context?
association && association.target
elsif autosave
association.target.find_all(&:changed_for_autosave?)
else
association.target.find_all(&:new_record?)
end
end
# Go through nested autosave associations that are loaded in memory (without loading
# any new ones), and return true if any are changed for autosave.
# Returns false if already called to prevent an infinite loop.
def nested_records_changed_for_autosave?
@_nested_records_changed_for_autosave_already_called ||= false
return false if @_nested_records_changed_for_autosave_already_called
begin
@_nested_records_changed_for_autosave_already_called = true
self.class._reflections.values.any? do |reflection|
if reflection.options[:autosave]
association = association_instance_get(reflection.name)
association && Array.wrap(association.target).any?(&:changed_for_autosave?)
end
end
ensure
@_nested_records_changed_for_autosave_already_called = false
end
end
# Validate the association if :validate or :autosave is
# turned on for the has_one association.
def validate_has_one_association(reflection)
association = association_instance_get(reflection.name)
record = association && association.reader
return unless record && (record.changed_for_autosave? || custom_validation_context?)
inverse_association = reflection.inverse_of && record.association(reflection.inverse_of.name)
return if inverse_association && (record.validating_belongs_to_for?(inverse_association) ||
record.autosaving_belongs_to_for?(inverse_association))
association_valid?(association, record)
end
# Validate the association if :validate or :autosave is
# turned on for the belongs_to association.
def validate_belongs_to_association(reflection)
association = association_instance_get(reflection.name)
record = association && association.reader
return unless record && (record.changed_for_autosave? || custom_validation_context?)
begin
@validating_belongs_to_for ||= {}
@validating_belongs_to_for[association] = true
association_valid?(association, record)
ensure
@validating_belongs_to_for[association] = false
end
end
# Validate the associated records if :validate or
# :autosave is turned on for the association specified by
# +reflection+.
def validate_collection_association(reflection)
if association = association_instance_get(reflection.name)
if records = associated_records_to_validate_or_save(association, new_record?, reflection.options[:autosave])
records.each { |record| association_valid?(association, record) }
end
end
end
# Returns whether or not the association is valid and applies any errors to
# the parent, self, if it wasn't. Skips any :autosave
# enabled records if they're marked_for_destruction? or destroyed.
def association_valid?(association, record)
return true if record.destroyed? || (association.options[:autosave] && record.marked_for_destruction?)
context = validation_context if custom_validation_context?
unless valid = record.valid?(context)
if association.options[:autosave]
record.errors.each { |error|
self.errors.objects.append(
Associations::NestedError.new(association, error)
)
}
else
errors.add(association.reflection.name)
end
end
valid
end
# Is used as an around_save callback to check while saving a collection
# association whether or not the parent was a new record before saving.
def around_save_collection_association
previously_new_record_before_save = (@new_record_before_save ||= false)
@new_record_before_save = !previously_new_record_before_save && new_record?
yield
ensure
@new_record_before_save = previously_new_record_before_save
end
# Saves any new associated records, or all loaded autosave associations if
# :autosave is enabled on the association.
#
# In addition, it destroys all children that were marked for destruction
# with #mark_for_destruction.
#
# This all happens inside a transaction, _if_ the Transactions module is included into
# ActiveRecord::Base after the AutosaveAssociation module, which it does by default.
def save_collection_association(reflection)
if association = association_instance_get(reflection.name)
autosave = reflection.options[:autosave]
# By saving the instance variable in a local variable,
# we make the whole callback re-entrant.
new_record_before_save = @new_record_before_save
# reconstruct the scope now that we know the owner's id
association.reset_scope
if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave)
if autosave
records_to_destroy = records.select(&:marked_for_destruction?)
records_to_destroy.each { |record| association.destroy(record) }
records -= records_to_destroy
end
records.each do |record|
next if record.destroyed?
saved = true
if autosave != false && (new_record_before_save || record.new_record?)
association.set_inverse_instance(record)
if autosave
saved = association.insert_record(record, false)
elsif !reflection.nested?
association_saved = association.insert_record(record)
if reflection.validate?
errors.add(reflection.name) unless association_saved
saved = association_saved
end
end
elsif autosave
saved = record.save(validate: false)
end
raise(RecordInvalid.new(association.owner)) unless saved
end
end
end
end
# Saves the associated record if it's new or :autosave is enabled
# on the association.
#
# In addition, it will destroy the association if it was marked for
# destruction with #mark_for_destruction.
#
# This all happens inside a transaction, _if_ the Transactions module is included into
# ActiveRecord::Base after the AutosaveAssociation module, which it does by default.
def save_has_one_association(reflection)
association = association_instance_get(reflection.name)
return unless association && association.loaded?
record = association.load_target
return unless record && !record.destroyed?
autosave = reflection.options[:autosave]
if autosave && record.marked_for_destruction?
record.destroy
elsif autosave != false
primary_key = Array(compute_primary_key(reflection, self)).map(&:to_s)
primary_key_value = primary_key.map { |key| _read_attribute(key) }
return unless (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, primary_key_value)
unless reflection.through_reflection
foreign_key = Array(reflection.foreign_key)
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
association_id = _read_attribute(primary_key)
record[foreign_key] = association_id unless record[foreign_key] == association_id
end
association.set_inverse_instance(record)
end
inverse_association = reflection.inverse_of && record.association(reflection.inverse_of.name)
return if inverse_association && record.autosaving_belongs_to_for?(inverse_association)
saved = record.save(validate: !autosave)
raise ActiveRecord::Rollback if !saved && autosave
saved
end
end
# If the record is new or it has changed, returns true.
def _record_changed?(reflection, record, key)
record.new_record? ||
(association_foreign_key_changed?(reflection, record, key) ||
inverse_polymorphic_association_changed?(reflection, record)) ||
record.will_save_change_to_attribute?(reflection.foreign_key)
end
def association_foreign_key_changed?(reflection, record, key)
return false if reflection.through_reflection?
foreign_key = Array(reflection.foreign_key)
return false unless foreign_key.all? { |key| record._has_attribute?(key) }
foreign_key.map { |key| record._read_attribute(key) } != Array(key)
end
def inverse_polymorphic_association_changed?(reflection, record)
return false unless reflection.inverse_of&.polymorphic?
class_name = record._read_attribute(reflection.inverse_of.foreign_type)
reflection.active_record != record.class.polymorphic_class_for(class_name)
end
# Saves the associated record if it's new or :autosave is enabled.
#
# In addition, it will destroy the association if it was marked for destruction.
def save_belongs_to_association(reflection)
association = association_instance_get(reflection.name)
return unless association && association.loaded? && !association.stale_target?
record = association.load_target
if record && !record.destroyed?
autosave = reflection.options[:autosave]
if autosave && record.marked_for_destruction?
foreign_key = Array(reflection.foreign_key)
foreign_key.each { |key| self[key] = nil }
record.destroy
elsif autosave != false
saved = if record.new_record? || (autosave && record.changed_for_autosave?)
begin
@autosaving_belongs_to_for ||= {}
@autosaving_belongs_to_for[association] = true
record.save(validate: !autosave)
ensure
@autosaving_belongs_to_for[association] = false
end
end
if association.updated?
primary_key = Array(compute_primary_key(reflection, record)).map(&:to_s)
foreign_key = Array(reflection.foreign_key)
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
association_id = record._read_attribute(primary_key)
self[foreign_key] = association_id unless self[foreign_key] == association_id
end
association.loaded!
end
saved if autosave
end
end
end
def compute_primary_key(reflection, record)
if primary_key_options = reflection.options[:primary_key]
primary_key_options
elsif reflection.options[:query_constraints] && (query_constraints = record.class.query_constraints_list)
query_constraints
elsif record.class.has_query_constraints? && !reflection.options[:foreign_key]
record.class.query_constraints_list
elsif record.class.composite_primary_key?
# If record has composite primary key of shape [:, :id], infer primary_key as :id
primary_key = record.class.primary_key
primary_key.include?("id") ? "id" : primary_key
else
record.class.primary_key
end
end
def _ensure_no_duplicate_errors
errors.uniq!
end
end
end