こんにちは。エンジニアの @localdisk です。タイトルの件、最近まで自分も知らなくてマニュアルにも載ってないのでブログに書くことにしました。
具体的にどういうこと?
たとえば User
クラスと 1:1 の関連がある Profile
クラスがあるとします。
<?php namespace App; class User extends Authenticatable { public function profile() { return $this->hasOne(Profile::class); } }
上記のように profile
メソッドを定義して、関連の存在しない状態で profile
メソッドを呼び出すと null
が戻ってきて悲しいことになります。そのことを知らずにメソッドチェインすると…。
<?php // PHP error: Trying to get property of non-object 😢 \App\User::find(1)->profile->nickname
こういう😢ことが起こります*1。これをどうにか解決できないかなぁと思い、久しぶりにソースを読んでみました。
リレーションがロードされるまでの流れ
Illuminate\Database\Eloquent\Model::__get($key)
最初は存在しないフィールドなので __get($key)
が呼ばれます。 getAttribute($key)
メソッドが呼ばれるだけ
Illuminate\Database\Eloquent\Model::getAttribute($key)
このメソッドはモデルの属性(カラム)、あるいは Mutator
で定義されているものをチェックして存在していればそちらを先に返します。存在しない場合は getRelationValue($key)
を呼び出します。
Illuminate\Database\Eloquent\Concerns\HasAttributes::getRelationValue($key)
このメソッドはリレーションが既にロードされている場合はそれを返す。されていない場合は getRelationshipFromMethod($key)
を呼び出します。
Illuminate\Database\Eloquent\Concerns\HasAttributes::getRelationshipFromMethod($method)
ここで HasOne
リレーションの場合 Illuminate\Database\Eloquent\Relations\HasOne::getResults
を経て getDefaultFor
を呼び出します。ここがポイントです。 withDefault
が定義されてないとそのまま return
。これが null
が戻る元凶です。ということは withDefault
が定義されていればよい。
<?php class HasOne extends HasOneOrMany { protected function getDefaultFor(Model $model) { // ここで return されて null になる if (! $this->withDefault) { return; } $instance = $this->related->newInstance()->setAttribute( $this->getForeignKeyName(), $model->getAttribute($this->localKey) ); if (is_callable($this->withDefault)) { return call_user_func($this->withDefault, $instance) ?: $instance; } // ここがポイント!! if (is_array($this->withDefault)) { $instance->forceFill($this->withDefault); } return $instance; } }
解決策
こうしよう。
<?php namespace App; class User extends Authenticatable { public function profile() { return $this->hasOne(Profile::class)->withDefault(); } }
これで空の Profile
インスタンスがロードされます。このメソッドは引数に bool
or callable
で定義されているので、デフォルト値が欲しい場合は…
<?php namespace App; class User extends Authenticatable { public function profile() { return $this->hasOne(Profile::class)->withDefault(function($model) { $model->nickname = 'foo'; }); } }
このように実装すると nickname
という属性に foo
が代入された状態で Profile
のインスタンスが戻ります。簡単ですね。これで関連先をロードすると null
で困る状態はなくなりました。ちなみに存在チェックをしたい場合は…。
<?php if (App\User::find(1)->profile->exists) { // 存在するときの処理 }
でOKです。
いつもの
最近はサーバーサイドだけでなく、モバイルアプリケーションの作成にも取り掛かろうという動きがあるので iOS/Android アプリケーションエンジニアの方の応募もお待ちしています(切実)。
*1:HasMany等は空の Collection が戻ります。やさしいせかい