Laravelのeloquentに悩まされる
業務でLaravelを触っています。
個人では最近はGoやNextjs(Typescript)、少し前まではDjangoをよく触っていました。
LaravelはDjangoと比べてもコードの自動生成が圧倒的に充実しており便利である反面、型がゆるゆる過ぎて変なところでつまづき時折腹が立ちます。あと、フレームワークとして大きすぎるかななんて思ったりもしてます。
Laravel歴3ヶ月弱の自分がeloquentを使ってDBからデータを引っ張ってきてtoArrayした際に予期せぬ型で返すことがありました。
なんだコイツと思いつつ謎を放置したままいるのも嫌だったのでソースコードを見てみようと思いました。
ということでEloquentのtoArrayのソースコードを読み解いていこうと思います。
toArrayの定義はこちらです
/**
* Convert the model instance to an array.
*
* @return array
*/
public function toArray()
{
return array_merge($this->attributesToArray(), $this->relationsToArray());
}
関数名から察するに
attributesをarrayにしたものとリレーションをarrayにしたものをくっつけています。
attributes
まずはattributesToArrayの中身を見ていきましょう
https://github.com/illuminate/database/blob/d947aa9fa3fa93875990f3194cb9154fae1b0768/Eloquent/Concerns/HasAttributes.php#L138
public function attributesToArray()
{
// created_atやupdated_at
// ここでCarbonインスタンスに変換後stringにキャストしています
$attributes = $this->addDateAttributesToArray(
$attributes = $this->getArrayableAttributes()
);
// mutate定義したカラム
$attributes = $this->addMutatedAttributesToArray(
$attributes, $mutatedAttributes = $this->getMutatedAttributes()
);
// cast設定したカラム
$attributes = $this->addCastAttributesToArray(
$attributes, $mutatedAttributes
);
// その他のカラム
foreach ($this->getArrayableAppends() as $key) {
$attributes[$key] = $this->mutateAttributeForArray($key, null);
}
return $attributes;
}
これらの処理ではいずれも以下の汎用関数が使用されております。
toArrayで使われている汎用関数
protected function getArrayableItems(array $values)
{
if (count($this->getVisible()) > 0) {
$values = array_intersect_key($values, array_flip($this->getVisible()));
}
if (count($this->getHidden()) > 0) {
$values = array_diff_key($values, array_flip($this->getHidden()));
}
return $values;
}
models上で定義したvisibleにあるものだけ抜き出し、その後
hiddenにあるものを除外する処理をしています。
hiddenにもvisibleにも入っている場合にはhiddenが優先されることがここからわかりますね。
リレーション側
続いてリレーション側もみてみましょう
/**
* Get the model's relationships in array form.
*
* @return array
*/
public function relationsToArray()
{
$attributes = [];
foreach ($this->getArrayableRelations() as $key => $value) {
if ($value instanceof Arrayable) {
$relation = $value->toArray();
}
elseif (is_null($value)) {
$relation = $value;
}
if (static::$snakeAttributes) {
$key = Str::snake($key);
}
if (isset($relation) || is_null($value)) {
$attributes[$key] = $relation;
}
unset($relation);
}
return $attributes;
}
力尽きたのでこっちは結構適当に端折ります。
この関数でも上記のgetArrayableItemsがgetArrayableRelationsで呼ばれており、hiddenのカラムをしっかり除外してくれています。
Str::snake($key)でスネークケースにしていることがわかります。
またそれを返すモデルインスタンスのキーにしていることがわかりますね。
これらを踏まえて
toArrayはリレーションも深掘りしたうえで、created_at等をよしなに文字列に変換し、primary_keyはデフォルトでint、キャストに設定しておけばその通り、残りは文字列や文字列の配列で返してくれていることがわかりました。
だからintであるはずのデータがstringになっていたんですね〜
ちゃんとした型で返して欲しいカラムはちゃんとcastsに定義してあげましょう
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
// ageカラムをintで返す
class Profile extends Model
{
/**
* @var array
*/
protected $casts = [
'age' => 'integer',
];
}
余談ですが、DjangoのmodelsのORMはmodels上に定義していないfieldにアクセスしようとすると、unknown fieldsエラーになります。
ここは"暗黙より明示"というDjangoやpythonの哲学が反映されている部分で面白いところですね。
結局 toArrayって何?
modelオブジェクトやcollectionオブジェクトを投げるとpk, created_at, updated_atを自動でcastした状態でarrayを返してくれるメソッドです。
さらにmodelでcastsを定義してあげることで、このメソッドはオーバーライドされます。
一々定義せずともデフォルトでこんなことをしてくれるなんてeloquentのtoArrayは、なんて親切なんでしょうか
Laravelの心子知らずですね
Goとの比較
私の触ったことのある言語で一番堅い言語であるgoと比較してみましょう。
例えば以下のようなテーブルがあると仮定して全件取得してその配列を返してみます。
Go側はORMを使っていないので、比較対象として少しおかしいですが、そこは目をつぶってください。
table_profile
id: INT UNSIGNED AUTO INCREMENT
name: VARCHAR(64)
age: TINYINT UNSIGNED
birthday: DATETIME
かなり適当に書くので悪しからず...
import "database/sql"
type TProfile struct {
Id: int
Name: string
Age: int
Birthday: string
}
func sample() []TProfile {
db, err := sql.Open("mysql", "...")
if err != nil {
panic(err)
}
defer db.Close()
rows, _ = db.Query("SELECT * FROM table_profile")
defer rows.Close()
var ps []TProfile
for rows.Next() {
var p TProfile
err := rows.Scan(&p.Id, &p.Name, &p.Age, &p.Birthday)
if err != nil {
panic(err)
}
append(ps, p)
}
return ps
}
このようにすることで、最初に定義した型にそったデータを取得できます。
とても簡単で型も正確で最高ですね。
しかし、仮にテーブルのカラムが大量にあった場合どうでしょう。scanner等を使って最小限に留めつつも何十カラムにわたり型を定義するのはなかなか億劫ではないでしょうか。
その反面、LaravelのeloquentのtoArrayは自動でcreated_atやprimary_keyの型を勝手にうまいことやってくれ、あとはテキトーにstringで済ませてくれます。
前述したようにどうしても指定した型で渡したいならmodelにcastsを定義することで指定した型でとってくることができます。
goで例えるとこんな感じのことをしてくれているんじゃないでしょうか
castsとかリレーションとかまではパパッと作れなかったんで省略します。
あとちゃんと動くかわかりません。雰囲気で描いてます
import (
"fmt"
"strconv"
)
type TObject struct {}
type Models interface {
toArray() map[string]interface{}
}
func (obj *TObject) toArray() map[string]interface{} {
ret := make(map[string]interface{}) // mapの宣言
for key, value := range obj {
switch key {
case "Id":
id, _ := strconv.Atoi(value)
ret["id"] = id
case "CreatedAt":
ret["created_at"] = fmt.Sprint(value)
case "UpdatedAt":
ret["updated_at"] = fmt.Sprint(value)
default:
snakeKey := // なんらかのスネークケースにする処理 (省略)
ret[snakeKey] = fmt.Sprint(value)
}
}
return ret
}
まとめ
一々型を定義して安全性や動作の正確性を担保するか、
ある程度適当に大切な部分だけいい感じにやるか。
どちらが良いかは一概には言えないと思います。
これは開発期間や規模、何を開発するか、プロジェクトがどのステージにいるか等のユースケースによって最適解が変わってくると思います。
脳死で技術選定せずにプロジェクト、プロダクトに合致した技術選定が大切だと改めて思いました。
あと、ドキュメント等を読んでちゃんと仕様を理解しなきゃいけませんね