search
LoginSignup
5

More than 1 year has passed since last update.

posted at

updated at

Organization

Railsのscopeのアンチパターンとその解消法

本記事は Classi Advent Calendar 2020 23日目の記事です。

こんにちは。@seigaです。今回はRailsのscopeのアンチパターンとその解消法について解説します。

scope(named_scope)はRails2.1の目玉機能として野良gemからRails本体に取り込まれた経緯を持つ機能です。RubyKaigi2008で松田さんが話されたのが周知の初めでしょうか。これまでクエリの組み立てを直接行っていたのが、そのクエリ断片を名前のついたscopeとして定義、動的に組み合わせることで一つの集合とすることが出来るようになりました。

some_model.rb
  scope :published, -> { where(published: true) }
> SomeModel.published == SomeModel.where(published: true)
> true

ですが便利な分、安易に使うとすぐに質の低いコードを生み出してしまうことになります。
Railsのscopeを使いこなすためには、以下のような点で相応の検討が必要です。

ユースケースを表現するscopeは地雷。クエリオブジェクトに置き換えろ

scopeは記述されたModelに対しパブリックなメソッドとして振る舞います。すると以下のような問題が発生します。
二つのControllerで同じscopeが利用されているパターンを考えてみましょう


Aさんがある日Some1Controllerとそこで呼び出されるscopeを実装しました。scopeがこのユースケース専用の挙動になっているように見えますが、Controllerでクエリ書くよりかはましでしょ、と完了

app/models/some.rb
scope :in_some_scope, ->(param) do
  .published
  .eager_load(SomeBelongsToModel)
  .where(some_belongs_to_model: {someparam: param})
end
app/controllers/some1_controller.rb
SomeModel.in_some_scope(param)

しばらく経った後、BさんはSome2Controllerを実装しました。その際、Modelを眺めたらなんとぴったりのscopeがあるではありませんか!喜んで流用しました

app/controllers/some2_controller.rb
SomeModel.in_some_scope(param)

その後、Some1Controllerでだけ並び順の制御が必要になったため、Aさんはin_some_scopeというscopeにorderを追加します

app/models/some.rb
scope :in_some_scope, ->(param) do
  .published
  .eager_load(SomeBelongsToModel)
  .where(some_belongs_to_model: {someparam: param})
  .order(created_at: :desc)
end

するとなんということでしょう、Aさんの預かり知らぬところでバグが発生してしましました。それは同じscopeを使っていたBさんの作成したSome2Controllerでした...fin


このようにscopeは修正に対して弱く、影響範囲も広くなりがちという特性があります。

上記の例はユースケースを表現していたscopeを安易に使いまわしてしまったことによる暗黙的な結合でした。

ユースケースとはざっくり言うと特定のワンアクション専用の状態のことです。これをscopeにしてしまった場合、おそらくscopeの命名でしっくりくるものがないはずです。

それに対し、published、のようにドメインとしての抽象化ができているscopeもあります。これは「公開済み」と言うドメインを表現するscopeになるので変更耐性も強く、使いまわしても悪い作用が起こることは少ないです。

some_model.rb
  # 特定のアクション状態に依存しない、普遍的な「公開済み」を表すscope
  scope :published, -> { where(published: true) }

クエリがユースケースによって複雑に組み立てられる場合、scopeをまとめたscopeを新たに用意する、Modelのクラスメソッドにする、などの選択肢があります。が、どちらもパプリックアクセス可能なので別場所で呼び出される可能性があり、潜在的な結合を生む要因となります。

オススメはクエリオブジェクトを作ってそちらにクエリ組み立てを委譲する方法です。こうすることで暗黙的な結合を防ぎつつ、ユースケース依存の複雑なクエリを組み立てることができます。

app/controllers/some1_controller.rb
::Some::Some1ListQuery.call(param)
app/queries/some1/some1_list_query.rb
module Some1
  class Some1ListQuery
    class << self
      def call(param)
        SomeModel.published
                 .eager_load(SomeBelongsToModel)
                 .where(some_belongs_to_model: {someparam: param})
                 .order(created_at: :desc)
      end
    end
  end
end
app/controllers/some2_controller.rb
::Some::Some2ListQuery.call(param)
app/queries/some2/some2_list_query.rb
module Some2
  class Some2ListQuery
    class << self
      def call(param)
        SomeModel.published
                 .eager_load(SomeBelongsToModel)
                 .where(some_belongs_to_model: {someparam: param})
      end
    end
  end
end

こうすることで各ユースケースでの暗黙的な結合が解消され、各自のユースケースのみに集中することができます。ちなみにこの場合クエリオブジェクト内でドメインを表現するscope以外にアクセスしないようにしましょう。せっかくユースケースを表現していたクエリオブジェクトの意義が失われます。

条件分岐があるscopeを置き換えたい場合はどうすべきでしょうか?
この場合はthenを使ったチェインを用意することで、同じ構造にすることができます。

app/queries/some/some_list_query.rb
      # 公開状態の一覧を取得。タイトル検索、順序指定あれば追加で適応
      def call(search_tilte: nil, sort_order: nil)
        SomeModel.published
                 .then { |relation| search_by_title(relation, search_title) }
                 .then { |relation| sort_by_order(relation, sort_order) }
      end

      private
      # 名前検索検索あれば追加
      def search_by_title(relation, search_title)
        return relation if search_title.blank?
        relation.where('title LIKE ?', "%#{search_title}%")
      end

      # 順序指定で並び替え
      def sort_by_order(relation, sort_order)
        case sort_order
        when 'desc'
          relation.order(created_at: :desc)
        when 'asc'
          relation.order(created_at: :asc)
        else
          relation
        end
      end

ActiveRecord::Relationは遅延評価なのでこのようにチェインすればscopeのようにクエリが組みあがります。ただしこの際thenの戻り値はActiveRecord::Relationを返すようにしないと、scopeと同じ挙動とは言えないので気をつけましょう。

scopeのメリットはクエリの抽象化

scopeを使ってドメインを表現する集合を引っ張ってくるときに、どんなカラムにどんな絞り込みをかけるのかは抽象化することができます。これはscopeのメリットです。

つまりどんな手続きでドメインを表現する集合を得るのかを隠蔽することができます。

が、往往にして

app/models/some.rb
scope :by_column_name, ->(param){ where(column_name: param) }

のようにscope名に「どのカラムでどんな操作をするか」が記述されたscopeが実装されてしまうことがあります。確かにscopeを挟むことでModelへの直接操作はしていないように見えますが、これだとscope呼び出し側で「どのカラムで絞り込むか」という知識を持っていることになります。つまり

app/controllers/some_controller.rb
SomeModel.by_column_name(param)

app/controllers/some_controller.rb
SomeModel.where(column_name: param)

は全く同質であり、隠蔽になっておらずscopeにするのは無意味です。こうするぐらいならControllerで直接ActiveRecord::Relationメソッドを呼び出しちゃう方がまだマシです。

scopeの命名ではドメインを表現する抽象化された集合の名前をちゃんと考えて命名しましょう。

最悪弊社VPoTに切腹させられます

scopeの戻り値として設定するのはActiveRecord::Relationだけにすべき

scopeでは戻り値の設定は自由にできます。

some_model.rb
  scope :some, -> { 1 }
> SomeModel.some
> 1

ただし戻り値がnilやfalseになった場合だけallを返します。

some_model.rb
  scope :some, -> { false }
> SomeModel.some == SomeModel.all
> true

scopeはscope同士でチェインできる事もメリットです。が、scopeの戻り値がバラバラだった場合、チェインする事なんてできませんよね?scopeのチェイン順に気をつけてチェインしましょう、というのはあまりにも本末転倒です。

また、nilやfalseが置き換えられてallの結果が返るのもコントロール外と感じられますね?
ですので、scopeの戻り値として設定するのはActiveRecord::Relationだけにすべきです。

ちなみにscopeがActiveRecord::Relationを返さない実装が可能になっている点については、warningを出そうという提案がなされたことがありますが、パフォーマンスが低下するとのことでクローズ。代わりにドキュメントにてなるべくActiveRecord::Relationかnilを返そうね、という形に落ち着いた経緯があります。
https://github.com/rails/rails/pull/32846
https://github.com/rails/rails/issues/34532

また、Rails5.2までのガイドでは

どのスコープメソッドも、常にActiveRecord::Relationオブジェクトを返します

となっており、いかにもフレームワーク側で戻り値をActiveRecord::Relationに変換しているような書き方になっていて紛らわしかったのですが、
Rails6のガイドでは

どのスコープボディも、ActiveRecord::Relationかnilを返す必要があります

という文言に変更されています

あれっスコープボディにはfalseも返せるはずなんですが記載から漏れてそうですね。これは検証してコントリビュートチャンスにします。

scopeはクラスメソッドのエイリアスではない

Railsガイドには

スコープでのメソッドの設定は、クラスメソッドの定義と完全に同じ (というよりクラスメソッドの定義そのもの)です。

という記述があったのですが削除されました。

経緯としては、このstackoverflowの回答を軸に

スコープが常にActiveRecord::Relationオブジェクトを返すという点を除いては、クラスメソッドの定義と*ほぼ*同じと言う文言に改変するPRがマージされたのですが、

改変された説明が、既にあった条件文の使用の内容と重複することになったため削除されたという流れです

ただし1つ注意点があります。それは条件文を評価した結果がfalseになった場合であっても、スコープは常にActiveRecord::Relationオブジェクトを返すという点です。クラスメソッドの場合はnilを返すので、この振る舞いが異なります。したがって、条件文を使ってクラスメソッドをチェインさせていて、かつ、条件文のいずれかがfalseを返す場合、NoMethodErrorを発生することがあります。

よって、Rails6.1のRails Guideにおいては、引数付きのscopeはクラスメソッドに置き換えた方が良いとの記述は消されています。上記のようにscopeとクラスメソッドの挙動は異なるためです。

default scope is evil

これはもう一大ムーブメントのようなものがあって叩かれまくっているわけで、知らない方はこのセクションタイトルで調べていただくとして、弊社ではこれ起因のバグ踏んだ経験多く反対派が多数です。unscopeやイニシャライズ時に注意して使えば便利だと思うのですが、地雷度高いので厳しいですね...ちなみに私はnested_attributesとacts_as_paranoidとcomposite_primary_keyの合わせ技を食らって大変な目にあったことがありますので反対派です。

まとめ

「Railsのscopeを使いこなす」なんて事を言いながら、半分ぐらい脱scopeの話をしていたような...?
scopeは確かに便利です。一見簡単にクエリ隠蔽できるのでかるーくいっぱい使いたくなる気持ちはとてもよく分かります。ですがその分アンチパタンな使い方されやすいと感じます。何事も用法用量、発行されるクエリが果たしていいものなのか、潜在的なリスク生んでないかなど、scopeにすべき部分そうでない部分、しっかり考えて使っていく必要がありますね。

参考文献

https://github.com/rails/rails/blob/master/guides/source/active_record_querying.md
https://nekogata.hatenablog.com/entry/2012/12/11/054555
https://qiita.com/SoarTec-lab/items/6e5f7781edf8d3fe4889
https://www.slideshare.net/moro/namedscope-more-detail-webcareer-presentation
https://techracho.bpsinc.jp/hachi8833/2018_06_14/57526
https://techracho.bpsinc.jp/hachi8833/2018_11_13/64284

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
5