PHP
AdventCalendar
Eloquent
laravel5

Laravel Eloquent でハマらないために知っておきたいこと

More than 1 year has passed since last update.


はじめに

はじめまして、オープンロジのエンジニアで @ttaka と申します。

この記事は OPENLOGI Advent Calendar 2017 の8日目になります。

さて、弊社サービスのサーバーサイド開発には、 PHP のフレームワークである Laravel が使われています。今回はその Laravel の ORM である Eloquent を使う際に、ちょっとハマりそうなポイントをいくつかご紹介したいと思います。


ハマりポイント


論理削除トレイトはテーブルの結合先では動作しない

論理削除自体には賛否ありますが、 Eloquent には標準でその機能が提供されています。

https://laravel.com/docs/5.5/eloquent#soft-deleting

例えば、次のような論理削除を使用している UserProfile クラスがあるとします。

class User extends Model

{
use SoftDeletes;

public function profile()
{
return $this->hasOne(Profile::class);
}
}

class Profile extends Model

{
use SoftDeletes;
}

各モデルで findwhere などを利用すると、論理削除されたレコードを除外する条件が自動的にクエリーへ追加されます。

User::where('name', 'hoge')

->toSql();

select * from `users`

where `name` = ?
and `users`.`deleted_at` is null

また、定義したリレーションを使って Profile を取得した場合や、 with を使った Eager Loading の場合も、論理削除されたレコードは自動的に除外されます。

$user = User::find(1);

$profile = $user->profile; // 論理削除されてると null が返る

$user = User::with('profile')->find(1);

$profile = $user->profile; // 論理削除されてると null が返る

では、次のように Profile が存在する User のみ取得しようと join を指定した場合、結合先の条件はどうなるでしょうか?

User::join('profiles', 'users.id', '=', 'profiles.user_id')

->toSql();

select * from `users`

inner join `profiles` on `users`.`id` = `profiles`.`user_id`
where `users`.`deleted_at` is null

このように、結合先の profiles に対しては論理削除の条件が付加されません。そのため、手動で profiles.deleted_at is null の条件を加える必要があります。


クエリービルダーによる削除ではオブザーバーが動作しない

Eloquent には Observer を設定することで、作成や削除といったイベント時に特定の処理をさせる機能があります。

https://laravel.com/docs/5.5/eloquent#observers

ただ、クエリービルダーによる delete などでは、そのイベントが発火しません。

$user->items()->delete(); // 発火しない

そのため、ちょっと面倒ではありますが、各 Eloquent インスタンスの delete メソッドを呼んであげる必要があります。

$user->items->each(function ($item) {

$item->delete(); // 発火する
});


テーブルを結合すると同名のカラムは上書きされる

join 先のテーブルに同じ名称のカラムがあると、主テーブルの値が上書きされてしまうため、必ず select を一緒に指定するようにしましょう。

User::join('profiles', 'users.id', '=', 'profiles.user_id')

->select('users.*') // 必ず指定しよう!
->get();


予約語のカラム名を使う際は取得時に注意

hidden などのように Eloquent 側で利用しているカラム名を使うと、そのクラスのメソッド内から $this->hidden ではアクセスできません。その際は $this->getAttribute('hidden') のようにアクセスする必要があります。


条件に OR を使う際は必ずグループ化する

一見すると問題なさそうな次のクエリーですが……

User::where('name', 'hoge')

->orWhere('is_admin', true)
->get();

論理削除を使ってると、自動的に条件が追加されて正しく動作しなくなります。そのため、必ずグルーピングしてまとめる必要があります。

User::where(function ($query) {

$query->where('name', 'hoge')
->orWhere('is_admin', true);
})->get();

論理削除などを使ってなくても、後から条件を追加することはありますし、常にグループ化しておきたいですね。


chunk を使う際は必ず orderBy を指定

レコードが大量にあっても、分割して取得できる便利な chunk メソッドがあります。

Users::query()->chunk(100, function ($users) {

foreach ($users as $user) {
// 悪い例
}
});

このメソッドですが、内部的には SELECT * FROM users LIMIT ?,? の結果を順に返してるだけですので、次のように orderBy を指定しないと、正しく動作しないことがあります。

Users::query()->orderBy('id')->chunk(100, function ($users) {

foreach ($users as $user) {
// 良い例
}
});

SQL では ORDER を付け忘れないのに、ソースコードになると見た目の問題かつい忘れがちなので、気をつけたいですね。

ちなみに Laravel のドキュメントでも、ちゃんと orderBy を指定した例になってます。

https://laravel.com/docs/5.5/queries#chunking-results


属性キャストを指定しないとそのままの値

例えば、マイグレーションで is_admin カラムを次のように指定し、レコードには 0/1 の値が格納されているものとします。

Schema::table('users', function (Blueprint $table) {

$table->boolean('is_admin')->default(false);
});

このまま $user->is_admin にアクセスしても、残念ながらそのまま 0/1 の値が返ってきます……。

そんな時は、属性キャストを指定することで、その型に自動変換されます。

class User extends Model

{
protected $casts = [
'is_admin' => 'boolean',
];
}

これで曖昧比較が嫌いな方も、安心して $user->is_admin === true で判定できますね。


おわりに

以上、知ってれば何ということはないものですが、他のフレームワークなどと動きが違っていたり、ついやってしまいがちなことをいくつかまとめてみました。

アドベントカレンダーどころか Qiita に書くのもはじめてのことなので、何か至らないところがありましたら、ご指摘くださると助かります。

それでは、みなさんが Eloquent を使って何かハマったときに、解決の一助になれば幸いです。