EloquentのHasOneリレーションで関連先がないと null になる問題の対処方法

こんにちは。エンジニアの @localdisk です。タイトルの件、最近まで自分も知らなくてマニュアルにも載ってないのでブログに書くことにしました。

f:id:localdisk:20160824143945p:plain

具体的にどういうこと?

たとえば 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 アプリケーションエンジニアの方の応募もお待ちしています(切実)。

recruit.innovator.jp.net

*1:HasMany等は空の Collection が戻ります。やさしいせかい