はじめに
過去にLaravel(5.6)のリクエストライフサイクルを中心にご紹介しました。
今回はEloquentの基本動作とリレーションについてご紹介したいと思います。その他のコレクションやミューテタなどの機能はここでは触れませんので公式サイトをご覧ください。
[公式サイト]Eloquent
https://laravel.com/docs/5.6/eloquent
処理の流れ
Eloquentの仕組みを見るために、まずは簡単なCRUD操作を例に基本的な内部処理の流れを見てみたいと思います。
Read
以下のようなモデルからデータを取得する処理の流れを順番に見てみます。
public function show($id)
{
$user = User::find($id);
}
処理の流れを簡略化したものが以下のようになります(実際はもう少し複雑ですが分かりやすくするために省略しています)。
まず、アプリケーションモデルが継承する基底ModelがEloquentBuilderにfindの処理を依頼します。EloquentBuilderはModelとQueryBuilderを仲介するAdapterのような存在で、ORMの処理を組み立てる中心的な役割を果たします。なお、EloquentBuilderはマクロを使って独自に拡張することもできます。
use Illuminate\Database\Eloquent\Builder;
public function register()
{
// 拡張例(ロックしてeagerロードする)
Builder::macro('loadWithLock', function($relations){
$eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);
foreach ($eagerLoad as $name => $closure) {
$eagerLoad[$name] = function($relation) use ($closure) {
$closure($relation);
$relation->lockForUpdate();
};
}
$this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);
return $this;
});
}
// 使用例
$posts = Post::loadWithLock('comments')->lockForUpdate()->find(1);
QueryBuilderはfrom句、join句、where句、group句、having句、order句、limit句、offset句、union句などの情報を保持する器を持っており、EloquentBuilderはその器にデータをセットします。このケースだとfrom句に'users'、where句に'users.id = 1'、limit句に'1'となります。
from句にセットされるテーブル名について補足すると、テーブル名はPluralizerというクラスを使ってモデル名から複数形の単語を生成しています(実際の生成はDoctrineのInflectorというクラスが行います)。ただし、data(datumの複数形)のような単語はそのままテーブル名として使用されます。特殊な単語の一覧はPluralizerとInflectorでそれぞれ以下のように定義されています。
public static $uncountable = [
'audio','bison','cattle','chassis','compensation','coreopsis',
'data','deer','education','emoji','equipment','evidence',
'feedback','firmware','fish','furniture','gold','hardware',
'information','jedi','kin','knowledge','love','metadata',
'money','moose','news','nutrition','offspring','plankton',
'pokemon','police','rain','rice','series','sheep',
'software','species','swine','traffic','wheat',
];
private static $uninflected = array(
'.*?media', 'Amoyese', 'audio', 'bison', 'Borghese', 'bream', 'breeches',
'britches', 'buffalo', 'cantus', 'carp', 'chassis', 'clippers', 'cod', 'coitus', 'compensation', 'Congoese',
'contretemps', 'coreopsis', 'corps', 'data', 'debris', 'deer', 'diabetes', 'djinn', 'education', 'eland',
'elk', 'emoji', 'equipment', 'evidence', 'Faroese', 'feedback', 'fish', 'flounder', 'Foochowese',
'Furniture', 'furniture', 'gallows', 'Genevese', 'Genoese', 'Gilbertese', 'gold',
'headquarters', 'herpes', 'hijinks', 'Hottentotese', 'information', 'innings', 'jackanapes', 'jedi',
'Kiplingese', 'knowledge', 'Kongoese', 'love', 'Lucchese', 'Luggage', 'mackerel', 'Maltese', 'metadata',
'mews', 'moose', 'mumps', 'Nankingese', 'news', 'nexus', 'Niasese', 'nutrition', 'offspring',
'Pekingese', 'Piedmontese', 'pincers', 'Pistoiese', 'plankton', 'pliers', 'pokemon', 'police', 'Portuguese',
'proceedings', 'rabies', 'rain', 'rhinoceros', 'rice', 'salmon', 'Sarawakese', 'scissors', 'sea[- ]bass',
'series', 'Shavese', 'shears', 'sheep', 'siemens', 'species', 'staff', 'swine', 'traffic',
'trousers', 'trout', 'tuna', 'us', 'Vermontese', 'Wenchowese', 'wheat', 'whiting', 'wildebeest', 'Yengeese'
);
artisanコマンドを使ってモデルの作成およびマイグレーションを行っていればテーブル名を解決してくれますので特に意識する必要はありませんが、自分でテーブルを作る場合は上記単語は気を付ける必要があります。
話を元に戻して、QueryBuilderにwhere句やlimit句をセットした後、スコープが設定されていればスコープに定義されたwhere句などの情報を追加でセットします。
class User extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope('prefecture', function (Builder $builder) {
$builder->where('prefecture', '=', '13');
});
}
}
全ての情報がセットし終わるとQueryBuilderにクエリの実行を依頼します。QueryBuilderはGrammar(MySql、Postgres、SQLite、SQL Serverの4種類)を使ってwhere句やlimit句の情報をプリペアドステートメントにコンパイルします。そしてコンパイルされたプリペアドステートメントとバインドパラメータをConnectionに渡し、Connectionがクエリを実行します。実行結果はEloquentBuilderでモデルに変換され、retrieved
イベントが発行されて処理が終了となります。
モデルイベント
Eloquentではモデル操作の前後で様々なイベントを発行します。モデルのdispatchesEvents
プロパティにアプリケーションで作成したイベントを登録し、イベントリスナーでキャッチすることで処理をフックすることができます(creating、updatingなどの~ing系はリスナーでfalseをリターンすれば処理を中断することができる)。
namespace App;
class User extends Model
{
protected $dispatchesEvents = ['retrieved' => \App\Events\UserRetrieved::class];
}
namespace App\Events;
use App\User;
class UserRetrieved
{
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
}
namespace App\Listeners;
use App\Events\UserRetrieved as UserRetrievedEvent;
class UserRetrieved
{
public function handle(UserRetrievedEvent $event)
{
$user = $event->user;
// do something
}
}
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
\App\Events\UserRetrieved::class => [
\App\Listeners\UserRetrieved::class,
],
];
}
また、モデルにはObserverを登録するためのobserve
メソッドが用意されていますので、Observerを利用すれば複数のイベントの処理を管理することができます。
namespace App\Observers;
use App\User;
class UserObserver
{
public function creating(User $user)
{
// do something
}
public function updating(User $user)
{
// do something
}
}
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
User::observe(UserObserver::class);
}
}
Read系メソッド一覧
Read系メソッドでよく使うものをリストアップしてご紹介します。(は発行されるイベント)
all
retrieved
レコード全件を取得します。
$users = User::all();
find
retrieved
主キーで検索し、該当するレコードを取得します。
$user = User::find(1); // 主キーの値が1のレコードを取得
$users = User::find([1, 2]); // 主キーの値が1か2のレコードを取得
$user = User::find(1, ['id', 'name']); // 取得するカラムを指定
findOrFail
retrieved
主キーで検索し、該当するレコードがない場合はModelNotFoundException
をスローします(例外をキャッチしない場合は例外ハンドラでNotFoundHttpException
にチェーンされて404ページへ遷移する)。
$user = User::findOrFail(1);
findOrNew
retrieved
主キーで検索し、該当するレコードがない場合はnewしたモデルインスタンスを返します。
$user = User::findOrNew(1); // 下と同じ
$user = User::find(1) ?? new User;
firstOrNew
retrieved
attributeにマッチしたレコードの1レコード目を返します。該当するレコードがない場合はnewしたモデルインスタンスに指定したattributeをセットして返します。
$user = User::firstOrNew(['name' => 'qiita']); // レコードがない場合はnewしたモデルにnameをセット
firstOrFail
retrieved
条件に該当するレコードの1レコード目を返します。該当するレコードがない場合はModelNotFoundException
をスローします。
$user = User::where('active', 1)->firstOrFail();
firstOr
retrieved
条件に該当するレコードの1レコード目を返します。該当するレコードがない場合は指定したコールバック関数を実行した結果を返します。
$user = User::where('active', 1)->firstOr(function(){
return new User;
});
value
retrieved
レコードセットの1レコード目から指定したシングルカラムの値を取得します。
$age = User::where('age', '>', 20)->value('age');
get
retrieved
クエリの結果を取得します。
$users = User::where('age', '>', 20)
->orderBy('age', 'desc')
->get();
pluck
retrieved
レコードセットから指定したカラムだけを取り出してコレクションとして返します。
$ages = User::where('age', '>', 20)->pluck('age');
※retrievedイベントが発行されるのは指定したカラムのアクセサーや日付ミューテタ、属性キャストが設定されている場合のみです。
class Post extends Model
{
// アクセサー
public function getTitleAttribute($value)
{
return strtoupper($value);
}
}
cursor
retrieved
レコードセットから一行ずつレコードを取得します。レコードセットが配列に全件分展開されないため、バッチ処理のような大量のデータを扱う場合にメモリの使用量を抑えることができます。
foreach (User::where('active', 1)->cursor() as $user) {
// do something
}
chunk
retrieved
指定した件数ずつレコードを取得し、結果をコールバック関数に渡します。コールバック関数の中でfalseをリターンするとそこでレコードの取得は終了します。
$bool = User::where('active', 1)->chunk(100, function($users, $page) {
// do something
});
chunkById
retrieved
指定した件数ずつレコードを取得し、結果をコールバック関数に渡します。chunkの方はOFFSETを使用しているため、後ろに行けば行くほどOFFSETの位置まで読み込むオーバーヘッドが大きくなりパフォーマンスが低下します。chunkByIdはSELECT * FROM users WHERE id > ? ORDER BY id LIMIT 100;
のようにidを使って開始位置を移動するためOFFSETよりもパフォーマンスが良くなります。キーがオートインクリメントのIDで大量のレコードを扱う場合には、chunkよりもこちらを利用するとパフォーマンスの向上が期待できます。
$bool = User::where('active', 1)->chunkById(100, function($users) {
// do something
});
each
retrieved
指定した件数ずつレコードを取得し、結果をコールバック関数に渡します。コールバック関数の中でfalseをリターンするとそこでレコードの取得は終了します。chunkとほぼ同じですが、chunkのコールバックの引数はレコードセットなのに対し、こちらは単一レコードになります。
$bool = User::where('active', 1)->each(function($user) {
// do something
}, 100);
paginate、simplePaginate
retrieved
OFFSETを利用したページング検索結果を返します。paginateはページ番号のリンクを作成するためにレコードの件数をカウントするクエリを実行した上でOFFSETのクエリを実行しますが、simplePaginateは「次」「前」のリンクしか作成しないためOFFSETのクエリのみで済みます。
// GETで送られてきたpage番号から10件を取得する
$users = User::where('active', 1)->paginate(10);
$users = User::where('active', 1)->simplePaginate(10);
fresh
retrieved
モデルをDBから再取得し、新しいモデルインスタンスを返します。
$user = User::find(1);
$user2 = $user->fresh(); // idが1のレコードを再取得する
var_dump($user === $user2); // false
refresh
retrieved
モデルをDBから再取得し、取得したデータでモデルのattributeを更新します。
$user = User::find(1);
$user2 = $user->refresh(); // idが1のレコードを再取得する
var_dump($user === $user2); // true
Create & Update
以下のようなモデルの新規作成および更新する処理を流れを順番に見てみます。
public function store(Request $request)
{
$user= new User;
$user->name = $request->name;
$user->save();
}
public function update(Request $request, $id)
{
$user = User::find($id);
$user->name = $request->name;
$user->save();
}
処理の流れを簡略化したものが以下のようになります。
まず、基底モデルでsavingイベントが発行されます。そして、モデルがnewしたものか、既存のモデルかで処理が分岐します。
既存のモデルの場合、最初にupdatingイベントが発行されます。次にモデルのデータに変更があったかどうかをチェックします。変更がなかった場合はupdate処理はスキップされます。データに変更があった場合はモデルに更新日付がセットされます。次にEloquentBuilderがWHERE句のセットを行い、QueryBuilderがクエリの実行を行います。実行が終わるとupdatedイベントが発行され、更新情報に今回の更新データを同期させます。
※モデルは内部でDBからデータを取得した時点でのオリジナル情報、現在の情報、DBに反映された更新情報の3種類を保持しています。
public function update(Request $request, $id)
{
$user = User::find($id);
var_dump($user->getOriginal()); // オリジナル情報
$user->name = 'qiita';
var_dump($user->getAttributes()); // 現在の情報
var_dump($user->getChanges()); // DBに反映された更新情報
var_dump($user->getDirty()); // 変更された情報
$user->save();
var_dump($user->getOriginal());
var_dump($user->getAttributes());
var_dump($user->getChanges());
}
// getOriginal
array
'id' => int 1
'name' => string 'no name'
'created_at' => string '2018-03-18 08:32:43'
'updated_at' => null
// getAttributes - nameを変更したので情報も上書きされる
array
'id' => int 1
'name' => string 'qiita'
'created_at' => string '2018-03-18 08:32:43'
'updated_at' => null
// getChanges - まだDBに反映してないので空
array
empty
// getDirty - nameを変更したので情報が追加される
array
'name' => string 'qiita'
// getOriginal - DBに反映したのでオリジナル情報のnameも上書きされる
array
'id' => int 1
'name' => string 'qiita'
'created_at' => string '2018-03-18 08:32:43'
'updated_at' => string '2018-03-18 08:38:38'
// getAttributes - オリジナル情報と同じ
array
'id' => int 1
'name' => string 'qiita'
'created_at' => string '2018-03-18 08:32:43'
'updated_at' => string '2018-03-18 08:38:38'
// getChanges - クエリを実行してnameとupdated_atが更新されたので情報が追加される
array
'name' => string 'qiita'
'updated_at' => string '2018-03-18 08:38:38'
モデルをnewした場合、最初にcreatingイベントが発行されます。モデルに作成日付がセットされた後にQueryBuilderがinsertのクエリを実行します。もし、モデルの主キーがオートインクリメントのIDの場合、先程insertしたデータのIDを取得し、モデルにIDをセットします。クエリの実行が終わるとcreatedイベントが発行されます。
そして、後処理としてsavedイベントが発行され、もしモデルにtouchesプロパティが設定されている場合は親モデルの更新日付を更新します。
class Profile extends Model
{
// Profileに更新があった場合、親のUserの更新日付も更新する
protected $touches = ['user'];
public function user()
{
return $this->belongsTo('App\User');
}
}
最後にDBからデータを取得した時点でのオリジナル情報を現在の情報で上書きします。
Create & Update系メソッド一覧
Create & Update系メソッドでよく使うものをリストアップしてご紹介します。
save
saving、saved、creating、created、updating、updated
DBにレコードを保存します。新規モデルの場合はinsert、既存モデルの場合はupdateになります。
// insert
$user = new User;
$user->name = 'qiita';
$success = $user->save();
// update
$user = User::find(1);
$user->active = 0;
$success = $user->save();
$profile = Profile::find(1);
$profile->bio = 'hello world';
$success = $profile->save(['touch' => false]); // 親モデルの更新日付を更新しない
create
saving、saved、creating、created
指定したattributeでレコードを作成します(インスタンスを返しますがattributeはセットした項目とキー、作成日付、更新日付しか持っていません。DBからセレクトしている訳ではないので当然ではありますが一応念のため)。
$post = new Post;
$post = $post->create([
'user_id' => 1,
'title' => 'sample',
'content' => 'this is a sample page.'
]);
なお、createでattributeを渡すためにはモデルのfillable
プロパティに許可するattributeを設定するか、guarded
プロパティで禁止するプロパティから除外する必要があります。それ以外のattributeを渡した場合は例外がスローされます。
class Post extends Model
{
// user_id、title、contentを許可
protected $fillable = ['user_id', 'title', 'content'];
}
class Post extends Model
{
// user_idを禁止(デフォルトは全て)
protected $guarded= ['user_id'];
}
forceCreate
saving、saved、creating、created
モデルのfillabl/guardedプロパティの有無に関係なく、指定したattributeでレコードを作成します。
$post = new Post;
$post = $post->forceCreate([
'user_id' => 1,
'title' => 'sample',
'content' => 'this is a sample page.'
]);
insert
なし
指定したattributeでレコードを作成します。QueryBuilderを直接呼び出しているため作成日付は自動的にセットされません。また、fillableプロパティやguardedプロパティも無効となります。
$success = Post::insert([
'user_id' => 1,
'title' => 'sample',
'content' => 'this is a sample page.',
'created_at' => new \DateTime
]);
// bulk insert
$success = Post::insert([
['user_id' => 1,
'title' => 'sample',
'content' => 'this is a sample page.',
'created_at' => new \DateTime],
['user_id' => 2,
'title' => 'sample',
'content' => 'this is a sample page.',
'created_at' => new \DateTime],
]);
update
saving、saved、updating、updated
変更するカラムをattributeで渡してレコードを更新します。
$post = Post::find(1);
$success = $post->update(['title' => 'Hello World', 'content' => 'hello world.']);
※createと同じようにモデルにfillabl/guardedプロパティを設定しておく必要があります。もしくは以下のような方法で代替することもできます。
$success = $post
->fillable(['title', 'content'])
->update(['title' => 'Hello World', 'content' => 'hello world.']);
$success = $post
->guard(['user_id'])
->update(['title' => 'Hello World', 'content' => 'hello world.']);
Post::unguard(); // ガードを外す
$post = Post::find(1);
$success = $post->update(['title' => 'Hello World', 'content' => 'hello world.']);
Post::reguard(); // ガードを元に戻す
push
saving、saved、updating、updated
モデルおよびリレーションのあるモデルもまとめてDBに保存します。
$user = User::find(1);
$user->name = 'qiita';
$profile = $user->profile;
$profile->bio = 'hello world';
$success = $user->push(); // userに加えてprofileも更新される
firstOrCreate
retrieved、saving、saved、updating、updated
attributeにマッチしたレコードの1レコード目を返します。該当するレコードがない場合はinsertを行います。
// レコードがない場合はuser_id、title、contentの値を使ってinsertを行う
$post = Post::firstOrCreate(
['user_id' => 1, 'title' => 'sample'],
['content' => 'this is a sample page.']
);
※createと同じようにモデルにfillabl/guardedプロパティを設定しておく必要があります。
updateOrCreate
retrieved、saving、saved、creating、created、updating、updated
attributeにマッチしたレコードを更新します。該当するレコードがない場合はinsertを行います。
// レコードがない場合はuser_id、title、contentの値を使ってinsertを行う
$post = Post::updateOrCreate(
['user_id' => 1, 'title' => 'sample'],
['content' => 'hello world.']
);
※createと同じようにモデルにfillabl/guardedプロパティを設定しておく必要があります。
increment
なし
指定したカラムの値を増やしてレコードを更新します。
$post = Post::find(1);
$post->increment('page_views'); // +1
$post->increment('page_views', 10); // +10
decrement
なし
指定したカラムの値を減らしてレコードを更新します。
$post = Post::find(1);
$post->decrement('days_left'); // -1
$post->decrement('days_left', 10); // -10
replicate
なし
モデルを複製して新しいインスタンスを作成します。複製されたモデルのattributeは元のモデルからキー、作成日付、更新日付を取り除いたものになります。
$post = Post::find(1);
$clone = $post->replicate();
var_dump($post->exists); // 既存モデルのため true
var_dump($clone->exists); // 新規モデルのため false
var_dump($post->is($clone)); // 複製したモデルにはキーがないため false
$clone->save(); // 複製元と同じ情報で新しいレコードを作成
$clone = $post->replicate(['pageviews']); // 複製したくないattributeを指定することも可
Delete
以下のようなモデルを削除する処理を流れを順番に見てみます。
public function destroy($id)
{
$user = User::find($id);
$user->delete();
}
処理の流れを簡略化したものが以下のようになります。
まず、基底モデルでdeletingイベントが発行されます。次にモデルにtouchesプロパティが設定されている場合は親モデルの更新日付を更新します。EloquentBuilderがWHERE句のセットを行いQueryBuilderがクエリの実行を行います。実行が終わるとdeletedイベントが発行されます。
Delete系メソッド一覧
Delete系メソッドでよく使うものをリストアップしてご紹介します。
delete
deleting、deleted
レコードを削除します(モデルのソフトデリートが有効な場合は論理削除)。
$user = User::find(1);
$success = $user->delete();
forceDelete
deleting、deleted、forceDeleted
レコードを物理削除します。
$user = User::find(1);
$success= $user->forceDelete();
destroy
deleting、deleted
キーを指定してレコードを削除します。
$count = User::destroy(1);
$count = User::destroy([1, 2, 3]);
truncate
なし
テーブルをtruncateします。
User::truncate();
restore
restoring、restored、saving、saved、updating、updated
論理削除されたレコードを元に戻します。
$users = User::onlyTrashed()->get();
foreach ($users as $user) {
$user->restore();
}
リレーション
基本的な処理の流れを見ましたので、次はリレーションの仕組みを見てみたいと思います。
1対1(hasOne、belongsTo)
親モデルが子モデルと1対1のリレーションを持っている場合、親モデルではhasOne
メソッドを使って子モデルとのリレーションを定義します。
class User extends Model
{
public function profile()
{
return $this->hasOne('App\Profile');
}
}
子モデルを取得する場合には以下のように親モデルのattributeとしてアクセスします。
public function show($id)
{
$user = User::find($id);
$profile = $user->profile;
}
この時の処理の流れを簡略化したものが以下のようになります。
まず、モデルのattributeにアクセスすると基底クラスはモデル自身が持つattributeなのか、そうでないのかを判断し、そうでない場合はモデルに定義されたメソッドを実行します。
モデルはメソッドのリターンとしてhasOneメソッドを使ってHasOneオブジェクトを基底クラスに返します。hasOneメソッドは引数として子モデルのクラス名、外部キー、ローカルキーを指定します。
return $this->hasOne('App\Profile', '外部キー', 'ローカルキー');
外部キーを指定しない場合、親モデルのクラス名のスネークケースとキーをアンダースコアでつなげた形になります(例:user_id)。
public function getForeignKey()
{
return Str::snake(class_basename($this)).'_'.$this->getKeyName();
}
ローカルキーとは子モデルの外部キーが参照する親モデルのキーを表します。指定しない場合は親モデルのキー(id)になります。実行されるクエリのイメージは以下のようになります。
SELECT * FROM 子モデル WHERE 子モデルの外部キー = 親モデルのキー(ローカルキー)の値
基底クラスは実行したクエリの結果をEloquentBuilderから受け取りモデルに返します。なお、取得した結果はロード済みリレーションとして保持され、同一リクエスト内であればattributeに何回アクセスしてもクエリが実行されるのは最初の1回だけになります。
public function show($id)
{
$user = User::find($id);
$profile = $user->profile; // クエリが実行される
$profile = $user->profile; // 保持している結果を返すのでクエリは実行されない
$user = User::find($id);
$profile = $user->profile; // 最初の$userとはインスタンスが異なるのでクエリが実行される
}
なお、hasOneはwithDefault
メソッドを使ってデフォルトモデルを設定することができます。親モデルに紐づく子モデルがない場合、子モデルのインスタンスを生成して返します。子モデルのattributeの外部キーには親モデルのキー(ローカルキー)の値がセットされます。
// データがない場合は new Profile(['user_id' => xxx]) が返される
return $this->hasOne('App\Profile')->withDefault();
// attributeを指定することも可能。fillableプロパティかguardedプロパティの設定が必要
return $this->hasOne('App\Profile')->withDefault(['bio' => 'hello world']);
次は子モデルを見てみましょう。子モデルではbelongsTo
メソッドを使って親モデルとのリレーションを定義します。
class Profile extends Model
{
public function user()
{
return $this->belongsTo('App\User');
}
}
親モデルを取得する場合には以下のように子モデルのattributeとしてアクセスします。
public function show($id)
{
$profile= Profile::find($id);
$user= $profile->user;
}
この時の処理の流れを簡略化したものが以下のようになります。
基本的に前述したhasOneと流れは同じです。モデルはメソッドのリターンとしてbelongsToメソッドを使ってBelongsToオブジェクトを基底クラスに返します。belongsToメソッドは引数として子モデルのクラス名、外部キー、オーナーキー、リレーション名を指定します。
return $this->belongsTo('App\User', '外部キー', 'オーナーキー', 'リレーション名');
外部キーを指定しない場合、リレーション名のスネークケースと親モデルのキーをアンダースコアでつなげた形になります。リレーション名はデフォルトでbelongsToメソッドを呼び出したfunctionの名前が設定されます(userというメソッドで呼び出していたらuserになる)。
protected function guessBelongsToRelation()
{
list($one, $two, $caller) = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
return $caller['function'];
}
オーナーキーとは親モデルのキーを表します。指定しない場合は親モデルのキー(id)になります。実行されるクエリのイメージは以下のようになります。
SELECT * FROM 親モデル WHERE オーナーキー(親モデルのキー) = 子モデルの外部キーの値
なお、belongsToはwithDefault
メソッドを使ってデフォルトモデルを設定することができます。子モデルに紐づく親モデルがない場合、親モデルの空のインスタンスを生成して返します。
// データがない場合は new User() が返される
return $this->belongsTo('App\User')->withDefault();
// attributeを指定することも可能。fillableプロパティかguardedプロパティの設定が必要
return $this->belongsTo('App\User')->withDefault(['name' => 'anonymous']);
リレーションモデルのinsert、updateおよびリレーションの更新
子モデルのレコードを新規追加する場合、saveメソッドを使うとちょっと便利です(外部キーにも値が自動的に設定される)。
$user = User::find(1);
$profile = new Profile(['nick_name' => 'qiita']);
// 外部キー(user_id)に親モデルのキーが自動的に入る
$profile = $user->profile()->save($profile);
saveメソッド以外にも以下のようなメソッドが利用できます。
// insert
$profile = $user->profile()->create(['nick_name' => 'qiita']);
// selectしてレコードがなければinsert
$profile = $user->profile()->firstOrCreate([], ['nick_name' => 'qiita']);
// selectしてレコードがあればupdate、なければinsert
$profile = $user->profile()->updateOrCreate([], ['nick_name' => 'qiita']);
なお、上記メソッドで子モデルを追加しても親モデルのリレーションに追加される訳ではありません。
$profile = $user->profile()->save($profile);
var_dump($user->getRelations()); // empty
$profile = $user->profile;
var_dump($user->getRelations()); // ['profile' => object(App\Profile)]
associate
メソッドおよびdissociate
メソッドを使ってbelongsToリレーションを更新することができます。
class User extends Model
{
public function organization()
{
return $this->belongsTo('App\Organization');
}
}
$organization = Organization::find(1);
$user = User::find(1);
// ユーザーのorganization_idに値がセットされ、リレーションが追加される
$user->organization()->associate($organization);
$user->save();
var_dump($user->getRelations()); // ['organization' => object(App\Organization)]
// ユーザーのorganization_idにnullがセットされ、リレーションが解除される
$user->organization()->dissociate();
var_dump($user->getRelations()); // empty
1対多(hasMany、belongsTo)
1対多は基本的に1対1と同じでほとんど違いはありません。hasOneの場合は返ってくる結果が1件ですが、hasManyの場合はコレクションが返ってきます。hasManyにはデフォルトモデルを設定することはできません。
リレーションはQueryBuilderとして動作しますので、条件を追加して絞り込むことができます(1対多に限らず他のリレーションも同様です)。
$post = Post::find(1);
$comments = $post->comments()->where('user_id', 2)->get();
以下のようにリレーションモデルのレコードに基づいてモデルのレコードを絞り込むこともできます。
// コメントのある投稿を取得
$post = Post::has('comments')->get();
// コメントか いいね がある投稿を取得
$post = Post::has('comments')->orHas('likes')->get();
// コメントがない投稿を取得
$post = Post::doesntHave('comments')->get();
// コメントがないか いいね がない投稿を取得
$post = Post::doesntHave('comments')->orDoesntHave('likes')->get();
// 直近10日間でコメントがあった投稿を取得
$post = Post::whereHas('comments', function($query) {
$query->where('created_at', '>=', Carbon::now()->subDay(10));
})->get();
// 直近10日間でコメントか いいね があった投稿を取得
$post = Post::whereHas('comments', function($query) {
$query->where('created_at', '>=', Carbon::now()->subDay(10));
})->orWhereHas('likes', function($query) {
$query->where('created_at', '>=', Carbon::now()->subDay(10));
})->get();
// 直近10日間でコメントがなかった投稿を取得
$post = Post::whereDoesntHave('comments', function($query) {
$query->where('created_at', '>=', Carbon::now()->subDay(10));
})->get();
// 直近10日間でコメントか いいね がなかった投稿を取得
$post = Post::whereDoesntHave('comments', function($query) {
$query->where('created_at', '>=', Carbon::now()->subDay(10));
})->orWhereDoesntHave('likes', function($query) {
$query->where('created_at', '>=', Carbon::now()->subDay(10));
})->get();
また、前述した1対1のsaveメソッドやcreateメソッド以外に、複数モデルのinsertに対応したsaveMany
メソッドやcreateMany
メソッドが利用できます。
$post = Post::find(1);
$posts = $post->comments()->saveMany([
new Comment(['user_id' => 1, 'comment' => 'hoge']),
new Comment(['user_id' => 2, 'comment' => 'hoge']),
]);
$posts = $post->comments()->createMany([
['user_id' => 1, 'comment' => 'hoge'],
['user_id' => 2, 'comment' => 'hoge'],
]);
firstOrCreateやupdateOrCreateを使って条件を指定したinsert、updateを行うこともできます。
// user_idが2のコメントがなければuser_idが2のコメントをinsert
$post = $post->comments()->firstOrCreate(
['user_id' => 2], ['comment' => 'hoge']
);
// user_idが2のコメントがあればupdate、なければinsert
// ※ただし、user_idが2のコメントが複数あっても最初の1レコード目しか更新されない
$post = $post->comments()->updateOrCreate(
['user_id' => 2], ['comment' => 'hoge']
);
多対多(belongsToMany)
モデルが多対多のリレーションを持っている場合、お互いのモデルはbelongsToMany
メソッドを使ってリレーションを定義します。
class Post extends Model
{
public function tags()
{
return $this->belongsToMany('App\Tag');
}
}
class Tag extends Model
{
public function posts()
{
return $this->belongsToMany('App\Post');
}
}
モデルを取得する場合には以下のようにモデルのattributeとしてアクセスします。
public function show($id)
{
$post = Post::find($id);
foreach ($post->tags as $tag) {
// do something
}
}
この時の処理の流れを簡略化したものが以下のようになります。
前述したhasOneと流れは大体同じです。モデルはメソッドのリターンとしてbelongsToManyメソッドを使ってBelongsToManyオブジェクトを基底クラスに返します。belongsToManyメソッドは引数としてリレーションモデルのクラス名、中間テーブル名、外部pivotキー、関連pivotキー、親キー、関連キー、リレーション名を指定します。
return $this->belongsToMany('App\Tag', '中間テーブル名', '外部pivotキー', '関連pivotキー', '親キー', '関連キー', 'リレーション名');
中間テーブルとはリレーション元テーブルとリレーション先テーブルを紐づけるためのテーブルになります。例えば投稿に複数のタグが付けられるようなデータモデルの場合、投稿に付けられたタグを取得したり、タグが付いた投稿を取得するには、投稿とタグを紐づけるための中間テーブル(投稿IDとタグIDをもつようなテーブル)が必要になります。
中間テーブル名はデフォルトで互いのモデル名のスネークケースをアンダースコアで繋いだものになります(モデル名はsort関数でソート)。つまり、PostとTagというモデルがあった場合、'post_tag'が中間テーブル名になります。
public function joiningTable($related)
{
$models = [
Str::snake(class_basename($related)),
Str::snake(class_basename($this)),
];
sort($models);
return strtolower(implode('_', $models));
}
中間テーブルとの結合条件には関連キーと関連pivotキーが使用されます。関連キーを指定しない場合はリレーション先テーブルのキー(id)になります。関連pivotキーを指定しない場合はリレーション先テーブルの外部キーとなり、モデル名のスネークケースとキーをアンダースコアでつなげた形になります。
INNER JOIN 中間テーブル ON リレーション先テーブル.関連キー = 中間テーブル.関連pivotキー
WHERE句には外部pivotキーと親キーが使用されます。外部pivotキーを指定しない場合はリレーション元テーブルの外部キーとなり、モデル名のスネークケースとキーをアンダースコアでつなげた形になります。親キーを指定しない場合はリレーション元テーブルのキー(id)が使用されます。
WHERE 中間テーブル.外部pivotキー = 親キーの値
また、SELECTするカラムにはリレーション先のカラムの他に中間テーブルのpivotキーが追加されます。
SELECT リレーション先テーブル.*, 中間テーブル.外部pivotキー, 中間テーブル.関連pivotキー
従って実際に実行されるクエリのイメージは以下のようになります。
SELECT tags.*, post_tag.post_id, post_tag.tag_id
FROM posts
INNER JOIN post_tag -- 中間テーブル名
ON tags.id = post_tag.tag_id -- 関連キー、関連pivotキー
WHERE post_tag.post_id = ? -- 外部pivotキー、親キーの値
取得した中間テーブルのデータはPivotモデルに格納されpivot
というキー名でロード済みリレーションに保持されますので、pivot
という名前のattributeにアクセスすればデータを取得することができます。
public function show($id)
{
$post = Post::find($id);
foreach ($post->tags as $tag) {
$post_id = $tag->pivot->post_id;
}
}
リレーションの定義でas
メソッドを使えばpivotという名前を変更することができます。
class Post extends Model
{
public function tags()
{
return $this->belongsToMany('App\Tag')->as('taginfo');
}
}
また、リレーションの定義でusing
メソッドを使えば中間テーブルデータのモデルをPivotから独自のモデルへ変更することができます。
class Post extends Model
{
public function tags()
{
return $this->belongsToMany('App\Tag')->using('App\TagInfo');
}
}
use Illuminate\Database\Eloquent\Relations\Pivot;
// Pivotモデルを継承する必要がある
class TagInfo extends Pivot
{
}
前述したようにデフォルトでは中間テーブルの外部キー2つしかデータを取得しないためそれ以外のカラムにはアクセスできません。しかし、リレーション定義時にwithPivot
やwithTimestamps
を使うことでカラムを追加することができます。
class Post extends Model
{
public function tags()
{
return $this->belongsToMany('App\Tag', 'tag_infos') // 中間テーブル名をtag_infosに変更
->using('App\TagInfo') // モデルをPivotからTagInfoに変更
->as('taginfo') // taginfoというattributeでアクセスできるように変更
->wherePivot('genre', 1) // genreが1のデータに絞り込む
->withPivot('tag_order', 'tag_color') // 取得するカラムを追加
->withTimestamps(); // created_atとupdated_atを追加
}
}
中間テーブルのデータ操作
Eloquentには中間テーブルにデータを追加、削除するための便利なメソッドが用意されています。
$post = Post::find(1);
// 中間テーブルに投稿IDが1、タグIDが1のデータを追加する
$post->tags()->attach(1);
// 中間テーブルにタグIDが1のデータを指定したattributeで追加する
$post->tags()->attach(1, ['tag_order' => 1, 'tag_color' => 'red']);
// 中間テーブルにタグIDが1および2のデータを指定したattributeで追加する
$post->tags()->attach([
1 => ['tag_order' => 1, 'tag_color' => 'red'],
2 => ['tag_order' => 2, 'tag_color' => 'blue']
]);
// 中間テーブルからタグIDが1のデータを削除する
$count = $post->tags()->detach(1);
// 中間テーブルからタグIDが1および2のデータを削除する
$count = $post->tags()->detach([1, 2]);
// 中間テーブルから投稿IDが1のデータを全て削除する
$count = $post->tags()->detach();
// 中間テーブルにタグIDが1および2のデータが無ければ追加し、それ以外のものがあれば削除する
$changes = $post->tags()->sync([1, 2]);
// 中間テーブルにタグIDが1および2のデータが無ければ追加・更新し、それ以外のものがあれば削除する
$changes = $post->tags()->sync([
1 => ['tag_order' => 1, 'tag_color' => 'red'],
2 => ['tag_order' => 2, 'tag_color' => 'blue']
]);
// 中間テーブルにタグIDが1および2のデータが無ければ追加・更新し、それ以外のものは残したまま
$changes = $post->tags()->syncWithoutDetaching([
1 => ['tag_order' => 1, 'tag_color' => 'red'],
2 => ['tag_order' => 2, 'tag_color' => 'blue']
]);
// 中間テーブルに指定されたタグIDのものがあれば削除、なければ追加する
$changes = $post->tags()->toggle([1, 2]);
// 中間テーブルに指定されたタグIDのものがあれば削除、なければ指定したattributeで追加する
$changes = $post->tags()->toggle([
1 => ['tag_order' => 1, 'tag_color' => 'red'],
2 => ['tag_order' => 2, 'tag_color' => 'blue']
]);
// 中間テーブルにタグIDが1のデータを追加
$tag = Tag::find(1);
$tag = $post->tags()->save($tag, ['tag_order' => 1, 'tag_color' => 'red']);
// 中間テーブルのタグIDが1のデータを更新
$count = $post->tags()->updateExistingPivot(1, ['tag_order' => 1, 'tag_color' => 'red']);
経由テーブル越しの多対多(hasManyThrough)
例えば以下のようなデータモデルで、組織の投稿を取得するためには組織に所属するユーザーを経由して投稿を取得する必要があります。
このようなデータモデルに対応する場合、hasManyThrough
メソッドを使ってリレーションを定義します。
class Organization extends Model
{
public function posts()
{
return $this->hasManyThrough('App\Post', 'App\User');
}
}
モデルを取得する場合には以下のようにモデルのattributeとしてアクセスします。
public function show($id)
{
$organization = Organization::find($id);
foreach ($organization->posts as $post) {
$title = $post->title;
}
}
この時の処理の流れを簡略化したものが以下のようになります。
前述したbelongsToManyと流れは大体同じです。モデルはメソッドのリターンとしてhasManyThroughメソッドを使ってHasManyThroughオブジェクトを基底クラスに返します。hasManyThroughメソッドは引数としてリレーションモデルのクラス名、経由モデルのクラス名、ファーストキー、セカンドキー、ローカルキー、セカンドローカルキーを指定します。
return $this->hasManyThrough('App\Post', 'App\User', 'ファーストキー', 'セカンドキー', 'ローカルキー', 'セカンドローカルキー');
第2引数で指定した経由テーブルが前述した多対多の中間テーブルと同等の役割を果たします。経由テーブルとの結合条件にはセカンドローカルキーとセカンドキーが使用されます。セカンドローカルキーを指定しない場合は経由テーブルのキー(id)になります。セカンドキーを指定しない場合は経由テーブルの外部キーとなり、モデル名のスネークケースとキーをアンダースコアでつなげた形になります。
INNER JOIN 経由テーブル ON 経由テーブル.セカンドローカルキー = 経由先テーブル.セカンドキー
WHERE句にはファーストキーとローカルキーが使用されます。ファーストキーを指定しない場合は経由元テーブルの外部キーとなり、モデル名のスネークケースとキーをアンダースコアでつなげた形になります。ローカルキーを指定しない場合は経由元テーブルのキー(id)になります。
WHERE 経由テーブル.ファーストキー = ローカルキーの値
また、SELECTするカラムには経由先テーブルのカラムの他にファーストキーが追加されます。
SELECT 経由先テーブル.*, 経由元テーブル.ファーストキー
従って実際に実行されるクエリのイメージは以下のようになります。
SELECT posts.*, users.organization_id
FROM posts
INNER JOIN users -- 経由元テーブル
ON users.id = posts.user_id -- セカンドローカルキー、セカンドキー
WHERE users.organization_id = ? -- ファーストキー、ローカルキーの値
ポリモーフィック リレーション(morphTo、morphMany)
ポリモーフィック リレーションとは、1つのキーが持つリレーションが複数のモデルにまたがるようなものを表します。例えば以下のように投稿やコメントに対する「いいね」を一つのテーブルで管理する場合が該当します(target_typeとtarget_idで投稿に対するものかコメントに対するものかを管理する)。
このようなデータモデルに対応する場合、morphTo
メソッドとmorphMany
メソッドを使ってリレーションを定義します。
class Post extends Model
{
public function comments()
{
return $this->hasMany('App\Comment');
}
public function likes()
{
return $this->morphMany('App\Like', 'target');
}
}
class Comment extends Model
{
public function likes()
{
return $this->morphMany('App\Like', 'target');
}
}
class Like extends Model
{
public function target()
{
return $this->morphTo();
}
}
モデルを取得する場合には以下のようにモデルのattributeとしてアクセスします。
public function show($id)
{
$post = Post::find($id);
foreach ($post->likes as $like) {
$user_id = $like->user_id;
}
foreach ($post->comments as $comment) {
foreach ($comment->likes as $like) {
$user_id = $like->user_id;
}
}
}
この時の処理の流れを簡略化したものが以下のようになります。
基本的には1対1や1対多の流れと同じです。モデルはメソッドのリターンとしてmorphManyメソッドを使ってMorphManyオブジェクトを基底クラスに返します。morphManyメソッドは引数としてリレーションモデルのクラス名、morph名、morphタイプ、morph ID、ローカルキーを指定します。
return $this->morphMany('App\Like', 'morph名', 'morphタイプ', 'morph ID', 'ローカルキー');
morphタイプを指定しない場合はmorph名と'type'をアンダースコアで繋げた文字列(xxxx_type)となり、morph IDを指定しない場合はmorph IDと'id'をアンダースコアで繋げた文字列(xxxx_id)となります。ローカルキーを指定しない場合はリレーション元テーブルのキー(id)になります。
public function show($id)
{
$post = Post::find($id);
foreach ($post->likes as $like) {
$user_id = $like->user_id;
}
}
上記のように投稿の「いいね」を取得する場合に実行されるクエリのイメージは以下のようになります。
SELECT * FROM likes WHERE target_type = 'App\Post' AND target_id = (投稿idの値)
morphタイプの値にはモデルのクラス名が入ります。ただし、サービスプロバイダなどでmorphMapを設定することでクラス名から代替文字列に変更することができます。
use Illuminate\Database\Eloquent\Relations\Relation;
public function boot()
{
Relation::morphMap([
'post' => 'App\Post',
'comment' => 'App\Comment',
]);
}
ポリモーフィック リレーションを利用してさらに複雑なケースに対応することができます。例えば以下のデータモデルのように、中間テーブルを介して投稿やコメントに「いいね」したユーザーを取得したり、ユーザーが「いいね」した投稿やコメントを取得する多対多の場合です。
この場合のリレーションの定義はmorphedByMany
メソッドとmorphToMany
メソッドを使用します。
class User extends Model
{
public function myLikedPosts()
{
return $this->morphedByMany('App\Post', 'target', 'likes');
}
public function myLikedComments()
{
return $this->morphedByMany('App\Comment', 'target', 'likes');
}
}
class Post extends Model
{
public function likedUsers()
{
return $this->morphToMany('App\User', 'target', 'likes');
}
}
class Comment extends Model
{
public function likedUsers()
{
return $this->morphToMany('App\User', 'target', 'likes');
}
}
上記の例では第3引数にテーブル名を指定していますが、省略した場合は第2引数の文字列からテーブル名が決定されます(この場合はtargets)。
モデルを取得する場合には以下のようにモデルのattributeとしてアクセスします。
public function show($id)
{
$user= User::find($id);
foreach ($user->myLikedPosts as $post) {
$title = $post->title;
}
}
また、前述した多対多の時と同様に中間テーブルのデータにもアクセスすることができます。
class User extends Model
{
public function myLikedPosts()
{
return $this->morphedByMany('App\Post', 'target', 'likes')
->as('like')
->withTimestamps();
}
}
public function show($id)
{
$user= User::find($id);
foreach ($user->myLikedPosts as $post) {
$likedAt = $post->like->created_at;
}
}
Eagerロード
Eloquentではリレーションにアクセスする時、標準では遅延ロードになります。
$posts = Post::all();
foreach ($posts as $post) {
// このタイミングで投稿に紐づくコメントを取得するためのクエリが実行される
$comments = $post->comments;
}
上記のような場合だとループの回数分クエリが実行され無駄にリソースを使ってしまう可能性があります。これを回避する方法としてEagerロードという仕組みがあり、Eagerロードを使うと先程のループのクエリが1回で済むようになります。Eagerロードを使うには以下のようにwith
メソッドでリレーションを指定します。
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
$comments = $post->comments;
}
この時の処理の流れを簡略化したものが以下のようになります(例として1対多のリレーションがあった場合を取り上げています)。
まず、最初にwithメソッドで指定されたリレーションをパースします。withメソッドでは単一でリレーションを指定するだけでなく、様々な指定方法があります。
// 単一のリレーションを指定
$posts = Post::with('comments')->get();
// 複数のリレーションを指定
$posts = Post::with(['comments', 'likes'])->get();
// コメントとそれに紐づくユーザーを指定
$posts = Post::with('comments.user')->get();
// コメントの特定のカラムだけ取得(PostとCommentはpost_idで紐づいているためpost_idは必須)
$posts = Post::with('comments:post_id,comment')->get();
パースしてリレーション情報を取り出すと、まずは大元のモデル(この場合Post)のfindを行います。この処理の流れは前述したものと同じなので説明は割愛します。findした結果を受け取るとリレーション情報からリレーションオブジェクト(この場合HasMany)を取得し、IN句に大元のモデルのキー(id)をセットします。もし、withメソッドで制約を定義していたら、そちらも追加されます。
$posts = Post::with(['comments' => function($relation) {
$relation->where('created_at', '>', Carbon::now()->subDay(3));
}])->get();
そしてクエリを実行して結果を取得するとロードされたリレーションとして大元のモデルにセットされます。この操作がループかつネストしていれば再帰的に行われモデルのツリーが作成されます。これにより、モデルのattributeにアクセスしてもクエリが実行されることなく、既に作成されたモデルが返されることになります。
もし、動的にリレーションを定義したい場合はload
メソッドを使用してください。
// コレクションとモデルのどちらでもloadメソッドは使用可
$posts = Post::all();
if (Auth::check()) {
$posts->load('comments');
}
$post = Post::find(1);
if (Auth::check()) {
$post->load('comments');
}
常にEagerロードを適用したい場合があるかもしれません。その場合は、モデルにwith
プロパティを設定することができます。
class Post extends Model
{
protected $with = ['comments'];
}
もし、withプロパティを設定していてEagerロードを適用したくないケースがあった場合はwithout
メソッドを使用することでEagerロードを解除することができます。
$posts = Post::without('comments')->get();
最後に
一通りEloquentの基本的な操作を処理の流れを追いながら紹介してみました。insertの部分で少し触れましたがEloquentを使っているつもりが実はQueryBuilderの方を使っていて意図した動作にならないということも起こりえますので十分にご注意ください。
また、Eloquentに限ったことではありませんがORMは非常に便利な反面、その特性や制限を理解していないとシステムが大きく複雑になるにつれてそのアドバンテージを消失してしまいます。EloquentはActiveRecordパターンを実装したORMですが、そのシンプルな設計思想が故に複雑なDB設計とはあまり相性が良くありません。そのような事も理解した上で設計が必要になるということも念頭に置いておいた方が良いかもしれません(システムは生き物なのでどんなに頑張って設計してもインピーダンスミスマッチが起こることは避けられませんが)。