【Laravel】Eloquentのプロパティに関する不可解な挙動

0
913

Eloquentのプロパティ関連で謎の事象にハマったので、改めて諸々整理してみたいと思います。

問題の事象

まず、問題に関わる部分のみを含むごく簡単なEloquentを考えます。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Sample extends Model
{
    private $param=2;

    // paramアクセサ
    public function getParamAttribute()
    {
        return $this->param * 2;
    }
}

上記に対して以下のような処理を行います。

$obj = new \App\Models\Sample;
$obj->param = 3;
dump($obj->toArray());

ポイントはプロパティ「param」はプライベートであるにも関わらず値を代入しようとしている点です。
しかし、エラーは発生せず結果は以下のようになります。

array:1 [
  "param" => 4
]

エラーにはなりませんでしたが、設定自体は無効であり、結果として「param」初期値(2)に対するアクセサでの演算結果(4)が出力されています。

少なくとも「param」に対して代入を行っている点は不正なので、これを削除して再度動作確認してみます。

$obj = new \App\Models\Sample;
dump($obj->toArray());

結果は以下の通り。

[]

余計な処理を消しただけと思ったのですが、今度は「param」の出力自体がなくなってしまいました。

実際に発生した状況はこれほど単純ではありませんが、問題に関わる部分に大雑把に抽出すると上記のような現象でした。

定義されていないプロパティへの値設定

最初の疑問点であるプライベートなプロパティへの代入がエラーとならない件ですが、それ以前に代入しようとするプロパティが存在しない場合でも代入処理はできてしまいますし、同プロパティを参照すれば先に代入した値を取得できてしまいます。

そもそもEloquentはORMとしてDB上のレコードと紐付けて使用するケースが大半だと思いますが、その際にDBから読み出した各カラムの値をあたかもEloquentのプロパティのように参照できます。これはレコード読み込み時にカラムの各値をEloquent内の配列型プロパティ「attributes」の要素(キーはカラム名)として保持し、アクセス時はEloquent内のマジックメソッド(__get)経由で「attributes」からプロパティ名として指定されたキーに対応する値を取得するように変換されているからです。

代入に関しても同様にマジックメソッド(__set)経由で「attributes」の要素(キーは指定したプロパティ名)で設定できる機能があるようです。
確かにEloquentのORM的に使用している場合、対応するテーブルが持つ特定のカラムへの値設定を同カラム名のプロパティに対する代入のように扱うのは普通のことです。

では今回のように存在はするものの外部アクセス不可であるプロパティの扱いはどうでしょう。
実はこのようなケースもマジックメソッドが呼び出されるんですね。普段あまりマジックメソッドは使わないですし、使う場合でも存在しないプロパティへのアクセスを考慮するケースになるかと思うので、外部アクセス不可なプロパティへのアクセスに関しては考えたことがありませんでした。

と言うことで、プライベートなプロパティへの代入がエラーとならなかったのはEloquentのマジックメソッド(__set)経由で「attributes」への設定に変換されていたからでした。確認してみたところ、確かに「attributes」の中にキー「param」値「3」の要素が存在しました。

しかし、本来は上記のような裏のカラクリなどは知る必要もなく、あくまでORMとして対応するテーブルの「カラム」に対する入出力を行っている認識でプロパティへの入出力を行っていると思います。当然ながらそこには「対応するテーブルに存在しないカラム(プロパティ)名でのアクセス」がどうなるかなどと言った視点はありません。
ましてや、プライベートなプロパティに対する誤った代入が上記のような結果となることは予想し難いと思うのですが。

この辺はEloquentの便利な機能ではあるものの、要注意な点でもあるかと思います。

toArray()とアクセサの関係

と言うことで、「param」の値の代入が「attributes」への設定に変換されてしまっていたことは分かりました。
では、「attributes」の値ではなくアクセサの出力結果がtoArray()に反映されていたのはなぜでしょう。
また、「param」への値の代入、つまり「attributes」への設定を止めた結果、アクセサの出力も含めてtoArray()に反映されなくなったのはなぜでしょう。

まず、toArray()とアクセサの関係を調べていくと、頻繁に出てくるネタは以下のようなものかと思います。

Eloquent内に存在しない要素をtoArray()やtoJson()のようなシリアライズ処理に反映するには、アクセサを用意し、「appends」プロパティにアクセサに対応する属性名(アクセサがgetAbcDefAttributeであればabc_def)を設定する

つまりアクセサを用意し、「appends」プロパティに当該アクセサの名称(所定のルールで変換要)を設定することで、シリアライズ処理の結果にアクセサの出力が反映されるようになる、と言う訳です。

しかし、上記説明には若干の罠があります。
上記はあくまで「appendsにアクセサ名を設定しておけばシリアライズ時に有効」と言っているだけで、「appendsに設定されていないアクセサはシリアライズ時に無効」と言っている訳ではないと言う点に注意が必要です。

実は、シリアライズ時に普通に対象となる「attributes」内の属性に対して、対応するアクセサがあればその出力結果が有効になります。
つまり「attributes」内に「param」属性が存在すれば、シリアライズ時にアクセサ「getParamAttribute」の出力が有効になると言うことです。
一般的にアクセサは当該Eloquentが本来持っている属性、つまり対応するテーブルの特定のカラムに対応する値を加工しながら出力すると言う目的で使用されると思います。例えば時刻情報などを所定の書式に変換して出力するなど。この目的からすれば、当該カラム(プロパティ)への個別アクセス時でもシリアライズ時でも等しくアクセサでの処理結果を期待するとは思いますね。

さて、ここまでくると最初に示した2つの疑問は解決したようなものです。
「param」の設定を行っていた際は「attributes」に対応する要素が(意図せず)生成されるためアクセサ「getParamAttribute」の出力が有効となり、「param」の設定を止めた際は「attributes」に対応する要素がないためアクセサ「getParamAttribute」の出力も無効となった訳です。
今回のアクセサは「attributes」とは無関係な処理を実装したものであり「attributes」との繋がりは全く意識していなかったにも関わらず、結果として「attributes」の設定状況に依存して挙動が変わっていた点が分かり難いところでした。

総括

上記内容に若干の補足を加えてEloquentのプロパティに関する処理を整理すると以下のようになります。

  1. プロパティへの入力に関しては、パブリックプロパティが存在する場合はそのプロパティに、存在しない場合はattributesの要素に反映される。
  2. プロパティからの出力に関しては、パブリックプロパティが存在する場合はそのプロパティから、存在しない場合はattributesの要素から取得される。
    両方存在しないプロパティが指定された場合はNULLになる。
  3. アクセサ・ミューテタでattributesおよびパブリックでないプロパティへの入出力を制御できるが、パブリックプロパティへの制御は不可(当該プロパティに直接入出力ができるため、アクセサ・ミューテタを実行するマジックメソッドの呼び出し自体が行われない)
  4. シリアライズの対象は基本的にはattributesの要素。独自に用意したプロパティは対象外。
    attributesの要素に対応するアクセサがあれば、同アクセサの処理結果が反映される。
    attributesに対応する要素が存在しないアクセサは基本的にはシリアライズに関与しないが、appendsに対応する要素を設定することで処理結果を反映できる(この方法で独自プロパティの内容をシリアライズ結果に反映させることは可能)。
  5. 当該Eloquentの内容をDBに保存する際はattributesの各要素を同名のカラムが存在するものとして処理が実行される。
    テーブルのカラムとして存在しない要素がattributesに存在する場合は保存処理がエラーとなる。

5で示した事情からattributesの中身はレコードに結び付くものだけに限定したく、他の情報をEloquentに保持したい場合は独自プロパティを用意してそちらに設定しておくようにするのですが、1の機能の副作用でレコードに関係ない要素が事故的にattributesに生成できてしまうのが少々難点です。

2に関しても、プロパティ名を間違った場合などもエラーにならず、さりげなくNULLになっていたりするので紛らわしいです。ここはしっかり例外を発生させて欲しいと思ったりするのですが、どうなんでしょう?

3で示したようにアクセサ・ミューテタはパブリックプロパティには無効ですが、カプセル化なども考慮するとプロパティはオブジェクト内に隠蔽すべきで、パブリックプロパティを安易に作ること自体が問題なんでしょうね。アクセサ・ミューテタは入出力時のデータ加工の文脈で語られることが多いですが、ゲッター・セッター的な意味合いもあると思うので、その意味ではパブリックプロパティと結びつけること自体が筋違いであるようにも思います。

4で示したシリアライズ時のアクセサの扱いに関しても、挙動に関しては特に異論はありませんが、そもそもシリアライズとアクセサの関係に関して深く考えたことがありませんでした。その結果、今回のように事故ったり、原因究明に時間を要したりすることになった訳で、この辺のカラクリはしっかり認識しておきたいと思います。

Laravel全般に言えることですが、多数の便利機能をお手軽に使える反面、そのお手軽さから仕様を十分理解せずに使っていてハマる時があります。やはり仕様はしっかり把握しておくべきですね(と言うことで、そろそろ保留してあったコレクションのメソッド整理を再開させなければ…)。