This may not be a common pattern in rails applications, but a recent change in activerecord (somewhere between 2.3.5 and 2.3.11) code broke the following scenario:
class LineItem < ActiveRecord::Base before_validation :build_visual_attributes attr_writer :color, :height, :width # the components of visual attributes serialize :visual_attributes attr_accessible :quantity, :cost def build_visual_attributes write_attribute(:attributes, {:color => color, :height => height, :width => width}) end end class Order < ActiveRecord::Base has_many :line_items accepts_nested_attributes_for :line_items attr_accessible :order_number end # LineItem #1 NOT updated Order.update_attributres(:order_number => 123, :line_item_attributes => {:id => 1, :color => "blue", :height => "10in", :width => "20in"}) |
The change made it so that the line item #1 was not updated. If however a real attribute was included in the line_item_attributes, then it would be updated (as below).
# LineItem #1 is updated Order.update_attributres(:order_number => 123, :line_item_attributes => {:id => 1, :color => "blue", :height => "10in", :width => "20in", :quantity => 10})
The reason is that when updating with a nested attribute, activerecord now checks to see if the record has actually changed. It does this in AutosaveAssociation#associated_records_to_validate_or_save in lib/activerecord/autosave_association.rb.
# Returns the record for an association collection that should be validated # or saved. If +autosave+ is +false+ only new records will be returned, # unless the parent is/was a new record itself. def associated_records_to_validate_or_save(association, new_record, autosave) if new_record association elsif autosave association.target.select { |record| record.changed_for_autosave? } else association.target.select { |record| record.new_record? } end end |
The old version of this method looks like this:
# Returns the record for an association collection that should be validated # or saved. If +autosave+ is +false+ only new records will be returned, # unless the parent is/was a new record itself. def associated_records_to_validate_or_save(association, new_record, autosave) if new_record association elsif association.loaded? autosave ? association : association.select { |record| record.new_record? } else autosave ? association.target : association.target.select { |record| record.new_record? } end end |
The old way returned the association if it was loaded or loads it if not. I am not sure why autosave would return true or not. But I know in this case it does return true. So in the new case, we now check to see if the record has changed using the following code:
# Returns whether or not this record has been changed in any way (including whether # any of its nested autosave associations are likewise changed) def changed_for_autosave? new_record? || changed? || marked_for_destruction? || nested_records_changed_for_autosave? end |
which in our case calls changed? in lib/activerecord/dirty.rb
# Do any attributes have unsaved changes? # person.changed? # => false # person.name = 'bob' # person.changed? # => true def changed? !changed_attributes.empty? end |
And here is the crux of our problem. When we use attribute accessors to set the values from our form, our activerecord attributes don’t change. Since they don’t change, they aren’t saved and so the callbacks are never called which cause us to update the serialized attribute.
My solution to this issue, which may not be the best way, is to force a dummy value to be written to the attribute that we serialize to so that the callbacks get called so we can write the final value for this attribute. To accomplish this, I did a little meta programming to force that dummy attribute write anytime one of the values that feed into the serialized attribute gets written.
class LineItem < ActiveRecord::Base class << self # serialized_attr_writer is used to allow writing to variables that are not active record attributes but are combined # into a serialized attribute for storage in the database # arguments: # list of symbols for each instance variable to create a writer for -- just like the arguments to attr_writer # :as - symbol for the name of the serialized attribute that combines the above symbols # :value - a dummy value that the serialized attribute will be set to force the record to be dirty # def serialized_attr_writer(*args) raise "last argument should be hash with keys of :as and :value" unless args.last.is_a?(Hash) && args.last[:as] && args.last[:value] options = args.last as_sym = options[:as] dummy_value = options[:value] args[0..-2].each do |arg| define_method(arg.to_s + "=") do |val| instance_variable_set("@#{arg}", val) write_attribute(as_sym, dummy_value) end end end end before_validation :build_visual_attributes serialized_attr_writer :color, :height, :width, :as => visual_attributes, :value => {} # the components of visual attributes serialize :visual_attributes attr_accessible :quantity, :cost def build_visual_attributes write_attribute(:attributes, {:color => color, :height => height, :width => width}) end end |
The original commit that caused this issue to appear fixed this issue