class Hash
# Return a hash containing the entries that differ to the passed hash.
# Blank objects are considered equal.
#
# { 1 => 1, 2 => 3 }.substantial_differences_to({ 1 => 4, 2 => 3 }) => { 1 => 1 }
# { 1 => 1 }.substantial_differences_to({ 1 => 1 }) => {}
def substantial_differences_to(hash)
reject do |key, value|
hash[key] == value || (hash[key].blank? && value.blank?)
end
end
def substantial_difference_to?(hash)
substantial_differences_to(hash).any?
end
# From edge rails
def slice(*keys)
allowed = Set.new(respond_to?(:convert_key) ? keys.map { |key| convert_key(key) } : keys)
reject { |key,| !allowed.include?(key) }
end
def slice!(*keys)
replace(slice(*keys))
end
def except(*keys)
rejected = Set.new(respond_to?(:convert_key) ? keys.map { |key| convert_key(key) } : keys)
reject { |key,| rejected.include?(key) }
end
def except!(*keys)
replace(except(*keys))
end
end
module ActiveRecord
module Acts
module Modified
def self.included(base)
base.extend(ClassMethods)
base.class_inheritable_array :excluded_attributes, :included_attributes
base.excluded_attributes = %w(updated_at updated_on)
end
module ClassMethods
# == Configuration options
#
# * :clear_after_save - should the original attributes be cleared after saving, defaults to false. Causes modified? to return false after save has been called.
# * :except - A list of attributes that should not be monitored for modifications.
# * :only - If specified, only the attributes listed will be monitored for modifications.
def acts_as_modified(options = {})
options.assert_valid_keys :clear_after_save, :except, :only
after_save :clear_original_attributes if options[:clear_after_save]
self.excluded_attributes = [*options[:except]].map(&:to_s) if options[:except]
self.included_attributes = [*options[:only]].map(&:to_s) if options[:only]
unless self.included_modules.include?(ActiveRecord::Acts::Modified::InstanceMethods)
include InstanceMethods
alias_method_chain :write_attribute, :original_attributes
alias_method_chain :method_missing, :modified
alias_method_chain :reload, :clear_original_attributes
alias_method_chain :read_attribute, :freeze
alias_method_chain :evaluate_read_method, :freeze
end
end
end
module InstanceMethods
# Updates the attribute identified by attr_name with the specified +value+. Empty strings for fixnum and float columns are turned into nil.
# The first call causes the original values of all attributes to be stored
def write_attribute_with_original_attributes(attr_name, value) #:nodoc:
ensure_original_attribute_stored(attr_name)
write_attribute_without_original_attributes(attr_name, value)
end
def read_attribute_with_freeze(attr_name) #:nodoc:
freeze_attribute(read_attribute_without_freeze(attr_name))
end
# Like ActiveRecord::Base#attributes_before_type_cast, but returns original attribute values
def original_attributes_before_type_cast
clone_attributes :read_original_attribute_before_type_cast
end
# Like ActiveRecord::Base#attributes, but returns original attribute values
def original_attributes
clone_attributes :read_original_attribute
end
# Like ActiveRecord::Base#read_attribute, but returns original attribute value
def read_original_attribute(attr_name)
attr_name = attr_name.to_s
unless original_attribute_stored?(attr_name)
read_attribute(attr_name)
else
if !(value = retrieve_original_attribute_value(attr_name)).nil?
if column = column_for_attribute(attr_name)
if unserializable_attribute?(attr_name, column)
unserialize_attribute(attr_name)
else
column.type_cast(value)
end
else
value
end
else
nil
end
end
end
# Like ActiveRecord::Base#read_attribute_before_type_cast, but returns original attribute value
def read_original_attribute_before_type_cast(attr_name)
attr_name = attr_name.to_s
unless original_attribute_stored?(attr_name)
read_attribute_before_type_cast(attr_name)
else
retrieve_original_attribute_value(attr_name)
end
end
# Replaces the current set of original values with the current attribute values.
# This makes it as if the attributes were never modified.
#
# person = Person.find(:first)
# person.name = 'New name'
# person.modified? # true
#
# person.clear_original_attributes
#
# person.modified? # false
#
# Pass :only or :except to refine the attributes that this is applied to.
#
# person = Person.find(:first)
# person.name = 'New name'
# person.age = 49
#
# person.name_modified? # true
# person.age_modified? # true
#
# person.clear_original_attributes :only => :name
#
# person.name_modified? # false
# person.age_modified? # true
def clear_original_attributes(options = {})
attributes_to_clear = case
when !options[:only] && !options[:except]
self.class.column_names
when options[:only]
Array(options[:only]).map(&:to_s)
when options[:except]
except = Array(options[:except]).map(&:to_s)
self.class.column_names - except
end
attributes_to_clear.each do |attribute|
remove_original_attribute(attribute)
end
end
# Returns true if any of the attributes have changed. New records always return false.
# Pass :reload to compare the current attributes against those in the database.
#
# person = Person.find(:first)
# person.modified? # false
# person.name = "New name"
# person.modified? # true
# person.modified?(:reload) # true
def modified?(reload = nil)
return true if new_record?
return attributes.substantial_difference_to?(self.class.find(id).attributes) if reload
original_attributes.substantial_difference_to?(attributes)
end
# Returns a hash containing changed attributes and their original values.
# Pass :changed to return the current attribute values instead.
#
# person = Person.find(:first)
# person.name # Jonathan
# person.name = 'New name'
# person.modified_attributes # { "name" => "Jonathan" }
# person.modified_attributes(:changed) # { "name" => "New name" }
def modified_attributes(changed = nil)
returning original_attributes.substantial_differences_to(attributes) do |values|
if changed
values.update(attributes.slice(*values.keys))
end
end
end
# Restore the attributes to their original values. Use :only or :except to restore specific attributes.
#
# person = Person.find(:first)
# person.name # Jonathan
# person.age # 100
# person.name = 'New name'
# person.age = 25
#
# person.restore_attributes # Restores name and age to original values
# person.restore_attributes :only => :name # Restores name to its original value
# person.restore_attributes :except => [:name, :age] # Restores all attributes except name and age to their original values
def restore_attributes(options = {})
original_attributes_before_type_cast = self.original_attributes_before_type_cast.dup
if options[:only]
only = Array(options[:only]).map(&:to_s)
original_attributes_before_type_cast.slice!(*only)
elsif options[:except]
except = Array(options[:except]).map(&:to_s)
original_attributes_before_type_cast.except!(*except)
end
@attributes.update(original_attributes_before_type_cast)
end
# Use +attribute+_modified? to find out if a specific attribute has been modified.
#
# person = Person.find(:first)
# person.name_modified? # false
# person.name = 'New name'
# person.name_modified? # true
#
# Use original_+attribute+ to get the original value of an attribute.
#
# person = Person.find(:first)
# person.name # 'Jonathan'
# person.name = 'Changed'
# person.original_name # Jonathan
#
# You can also call original_+association+ to get the original object of a belongs_to association.
#
# person.original_school instead of School.find(person.original_school_id)
#
# person = Person.find(:first)
# person.school_id # 1
# person.school_id = 3
# person.original_school # will do School.find(1)
def method_missing_with_modified(method_id, *arguments, &block)
method_name = method_id.to_s
if md = /_modified\?$/.match(method_name)
modified_attributes.has_key?(md.pre_match)
elsif md = /^original_/.match(method_name)
if self.class.column_names.include?(md.post_match)
read_original_attribute(md.post_match)
elsif reflection = self.class.reflections[md.post_match.to_sym]
begin
reflection.klass.find(read_original_attribute(reflection.primary_key_name))
rescue ActiveRecord::RecordNotFound
end
else
method_missing_without_modified method_id, *arguments, &block
end
else
method_missing_without_modified method_id, *arguments, &block
end
end
# When Base#reload is called, the original attributes should be cleared
def reload_with_clear_original_attributes #:nodoc:
clear_original_attributes
reload_without_clear_original_attributes
end
private
def included_attribute?(attr_name)
included_attributes && included_attributes.include?(attr_name.to_s)
end
def excluded_attribute?(attr_name)
excluded_attributes && excluded_attributes.include?(attr_name.to_s)
end
def store_original_attribute?(attr_name)
attr_name = attr_name.to_s
# If attributes are exclusively included, this attribute must be one of them and not excluded
if included_attributes
return included_attribute?(attr_name) && !excluded_attribute?(attr_name)
end
# Otherwise, this attribute just needs to not be excluded
!excluded_attribute?(attr_name)
end
def ensure_original_attribute_stored(attr_name)
attr_name = attr_name.to_s
if store_original_attribute?(attr_name) and !original_attribute_stored?(attr_name)
instance_variable_set(original_attribute_variable_name(attr_name), @attributes[attr_name])
end
end
def retrieve_original_attribute_value(attr_name)
instance_variable_get(original_attribute_variable_name(attr_name))
end
def remove_original_attribute(attr_name)
remove_instance_variable(original_attribute_variable_name(attr_name)) if original_attribute_stored?(attr_name)
end
def original_attribute_stored?(attr_name)
instance_variables.include?(original_attribute_variable_name(attr_name))
end
def original_attribute_variable_name(attr_name)
"@__original_#{attr_name}"
end
def evaluate_read_method_with_freeze(attr_name, method_definition)
evaluate_read_method_without_freeze(attr_name, method_definition)
# The method name may be different from the attribute name. Extract it from the definition.
method_name = /def (.+?);/.match(method_definition)[1]
unless method_name.ends_with?('?')
self.class.class_eval <<-END
def #{method_name}_with_freeze
freeze_attribute(#{method_name}_without_freeze)
end
END
self.class.alias_method_chain method_name, :freeze
end
end
# Running to_s on a frozen Date in Ruby 1.8.6 raises a TypeError (eg: Date.today.freeze.to_s)
# Work around this by using dup instead.
def freeze_attribute(value)
value.is_a?(Date) ? value.dup : value.freeze
end
end
end
end
end
ActiveRecord::Base.send(:include, ActiveRecord::Acts::Modified)