1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Concernify AR AttributeMethods

This commit is contained in:
Joshua Peek 2009-07-24 00:25:27 -05:00
parent 2c2ca833a5
commit c2b075bed0
10 changed files with 451 additions and 383 deletions

View file

@ -52,7 +52,6 @@ module ActiveRecord
autoload :Batches, 'active_record/batches'
autoload :Calculations, 'active_record/calculations'
autoload :Callbacks, 'active_record/callbacks'
autoload :Dirty, 'active_record/dirty'
autoload :DynamicFinderMatch, 'active_record/dynamic_finder_match'
autoload :DynamicScopeMatch, 'active_record/dynamic_scope_match'
autoload :Migration, 'active_record/migration'
@ -71,6 +70,15 @@ module ActiveRecord
autoload :Transactions, 'active_record/transactions'
autoload :Validations, 'active_record/validations'
module AttributeMethods
autoload :BeforeTypeCast, 'active_record/attribute_methods/before_type_cast'
autoload :Dirty, 'active_record/attribute_methods/dirty'
autoload :Query, 'active_record/attribute_methods/query'
autoload :Read, 'active_record/attribute_methods/read'
autoload :TimeZoneConversion, 'active_record/attribute_methods/time_zone_conversion'
autoload :Write, 'active_record/attribute_methods/write'
end
module Locking
autoload :Optimistic, 'active_record/locking/optimistic'
autoload :Pessimistic, 'active_record/locking/pessimistic'

View file

@ -4,20 +4,11 @@ module ActiveRecord
module AttributeMethods #:nodoc:
extend ActiveSupport::Concern
DEFAULT_SUFFIXES = %w(= ? _before_type_cast)
ATTRIBUTE_TYPES_CACHED_BY_DEFAULT = [:datetime, :timestamp, :time, :date]
included do
attribute_method_suffix(*DEFAULT_SUFFIXES)
cattr_accessor :attribute_types_cached_by_default, :instance_writer => false
self.attribute_types_cached_by_default = ATTRIBUTE_TYPES_CACHED_BY_DEFAULT
cattr_accessor :time_zone_aware_attributes, :instance_writer => false
self.time_zone_aware_attributes = false
class_inheritable_accessor :skip_time_zone_conversion_for_attributes, :instance_writer => false
self.skip_time_zone_conversion_for_attributes = []
end
# Declare and check for suffixed attribute methods.
@ -60,7 +51,6 @@ module ActiveRecord
@@attribute_method_regexp.match(method_name)
end
# Contains the names of the generated attribute methods.
def generated_methods #:nodoc:
@generated_methods ||= Set.new
@ -74,27 +64,18 @@ module ActiveRecord
# accessors, mutators and query methods.
def define_attribute_methods
return if generated_methods?
columns_hash.each do |name, column|
unless instance_method_already_implemented?(name)
if self.serialized_attributes[name]
define_read_method_for_serialized_attribute(name)
elsif create_time_zone_conversion_attribute?(name, column)
define_read_method_for_time_zone_conversion(name)
columns_hash.keys.each do |name|
# TODO: Generate for all defined suffixes
["", "=", "?"].each do |suffix|
method_name = "#{name}#{suffix}"
unless instance_method_already_implemented?(method_name)
generate_method = "define_attribute_method#{suffix}"
if respond_to?(generate_method)
send(generate_method, name)
else
define_read_method(name.to_sym, name, column)
evaluate_attribute_method(name, "def #{method_name}(*args); attribute#{suffix}('#{name}', *args); end", method_name)
end
end
unless instance_method_already_implemented?("#{name}=")
if create_time_zone_conversion_attribute?(name, column)
define_write_method_for_time_zone_conversion(name)
else
define_write_method(name.to_sym)
end
end
unless instance_method_already_implemented?("#{name}?")
define_question_method(name)
end
end
end
@ -104,15 +85,13 @@ module ActiveRecord
# method is defined by Active Record though.
def instance_method_already_implemented?(method_name)
method_name = method_name.to_s
return true if method_name =~ /^id(=$|\?$|$)/
return true if method_name =~ /^id(=$|\?$|$)/ # TODO: Check against all defined suffixes
@_defined_class_methods ||= ancestors.first(ancestors.index(ActiveRecord::Base)).sum([]) { |m| m.public_instance_methods(false) | m.private_instance_methods(false) | m.protected_instance_methods(false) }.map {|m| m.to_s }.to_set
@@_defined_activerecord_methods ||= (ActiveRecord::Base.public_instance_methods(false) | ActiveRecord::Base.private_instance_methods(false) | ActiveRecord::Base.protected_instance_methods(false)).map{|m| m.to_s }.to_set
raise DangerousAttributeError, "#{method_name} is defined by ActiveRecord" if @@_defined_activerecord_methods.include?(method_name)
@_defined_class_methods.include?(method_name)
end
alias :define_read_methods :define_attribute_methods
# +cache_attributes+ allows you to declare which converted attribute values should
# be cached. Usually caching only pays off for attributes with expensive conversion
# methods, like time related columns (e.g. +created_at+, +updated_at+).
@ -133,83 +112,18 @@ module ActiveRecord
end
private
# Suffixes a, ?, c become regexp /(a|\?|c)$/
def rebuild_attribute_method_regexp
suffixes = attribute_method_suffixes.map { |s| Regexp.escape(s) }
@@attribute_method_regexp = /(#{suffixes.join('|')})$/.freeze
end
# Default to =, ?, _before_type_cast
def attribute_method_suffixes
@@attribute_method_suffixes ||= []
end
def create_time_zone_conversion_attribute?(name, column)
time_zone_aware_attributes && !skip_time_zone_conversion_for_attributes.include?(name.to_sym) && [:datetime, :timestamp].include?(column.type)
end
# Define an attribute reader method. Cope with nil column.
def define_read_method(symbol, attr_name, column)
cast_code = column.type_cast_code('v') if column
access_code = cast_code ? "(v=@attributes['#{attr_name}']) && #{cast_code}" : "@attributes['#{attr_name}']"
unless attr_name.to_s == self.primary_key.to_s
access_code = access_code.insert(0, "missing_attribute('#{attr_name}', caller) unless @attributes.has_key?('#{attr_name}'); ")
end
if cache_attribute?(attr_name)
access_code = "@attributes_cache['#{attr_name}'] ||= (#{access_code})"
end
evaluate_attribute_method attr_name, "def #{symbol}; #{access_code}; end"
end
# Define read method for serialized attribute.
def define_read_method_for_serialized_attribute(attr_name)
evaluate_attribute_method attr_name, "def #{attr_name}; unserialize_attribute('#{attr_name}'); end"
end
# Defined for all +datetime+ and +timestamp+ attributes when +time_zone_aware_attributes+ are enabled.
# This enhanced read method automatically converts the UTC time stored in the database to the time zone stored in Time.zone.
def define_read_method_for_time_zone_conversion(attr_name)
method_body = <<-EOV
def #{attr_name}(reload = false)
cached = @attributes_cache['#{attr_name}']
return cached if cached && !reload
time = read_attribute('#{attr_name}')
@attributes_cache['#{attr_name}'] = time.acts_like?(:time) ? time.in_time_zone : time
end
EOV
evaluate_attribute_method attr_name, method_body
end
# Defines a predicate method <tt>attr_name?</tt>.
def define_question_method(attr_name)
evaluate_attribute_method attr_name, "def #{attr_name}?; query_attribute('#{attr_name}'); end", "#{attr_name}?"
end
def define_write_method(attr_name)
evaluate_attribute_method attr_name, "def #{attr_name}=(new_value);write_attribute('#{attr_name}', new_value);end", "#{attr_name}="
end
# Defined for all +datetime+ and +timestamp+ attributes when +time_zone_aware_attributes+ are enabled.
# This enhanced write method will automatically convert the time passed to it to the zone stored in Time.zone.
def define_write_method_for_time_zone_conversion(attr_name)
method_body = <<-EOV
def #{attr_name}=(time)
unless time.acts_like?(:time)
time = time.is_a?(String) ? Time.zone.parse(time) : time.to_time rescue time
end
time = time.in_time_zone rescue nil if time
write_attribute(:#{attr_name}, time)
end
EOV
evaluate_attribute_method attr_name, method_body, "#{attr_name}="
end
# Evaluate the definition for an attribute related method
def evaluate_attribute_method(attr_name, method_definition, method_name=attr_name)
def evaluate_attribute_method(attr_name, method_definition, method_name)
unless method_name.to_s == primary_key.to_s
generated_methods << method_name
end
@ -225,8 +139,7 @@ module ActiveRecord
end
end
end
end # ClassMethods
end
# Allows access to the object attributes, which are held in the <tt>@attributes</tt> hash, as though they
# were first-class methods. So a Person class with a name attribute can use Person#name and
@ -266,80 +179,6 @@ module ActiveRecord
end
end
# Returns the value of the attribute identified by <tt>attr_name</tt> after it has been typecast (for example,
# "2004-12-12" in a data column is cast to a date object, like Date.new(2004, 12, 12)).
def read_attribute(attr_name)
attr_name = attr_name.to_s
if !(value = @attributes[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
def read_attribute_before_type_cast(attr_name)
@attributes[attr_name]
end
# Returns true if the attribute is of a text column and marked for serialization.
def unserializable_attribute?(attr_name, column)
column.text? && self.class.serialized_attributes[attr_name]
end
# Returns the unserialized object of the attribute.
def unserialize_attribute(attr_name)
unserialized_object = object_from_yaml(@attributes[attr_name])
if unserialized_object.is_a?(self.class.serialized_attributes[attr_name]) || unserialized_object.nil?
@attributes.frozen? ? unserialized_object : @attributes[attr_name] = unserialized_object
else
raise SerializationTypeMismatch,
"#{attr_name} was supposed to be a #{self.class.serialized_attributes[attr_name]}, but was a #{unserialized_object.class.to_s}"
end
end
# Updates the attribute identified by <tt>attr_name</tt> with the specified +value+. Empty strings for fixnum and float
# columns are turned into +nil+.
def write_attribute(attr_name, value)
attr_name = attr_name.to_s
@attributes_cache.delete(attr_name)
if (column = column_for_attribute(attr_name)) && column.number?
@attributes[attr_name] = convert_number_column_value(value)
else
@attributes[attr_name] = value
end
end
def query_attribute(attr_name)
unless value = read_attribute(attr_name)
false
else
column = self.class.columns_hash[attr_name]
if column.nil?
if Numeric === value || value !~ /[^0-9]/
!value.to_i.zero?
else
return false if ActiveRecord::ConnectionAdapters::Column::FALSE_VALUES.include?(value)
!value.blank?
end
elsif column.number?
!value.zero?
else
!value.blank?
end
end
end
# A Person object with a name attribute can ask <tt>person.respond_to?(:name)</tt>,
# <tt>person.respond_to?(:name=)</tt>, and <tt>person.respond_to?(:name?)</tt>
# which will all return +true+.
@ -380,20 +219,5 @@ module ActiveRecord
def missing_attribute(attr_name, stack)
raise ActiveRecord::MissingAttributeError, "missing attribute: #{attr_name}", stack
end
# Handle *? for method_missing.
def attribute?(attribute_name)
query_attribute(attribute_name)
end
# Handle *= for method_missing.
def attribute=(attribute_name, value)
write_attribute(attribute_name, value)
end
# Handle *_before_type_cast for method_missing.
def attribute_before_type_cast(attribute_name)
read_attribute_before_type_cast(attribute_name)
end
end
end

View file

@ -0,0 +1,21 @@
module ActiveRecord
module AttributeMethods
module BeforeTypeCast
extend ActiveSupport::Concern
included do
attribute_method_suffix "_before_type_cast"
end
def read_attribute_before_type_cast(attr_name)
@attributes[attr_name]
end
private
# Handle *_before_type_cast for method_missing.
def attribute_before_type_cast(attribute_name)
read_attribute_before_type_cast(attribute_name)
end
end
end
end

View file

@ -0,0 +1,187 @@
module ActiveRecord
module AttributeMethods
# Track unsaved attribute changes.
#
# A newly instantiated object is unchanged:
# person = Person.find_by_name('uncle bob')
# person.changed? # => false
#
# Change the name:
# person.name = 'Bob'
# person.changed? # => true
# person.name_changed? # => true
# person.name_was # => 'uncle bob'
# person.name_change # => ['uncle bob', 'Bob']
# person.name = 'Bill'
# person.name_change # => ['uncle bob', 'Bill']
#
# Save the changes:
# person.save
# person.changed? # => false
# person.name_changed? # => false
#
# Assigning the same value leaves the attribute unchanged:
# person.name = 'Bill'
# person.name_changed? # => false
# person.name_change # => nil
#
# Which attributes have changed?
# person.name = 'bob'
# person.changed # => ['name']
# person.changes # => { 'name' => ['Bill', 'bob'] }
#
# Before modifying an attribute in-place:
# person.name_will_change!
# person.name << 'by'
# person.name_change # => ['uncle bob', 'uncle bobby']
module Dirty
extend ActiveSupport::Concern
DIRTY_SUFFIXES = ['_changed?', '_change', '_will_change!', '_was']
included do
attribute_method_suffix *DIRTY_SUFFIXES
alias_method_chain :save, :dirty
alias_method_chain :save!, :dirty
alias_method_chain :update, :dirty
alias_method_chain :reload, :dirty
superclass_delegating_accessor :partial_updates
self.partial_updates = true
end
# Do any attributes have unsaved changes?
# person.changed? # => false
# person.name = 'bob'
# person.changed? # => true
def changed?
!changed_attributes.empty?
end
# List of attributes with unsaved changes.
# person.changed # => []
# person.name = 'bob'
# person.changed # => ['name']
def changed
changed_attributes.keys
end
# Map of changed attrs => [original value, new value].
# person.changes # => {}
# person.name = 'bob'
# person.changes # => { 'name' => ['bill', 'bob'] }
def changes
changed.inject({}) { |h, attr| h[attr] = attribute_change(attr); h }
end
# Attempts to +save+ the record and clears changed attributes if successful.
def save_with_dirty(*args) #:nodoc:
if status = save_without_dirty(*args)
changed_attributes.clear
end
status
end
# Attempts to <tt>save!</tt> the record and clears changed attributes if successful.
def save_with_dirty!(*args) #:nodoc:
status = save_without_dirty!(*args)
changed_attributes.clear
status
end
# <tt>reload</tt> the record and clears changed attributes.
def reload_with_dirty(*args) #:nodoc:
record = reload_without_dirty(*args)
changed_attributes.clear
record
end
private
# Map of change <tt>attr => original value</tt>.
def changed_attributes
@changed_attributes ||= {}
end
# Handle <tt>*_changed?</tt> for +method_missing+.
def attribute_changed?(attr)
changed_attributes.include?(attr)
end
# Handle <tt>*_change</tt> for +method_missing+.
def attribute_change(attr)
[changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
end
# Handle <tt>*_was</tt> for +method_missing+.
def attribute_was(attr)
attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr)
end
# Handle <tt>*_will_change!</tt> for +method_missing+.
def attribute_will_change!(attr)
changed_attributes[attr] = clone_attribute_value(:read_attribute, attr)
end
# Wrap write_attribute to remember original attribute value.
def write_attribute(attr, value)
attr = attr.to_s
# The attribute already has an unsaved change.
if changed_attributes.include?(attr)
old = changed_attributes[attr]
changed_attributes.delete(attr) unless field_changed?(attr, old, value)
else
old = clone_attribute_value(:read_attribute, attr)
changed_attributes[attr] = old if field_changed?(attr, old, value)
end
# Carry on.
super(attr, value)
end
def update_with_dirty
if partial_updates?
# Serialized attributes should always be written in case they've been
# changed in place.
update_without_dirty(changed | self.class.serialized_attributes.keys)
else
update_without_dirty
end
end
def field_changed?(attr, old, value)
if column = column_for_attribute(attr)
if column.number? && column.null && (old.nil? || old == 0) && value.blank?
# For nullable numeric columns, NULL gets stored in database for blank (i.e. '') values.
# Hence we don't record it as a change if the value changes from nil to ''.
# If an old value of 0 is set to '' we want this to get changed to nil as otherwise it'll
# be typecast back to 0 (''.to_i => 0)
value = nil
else
value = column.type_cast(value)
end
end
old != value
end
module ClassMethods
def self.extended(base)
class << base
alias_method_chain :alias_attribute, :dirty
end
end
def alias_attribute_with_dirty(new_name, old_name)
alias_attribute_without_dirty(new_name, old_name)
DIRTY_SUFFIXES.each do |suffix|
module_eval <<-STR, __FILE__, __LINE__+1
def #{new_name}#{suffix}; self.#{old_name}#{suffix}; end # def subject_changed?; self.title_changed?; end
STR
end
end
end
end
end
end

View file

@ -0,0 +1,37 @@
module ActiveRecord
module AttributeMethods
module Query
extend ActiveSupport::Concern
included do
attribute_method_suffix "?"
end
def query_attribute(attr_name)
unless value = read_attribute(attr_name)
false
else
column = self.class.columns_hash[attr_name]
if column.nil?
if Numeric === value || value !~ /[^0-9]/
!value.to_i.zero?
else
return false if ActiveRecord::ConnectionAdapters::Column::FALSE_VALUES.include?(value)
!value.blank?
end
elsif column.number?
!value.zero?
else
!value.blank?
end
end
end
private
# Handle *? for method_missing.
def attribute?(attribute_name)
query_attribute(attribute_name)
end
end
end
end

View file

@ -0,0 +1,79 @@
module ActiveRecord
module AttributeMethods
module Read
extend ActiveSupport::Concern
# included do
# attribute_method_suffix ""
# end
module ClassMethods
protected
def define_attribute_method(attr_name)
if self.serialized_attributes[attr_name]
define_read_method_for_serialized_attribute(attr_name)
else
define_read_method(attr_name.to_sym, attr_name, columns_hash[attr_name])
end
end
private
# Define read method for serialized attribute.
def define_read_method_for_serialized_attribute(attr_name)
evaluate_attribute_method attr_name, "def #{attr_name}; unserialize_attribute('#{attr_name}'); end", attr_name
end
# Define an attribute reader method. Cope with nil column.
def define_read_method(symbol, attr_name, column)
cast_code = column.type_cast_code('v') if column
access_code = cast_code ? "(v=@attributes['#{attr_name}']) && #{cast_code}" : "@attributes['#{attr_name}']"
unless attr_name.to_s == self.primary_key.to_s
access_code = access_code.insert(0, "missing_attribute('#{attr_name}', caller) unless @attributes.has_key?('#{attr_name}'); ")
end
if cache_attribute?(attr_name)
access_code = "@attributes_cache['#{attr_name}'] ||= (#{access_code})"
end
evaluate_attribute_method attr_name, "def #{symbol}; #{access_code}; end", attr_name
end
end
# Returns the value of the attribute identified by <tt>attr_name</tt> after it has been typecast (for example,
# "2004-12-12" in a data column is cast to a date object, like Date.new(2004, 12, 12)).
def read_attribute(attr_name)
attr_name = attr_name.to_s
if !(value = @attributes[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
# Returns true if the attribute is of a text column and marked for serialization.
def unserializable_attribute?(attr_name, column)
column.text? && self.class.serialized_attributes[attr_name]
end
# Returns the unserialized object of the attribute.
def unserialize_attribute(attr_name)
unserialized_object = object_from_yaml(@attributes[attr_name])
if unserialized_object.is_a?(self.class.serialized_attributes[attr_name]) || unserialized_object.nil?
@attributes.frozen? ? unserialized_object : @attributes[attr_name] = unserialized_object
else
raise SerializationTypeMismatch,
"#{attr_name} was supposed to be a #{self.class.serialized_attributes[attr_name]}, but was a #{unserialized_object.class.to_s}"
end
end
end
end
end

View file

@ -0,0 +1,60 @@
module ActiveRecord
module AttributeMethods
module TimeZoneConversion
extend ActiveSupport::Concern
included do
cattr_accessor :time_zone_aware_attributes, :instance_writer => false
self.time_zone_aware_attributes = false
class_inheritable_accessor :skip_time_zone_conversion_for_attributes, :instance_writer => false
self.skip_time_zone_conversion_for_attributes = []
end
module ClassMethods
protected
# Defined for all +datetime+ and +timestamp+ attributes when +time_zone_aware_attributes+ are enabled.
# This enhanced read method automatically converts the UTC time stored in the database to the time zone stored in Time.zone.
def define_attribute_method(attr_name)
if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
method_body = <<-EOV
def #{attr_name}(reload = false)
cached = @attributes_cache['#{attr_name}']
return cached if cached && !reload
time = read_attribute('#{attr_name}')
@attributes_cache['#{attr_name}'] = time.acts_like?(:time) ? time.in_time_zone : time
end
EOV
evaluate_attribute_method attr_name, method_body, attr_name
else
super
end
end
# Defined for all +datetime+ and +timestamp+ attributes when +time_zone_aware_attributes+ are enabled.
# This enhanced write method will automatically convert the time passed to it to the zone stored in Time.zone.
def define_attribute_method=(attr_name)
if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
method_body = <<-EOV
def #{attr_name}=(time)
unless time.acts_like?(:time)
time = time.is_a?(String) ? Time.zone.parse(time) : time.to_time rescue time
end
time = time.in_time_zone rescue nil if time
write_attribute(:#{attr_name}, time)
end
EOV
evaluate_attribute_method attr_name, method_body, "#{attr_name}="
else
super
end
end
private
def create_time_zone_conversion_attribute?(name, column)
time_zone_aware_attributes && !skip_time_zone_conversion_for_attributes.include?(name.to_sym) && [:datetime, :timestamp].include?(column.type)
end
end
end
end
end

View file

@ -0,0 +1,36 @@
module ActiveRecord
module AttributeMethods
module Write
extend ActiveSupport::Concern
included do
attribute_method_suffix "="
end
module ClassMethods
protected
def define_attribute_method=(attr_name)
evaluate_attribute_method attr_name, "def #{attr_name}=(new_value); write_attribute('#{attr_name}', new_value); end", "#{attr_name}="
end
end
# Updates the attribute identified by <tt>attr_name</tt> with the specified +value+. Empty strings for fixnum and float
# columns are turned into +nil+.
def write_attribute(attr_name, value)
attr_name = attr_name.to_s
@attributes_cache.delete(attr_name)
if (column = column_for_attribute(attr_name)) && column.number?
@attributes[attr_name] = convert_number_column_value(value)
else
@attributes[attr_name] = value
end
end
private
# Handle *= for method_missing.
def attribute=(attribute_name, value)
write_attribute(attribute_name, value)
end
end
end
end

View file

@ -3212,7 +3212,9 @@ module ActiveRecord #:nodoc:
include Validations
include Locking::Optimistic, Locking::Pessimistic
include AttributeMethods
include Dirty
include AttributeMethods::Read, AttributeMethods::Write, AttributeMethods::BeforeTypeCast, AttributeMethods::Query
include AttributeMethods::TimeZoneConversion
include AttributeMethods::Dirty
include Callbacks, ActiveModel::Observing, Timestamp
include Associations, AssociationPreload, NamedScope
include ActiveModel::Conversion

View file

@ -1,186 +0,0 @@
module ActiveRecord
# Track unsaved attribute changes.
#
# A newly instantiated object is unchanged:
# person = Person.find_by_name('uncle bob')
# person.changed? # => false
#
# Change the name:
# person.name = 'Bob'
# person.changed? # => true
# person.name_changed? # => true
# person.name_was # => 'uncle bob'
# person.name_change # => ['uncle bob', 'Bob']
# person.name = 'Bill'
# person.name_change # => ['uncle bob', 'Bill']
#
# Save the changes:
# person.save
# person.changed? # => false
# person.name_changed? # => false
#
# Assigning the same value leaves the attribute unchanged:
# person.name = 'Bill'
# person.name_changed? # => false
# person.name_change # => nil
#
# Which attributes have changed?
# person.name = 'bob'
# person.changed # => ['name']
# person.changes # => { 'name' => ['Bill', 'bob'] }
#
# Before modifying an attribute in-place:
# person.name_will_change!
# person.name << 'by'
# person.name_change # => ['uncle bob', 'uncle bobby']
module Dirty
extend ActiveSupport::Concern
DIRTY_SUFFIXES = ['_changed?', '_change', '_will_change!', '_was']
included do
attribute_method_suffix *DIRTY_SUFFIXES
alias_method_chain :write_attribute, :dirty
alias_method_chain :save, :dirty
alias_method_chain :save!, :dirty
alias_method_chain :update, :dirty
alias_method_chain :reload, :dirty
superclass_delegating_accessor :partial_updates
self.partial_updates = true
end
# Do any attributes have unsaved changes?
# person.changed? # => false
# person.name = 'bob'
# person.changed? # => true
def changed?
!changed_attributes.empty?
end
# List of attributes with unsaved changes.
# person.changed # => []
# person.name = 'bob'
# person.changed # => ['name']
def changed
changed_attributes.keys
end
# Map of changed attrs => [original value, new value].
# person.changes # => {}
# person.name = 'bob'
# person.changes # => { 'name' => ['bill', 'bob'] }
def changes
changed.inject({}) { |h, attr| h[attr] = attribute_change(attr); h }
end
# Attempts to +save+ the record and clears changed attributes if successful.
def save_with_dirty(*args) #:nodoc:
if status = save_without_dirty(*args)
changed_attributes.clear
end
status
end
# Attempts to <tt>save!</tt> the record and clears changed attributes if successful.
def save_with_dirty!(*args) #:nodoc:
status = save_without_dirty!(*args)
changed_attributes.clear
status
end
# <tt>reload</tt> the record and clears changed attributes.
def reload_with_dirty(*args) #:nodoc:
record = reload_without_dirty(*args)
changed_attributes.clear
record
end
private
# Map of change <tt>attr => original value</tt>.
def changed_attributes
@changed_attributes ||= {}
end
# Handle <tt>*_changed?</tt> for +method_missing+.
def attribute_changed?(attr)
changed_attributes.include?(attr)
end
# Handle <tt>*_change</tt> for +method_missing+.
def attribute_change(attr)
[changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
end
# Handle <tt>*_was</tt> for +method_missing+.
def attribute_was(attr)
attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr)
end
# Handle <tt>*_will_change!</tt> for +method_missing+.
def attribute_will_change!(attr)
changed_attributes[attr] = clone_attribute_value(:read_attribute, attr)
end
# Wrap write_attribute to remember original attribute value.
def write_attribute_with_dirty(attr, value)
attr = attr.to_s
# The attribute already has an unsaved change.
if changed_attributes.include?(attr)
old = changed_attributes[attr]
changed_attributes.delete(attr) unless field_changed?(attr, old, value)
else
old = clone_attribute_value(:read_attribute, attr)
changed_attributes[attr] = old if field_changed?(attr, old, value)
end
# Carry on.
write_attribute_without_dirty(attr, value)
end
def update_with_dirty
if partial_updates?
# Serialized attributes should always be written in case they've been
# changed in place.
update_without_dirty(changed | self.class.serialized_attributes.keys)
else
update_without_dirty
end
end
def field_changed?(attr, old, value)
if column = column_for_attribute(attr)
if column.number? && column.null && (old.nil? || old == 0) && value.blank?
# For nullable numeric columns, NULL gets stored in database for blank (i.e. '') values.
# Hence we don't record it as a change if the value changes from nil to ''.
# If an old value of 0 is set to '' we want this to get changed to nil as otherwise it'll
# be typecast back to 0 (''.to_i => 0)
value = nil
else
value = column.type_cast(value)
end
end
old != value
end
module ClassMethods
def self.extended(base)
class << base
alias_method_chain :alias_attribute, :dirty
end
end
def alias_attribute_with_dirty(new_name, old_name)
alias_attribute_without_dirty(new_name, old_name)
DIRTY_SUFFIXES.each do |suffix|
module_eval <<-STR, __FILE__, __LINE__+1
def #{new_name}#{suffix}; self.#{old_name}#{suffix}; end # def subject_changed?; self.title_changed?; end
STR
end
end
end
end
end