Tech Racho エンジニアの「?」を「!」に。
  • 開発

よくある?Rails失敗談 default_scope編

モデルからデータを取得する際に常に特定の検索条件を指定することができるdefault_scopeですが、
デメリットについてあまり注意を払わずに使ってしまって失敗しました。

サンプル事例

環境
* Rails 3.2.12, 4.0
* MySQL 5.1.65

※ 実際に問題が起きたバージョンが3.2.12 なのでそちらが中心になっています。

データを「名前」「年齢」どちらか指定された値で並び替えるという処理があったので、
特に指定が無ければid順で取得するという条件をdefault_scope を使って追加しました。

class User < ActiveRecord::Base
  default_scope -> { order(:id) }
end

これで、モデルからデータを取得する際に必ずORDERが指定されるようになります。

User.all #=> SELECT "users".* FROM "users" ORDER BY id
User.where(name: "太郎") #=> SELECT "users".* FROM "users" WHERE "users"."name" = '太郎' ORDER BY id

失敗1 並び替えの条件が切り替えられない?

「名前」か「年齢」のどちらか指定された条件で並び替えたいので、以下のようなコードを書きました。

@users = case params[:ordering]
when "name"
  User.order(:name)
when "age"
  User.order(:age)
else
  User.all
end

しかし、この書き方では並び順に変化がありません。
実際に発行されているSQLを見てみます。

User.order(:name) #=> SELECT "users".* FROM "users" ORDER BY id,name

default_scopeで指定したORDERに追加する形で name での並び替えが指定されています。
先ずid順で並び替えてから、名前順で並び替えているので結局id順で並び替えたのと同じ結果になっています。
ORDERの指定を上書きしなくては期待した動作になりません。
そういった場合に使うのが reorder です。

User.reorder(:name) #=> SELECT "users".* FROM "users" ORDER BY name

idによる並び替えが消えてnameだけになりました。
これで期待通り名前順で結果が取得出来ました。

失敗2 他のテーブルとJOINしたらエラー

User単独で使っている間は問題なかったのですが、他のテーブルとJOINしてデータを取得した際にエラーが起きてしまいました。
具体的で良い例が思い浮かばなかったのですが。
UserテーブルにUserProfileテーブルをJOINして、UserProfileからデータを取得しようとする場合にエラーが起きます。

User.joins(:user_profile).select("user_profiles.location")
#=> Mysql2::Error: Column 'id' in order clause is ambiguous

idで並び替えようとしたけれど、idという列が複数あるので、どのidで並び替えたらいいのかわからないというエラーですね。
どのidを使うのかを指定します。

class User < ActiveRecord::Base
  default_scope -> { order("users.id") }
end

これでエラーは出なくなりました。
ちなみに、Rails4 では order(:id) という記述でも発行されるSQLでは

ORDER BY "users"."id"

となるのでこの例で上げたようなエラーは起こりません。
ただし、order("id DESC") のように文字列で指定した場合はテーブル名が補完されることは無いので注意が必要です。

ORDER以外を指定した場合の問題

今回の例ではorderを指定した場合だけをとりあげましたが、default_scopeでは当然、whereselectなど他のクエリメソッドをつかうことができます。
ただ、orderはreorderで上書きできるのですがそれ以外のメソッドでは、簡単にscopeを解除する方法が無いようです。(本当はあるのかもしれません)
scopeを解除する方法として思いつくのは、unscopedをつかって解除するというものです。

User.all #=> SELECT "users".* FROM "users" ORDER BY id
User.unscoped #=> SELECT "users".* FROM "users"

この方法は、default_scopeを設定したのが自分だけであればうまくいくと思います。
ですが、unscopedは acts_as_paranoid等のgemが指定しているscopeも解除してしまいます。
削除したはずのデータが取得できていることに気づかずに思わぬバグを生むことになりそうです。
gem等を使っていなくてもorderはそのままに、whereだけを解除するということも出来ないので気軽に使えるというわけでもないですね。

Rails4で使えるdefault_scope解除方法

Rails4からscopeを解除するunscope というメソッドが追加されたので、
他のscope解除メソッドとあわせてdefault_scopeも解除できるかどうか試してみました。
結論からいうと、Rails 4.0 でもdefault_scope を解除することができるのは reorderunscoped だけでした。

reorder

orderだけにしか効きませんが、Rails4でもdefault_scope を解除する事が出来ました。

class User < ActiveRecord::Base
  default_scope { order(:id) }
end

User.all #=> SELECT "users".* FROM "users" ORDER BY "users"."id" ASC
User.reorder(nil) #=> SELECT "users".* FROM "users"

unscoped

こちらも動作に変わりはないようです。
scopedefault_scope どちらも解除可能ですが、細かい制御は出来ません。

class User < ActiveRecord::Base
  default_scope { order(:id) }
  default_scope { where(id: [1, 2, 3]) }
end

User.all #=> SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3) ORDER BY id ASC
User.unscoped #=> SELECT "users".* FROM "users"

except

指定した条件を削除することができます。

User.where("id < 10").order(:id).except(:order) #=> SELECT "users".* FROM "users" WHERE (id < 10)

個別に解除できて便利なのですが、default_scope は解除出来ません。

unscope

:where, :select, :group, :order, :lock, :limit, :offset, :joins, :includes, :from, :readonly, :having
を削除可能です。
一見するとexceptと違いが無いようにも見えましたが、
whereを削除する場合のみHashとして渡すことで更に細かい指定が可能です。

User.where(id: [1,2,3]).order(:id).unscope(where: :id) #=> SELECT "users".* FROM "users" ORDER BY "users".id ASC

whereの条件に文字列を使っていた場合はエラーになります。

User.where("id < 10").order(:id).unscope(where: :id)  #=> RuntimeError: unscope(where: :id) failed: unscoping String is unimplemented. 

scope関連のメソッドは、引数がsymbolの場合と文字列の場合で動作が変わることが多いので注意が必要ですね。
exceptと同じように使う場合は文字列の条件でも問題ありません。

User.where("id < 10").order(:id).unscope(:where) #=> SELECT "users".* FROM "users" ORDER BY "users".id ASC

except よりも細かい制御が可能で良さそうなのですが、こちらもdefault_scopeは解除出来ませんでした。

感想

今回上げた例は単純なミスレベルですが、開発で実際に問題になる場合はもっと複雑なクエリを発行していることが多いのではないでしょうか?
特にJOINしたタイミングで初めて発覚するバグについてはテストがしっかりしていないと見逃してしまう可能性があるので厄介です。

default_scopeに関しては

  • 特定の条件で`unscoped`する必要がある場合は使わない
  • 一つのモデルに複数の条件を指定することは避ける
  • order以外を指定する場合は影響範囲を将来のことも含めて考えて不安があるなら使わない

という方針で暫くは行こうと思います。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。