Entkoppelte ActiveRecord Scopes durch Mergen

Named Scopes sind ein gutes Mittel, um die Datenbankstruktur der Models zu abstrahieren:

  • SQL Fragmente bleiben im Modell und werden nicht über die gesamte Codebasis verteilt
  • Vermeiden von Wiederholungen (gleiche SQL Fragmente) an verschiedenen Stellen
  • Erhöht die Lesbarkeit (unleserliches SQL verschwindet hinter einen verständlichen Bezeichner)
  • Vereinfachen das Testen

Besonders der letzte Punkt wird manchmal unterschätzt. Ein Name Scope kann sehr einfach explizit gestestet werden. In Tests, deren Implementierung auf dem Named Scope basiert, können diesen dann ruhigen Gewissens stubben. Resultat sind schnellere Tests, weil mindestens 2 Datenbankoperationen (Schreiben und Auslesen des Objektes) vermieden werden.
Und einfach zu testende Implementierungen sind immer auch ein Zeichen für soliden Code.
Allerdings können schlecht implementierte Named Scopes auch zur engeren Kopplung zwischen Models beitragen.

Die Ausgangssitation

Das Beispielszenario sind Bankkonten:

rails g model BankAccount serial:string && rake db:migrate

die mehrere Geldtransaktionen haben können:

class BankAccount < ActiveRecord::Base
  has_many :transactions
end

Jede Transaktion gehört also zu einem Bankkonto und wird zu einem bestimmten Zeitpunkt finalisiert:

rails g model Transaction bank_account:belongs_to finalized_at:datetime && rake db:migrate

Das Model:

class Transaction < ActiveRecord::Base
  belongs_to :bank_account

  validates :bank_account, presence: true
end

Enge Kopplung durch named scope

Es sollen alle Bankkonten gefunden werden, die an dem aktuellen Tag mindestens eine finalisierte Transaktion aufweisen. Die Anforderung wurde in dem Named Scope with_transaction_today umgesetzt:

class BankAccount < ActiveRecord::Base
  has_many :transactions

  scope :with_transaction_today, -> {
    joins(:transactions)
      .where('transactions.finalized_at >= :day_begin',
        { day_begin: Time.current.at_beginning_of_day })
  }
end

Der Named Scope funktioniert zwar:

BankAccount.with_transaction_today.count # => 5

hat aber ein schwerwiegendes Problem: In der Condition verweist er auf ein Attribut des Transaction Models. Das ist eine enge Kopplung zwischen beiden Klassen.
Stattdessen sollte das BankAccount nichts über die interne Struktur der Transaction wissen müssen.

Entkoppelter Named Scope

Alternativ zu dem verkoppelnden Named Scope sollte die notwendige Condition in einen Named Scope im Transaction Model verschoben werden:

class Transaction < ActiveRecord::Base
  belongs_to :bank_account

  validates :bank_account, presence: true

  scope :today, -> {
    where('finalized_at >= :day_begin', { day_begin: Time.current.at_beginning_of_day })
  }
end

Änderungen an der internen Struktur müssen auch nirgendwo sonst, als in der Transaction selbst gepflegt werden.
Dieser Scope kann mit ActiveRecord::Base#merge in dem BankAccount wiederverwendet werden:

class BankAccount < ActiveRecord::Base
  has_many :transactions

  scope :with_transaction_today, -> {
    joins(:transactions).merge(Transaction.today)
  }
end

Der refactorte Named Scope funktioniert noch genauso, aber ohne direkt Kopplung beider Models:

BankAccount.with_transaction_today.count # => 5