Wenn man seit mehreren Jahren mit Rails entwickelt, denkt man irgendwann, das Framework auch in Details zu kennen. Umso erstaunter waren wir, als wir in einem Test auf ein merkwürdiges Problem stießen, welches uns einige Stunden Fehlersuche beschert hat. Ziemlich schnell merkten wir: save von ActiveRecord funktionierte nicht so, wie erwartet.
Nach anfänglichem Unglauben (fühlten wir uns doch an 'Select Isn't Broken' aus dem Pragmatischen Programmierer erinnert) konnten wir es auf folgenden minimalen Spec runterbrechen:
describe "FailingExample" do
  class Person < ActiveRecord::Base
    belongs_to :address
  end
 
  class Address < ActiveRecord::Base
  end

  it 'should save object or id' do
    address = Adress.create
    person  = Person.create
    person.address = address
    person.save!
    person.address_id.should == address.id

    new_address = Adress.create
    person.address_id = new_address.id
    person.save!
      
    person.address_id.should == new_address.id # this will fail
  end
end
Der Haken liegt in der gleichzeitigen Verwendung der Id- und Objekt-Setter. Beim Speichern fragt Rails alle belongs_to-Beziehungen ab, ob sie sich geändert haben. Haben Sie sich geändert, wird ihre Id in das Objekt geschrieben. Falls man die Id selbst geändert hat, wird die eigene Änderung so wieder rückgängig gemacht. Das Problem ist nun, dass auch beim zweiten save!-Aufruf noch person.address.updated? == true gilt, wodurch die address.id wieder nach person.address_id geschrieben wird.

Interessanterweise tritt dieser Fall offensichtlich sehr selten in Rails-Anwendungen auf, sonst wären wir ja schon früher darauf gestoßen. Entweder möchte man ein Objekt möglichst generisch mit Formular-Daten befüllen und nutzt person.attributes = {...address_id => 42...}, oder man hat eigene Anwendungslogik, die die Objekt-Setter benutzt. Man nutzt aber selten beide Varianten gleichzeitig in einem Request. Genau das passiert nun aber häufig in Tests/Specs. Wir nutzen FactoryGirl, um uns Testobjekte inkl. Objekt-Geflecht aufzubauen. Hier ist es schnell passiert, dass man in der Factory Objekte setzt und direkt im Test Ids benutzt.

Die prinzipielle Lösung ist einfach: Ein person.reload hinter das erste person.save! bewirkt Wunder.

Nun wollen wir uns als Entwickler nicht zumuten, uns an Regeln zu halten wie "Wann immer du einen Objekt-Setter benutzt und anschließend speicherst, musst du einen reload machen". Zumal es in vielen Fällen offensichtlich nicht benötigt wird. Ein Versuch, uns stattdessen automatisch darauf hinweisen zu lassen, wenn man zwei konkurrierende Änderungen gemacht hat, ist folgender before_filter:
module ActiveRecord
  class Base
    # has to run before all other filters
    before_save :ensure_association_consistency
    def ensure_association_consistency
      reflections = self.class.reflections
      reflections.each do |name, reflection|
        if reflection.macro == :belongs_to
          association = self.send(name)
          prim_key    = reflection.primary_key_name.to_s
          if self.changes.include?(prim_key) && (association.present? && association.updated?)
            if association.id!=self.send(prim_key)
              raise "Association #{name} was set with #{prim_key}=#{self.changes[prim_key][1]} and #{name}.id=#{association.send :id} on object #{self}"
            end
          end
        end
      end if reflections.present?

      true
    end
  end
end
Er überprüft bei jedem Save, ob sich die Id einer belongs-to-Beziehung geändert hat. Wenn dem so ist und die Assoziation sich ebenfalls geändert hat, wird eine Exception geworfen. So bekommt man wenigstens im Test mit, dass hier ein Sonderfall vorliegt.

Diese Überprüfung hat uns zumindest in unseren Specs, die mit FactoryGirl arbeiten, einige Schwachpunkte aufgezeigt. Perfekt ist sie allerdings nicht. Folgender Spec läuft durch, obwohl man eine konkurrierende Änderung macht:
  it 'should delete instance' do
    person = Person.find_first_with_existing_address
    person.address_id = 99 # other address_id
    person.address = nil
    person.save!
    person.address_id.should be_nil # object getter will win
  end
Der nil-Setter setzt die Id sofort selbst um und die Assoziation selbst ist nicht mehr vorhanden.

Dieses "Feature" ist übrigens schon seit längerem bekannt. Eine Behebung klingt zunächst ziemlich simpel, weswegen sich auch schon einige Entwickler daran versucht haben. Bedenkt man aber, dass eine belongs_to-Beziehung auch in einer has_many :through-Beziehung verwendet werden kann und Änderungen an Ids/Objekten dann über diese Beziehungen verstreut werden müssten, bekommt man schnell Kopfschmerzen.
Ein kurzer Test zeigte leider, dass das Verhalten auch im vor kurzem erschienenen Rails 3.0.0 nachzustellen ist.