概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Say no to chained scopes!
- 原文公開日: 2015/06/24
- 著者: Jeroen Weeink
- サイト: Crafting Ruby
Rails: スコープをモデルの外でチェインするのはやめておけ(翻訳)
Railsアプリで、次のようにモデルのデータベーススキーマの内部にまで立ち入っている(コントローラ)コードをまれによく見かけます。
class Person < ActiveRecord::Base
enum gender: { male: 1, female: 2 }
end
class PeopleController < ApplicationController
def index
@people = Person.where(gender: Person.genders[:male])
.where('age >= 18')
.where(right_handed: false)
respond_to(:html)
end
end
このコードにはいくつか問題点があります。
- コントローラがモデルのデータベース構造に関する知識を持ちすぎています。背後の詳細な情報が上位の層に漏れると、背後の構造が変更しにくくなります。
- メソッド呼び出しがチェインしているので、モックを使ったテストが死ぬほどやりづらくなります。
このような実装の詳細はモデル内にカプセル化されなければなりません。ActiveRecordのスコープの助けを借りて何とかしてみましょう。
class Person < ActiveRecord::Base
enum gender: { male: 1, female: 2 }
scope :male, -> { where(gender: 1) }
scope :adult, -> { where('age >= 18') }
scope :left_handed, -> { where(right_handed: false) }
end
class PeopleController < ApplicationController
def index
@people = Person.male.adult.left_handed
respond_to(:html)
end
end
生SQLやモデル属性の知識はモデル内にカプセル化されました。これで一件落着…したのでしょうか?
テストの書きやすさはほんの少しだけましになりましたが、異なるスコープを組み合わせる長いメソッドチェインはまだ残っています。コントローラをテストするには、またしてもモック軍団を出動させなければなりません。
class PeopleControllerTest < ActionController::TestCase
def test_people_index
adult_finder = mock
left_handed_finder = mock
Person.expects(:male).returns(adult_finder)
adult_finder.expects(:adult).returns(left_handed_finder)
left_handed_finder.expects(:left_handed)
get :index
assert_response :success
end
end
テストコードはexpectationだらけで、しかもかなり脆くなっています。たとえテスト対象コードが正常だったとしても、スコープの順序がちょっと変わっただけでテストは失敗してしまいます。
スコープが複雑になると他にも問題が生じることがあります。スコープはいくらでも自由に組み合わせられますが、その組み合わせから正しいSQLが生成されるとは限りません。その組み合わせを全部テストしていたら心が削られてしまいます。
私は、スコープをモデルの外でがんがんチェインするのではなく、スコープの組み合わせをモデル内で単一のスコープやクラスメソッドにまとめるのが好みです。この方が処理を可能な限り内部化できますし、データベースクエリの最適化などの作業もずっとやりやすくなります。
class Person < ActiveRecord::Base
enum gender: { male: 1, female: 2 }
scope :male, -> { where(gender: 1) }
scope :adult, -> { where('age >= 18') }
scope :left_handed, -> { where(right_handed: false) }
class << self
def left_handed_male_adults
left_handed.male.adult
end
end
end
class PeopleController < ApplicationController
def index
@people = Person.left_handed_male_adults
respond_to(:html)
end
end
スコープはPerson.left_handed_male_adults
クラスメソッドの内部にラップされています。必要ならこのクラスメソッド自身をスコープとして定義することも可能な点にご注目ください。2つの方法の大きな違いは、スコープがActiveRecordリレーションを返すことを保証するかどうかです。
スコープの組み合わせはぐっとシンプルになり、しかもテストに対して頑丈になります。
class PeopleControllerTest < ActionController::TestCase
def test_people_index
Person.expects(:left_handed_male_adults)
get :index
assert_response :success
end
end
関連するモデルの外でスコープをチェインするのを避ければ、コードベースにおける結合を弱められ、それによってメンテナンスやリファクタリングもやりやすくなります。
もちろんあらゆるスコープはpublicなので、このスコープもその気になればチェインできます。スコープをモデルの外でチェインしたくなる衝動をぐっとこらえられれば、話は簡単になるのです。