Freitag, 24. Februar 2012

Ein paar Takte zu Uniqueness-Validatoren in ActiveRecord und DataMapper

Sowohl ActiveRecord als auch DataMapper (1.2) haben eine kleine fiese Angewohnheit:
Sie überprüfen bei jedem Speichern sämtliche "uniqueness"-Constraints des jeweiligen Modells.

Ein kleines Beispiel. Gegeben sei ein Modell der folgenden Form:
class Foo < ActiveRecord::Base
  validates_uniqueness_of :bar
end
Dann zeigt uns die Konsole folgendes:
irb> o = Foo.first
=> #<Foo id: 1, bar: "some_value">
irb> ActiveRecord::Base.logger = Logger.new(STDOUT) && nil
=> nil
irb> o.save
  Foo Exists (20.8ms)  SELECT 1 FROM `foos` WHERE (`foos`.`bar` = BINARY 'some_value' AND `foo`.`id` != 1) LIMIT 1
=> true
Das Feld `bar` wird also auf Eindeutigkeit überprüft, obwohl es überhaupt nicht geändert wurde. Das mag in einer Welt ohne Datenbank-Constraints wie bei ActiveRecord sogar irgendwie noch sinnvoll sein, bei DataMapper (was Datenbank-Constraints unterstützt), erscheint mir dies vollständig unsinnig.

Abhilfe

ActiveRecord
Jedes ActiveRecord-Objekt stellt Methoden bereit, um zu überprüfen, ob ein Attribut geändert wurde oder nicht. Eine solche Methode hieße für ein Feld mit dem Namen `foo` beispielsweise `foo_changed?`. Verwendet man dies, so sähe unser obiges Beispiel wie folgt aus:
class Foo < ActiveRecord::Base
  validates_uniqueness_of :bar, :if => :bar_changed?
end
DataMapper
Bei DataMapper gibt es ebenfalls die Möglichkeit eine Validierung mit einer `:if`-Option nur unter bestimmten Voraussetzungen auszuführen. Allerdings gibt es hier keine `_changed?`-Methoden für jedes Feld, sondern "nur" eine Methode, die Auskunft über alle geänderten Methoden gibt. Diese Methode heißt `dirty_attributes` und gibt einen Hash der Form `<#DataMapper::Property...> => dirty_value` zurück. Nutzt man dies aus, so könnte man folgendes schreiben:
class Foo
  include DataMapper::Resource
  property :bar, String
  validates_uniqueness_of(:bar, :if => lambda{|r| r.dirty_attributes.has_key?(Foo.properties[:bar])})
end
Dies ist natürlich vergleichsweise umständlich und ermöglicht nicht den Einsatz der praktischeren `:unique`-Option in der `property` selbst. Ich habe mir deshalb für diese Zwecke die DataMapper::Resource.property-Methode wie folgt überschrieben:
module MyModelExtension
  def property(name, type, options = {})
    options = options.dup
    unique = options.delete(:unique) # Don't pass the :unique option to DM directly
        
    property = super(name, type, options)

    if unique
      opts = {:if => lambda{|res| res.dirty_attributes.has_key?(property)}}
      opts.merge!(:scope => unique) if unique.is_a?(Array)
      self.validates_uniqueness_of(name, opts)
    end

    return property
  end

  DataMapper::Model.append_extensions self
end
Damit sollte dann auch wieder die `:unqiue`-Option funktionieren:
class Foo
  include DataMapper::Resource
  property :bar, String, :unqiue => true
end