LaravelのwhereRawで安易にOR句を使ってはいけない
この記事は、Qiita の Laravel Advent Calendar 2020 20日目の記事です。
LaravelのEloquentやQueryBuilderで使えるwhereRaw
ですが、安易にOR句を使うと落とし穴にはまることに気がつきました。
今回はその落とし穴と回避策について解説します。
TL;DR
- LaravelのwhereRawでOR句を使うと、SoftDeleteが考慮されなくなったり意図しない条件が実行される可能性がある
- whereRawでは括弧を自動的には追加しないため、
whereRaw("A OR B and C")
と記述した時に発行されるクエリはWHERE A or (B and C)
となってしまう - OR句を使うときはクロージャを使う。どうしても
whereRaw
を使いたいときはwhereRaw("(A OR B)")
と記述する
なぜwhereRaw
でOR句を使ってはいけないのか?
例えばwhereRaw('A OR B')
というクエリを実行すると、WHERE A OR B
というSQL文が発行されます。括弧を記述していないため、WHERE (A OR B)
というSQL文は発行されません。
whereRaw
は記述されたSQL文をそのまま実行しています。なので当然といえば当然ですが、括弧を自動的に追加するなんてことはしません。
この仕様が思わぬ副作用を生むため、安易にOR句を使ってはいけないと結論づけています。
whereRawでOR句を使った時に何が起こるか?
今回使用した動作環境のPHP/Laravelのバージョンは以下の通りです。
PHP8.0 Laravel 8.19
SoftDeleteが考慮されない可能性がある
deleted_at
が存在するusers
テーブルで、LaravelのSoftDeleteトレイトを使った時の場合を例にします。
クロージャを使ったケースと、whereRaw
でOR句を使ったケースで、実際に発行されるクエリは以下のようになります。
User::where(function ($query) { $query->where('is_admin', 1) ->orWhere('is_admin', 1); })->toSql(); >>> "select * from `users` where (`is_admin` = ? or `is_admin` = ?) and `users`.`deleted_at` is null" User::whereRaw('is_admin = 1 or is_client = 1')->toSql(); >>> "select * from `users` where is_admin = 1 or is_client = 1 and `users`.`deleted_at` is null"
同じクエリになるかと思いきや、違うクエリになります。クロージャの方では括弧が自動的に挿入されますが、whereRaw
では挿入されません。
結果としてwhereRaw
でOR句を使ったケースで発行されるクエリでは
"select * from `users` where is_admin = 1 or (is_client = 1 and `users`.`deleted_at` is null)"
と同等のSQL文が実行されてしまい、is_admin = 1 and deleted_at is not null
のレコードを抽出してしまうような条件式になってしまいます。
意図しない条件が実行される可能性がある
where句でis_admin = 1 or is_client = 1 and is_corporation
という条件を実行します。
orWhere
を使ったケースと、whereRaw
でOR句を使ったケースで、発行されるクエリは以下のようになります。
User::where(function ($query) { $query->where('is_admin', 1) ->orWhere('is_admin', 1); })->where('is_corporation', 1)->toSql(); >>> "select * from `users` where (`is_admin` = 1 or `is_admin` = 1) and `is_corporation` = 1" User::whereRaw('is_admin = 1 or is_client = 1 and is_corporation = 1')->toSql(); >>> "select * from `users` where is_admin = 1 or is_client = 1 and is_corporation = 1"
こちらも、whereRaw
でOR句を使ったケースで発行されるでは括弧が挿入されないため、
select * from `users` where is_admin = 1 or (is_client = 1 and is_corporation = 1)
と同等のクエリが実行されてしまい、is_admin = 1 and is_corporation = 0
の条件式レコードを抽出してしまうような条件式になってしまいます。
回避策
回避策は幾つがありますが、ここでは2つの例をご紹介します。
クロージャを使う
丁寧に書くのであれば、クロージャを使うと良いです。
User::where(function ($query) { $query->where('is_admin', 1) ->orWhere('is_client', 1); })->where('is_corporation', 1)->toSql(); >>> "select * from `users` where (`is_admin` = 1 or `is_admin` = 1) and `is_corporation` = 1"
クロージャ内でwhereRaw
を使っても良いです。ただし、発行されるクエリがクロージャ内でorWhere
を使った場合と厳密には違うので気をつけましょう。
User::where(function ($query) { $query->whereRaw('is_admin = 1 or is_client = 1'); })->where('is_corporation', 1)->toSql(); >>> "select * from `users` where (is_admin = 1 or is_client = 1) and `is_corporation` = 1"
whereRaw("(A OR B)")
と記述する
どうしてもwhereRaw
単品で使いたい場合は、括弧を明示してあげると上手くいきます。
User::whereRaw('(is_admin = 1 or is_client = 1) and is_corporation = 1')->toSql(); >>> "select * from `users` where (is_admin = 1 or is_client = 1) and is_corporation = 1"
おまけ:一応プルリクを出したが…
whereRaw("A OR B")
と記述していても WHERE (A OR B)
と自動的に括弧を追加する、という旨の改修をしてプルリクエストを出してみましたが、残念ながらマージされませんでした。
実際既存機能にどのくらい影響があるのか誰も(コミッターでも)想像つかないので、既存サービスへ影響を出さないことを優先したと考えれば納得です。
ならばドキュメントに注意書きを追加するのはどうだろう!と思ってプルリクエストを出してみましたが、こちらも残念ながらマージされませんでした。
できればこの記事を見なくても解決できる形にしたい気持ちがありましたが、残念です…
最後に
考えてみればそりゃそうなるよねって感じの仕様だとは思いますが、コードレビューだけではなかなか気づけない落とし穴だと思います。
また、OR句以外でもwhereRaw
で落とし穴がありそう…そんな予感がします。見つけた方は是非記事にして共有して頂けると嬉しいです!