# frozen_string_literal: true
require "active_support/core_ext/module/attribute_accessors"
require "active_record/attribute_mutation_tracker"
module ActiveRecord
module AttributeMethods
module Dirty
extend ActiveSupport::Concern
include ActiveModel::Dirty
included do
if self < ::ActiveRecord::Timestamp
raise "You cannot include Dirty after Timestamp"
end
class_attribute :partial_writes, instance_writer: false
self.partial_writes = true
after_create { changes_internally_applied }
after_update { changes_internally_applied }
# Attribute methods for "changed in last call to save?"
attribute_method_affix(prefix: "saved_change_to_", suffix: "?")
attribute_method_prefix("saved_change_to_")
attribute_method_suffix("_before_last_save")
# Attribute methods for "will change if I call save?"
attribute_method_affix(prefix: "will_save_change_to_", suffix: "?")
attribute_method_suffix("_change_to_be_saved", "_in_database")
end
# Attempts to +save+ the record and clears changed attributes if successful.
def save(*)
if status = super
changes_applied
end
status
end
# Attempts to save! the record and clears changed attributes if successful.
def save!(*)
super.tap do
changes_applied
end
end
# reload the record and clears changed attributes.
def reload(*)
super.tap do
@previous_mutation_tracker = nil
clear_mutation_trackers
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
end
end
def initialize_dup(other) # :nodoc:
super
@attributes = self.class._default_attributes.map do |attr|
attr.with_value_from_user(@attributes.fetch_value(attr.name))
end
clear_mutation_trackers
end
def changes_internally_applied # :nodoc:
@mutations_before_last_save = mutations_from_database
forget_attribute_assignments
@mutations_from_database = AttributeMutationTracker.new(@attributes)
end
def changes_applied # :nodoc:
@previous_mutation_tracker = mutation_tracker
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
@mutation_tracker = nil
@mutations_from_database = nil
end
def clear_changes_information # :nodoc:
@previous_mutation_tracker = nil
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
forget_attribute_assignments
clear_mutation_trackers
end
def raw_write_attribute(attr_name, *) # :nodoc:
result = super
clear_attribute_change(attr_name)
result
end
def clear_attribute_changes(attr_names) # :nodoc:
super
attr_names.each do |attr_name|
clear_attribute_change(attr_name)
end
end
def changed_attributes # :nodoc:
# This should only be set by methods which will call changed_attributes
# multiple times when it is known that the computed value cannot change.
if defined?(@cached_changed_attributes)
@cached_changed_attributes
else
emit_warning_if_needed("changed_attributes", "saved_changes.transform_values(&:first)")
super.reverse_merge(mutation_tracker.changed_values).freeze
end
end
def changes # :nodoc:
cache_changed_attributes do
emit_warning_if_needed("changes", "saved_changes")
super
end
end
def previous_changes # :nodoc:
unless previous_mutation_tracker.equal?(mutations_before_last_save)
ActiveSupport::Deprecation.warn(<<-EOW.strip_heredoc)
The behavior of `previous_changes` inside of after callbacks is
deprecated without replacement. In the next release of Rails,
this method inside of `after_save` will return the changes that
were just saved.
EOW
end
previous_mutation_tracker.changes
end
def attribute_changed_in_place?(attr_name) # :nodoc:
mutation_tracker.changed_in_place?(attr_name)
end
# Did this attribute change when we last saved? This method can be invoked
# as `saved_change_to_name?` instead of `saved_change_to_attribute?("name")`.
# Behaves similarly to +attribute_changed?+. This method is useful in
# after callbacks to determine if the call to save changed a certain
# attribute.
#
# ==== Options
#
# +from+ When passed, this method will return false unless the original
# value is equal to the given option
#
# +to+ When passed, this method will return false unless the value was
# changed to the given value
def saved_change_to_attribute?(attr_name, **options)
mutations_before_last_save.changed?(attr_name, **options)
end
# Returns the change to an attribute during the last save. If the
# attribute was changed, the result will be an array containing the
# original value and the saved value.
#
# Behaves similarly to +attribute_change+. This method is useful in after
# callbacks, to see the change in an attribute that just occurred
#
# This method can be invoked as `saved_change_to_name` in instead of
# `saved_change_to_attribute("name")`
def saved_change_to_attribute(attr_name)
mutations_before_last_save.change_to_attribute(attr_name)
end
# Returns the original value of an attribute before the last save.
# Behaves similarly to +attribute_was+. This method is useful in after
# callbacks to get the original value of an attribute before the save that
# just occurred
def attribute_before_last_save(attr_name)
mutations_before_last_save.original_value(attr_name)
end
# Did the last call to `save` have any changes to change?
def saved_changes?
mutations_before_last_save.any_changes?
end
# Returns a hash containing all the changes that were just saved.
def saved_changes
mutations_before_last_save.changes
end
# Alias for `attribute_changed?`
def will_save_change_to_attribute?(attr_name, **options)
mutations_from_database.changed?(attr_name, **options)
end
# Alias for `attribute_change`
def attribute_change_to_be_saved(attr_name)
mutations_from_database.change_to_attribute(attr_name)
end
# Alias for `attribute_was`
def attribute_in_database(attr_name)
mutations_from_database.original_value(attr_name)
end
# Alias for `changed?`
def has_changes_to_save?
mutations_from_database.any_changes?
end
# Alias for `changes`
def changes_to_save
mutations_from_database.changes
end
# Alias for `changed`
def changed_attribute_names_to_save
changes_to_save.keys
end
# Alias for `changed_attributes`
def attributes_in_database
changes_to_save.transform_values(&:first)
end
def attribute_was(*)
emit_warning_if_needed("attribute_was", "attribute_before_last_save")
super
end
def attribute_change(*)
emit_warning_if_needed("attribute_change", "saved_change_to_attribute")
super
end
def attribute_changed?(*)
emit_warning_if_needed("attribute_changed?", "saved_change_to_attribute?")
super
end
def changed?(*)
emit_warning_if_needed("changed?", "saved_changes?")
super
end
def changed(*)
emit_warning_if_needed("changed", "saved_changes.keys")
super
end
private
def mutation_tracker
unless defined?(@mutation_tracker)
@mutation_tracker = nil
end
@mutation_tracker ||= AttributeMutationTracker.new(@attributes)
end
def emit_warning_if_needed(method_name, new_method_name)
unless mutation_tracker.equal?(mutations_from_database)
ActiveSupport::Deprecation.warn(<<-EOW.squish)
The behavior of `#{method_name}` inside of after callbacks will
be changing in the next version of Rails. The new return value will reflect the
behavior of calling the method after `save` returned (e.g. the opposite of what
it returns now). To maintain the current behavior, use `#{new_method_name}`
instead.
EOW
end
end
def mutations_from_database
unless defined?(@mutations_from_database)
@mutations_from_database = nil
end
@mutations_from_database ||= mutation_tracker
end
def changes_include?(attr_name)
super || mutation_tracker.changed?(attr_name)
end
def clear_attribute_change(attr_name)
mutation_tracker.forget_change(attr_name)
mutations_from_database.forget_change(attr_name)
end
def attribute_will_change!(attr_name)
super
if self.class.has_attribute?(attr_name)
mutations_from_database.force_change(attr_name)
else
ActiveSupport::Deprecation.warn(<<-EOW.squish)
#{attr_name} is not an attribute known to Active Record.
This behavior is deprecated and will be removed in the next
version of Rails. If you'd like #{attr_name} to be managed
by Active Record, add `attribute :#{attr_name} to your class.
EOW
mutations_from_database.deprecated_force_change(attr_name)
end
end
def _update_record(*)
partial_writes? ? super(keys_for_partial_write) : super
end
def _create_record(*)
partial_writes? ? super(keys_for_partial_write) : super
end
def keys_for_partial_write
changed_attribute_names_to_save & self.class.column_names
end
def forget_attribute_assignments
@attributes = @attributes.map(&:forgetting_assignment)
end
def clear_mutation_trackers
@mutation_tracker = nil
@mutations_from_database = nil
@mutations_before_last_save = nil
end
def previous_mutation_tracker
@previous_mutation_tracker ||= NullMutationTracker.instance
end
def mutations_before_last_save
@mutations_before_last_save ||= previous_mutation_tracker
end
def cache_changed_attributes
@cached_changed_attributes = changed_attributes
yield
ensure
clear_changed_attributes_cache
end
def clear_changed_attributes_cache
remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes)
end
end
end
end