📖

Laravel Eloquentでwithを使って関係フィールド取得時のフラット化問題

2021/08/09に公開

タイトル長すぎw。

関係テーブルのフィールドの取得方法

例えば、itemsunitsのテーブルがあるとします。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