Named scopes are a great tool for abstracting database structure:
- SQL fragments remain in the model and are not spread all over the code base
- Prevent repetitions (equal SQL fragments) at several areas
- Improves readability (illegible SQL is hidden behind comprehensible names)
- Simplifies testing
Especially the last point is underestimated sometimes. A named scope can be tested explicitly in separation. Other tests, whose implementation relies on that named scope, then can stub out the scope with no remorse. This approach results in faster tests, because at least 2 database operations (write and read the object) can be avoided.
And easy to test implementations are the evidence for solid code.
However bad implemented named scopes also can lead to tight coupling between models.
The initial situation
The example is about bank accounts:
rails g model BankAccount serial:string && rake db:migrate
which can have many transactions:
class BankAccount < ActiveRecord::Base
has_many :transactions
end
That means each transaction belongs to a bank account and can be finalized at certain point of time:
rails g model Transaction bank_account:belongs_to finalized_at:datetime && rake db:migrate
The model:
class Transaction < ActiveRecord::Base
belongs_to :bank_account
validates :bank_account, presence: true
end
Tight coupling through named scopes
All bank accounts should be found, currently having at least one finalized transaction. The requirement is implemented with the named scope with_transaction_today:
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
Though the named scope works:
BankAccount.with_transaction_today.count # => 5
it reveals a serious problem: the condition refers to a Transaction model attribute. That is tight coupling between both classes.
Instead the BankAccount should know nothing about the internal structure of Transaction.
Decoupled named scope
An alternative to the coupling named scope is moving the necessary condition into the Transaction model own named scope:
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
Changes made to the internal structure need not to be maintained anywhere else than in the Transaction.
This scope can be reused in the BankAccount with ActiveRecord::Base#merge:
class BankAccount < ActiveRecord::Base
has_many :transactions
scope :with_transaction_today, -> {
joins(:transactions).merge(Transaction.today)
}
end
The refactored named scope works the same, but without any tight coupling:
BankAccount.with_transaction_today.count # => 5