From bbf738f269bac83bcc8a0100455c69cba638d877 Mon Sep 17 00:00:00 2001 From: Jeremy Kemper Date: Sat, 29 Mar 2008 00:04:27 +0000 Subject: Track changes to unsaved attributes git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@9127 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- activerecord/CHANGELOG | 2 + activerecord/lib/active_record.rb | 2 + activerecord/lib/active_record/dirty.rb | 117 ++++++++++++++++++++++++++++++++ activerecord/test/cases/dirty_test.rb | 80 ++++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 activerecord/lib/active_record/dirty.rb create mode 100644 activerecord/test/cases/dirty_test.rb diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index eed88ce4ae..71491db213 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,7 @@ *SVN* +* Track changes to unsaved attributes. [Jeremy Kemper] + * Switched to UTC-timebased version numbers for migrations and the schema. This will as good as eliminate the problem of multiple migrations getting the same version assigned in different branches. Also added rake db:migrate:up/down to apply individual migrations that may need to be run when you merge branches #11458 [jbarnette] * Fixed that has_many :through would ignore the hash conditions #11447 [miloops] diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index cedcd8b0a8..3fc40291ba 100755 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -55,6 +55,7 @@ require 'active_record/schema' require 'active_record/calculations' require 'active_record/serialization' require 'active_record/attribute_methods' +require 'active_record/dirty' ActiveRecord::Base.class_eval do extend ActiveRecord::QueryCache @@ -73,6 +74,7 @@ ActiveRecord::Base.class_eval do include ActiveRecord::Calculations include ActiveRecord::Serialization include ActiveRecord::AttributeMethods + include ActiveRecord::Dirty end require 'active_record/connection_adapters/abstract_adapter' diff --git a/activerecord/lib/active_record/dirty.rb b/activerecord/lib/active_record/dirty.rb new file mode 100644 index 0000000000..a9ae2b148c --- /dev/null +++ b/activerecord/lib/active_record/dirty.rb @@ -0,0 +1,117 @@ +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'] } + module Dirty + def self.included(base) + base.attribute_method_suffix '_changed?', '_change', '_was' + base.alias_method_chain :write_attribute, :dirty + base.alias_method_chain :save, :dirty + base.alias_method_chain :save!, :dirty + 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 + + + # Clear changed attributes after they are saved. + def save_with_dirty(*args) #:nodoc: + save_without_dirty(*args) + ensure + changed_attributes.clear + end + + # Clear changed attributes after they are saved. + def save_with_dirty!(*args) #:nodoc: + save_without_dirty!(*args) + ensure + changed_attributes.clear + end + + private + # Map of change attr => original value. + def changed_attributes + @changed_attributes ||= {} + 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. + unless changed_attributes.include?(attr) + old = read_attribute(attr) + + # Remember the original value if it's different. + changed_attributes[attr] = old unless old == value + end + + # Carry on. + write_attribute_without_dirty(attr, value) + end + + + # Handle *_changed? for method_missing. + def attribute_changed?(attr) + changed_attributes.include?(attr) + end + + # Handle *_change for method_missing. + def attribute_change(attr) + [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr) + end + + # Handle *_was for method_missing. + def attribute_was(attr) + attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr) + end + end +end diff --git a/activerecord/test/cases/dirty_test.rb b/activerecord/test/cases/dirty_test.rb new file mode 100644 index 0000000000..c6777f6bfd --- /dev/null +++ b/activerecord/test/cases/dirty_test.rb @@ -0,0 +1,80 @@ +require 'cases/helper' + +# Stub out an AR-alike. +class DirtyTestSubject + def self.table_name; 'people' end + def self.primary_key; 'id' end + def self.attribute_method_suffix(*suffixes) suffixes end + + def initialize(attrs = {}) @attributes = attrs end + + def save + changed_attributes.clear + end + + alias_method :save!, :save + + def name; read_attribute('name') end + def name=(value); write_attribute('name', value) end + def name_was; attribute_was('name') end + def name_change; attribute_change('name') end + def name_changed?; attribute_changed?('name') end + + private + def define_read_methods; nil end + + def read_attribute(attr) + @attributes[attr] + end + + def write_attribute(attr, value) + @attributes[attr] = value + end +end + +# Include the module after the class is all set up. +DirtyTestSubject.module_eval { include ActiveRecord::Dirty } + + +class DirtyTest < Test::Unit::TestCase + def test_attribute_changes + # New record - no changes. + person = DirtyTestSubject.new + assert !person.name_changed? + assert_nil person.name_change + + # Change name. + person.name = 'a' + assert person.name_changed? + assert_nil person.name_was + assert_equal [nil, 'a'], person.name_change + + # Saved - no changes. + person.save! + assert !person.name_changed? + assert_nil person.name_change + + # Same value - no changes. + person.name = 'a' + assert !person.name_changed? + assert_nil person.name_change + end + + def test_object_should_be_changed_if_any_attribute_is_changed + person = DirtyTestSubject.new + assert !person.changed? + assert_equal [], person.changed + assert_equal Hash.new, person.changes + + person.name = 'a' + assert person.changed? + assert_nil person.name_was + assert_equal %w(name), person.changed + assert_equal({'name' => [nil, 'a']}, person.changes) + + person.save + assert !person.changed? + assert_equal [], person.changed + assert_equal({}, person.changes) + end +end -- cgit v1.2.3