lib/aws/record/dirty_tracking.rb



# Copyright 2011-2012 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
#     http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

module AWS
  module Record

    # Provides a way to track changes in your records.
    #
    #   my_book = Book['bookid']
    #
    #   my_book.changed? #=> false
    #   my_book.title #=> "My Book"
    #   my_book.title = "My Awesome Book"
    #   my_book.changed? #=> true
    #
    # You can inspect further and get a list of changed attributes
    #
    #   my_book.changed #=> ['title']
    #
    # Or you can get a more detailed description of the changes.  {#changes}
    # returns a hash of changed attributes (keys) with their old and new 
    # values.
    #   
    #   my_book.changes
    #   #=> { 'title' => ['My Book', 'My Awesome Book']
    #
    # For every configured attribute you also get a handful of methods
    # for inspecting changes on that attribute.  Given the following
    # attribute:
    #
    #   string_attr :title
    # 
    # You can now call any of the following methods:
    #
    #   * title_changed?
    #   * title_change
    #   * title_was
    #   * reset_title!
    #   * title_will_change!
    #
    # Given the title change from above:
    # 
    #   my_book.title_changed? #=> true
    #   my_book.title_change #=> ['My Book', 'My Awesome Book']
    #   my_book.title_was #=> ['My Book']
    #
    #   my_book.reset_title!
    #   my_book.title #=> 'My Book'
    #
    # == In-Place Editing
    #
    # Dirty tracking works by comparing incoming attribute values upon 
    # assignment against the value that was there previously.  If you
    # use functions against the value that modify it (like gsub!)
    # you must notify your record about the coming change.
    #
    #   my_book.title #=> 'My Book'
    #   my_book.title_will_change!
    #   my_book.title.gsub!(/My/, 'Your')
    #   my_book.title_change #=> ['My Book', 'Your Book']
    #
    # == Partial Updates
    #
    # Dirty tracking makes it possible to only persist those attributes
    # that have changed since they were loaded.  This speeds up requests 
    # against AWS when saving data.
    #
    module DirtyTracking
  
      # Returns true if this model has unsaved changes.
      #
      #   b = Book.new(:title => 'My Book')
      #   b.changed?
      #   #=> true
      #
      # New objects and objects freshly loaded should not have any changes:
      #
      #   b = Book.new
      #   b.changed?      #=> false
      #
      #   b = Book.first
      #   b.changed?      #=> false
      #
      # @return [Boolean] Returns true if any of the attributes have
      #   unsaved changes.
      def changed?
        !orig_values.empty?
      end
  
      # Returns an array of attribute names that have changes. 
      #
      #   book.changed #=> []
      #   person.title = 'New Title'
      #   book.changed #=> ['title']
      #
      # @return [Array] Returns an array of attribute names that have 
      #   unsaved changes.
      def changed
        orig_values.keys
      end
  
      # Returns the changed attributes in a hash.  Keys are attribute names,
      # values are two value arrays.  The first value is the previous
      # attribute value, the second is the current attribute value.
      #
      #   book.title = 'New Title'
      #   book.changes
      #   #=> { 'title' => ['Old Title', 'New Title'] }
      #
      # @return [Hash] Returns a hash of attribute changes.
      def changes
        changed.inject({}) do |changes, attr_name|
          changes[attr_name] = attribute_change(attr_name)
          changes
        end
      end
  
      # Returns true if the named attribute has unsaved changes.
      #
      # This is an attribute method.  The following two expressions 
      # are equivilent:
      #
      #   book.title_changed?
      #   book.attribute_changed?(:title)
      #
      # @param [String] attribute_name Name of the attribute to check 
      #   for changes.
      #
      # @return [Boolean] Returns true if the named attribute
      #   has unsaved changes.
      # @private
      private
      def attribute_changed? attribute_name
        orig_values.keys.include?(attribute_name)
      end
  
      # Returns an array of the old value and the new value for 
      # attributes that have unsaved changes, returns nil otherwise.
      #
      # This is an attribute method.  The following two expressions 
      # are equivilent:
      #
      #   book.title_change
      #   book.attribute_change(:title)
      #
      # @example Asking for changes on an unchanged attribute
      #
      #   book = Book.new
      #   book.title_change #=> nil
      #
      # @example Getting changed attributes on a new object
      #
      #   book = Book.new(:title => 'My Book')
      #   book.title_change #=> [nil, 'My Book']
      #
      # @example Getting changed attributes on a loaded object
      #
      #   book = Book.first
      #   book.title = 'New Title'
      #   book.title_change #=> ['Old Title', 'New Title']
      # 
      # @param [String] attribute_name Name of the attribute to fetch
      #   a change for.
      # @return [Boolean] Returns true if the named attribute
      #   has unsaved changes.
      # @private
      private
      def attribute_change attribute_name
        self.class.attribute_for(attribute_name) do |attribute|
          if orig_values.has_key?(attribute.name)
            [orig_values[attribute.name], __send__(attribute.name)]
          else
            nil
          end
        end
      end
  
      # Returns the previous value for changed attributes, or the current
      # value for unchanged attributes.
      #
      # This is an attribute method.  The following two expressions 
      # are equivilent:
      #
      #   book.title_was
      #   book.attribute_was(:title)
      #
      # @example Returns the previous value for changed attributes:
      #
      #   book = Book.where(:title => 'My Book').first
      #   book.title = 'New Title'
      #   book.title_was #=> 'My Book'
      #
      # @example Returns the current value for unchanged attributes:
      #
      #   book = Book.where(:title => 'My Book').first
      #   book.title_was #=> 'My Book'
      #
      # @return Returns the previous value for changed attributes
      #   or the current value for unchanged attributes.
      # @private
      private
      def attribute_was attribute_name
        self.class.attribute_for(attribute_name) do |attribute|
          name = attribute.name
          orig_values.has_key?(name) ? orig_values[name] : __send__(name)
        end
      end
  
      # Reverts any changes to the attribute, restoring its original value.
      # @param [String] attribute_name Name of the attribute to reset.
      # @return [nil]
      # @private
      private
      def reset_attribute! attribute_name
        __send__("#{attribute_name}=", attribute_was(attribute_name))
        nil
      end
  
      # Indicate to the record that you are about to edit an attribute
      # in place.
      # @param [String] attribute_name Name of the attribute that will
      #   be changed.
      # @return [nil]
      # @private
      private
      def attribute_will_change! attribute_name
        self.class.attribute_for(attribute_name) do |attribute|
          name = attribute.name
          unless orig_values.has_key?(name)
            was = __send__(name)
            begin
              # booleans, nil, etc all #respond_to?(:clone), but they raise
              # a TypeError when you attempt to dup them.  
              orig_values[name] = was.clone
            rescue TypeError
              orig_values[name] = was
            end
          end
        end
        nil
      end

      private
      def orig_values
        @_orig_values ||= {}
      end
  
      private
      def clear_change! attribute_name
        orig_values.delete(attribute_name)
      end
  
      private
      def ignore_changes &block
        begin
          @_ignore_changes = true
          yield
        ensure
          @_ignore_changes = false
        end
      end
  
      private
      def if_tracking_changes &block
        yield unless @_ignore_changes
      end
  
      private
      def clear_changes!
        orig_values.clear
      end
      
    end
  end
end