Laravel のwithExistsやwithCountを利用して「お気に入り済みフラグ」などの動的カラムを実装する際、期待した値(1 / 0)が返らず、true/falseになったり、データがあるはずなのに0に固定される問題が発生しました。
発生した問題
posts(投稿)テーブルに対して、ログインユーザーがその投稿をfavorites(お気に入り)テーブルに登録済みかどうかをpost_is_favoriteという動的カラムで一緒に取得したいというのが、今回のシチュエーションです。Xや掲示板でよく見られるいいねやお気に入りの情報を投稿の一覧とともに取得します。
EloquentのwithExists()を使うと、実際には以下のようなSQLが発行されます。
select
`posts`.*,
exists(
select 1
from `favorites`
where `favorites`.`target_id` = `posts`.`id`
and `favorites`.`user_id` = ?
) as `post_is_favorite`
from `posts`
このSQLをDB上で直接実行すると、対象のお気に入りレコードは存在しており、post_is_favoriteは1が返ってきます。ところが、同じSQLが発行されているはずなのに、APIのレスポンスでは矛盾した結果になってしまいました。
修正前はpost_is_favorite: 0となり、存在するはずのデータが存在しないことになってしまいます。アクセサ(Accessor)を調整しても解決せず、今度はtrue(boolean)で返ってしまうこともあり、フロントエンド(React/TypeScript)側のnumber型定義と噛み合わなくなってしまいました。
SQL単体を実行すれば正しい値が返るのに、API経由だと値がおかしくなってしまうというのが、この問題の厄介なところです。原因はSQLそのものではなく、SQLが実行された後のLaravel/Eloquent内部の処理にありました。
主な原因
原因は大きく分けて3つありました。
アクセサの上書き
動的に追加したカラム(post_is_favorite)に対してモデル側でアクセサを定義している場合、引数$valueに正しく値が渡らない、あるいはnullが渡ってしまうケースがあります。その結果、内部ロジックreturn $value ? 1 : 0;が常に0を返してしまい、SQLの結果を上書きしてしまっていました。
Laravelによる自動型変換
Laravelのクエリビルダ経由でexists句を実行すると、DB側が1を返していても、Laravelが自動的にPHPのboolean型(true/false)へ変換してしまいます。 アクセサが定義されている場合、$castsよりもアクセサが優先して評価されるため、$castsでintegerを指定していても、アクセサ側で$valueが正しく受け取れず 0を返し続けてしまい、$castsの変換が意味をなさなくなってしまいます。
select句による上書き
クエリ構築の過程でaddSelectを使用しても、その後にselect()や、内部でselect()を実行するメソッドが呼ばれると、それまでの選択カラムがリセットされ、動的カラムが消失してしまいます。
修正前の実装
修正前の実装と、先述した3つの原因を確認します。
クエリ側では、withExistsを使ってリレーションの存在チェックを行っていました。
$query = Post::query()
->withExists(['favorites as post_is_favorite' => function ($q) use ($userId) {
$q->where('user_id', $userId);
}]);
さらにこの後で、一覧取得用のselect()が別途呼ばれていました。
$query->withExists([...]);
// ここでカラムを絞り込む処理が後から入っていた
$query->select([
'id',
'title',
'body',
// ...
]);
このため、せっかく追加したpost_is_favoriteがselect()のタイミングでリセットされて消えてしまっていました。
モデル側のアクセサは、以下のような実装でした。
public function getPostIsFavoriteAttribute($value)
{
return $value ? 1 : 0;
}
一見正しそうな実装ですが、動的に追加したカラムのため$valueにモデルの生の属性がうまく渡らず、$value ? 1 : 0が常にelse側の0に落ちてしまっていました。
対応策 castsによる型固定
withExists自体はそのまま維持し、アクセサを削除して$castsで数値型に変換する方法をとりました。
以下のようにgetPostIsFavoriteAttributeメソッドを削除して、$castsを定義します。
// このアクセサを削除
public function getPostIsFavoriteAttribute($value)
{
return $value ? 1 : 0;
}
// こちらを定義
protected $casts = [
'post_is_favorite' => 'integer',
];
クエリ側は特に変更していません。
// そのまま
$query = Post::query()
->withExists(['favorites as post_is_favorite' => function ($q) use ($userId) {
$q->where('user_id', $userId);
}]);
(int) trueは1、(int) falseは0になるため、withExistsが返すboolean値も$castsのinteger指定で1/0に変換されます。今回の不具合の本質は、withExistsがboolean値を返すこと自体ではなく、その手前でアクセサが$valueを正しく受け取れず0を返し続けていた点にありました。アクセサを取り除けば、$castsのキャストだけで意図通りの値になります。
本記事まとめ
- 型変換はモデル側の
$castsに任せる。アクセサで独自に1/0へ変換しようとすると、動的カラムの値がうまく渡らず不具合の原因になる withExistsのboolean値も$castsのinteger指定で1/0に変換できるため、クエリ側を変更する必要はない