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 139 139 end 140 140 end 141 141 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 142 231 private 143 232 def time_related_columns_on_topic 144 233 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 76 76 unless instance_method_already_implemented?("#{name}?") 77 77 define_question_method(name) 78 78 end 79 80 unless instance_method_already_implemented?("#{name}_changed?") 81 define_changed_method(name) 82 end 79 83 end 80 84 end 81 85 … … 149 153 def define_write_method(attr_name) 150 154 evaluate_attribute_method attr_name, "def #{attr_name}=(new_value);write_attribute('#{attr_name}', new_value);end", "#{attr_name}=" 151 155 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 153 161 # Evaluate the definition for an attribute related method 154 162 def evaluate_attribute_method(attr_name, method_definition, method_name=attr_name) 155 156 163 unless method_name.to_s == primary_key.to_s 157 164 generated_methods << method_name 158 165 end … … 211 218 # "2004-12-12" in a data column is cast to a date object, like Date.new(2004, 12, 12)). 212 219 def read_attribute(attr_name) 213 220 attr_name = attr_name.to_s 221 track_attribute(attr_name) if track_inplace_modifications 214 222 if !(value = @attributes[attr_name]).nil? 215 223 if column = column_for_attribute(attr_name) 216 224 if unserializable_attribute?(attr_name, column) … … 227 235 end 228 236 229 237 def read_attribute_before_type_cast(attr_name) 238 track_attribute(attr_name) if track_inplace_modifications 230 239 @attributes[attr_name] 231 240 end 232 241 … … 258 267 else 259 268 @attributes[attr_name] = value 260 269 end 270 271 @attributes_changed[attr_name] = true unless @attributes_changed.frozen? 261 272 end 262 273 263 274 … … 325 336 def attribute_before_type_cast(attribute_name) 326 337 read_attribute_before_type_cast(attribute_name) 327 338 end 339 340 # Handle *_changed? for method_missing 341 def attribute_changed?(attribute_name) 342 attribute_changed?(attribute_name) 343 end 328 344 end 329 345 end -
activerecord/lib/active_record/base.rb
old new 419 419 # Defaults to false. Set to true if you're writing a threaded application. 420 420 cattr_accessor :allow_concurrency, :instance_writer => false 421 421 @@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 422 437 438 423 439 # Specifies the format to use when dumping the database schema with Rails' 424 440 # Rakefile. If :sql, the schema is dumped as (potentially database- 425 441 # specific) SQL statements. If :ruby, the schema is dumped as an … … 1331 1347 end 1332 1348 1333 1349 object.instance_variable_set("@attributes", record) 1350 object.instance_eval("reset_changed_attributes") 1334 1351 object.instance_variable_set("@attributes_cache", Hash.new) 1335 1352 1336 1353 if object.respond_to_without_attributes?(:after_find) … … 1928 1945 @attributes = attributes_from_column_definition 1929 1946 @attributes_cache = {} 1930 1947 @new_record = true 1948 reset_changed_attributes 1931 1949 ensure_proper_type 1932 1950 self.attributes = attributes unless attributes.nil? 1933 1951 self.class.send(:scope, :create).each { |att,value| self.send("#{att}=", value) } if self.class.send(:scoped?, :create) … … 1975 1993 # * No record exists: Creates a new record with values matching those of the object attributes. 1976 1994 # * A record does exist: Updates the record with values matching those of the object attributes. 1977 1995 def save 1996 freeze_changed_attributes 1978 1997 create_or_update 1998 reset_changed_attributes 1979 1999 end 1980 2000 1981 2001 # Attempts to save the record, but instead of just returning false if it couldn't happen, it raises a … … 2088 2108 def reload(options = nil) 2089 2109 clear_aggregation_cache 2090 2110 clear_association_cache 2111 reset_changed_attributes 2091 2112 @attributes.update(self.class.find(self.id, options).instance_variable_get('@attributes')) 2092 2113 @attributes_cache = {} 2093 2114 self … … 2178 2199 @attributes.has_key?(attr_name.to_s) 2179 2200 end 2180 2201 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 2181 2219 # Returns an array of names for the attributes available on this object sorted alphabetically. 2182 2220 def attribute_names 2183 2221 @attributes.keys.sort … … 2248 2286 # Updates the associated record with values matching those of the instance attributes. 2249 2287 # Returns the number of affected rows. 2250 2288 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) 2252 2291 return 0 if quoted_attributes.empty? 2253 2292 connection.update( 2254 2293 "UPDATE #{self.class.quoted_table_name} " + 2255 "SET #{quoted_comma_pair_list(connection, quoted_attributes)} " + 2294 "SET #{quoted_comma_pair_list(connection, quoted_attributes)} " + 2256 2295 "WHERE #{connection.quote_column_name(self.class.primary_key)} = #{quote_value(id)}", 2257 2296 "#{self.class.name} Update" 2258 2297 ) … … 2337 2376 default << 'id' unless self.class.primary_key.eql? 'id' 2338 2377 default 2339 2378 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 2340 2404 2341 2405 # 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) 2344 2409 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 2347 2414 end 2348 2415 result 2349 2416 end