Laravel Eloquentでwithを使って関係フィールド取得時のフラット化問題
タイトル長すぎw。
関係テーブルのフィールドの取得方法
例えば、items
とunits
のテーブルがあるとします。items
ではunit_id
のFKがあります。それでクエリで単位のコードを一緒に取得したい場合:
SELECT items.name, units.code
FROM items JOIN units ON items.unit_id = units.id ;
をLaravelに書くと:
// Query Builder
DB::table('items')
->join('units', 'items.unit_id', '=', 'units.id')
->select('items.name', 'units.code')
->get();
// Eloquentモデルにも使える
Item::select('items.name','units.code')->join(...)->get();
これでシンプルな場合は特に不自由はないですが、関係テーブルが増えてしまうと、selectとjoinを書くのが結構ややこしくなってしまいます。
Laravel Eloquentではもう一つの方法を提供してくれています。with
を使って、モデルで定義された関係を用いて外部テーブルのデータを取り出すことができると。
// App/Models/Item.php
public function unit()
{
return $this->belongsTo(Unit::class, 'unit_id');
}
// App/Models/Unit.php
public function items()
{
return $this->hasMany(Item::class, 'unit_id');
}
// コントローラでwith('relation:id,field1,field2,...')で使う
Item::with('unit:id,code')->get(['name','unit_id']);
一見似ている機能をしていますが、実際の戻り値が違います。selectとjoinの場合は予想通りフラットなコレクションがリターンされますが、withの場合はnestedとなってしまいます。
# select + joinの場合
Illuminate\Support\Collection {#4075
all: [
{#4074
+"name": "Galaxy S20",
+"code": "31",
},
{#4036
+"name": "iPhone 11",
+"code": "31",
},
],
}
# with relationの場合
Illuminate\Database\Eloquent\Collection {#4141
all: [
App\Models\Item {#4313
name: "Galaxy S20",
unit_id: "6",
unit: App\Models\Unit {#4289
id: "6",
code: "31",
},
},
App\Models\MItem {#4314
name: "iPhone 11",
unit_id: "6",
unit: App\Models\Unit {#4289
id: "6",
code: "31",
},
},
],
}
理由はqueryLogをチェックすると:
>>> DB::getQueryLog();
=> [
[
"query" => "select [name], [unit_id] from [items] where [items].[deleted_at] is null",
"bindings" => [],
"time" => 10.46,
],
[
"query" => "select [id], [code] from [units] where [units].[id] in (6)",
"bindings" => [],
"time" => 7.86,
],
]
つまり、with
は全く違うクエリを実行していて、テーブルをジョインしていません!
withを使う場合のフラット化問題
とあるプロジェクトで、取得した項目をCSVファイルに出力することが必要となっています。
最終的に配列に変換してCSVファイルに出力するが、その前に配列をフラット化しなければなりません。selectとjoinをいっぱい書きたくないから、withでなんとかならないかを探ってみました。
結論から言うと、withだけでなんとかコレクションをフラット化することは無理です。
公式では、コレクション用のメソッドflatten()
がありますが、これは適応できません。理由もシンプルで、このItem {... ,Unit {...}}
というのがコレクションではないからです(Itemのインスタンス)。
App\Models\Item {#4313
name: "Galaxy S20",
unit_id: "6",
unit: App\Models\Unit {#4289
id: "6",
code: "31",
},
},
これで考え方を変えて、コレクションをフラット化するよりも、先に配列に変換してから処理するのがやりやすいと。主に1番目の関数がフラット化の役割ですが、2番目と3番目の関数に出力項目のフィルター機能も混ざっています(最初はgetの引数でしたが)。
// 外部フィールドがフラット化された後、unit_id.id, unit_id.codeのようにキーが作られる
function flatten(array $arr, String $prefix = '')
{
$flattened = [];
foreach ($arr as $key => $value) {
if (is_array($value)) {
$flattened = array_merge($flattened, flatten($value, "{$prefix}{$key}."));
} else {
$flattened[$prefix . $key] = $value;
}
}
return $flattened;
}
// これをコントローラで使って、collectionデータをフラット化した配列をリターン
function flattenCollection(Collection $items, array $columns)
{
$items_array = $items->toArray();
foreach ($items_array as $key => $item) {
$items_array[$key] = filterFields(flatten($item), $columns);
}
return $items_array;
}
// 出力する際に不要な項目を除外
function filterFields(array $arr, array $target_fields)
{
return array_filter($arr, function ($key) use ($target_fields) {
return in_array($key, $target_fields);
}, ARRAY_FILTER_USE_KEY);
}
// コントローラで
$items = Item::with('unit:id,code')->get(); // ここはとりあえず全てのフィールドを取得
$outputs = flattenCollection($items, $target_columns); // フラット化された配列になる
これで、コレクションデータを取得する際はwith+relationを使い、フラット化を配列に変換してから処理することにして、問題は解決できました。ただ、コレクションデータのままでは無理(少なくとも自分の知っている限り)そうで、もし配列ではなく、コレクションが必要であれば、select+joinでやるしかないかもしれません。
ちなみにですが、配列をコレクションに戻す方法も一応ありますので、collect($arr)でベースタイプのコレクションに変換されます。ベースタイプについて次節で説明します。
補足:コレクションデータタイプについて
補足ですが、実はコレクションタイプとはいえ、Laravelで2種類あります。
//その一
use Illuminate\Support\Collection;
//その二
use Illuminate\Database\Eloquent\Collection;
//併用
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
区別といえば、Eloquentのコレクションはサブクラスとなります。モデルメソッドを使う時に(例えばModel::all()とか)、二が戻ってきます。そのため、一の汎用性が高く、逆に二を使ってしまうと、たまにはタイプエラーが出てきます。筆者は関数定義にデータタイプのタイプヒントをいつも入れているため、一度この問題と会いました。EloquentのコレクションをSupportの汎用的コレクションに変えれば問題解決。
二を一に変更したいときはtoBase()
メソッドを使えば良い。Model::where...->get()
のgetも同じ効果らしいですね。他にテストを書く時にも、assertInstanceOf()
とかで使えそうです。
以上!
Discussion