Rails: スコープをモデルの外でチェインするのはやめておけ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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なので、このスコープもその気になればチェインできます。スコープをモデルの外でチェインしたくなる衝動をぐっとこらえられれば、話は簡単になるのです。

関連

Railsのdefault_scopeは使うな、絶対(翻訳)

Rails tips: モデルのクエリをカプセル化する2つの方法(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ