今日もヤバさをI/O中。

(物理的)大型エンジニアのブログです。基本的に何かが足りません。

LaravelのwhereRawで安易にOR句を使ってはいけない

この記事は、Qiita の Laravel Advent Calendar 2020 20日目の記事です。

qiita.com

LaravelのEloquentやQueryBuilderで使えるwhereRawですが、安易にOR句を使うと落とし穴にはまることに気がつきました。

今回はその落とし穴と回避策について解説します。

laravel.com

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)と自動的に括弧を追加する、という旨の改修をしてプルリクエストを出してみましたが、残念ながらマージされませんでした。

github.com

実際既存機能にどのくらい影響があるのか誰も(コミッターでも)想像つかないので、既存サービスへ影響を出さないことを優先したと考えれば納得です。

ならばドキュメントに注意書きを追加するのはどうだろう!と思ってプルリクエストを出してみましたが、こちらも残念ながらマージされませんでした。

github.com

できればこの記事を見なくても解決できる形にしたい気持ちがありましたが、残念です…

最後に

考えてみればそりゃそうなるよねって感じの仕様だとは思いますが、コードレビューだけではなかなか気づけない落とし穴だと思います。

また、OR句以外でもwhereRawで落とし穴がありそう…そんな予感がします。見つけた方は是非記事にして共有して頂けると嬉しいです!