【Ruby on Rails Advent Calendar 2019】ActiveRecord で後から where クエリを上書きする【2日目】

Ruby on Rails Advent Calendar 2019 2日目の記事になります。
今回は最近ハマっているクエリメソッド付けたり外したりしていることを簡単に書いてみようかと思います。

サンプルモデル

今回は以下のような scope を定義したモデルを例として説明していきたいと思います。

class User < ActiveRecord::Base
  # 管理者を絞り込む scope
  scope :administer, -> {
    # 以下の条件を管理者として扱う
    where(active: true).where.not(email: nil)
  }
end

# 管理者一覧を取得する
puts User.administer.to_sql
# => SELECT "users".*
#      FROM "users"
#     WHERE "users"."active" = TRUE
#       AND "users"."email" IS NOT NULL

これ自体はとてもシンプルなつくりになっていますね。

スコープを呼び出した後に rewhere を使ってクエリを上書きする

.administer を呼び出した後にその条件を書き換えたい場合があると思います。
しかし、普通に where をチェーンしただけであればクエリは上書きされずに AND で結合されます。

# where をチェーンした場合は "users"."active" = FALSE が結合される
puts User.administer.where(active: false).to_sql
# => SELECT "users".*
#      FROM "users"
#     WHERE "users"."active" = TRUE
#       AND "users"."email" IS NOT NULL
#       AND "users"."active" = FALSE      -- このクエリが追加されてしまう

こういうケースでは where ではなくて rewhere を使ってクエリを上書きする事が出来ます。

# rewhere を使うことですでに追加済みのクエリを上書き出る
puts User.administer.to_sql
# => SELECT "users".*
#      FROM "users"
#     WHERE "users"."email" IS NOT NULL
#       AND "users"."active" = FALSE

これで既存のクエリを上書きすることが出来ます。
rewhere の他にも reorder とういうクエリメソッドがあり、こちらは ORDER を上書き出来ます。
scope を使うと予め複雑なクエリを定義しておく事が出来るのですが、一部だけクエリを書き換えたい場合などに rewherereorder が使えることを覚えておくとよいと思います。

注意点

この rewhere ですが joins 等で複数のテーブルをクエリで使用している場合に意図しない動作が発生する事があります。
どういうことかと言うと例えば以下のような .joins して .merge している場合に "users"."active" = TRUE"blogs"."active" = TRUE の 2つのクエリが追加されます。

class User < ActiveRecord::Base
  scope :administer, -> {
    where(active: true).where.not(email: nil)
  }
  has_one :blog
end

class Blog < ActiveRecord::Base
  scope :active, -> { where(active: true) }
end

# User.administer と Blog.active を結合する
# 両方のテーブルのクエリがそれぞれ追加される
puts User.administer.joins(:blog).merge(Blog.active).to_sql
# => SELECT "users".*
#      FROM "users"
#     INNER JOIN "blogs"
#        ON "blogs"."user_id" = "users"."id"
#     WHERE "users"."active"  = TRUE         -- ここと
#       AND "users"."email" IS NOT NULL
#       AND "blogs"."active"  = TRUE         -- ここ

このようなクエリに対して rewhere(active: false) を呼び出すと意図しない挙動になります。

# rewhere(active: false) すると "blogs"."active" が削除される
puts User.administer.joins(:blog).merge(Blog.active).rewhere(active: false).to_sql
# => SELECT "users".*
#      FROM "users"
#     INNER JOIN "blogs"
#        ON "blogs"."user_id" = "users"."id"
#     WHERE "users"."email" IS NOT NULL
#       AND "users"."active" = FALSE

なぜか "blogs"."active" が削除され、 "users"."active" だけが FASLE に置き換わっています。
これは rewhere がテーブルを考慮してないからになります。
rewhere の実装を見てみると

    def rewhere(conditions)
      unscope(where: conditions.keys).where(conditions)
    end

https://github.com/rails/rails/blob/09a2979f75c51afb797dd60261a8930f84144af8/activerecord/lib/active_record/relation/query_methods.rb#L662-L664

となっています。
この unscope が肝で、このクエリメソッドを使用することで特定のカラムの where を削除することが出来ます。

# unscope すると特定の where が削除される
puts User.administer.joins(:blog).merge(Blog.active).unscope(where: :active).to_sql
# => SELECT "users".*
#      FROM "users"
#     INNER JOIN "blogs"
#        ON "blogs"."user_id" = "users"."id"
#     WHERE "users"."email" IS NOT NULL

この時に unscope はテーブルを考慮せずに "users""blogs" の両方のクエリが削除されしまいます。
結果的にその後に呼び出される where(active: false) だけが追加されることになります。
このように複数のテーブルを参照しているクエリに対して rewhere を呼び出す場合は注意が必要です。


ちなみにこの問題はもしかすれば今後改善されるかもしれません。