Rails upgrade from 2.3.5 to 2.3.11 breaks serialization through accepts_nested_attributes_for

Posted: June 3, 2011 by Steve in Uncategorized

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

Comments are closed.