save von ActiveRecord funktionierte nicht so, wie erwartet.
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.
Kommentar schreiben