0
Help us understand the problem. What are the problem?

posted at

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

はじめに

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のリレーション構造を意識しなければいけませんし、何よりも適切に設計されたテーブルとの相性が良いので、そちらも学ばなければ使いこなすことは難しいでしょう。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
0
Help us understand the problem. What are the problem?