1
/
5

ツリー構造を持つモデルをLaravelで使ってみる

E-kan株式会社の採用担当の渡邊です。
弊社エンジニア前中の記事をご紹介します!

はじめに

E-kanの前中です。
Laravelを業務で使うようになって2年以上経ちました。
最初は見よう見まねで取り組んできましたが、整理のために備忘録を兼ねて記事にしてみました。

Eloquentでツリー構造に挑戦する

LaravelではEloquentモデルを使ってDBのテーブル同士のリレーションを定義して、関係のあるテーブルのデータをまとめて扱うことができます。
実務上では「1対多」のリレーションを使う機会が多いと思いますが、「1対多」のリレーションの応用として、ツリー構造を持つモデルを定義できないか、試してみました。
ツリー構造というのは、PCのディレクトリ構造が良く例として挙げられますが、他にも上司と部下の関係をあらわす場合などに使われます。
例えば

社長
├部長1
│ ├課長A
│ ├課長B
│ └課長C
└部長2
  ├課長D
  └課長E
    ├係長a
    └係長b

のような構造です。
データベースで扱う場合は、以下のように上司をあらわすカラムを持つテーブルになります。

ID 上司 名前 役職
1 Null 斎藤 社長
2 斎藤 山田 部長
3 斎藤 木下 部長
4 山田 金山 課長
5 山田 後藤 課長
6 木下 佐々木 課長
7 木下 小野寺 課長
8 後藤 飯田 係長

※実際には上司のIDで参照するべきですが、見やすさ優先ということで。
※このあたりのテーブル設計については「入れ子集合モデル」などで調べるとよいです。

ソースを書いてみる

要は、自分自身に対してリレーションを持つテーブルということになりますので、1対多のリレーションを設定するhasMany()メソッドを使って定義すれば良いわけです。
役職でも良いのですが、地域を扱うモデルとして作ってみます。

まずはマイグレーションファイルを作ります。

create_areas_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateAreasTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('areas', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('memo')->nullable(true);
            $table->integer('parent_area_id')->nullable(true);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('areas');
    }
}

id、name、memo、parent_area_idとcreated_atとupdated_atのカラムを持つだけの割とシンプルなテーブルです。
※ $table->timestamps(); でcreated_atとupdated_atのカラムを作成できます。
parent_area_id カラムはnullを許容するようにします。親Areaを持たないデータがあるからです。先の例でいうなら、社長の上司が居ないのにあたります。

続いてModelを作成します。この中でリレーションも定義します。

Area.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Area extends Model
{
    use HasFactory;

    protected $table = 'areas';

    function childAreas()
    {
        return $this->hasMany(self::class, 'parent_area_id', 'id');
    }
}

childAreas()関数の中で自分自身に対して1対多のリレーションを定義しておきます。

テストも作ってみます。

AreaTest.php

<?php

namespace Tests\Unit\Model;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Area;

class AreaTest extends TestCase
{
    use RefreshDatabase;

    public function test_get_child_areas()
    {
        Area::factory()
            ->hasChildAreas(3)
            ->create();

        $area = Area::get();
        $this->assertCount(4, $area);
        $this->assertcount(3, $area->first()->childAreas);
    }
}

各関数の説明は省きますが、ここでは子Areaを3つ持つAreaを1つ生成させています。
つまり、Areaのレコードが4件作成され、親レコードの子レコードを参照すると3件のレコードが得られるかを確認しています。

注意点

結構簡単にModelを作成することができました。
あとは逆引きで親レコードを参照できるようにしたり、Rootレコード(parent_area_idがnullのレコード)を抽出する関数を定義したりすれば使い物になりそうです。
しかし、こういった構造を持つModelを扱う際には注意が必要です。

循環参照

一番の注意点は循環参照でしょう。
これは、階層構造を持つデータを作成したうえで、何らかの変更が必要になってRootレコードに親レコードを設定する時に起こりえます。
無関係のレコードを親に設定すれば問題は発生しませんが、既に自身の子レコードや孫レコードとして設定しているレコードを親にすればどうなるでしょうか?
永遠に参照が止まらなくなってしまいますね。処理がタイムアウトエラーになればまだマシな方で、タイムアウトしない設定にしていれば、永遠に処理中になってしまいます。
次回はこの循環参照の回避策について考えてみようと思います。
回避策の候補は2つありますので、大雑把に記載しておきます。

回避策1

データ編集時に循環参照のチェック処理を実装し、循環参照のデータ作成を阻止する。
実務的にもこの処理が最適でしょう。ただし、プログラム以外からデータ編集をされてしまうとどうしようもありません。
※ 「そんな奴おらへんやろ」と思った貴方は善良な人です。馬鹿げた想定が現実になるのが世の中ってものです。

回避策2

子レコードを参照する際に同一IDが2回目に現れた時点で参照を停止する。
意地でもエラーにさせないというなら、こうするしかありませんが、実務上はここまでやる必要はないでしょう。
現実的ではないと思いますが、ストレッチ課題として挑戦してみるのも良いかもしれません。

さいごに

LaravelのEloquentモデルは非常に便利です。
便利なのですが、習得するためには結構な時間がかかります。
DBのリレーション構造を意識しなければいけませんし、何よりも適切に設計されたテーブルとの相性が良いので、そちらも学ばなければ使いこなすことは難しいでしょう。

E-kan株式会社では一緒に働く仲間を募集しています
同じタグの記事
今週のランキング