Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

Ticket #3238: activerecord_track_changes.diff

File activerecord_track_changes.diff, 12.8 kB (added by michaelboutros, 6 months ago)
  • activerecord/test/attribute_methods_test.rb

    old new  
    139139    end 
    140140  end 
    141141 
     142  def test_attribute_changed_without_inplace_modifications 
     143    # Turn off in place modifications   
     144    Topic.track_inplace_modifications = false  
     145   
     146    # Test for mass attribute and creation  
     147    topic = Topic.new(:title => "Test Topic", :content => "Is this thing on?",   
     148                      :author_name => "Loren", :author_email_address => "lsegal@holycraplions.com")  
     149    assert topic.title_changed?  
     150    assert topic.author_email_address_changed?  
     151    assert_equal 4, topic.changed_attributes.size  
     152    topic.save  
     153    assert !topic.attribute_changed?(:title)  
     154    assert_equal 0, topic.changed_attributes.size  
     155      
     156    # Make sure inplace modifications don't log changes here  
     157    topic.title << " - Some other stuff"  
     158    assert_equal "Test Topic - Some other stuff", topic.title  
     159    assert !topic.title_changed?  
     160      
     161    # Test for an update  
     162    topic.title = "We changed the topic"  
     163    assert topic.title_changed?  
     164    assert_equal ["title"], topic.changed_attributes  
     165    topic.save  
     166    assert !topic.title_changed?  
     167    assert_equal 0, topic.changed_attributes.size  
     168      
     169    # Test for an existing record update  
     170    topic = Topic.find(:first)  
     171    assert !topic.content_changed?  
     172    assert_equal 0, topic.changed_attributes.size  
     173    topic.content = "We changed the content this time."  
     174    assert topic.content_changed?  
     175    assert_equal ["content"], topic.changed_attributes  
     176    topic.save  
     177    assert !topic.content_changed?  
     178    assert_equal 0, topic.changed_attributes.size  
     179   
     180    # Turn default behaviour back on  
     181    Topic.track_inplace_modifications = true  
     182  end 
     183   
     184  def test_reset_changed_attributes  
     185    topic = Topic.find(:first)  
     186    topic.author_name = "Johnny"  
     187    assert topic.author_name_changed?  
     188    assert_equal ["author_name"], topic.changed_attributes  
     189      
     190    # Reload and check if attributes reset  
     191    topic.reload  
     192    assert !topic.author_name_changed?  
     193  end  
     194    
     195  def test_attribute_changed_with_inplace_modifications  
     196    # Test for mass attribute and creation  
     197    topic = Topic.new(:title => "Test Topic", :content => "Is this thing on?",   
     198                      :author_name => "FooBar", :author_email_address => "foo@bar.com")  
     199 
     200    assert topic.class.track_inplace_modifications  
     201                  
     202    assert topic.title_changed?  
     203    assert topic.content_changed?  
     204    assert topic.author_name_changed?  
     205    assert topic.author_email_address_changed?  
     206    topic.save 
     207    assert !topic.title_changed?  
     208    assert_equal 0, topic.changed_attributes.size  
     209      
     210    # Test for an update 
     211    topic.title << " - No, really."  
     212    assert topic.title_changed?  
     213    assert_equal ["title"], topic.changed_attributes  
     214    topic.save  
     215    assert !topic.title_changed?  
     216    assert_equal 0, topic.changed_attributes.size 
     217      
     218    # Test for an existing record update  
     219    topic = Topic.find(:first)  
     220    assert topic.track_inplace_modifications  
     221    assert !topic.content_changed?  
     222    assert_equal 0, topic.changed_attributes.size  
     223    topic.content.gsub!(/./, '')  
     224    assert topic.content_changed?  
     225    assert_equal ["content"], topic.changed_attributes  
     226    topic.save  
     227    assert !topic.content_changed?  
     228    assert_equal 0, topic.changed_attributes.size      
     229  end 
     230 
    142231  private 
    143232  def time_related_columns_on_topic 
    144233    Topic.columns.select{|c| [:time, :date, :datetime, :timestamp].include?(c.type)}.map(&:name)Index: activerecord/lib/active_record/attribute_methods.rb 
  • activerecord/lib/active_record/attribute_methods.rb

    old new  
    7676          unless instance_method_already_implemented?("#{name}?") 
    7777            define_question_method(name) 
    7878          end 
     79           
     80          unless instance_method_already_implemented?("#{name}_changed?") 
     81            define_changed_method(name) 
     82          end 
    7983        end 
    8084      end 
    8185 
     
    149153        def define_write_method(attr_name) 
    150154          evaluate_attribute_method attr_name, "def #{attr_name}=(new_value);write_attribute('#{attr_name}', new_value);end", "#{attr_name}=" 
    151155        end 
    152  
     156         
     157        def define_changed_method(attr_name) 
     158          evaluate_attribute_method attr_name, "def #{attr_name}_changed?; attribute_changed?('#{attr_name}'); end", "#{attr_name}_changed?"           
     159        end 
     160           
    153161        # Evaluate the definition for an attribute related method 
    154162        def evaluate_attribute_method(attr_name, method_definition, method_name=attr_name) 
    155  
    156163          unless method_name.to_s == primary_key.to_s 
    157164            generated_methods << method_name 
    158165          end 
     
    211218    # "2004-12-12" in a data column is cast to a date object, like Date.new(2004, 12, 12)). 
    212219    def read_attribute(attr_name) 
    213220      attr_name = attr_name.to_s 
     221      track_attribute(attr_name) if track_inplace_modifications  
    214222      if !(value = @attributes[attr_name]).nil? 
    215223        if column = column_for_attribute(attr_name) 
    216224          if unserializable_attribute?(attr_name, column) 
     
    227235    end 
    228236 
    229237    def read_attribute_before_type_cast(attr_name) 
     238      track_attribute(attr_name) if track_inplace_modifications 
    230239      @attributes[attr_name] 
    231240    end 
    232241 
     
    258267      else 
    259268        @attributes[attr_name] = value 
    260269      end 
     270       
     271      @attributes_changed[attr_name] = true unless @attributes_changed.frozen? 
    261272    end 
    262273 
    263274 
     
    325336      def attribute_before_type_cast(attribute_name) 
    326337        read_attribute_before_type_cast(attribute_name) 
    327338      end 
     339       
     340      # Handle *_changed? for method_missing 
     341      def attribute_changed?(attribute_name) 
     342        attribute_changed?(attribute_name) 
     343      end 
    328344  end 
    329345end 
  • activerecord/lib/active_record/base.rb

    old new  
    419419    # Defaults to false. Set to true if you're writing a threaded application. 
    420420    cattr_accessor :allow_concurrency, :instance_writer => false 
    421421    @@allow_concurrency = false 
     422     
     423    # Determines whether or not to track in place modifications of attributes  
     424    # Turning this feature on will raise memory usage of each ActiveRecord object  
     425    # and take a performance hit for each attribute read, but will allow for  
     426    # more ruby-like syntax such as:  
     427    #     
     428    #   t = Topic.find_first  
     429    #   t.title << " Extra information"  
     430    #   t.title.gsub!('Extra', '')  
     431    #  
     432    # This feature can be left off and you will still have the ability to use the above   
     433    # code, but you will not be able to guarantee proper results from attribute_changed?  
     434    # and can cause problems with record saving. The default value is true.  
     435    cattr_accessor :track_inplace_modifications  
     436    @@track_inplace_modifications = true 
    422437 
     438 
    423439    # Specifies the format to use when dumping the database schema with Rails' 
    424440    # Rakefile.  If :sql, the schema is dumped as (potentially database- 
    425441    # specific) SQL statements.  If :ruby, the schema is dumped as an 
     
    13311347            end 
    13321348 
    13331349          object.instance_variable_set("@attributes", record) 
     1350          object.instance_eval("reset_changed_attributes")  
    13341351          object.instance_variable_set("@attributes_cache", Hash.new) 
    13351352 
    13361353          if object.respond_to_without_attributes?(:after_find) 
     
    19281945        @attributes = attributes_from_column_definition 
    19291946        @attributes_cache = {} 
    19301947        @new_record = true 
     1948        reset_changed_attributes 
    19311949        ensure_proper_type 
    19321950        self.attributes = attributes unless attributes.nil? 
    19331951        self.class.send(:scope, :create).each { |att,value| self.send("#{att}=", value) } if self.class.send(:scoped?, :create) 
     
    19751993      # * No record exists: Creates a new record with values matching those of the object attributes. 
    19761994      # * A record does exist: Updates the record with values matching those of the object attributes. 
    19771995      def save 
     1996        freeze_changed_attributes 
    19781997        create_or_update 
     1998        reset_changed_attributes 
    19791999      end 
    19802000 
    19812001      # Attempts to save the record, but instead of just returning false if it couldn't happen, it raises a 
     
    20882108      def reload(options = nil) 
    20892109        clear_aggregation_cache 
    20902110        clear_association_cache 
     2111        reset_changed_attributes 
    20912112        @attributes.update(self.class.find(self.id, options).instance_variable_get('@attributes')) 
    20922113        @attributes_cache = {} 
    20932114        self 
     
    21782199        @attributes.has_key?(attr_name.to_s) 
    21792200      end 
    21802201 
     2202      # Returns true if the given attribute has been modified  
     2203      def attribute_changed?(attr_name)  
     2204        attr_name = attr_name.to_s unless attr_name.is_a?(String)  
     2205        if @attributes_changed[attr_name].kind_of?(TrueClass)  
     2206          true   
     2207        elsif @attributes_changed.has_key?(attr_name)  
     2208          @attributes_changed[attr_name] != @attributes[attr_name]  
     2209        else  
     2210          false  
     2211        end  
     2212      end  
     2213         
     2214       # Returns an array of attributes modified since last database read or save  
     2215       def changed_attributes  
     2216         @attributes_changed.keys.collect {|key| attribute_changed?(key) ? key : nil }.compact  
     2217       end 
     2218       
    21812219      # Returns an array of names for the attributes available on this object sorted alphabetically. 
    21822220      def attribute_names 
    21832221        @attributes.keys.sort 
     
    22482286      # Updates the associated record with values matching those of the instance attributes. 
    22492287      # Returns the number of affected rows. 
    22502288      def update 
    2251         quoted_attributes = attributes_with_quotes(false, false) 
     2289        return true if changed_attributes.empty?  
     2290        quoted_attributes = attributes_with_quotes(false, false, true) 
    22522291        return 0 if quoted_attributes.empty? 
    22532292        connection.update( 
    22542293          "UPDATE #{self.class.quoted_table_name} " + 
    2255           "SET #{quoted_comma_pair_list(connection, quoted_attributes)} " + 
     2294          "SET #{quoted_comma_pair_list(connection, quoted_attributes)} " +  
    22562295          "WHERE #{connection.quote_column_name(self.class.primary_key)} = #{quote_value(id)}", 
    22572296          "#{self.class.name} Update" 
    22582297        ) 
     
    23372376        default << 'id' unless self.class.primary_key.eql? 'id' 
    23382377        default 
    23392378      end 
     2379       
     2380      # Resets attributes that have been modified since last database access or save  
     2381      def reset_changed_attributes  
     2382        @attributes_changed = {}  
     2383      end  
     2384        
     2385      # Freeze the changed attributes before a save so successive reads won't alter data before  
     2386      # it goes into the table.  
     2387      def freeze_changed_attributes  
     2388        @attributes_changed.freeze  
     2389      end  
     2390        
     2391      # Keeps track of attributes when a value is read for pre-emptive caching of data   
     2392      # so that we can monitor if the value was changed when track_inplace_modifications is on  
     2393      def track_attribute(attr_name)  
     2394        return if @attributes_changed.frozen? || !track_inplace_modifications  
     2395        attr_name = attr_name.to_s unless attr_name.is_a? String  
     2396        @attributes_changed[attr_name] = calculate_attribute_hash(attr_name)  
     2397      end  
     2398        
     2399      # Calculates the hash value for the attribute's data. Only used when track_inplace_modifications  
     2400      # is on. The current scheme uses the cloned attribute as a 'hash'  
     2401      def calculate_attribute_hash(attr_name)  
     2402        @attributes[attr_name.to_s].to_s.clone  
     2403      end 
    23402404 
    23412405      # Returns a copy of the attributes hash where all the values have been safely quoted for use in 
    2342       # an SQL statement. 
    2343       def attributes_with_quotes(include_primary_key = true, include_readonly_attributes = true) 
     2406      # an SQL statement. Can return only attributes that have been modified for an update of only   
     2407      # those specific values.  
     2408      def attributes_with_quotes(include_primary_key = true, include_readonly_attributes = true, only_changed_attributes = false) 
    23442409        quoted = attributes.inject({}) do |result, (name, value)| 
    2345           if column = column_for_attribute(name) 
    2346             result[name] = quote_value(value, column) unless !include_primary_key && column.primary 
     2410          unless only_changed_attributes && !attribute_changed?(name)  
     2411            if column = column_for_attribute(name)  
     2412              result[name] = quote_value(value, column) unless !include_primary_key && column.primary  
     2413            end 
    23472414          end 
    23482415          result 
    23492416        end