🥇

Laravelで単一テーブル継承の実装を試してみた

2023/12/02に公開1

この記事は Laravel Advent Calendar 2023 2日目の記事です。
https://qiita.com/advent-calendar/2023/laravel

はじめに

同じような構造のテーブルを機能ごとに作っていると1つにできないかなーとか考えていた時に
単一テーブル継承(STI = single table inheritance)という仕組みがあることを知りました。

Ruby on Railsでは既に用意されている仕組みなんですが、
今回はLaravelでも実現可能かちょっと試してみたってお話です。

単一テーブル継承(STI = single table inheritance)とは

そもそも、単一テーブル継承が何かって話なんですが、AIさんに聞くといい感じの答えが返ってきます。
今回はGoogle Bardさんに聞いてみると...

共通の属性ってやつはRuby on Railsでは、typeという属性をクラスの識別に使用している[1]ので
今回もtypeを使ってやっていこうと思います。

環境

  • PHP 8.2.12
  • Laravel 10.33.0

事前準備

今回はmembersテーブルを例にやっていこうと思うので、Artisanコマンドで親クラスになるEloquentモデルクラスをサクッと作っておきます。

-fでファクトリクラス、-mでマイグレーションファイルも一緒に作ります。

% php artisan make:model Member -f -m

   INFO  Model [app/Models/Member.php] created successfully.  

   INFO  Factory [database/factories/MemberFactory.php] created successfully.  
   INFO  Migration [database/migrations/2023_11_26_051552_create_members_table.php] created successfully.

コマンドで作ったあとにマイグレーションファイルは編集します。

database/migrations/2023_11_26_051552_create_members_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('members', function (Blueprint $table) {
            $table->id();
            $table->string('name'); // 追加
            $table->string('type'); // 追加 共通の属性
            $table->timestamps();
        });
    }

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

編集後にマイグレーションファイルは実行してテーブルを作っておきます。

% php artisan migrate

いまの時点ではEloquentモデルクラス、ファクトリクラスは特に変更を入れる必要はないのでそのままです。

実装

ここからが本題です。

さきほど作成したクラスに対して色々変更を入れていこうと思います。

親クラス

親クラスになるMemberクラスに対して修正を入れておきます。

まずは、Eloquentモデルクラスはインスタンスを生成した際はIlluminate\Database\Eloquent\Modelクラスに定義しているnewFromBuilderメソッドを経由するので、オーバーライドして、一部処理を書き換えます。

app/Models/Member.php
    /**
     * Create a new model instance that is existing.
     *
     * @param  array  $attributes
     * @param  string|null  $connection
     * @return static
     */
    public function newFromBuilder($attributes = [], $connection = null)
    {
        $attributes = (array) $attributes;
        $type = $attributes['type'];
        $class = $this->getSingleTableClassMap();

        // typeに紐づくクラスを生成する
        if ($class[$type]) {
            $model = (new $class[$type]())->newInstance([], true);
            $model->setRawAttributes((array) $attributes, true);
            $model->setConnection($connection ?: $this->getConnectionName());
            $model->fireModelEvent('retrieved', false);
            return $model;
        }
        // 紐づくクラスがない時は元の処理を動かす
        return parent::newFromBuilder($attributes, $connection);
    }

さらにtypeとクラスの紐付けはgetSingleTableClassMapメソッドに実装します。

app/Models/Member.php
    /**
     * @return array<string, string>
     */
    private function getSingleTableClassMap(): array
    {   
        // keyがtypeに登録している内容、valueが子クラスの名前の配列を返す
        return [
            'Gold' => GoldMember::class,
            'Silver' => SilverMember::class,
            'Bronze' => BronzeMember::class,
            'Normal' => NormalMember::class,
        ];
    }

これで、Member::find(1)などで取得した際にtypeに紐づく子クラスが作成できるようになったはずです。

子クラス

続いては先ほど登場した子クラスを作成します。

app/Models/GoldMember.php
<?php

namespace App\Models;

class GoldMember extends Member
{
}

親になるMemberクラスを継承するだけで終わりだったりします。
ただ、これだけではtypeGoldだけのMemberを取得ためには下記のようにwhereメソッドで指定する必要があります。

use App\Models\Member;

Member::query()->where('type', 'Gold')->get();

Goldで絞るならtypeに紐づく子クラス側で検索するだけにしたいので、Laravelの機能であれるグルーバルスコープ[2]を使えば解決しそうです。

さきほど作ったGoldMemberクラスにbootedメソッドをオーバーライドしてaddGlobalScopeメソッドを使って登録します。

app/Models/GoldMember.php

use Illuminate\Database\Eloquent\Builder;

    /**
     * Perform any actions required after the model boots.
     *
     * @return void
     */
    protected static function booted()
    {
        parent::booted();

        static::addGlobalScope('gold', function (Builder $builder) {
            $builder->where('type', 'Gold');
        });
    }

上記のような実装をすると次のようなクエリが流れるようになります。

% php artisan tinker
> use App\Models\GoldMember;
> GoldMember::query()->toRawSql();
= "select * from `gold_members` where `type` = 'Gold'"

whereメソッドを使わないでも条件が付くようになりました。
が、いまの状態では存在しないテーブルを見ているので実際に実行するとテーブルがないってエラーになります。
これは$tableプロパティが未指定の場合はクラス名からテーブル名を付けてくれる仕組みが動くためです。
これはこれで便利だけど今回は不要な仕組みなので親クラスのMemberクラスの方に$tableプロパティにテーブル名を指定しておきます。
そうすれば子クラス側でもmembersテーブルを参照するようになります。

app/Models/Member.php
class Member extends Model
{
    use HasFactory;

    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'members';
}

この状態で再度GoldMemberクラスからデータを取得するクエリを実行すると...

% php artisan tinker
> use App\Models\GoldMember;
> GoldMember::query()->toRawSql();
= "select * from `members` where `type` = 'Gold'"

membersテーブルを参照してくれるようになりました。

こんな感じで残りの子クラス(Silver、Bronze、Normal)も作っておきます。

ファクトリクラス

テストデータをサクッと作りたいのでファクトリクラスも少々手を入れておきます。
初期はNormal、それ以外のtypeは専用のメソッドを作成しておきます。

database/factories/MemberFactory.php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Member>
 */
class MemberFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'type' => 'Normal', // 初期値はNormal
        ];
    }

    public function gold(): static
    {
        return $this->state(fn (array $attributes) => [
            'type' => 'Gold',
        ]);
    }

    public function silver(): static
    {
        return $this->state(fn (array $attributes) => [
            'type' => 'Silver',
        ]);
    }

    public function bronze(): static
    {
        return $this->state(fn (array $attributes) => [
            'type' => 'Bronze',
        ]);
    }
}

成果物

ここまでの流れで出来た親子クラスは下記の通りです。

app/Models/Member.php
<?php

namespace App\Models;

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

class Member extends Model
{
    use HasFactory;

    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'members';

    /**
     * Create a new model instance that is existing.
     *
     * @param  array  $attributes
     * @param  string|null  $connection
     * @return static
     */
    public function newFromBuilder($attributes = [], $connection = null)
    {
        $attributes = (array) $attributes;
        $type = $attributes['type'];
        $class = $this->getSingleTableClassMap();

        // typeに紐づくクラスのインスタンスを生成
        if ($class[$type]) {
            $model = (new $class[$type]())->newInstance([], true);

            $model->setRawAttributes((array) $attributes, true);

            $model->setConnection($connection ?: $this->getConnectionName());

            $model->fireModelEvent('retrieved', false);

            return $model;
        }

        return parent::newFromBuilder($attributes, $connection);
    }

    /**
     * @return array<string, string>
     */
    private function getSingleTableClassMap(): array
    {   
        return [
            'Gold' => GoldMember::class,
            'Silver' => SilverMember::class,
            'Bronze' => BronzeMember::class,
            'Normal' => NormalMember::class,
        ];
    }
}
app/Models/GoldMember.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;

class GoldMember extends Member
{
    /**
     * Perform any actions required after the model boots.
     *
     * @return void
     */
    protected static function booted()
    {
        parent::booted();

        static::addGlobalScope('gold', function (Builder $builder) {
            $builder->where('type', 'Gold');
        });
    }
}

app/Models/SilverMember.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;

class SilverMember extends Member
{
    /**
     * Perform any actions required after the model boots.
     *
     * @return void
     */
    protected static function booted()
    {
        parent::booted();

        static::addGlobalScope('silver', function (Builder $builder) {
            $builder->where('type', 'Silver');
        });
    }
}

app/Models/BronzeMember.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;

class BronzeMember extends Member
{
    /**
     * Perform any actions required after the model boots.
     *
     * @return void
     */
    protected static function booted()
    {
        parent::booted();

        static::addGlobalScope('bronze', function (Builder $builder) {
            $builder->where('type', 'Bronze');
        });
    }
}

app/Models/NormalMember.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;

class NormalMember extends Member
{
    /**
     * Perform any actions required after the model boots.
     *
     * @return void
     */
    protected static function booted()
    {
        parent::booted();

        static::addGlobalScope('normal', function (Builder $builder) {
            $builder->where('type', 'Normal');
        });
    }
}

動作確認

作ったものを動かしてみましょう。
動作確認はtinkerを使っていきます。

% php artisan tinker
> use App\Models\Member;
> 
> Member::factory()->create();
= App\Models\Member {#6319
    name: "Louie Barrows V",
    type: "Normal",
    updated_at: "2023-11-26 06:40:32",
    created_at: "2023-11-26 06:40:32",
    id: 1,
  }

> Member::factory()->create();
= App\Models\Member {#6321
    name: "Jaren Beier I",
    type: "Normal",
    updated_at: "2023-11-26 06:40:39",
    created_at: "2023-11-26 06:40:39",
    id: 2,
  }

> Member::factory()->gold()->create();
= App\Models\Member {#6330
    name: "Miss Anais Cruickshank Sr.",
    type: "Gold",
    updated_at: "2023-11-26 06:41:01",
    created_at: "2023-11-26 06:41:01",
    id: 3,
  }

> Member::factory()->silver()->create();
= App\Models\Member {#6290
    name: "Ruthie McLaughlin",
    type: "Silver",
    updated_at: "2023-11-26 06:41:13",
    created_at: "2023-11-26 06:41:13",
    id: 4,
  }

> Member::factory()->bronze()->create();
= App\Models\Member {#6329
    name: "Americo Kihn DDS",
    type: "Bronze",
    updated_at: "2023-11-26 06:41:24",
    created_at: "2023-11-26 06:41:24",
    id: 5,
  }

> 

適当なデータを作ってMember::all()を使うと、それぞれtypeに紐づく子クラスで取得できましたね。

> Member::all();
= Illuminate\Database\Eloquent\Collection {#6261
    all: [
      App\Models\NormalMember {#6289
        id: 1,
        name: "Louie Barrows V",
        type: "Normal",
        created_at: "2023-11-26 06:40:32",
        updated_at: "2023-11-26 06:40:32",
      },
      App\Models\NormalMember {#6319
        id: 2,
        name: "Jaren Beier I",
        type: "Normal",
        created_at: "2023-11-26 06:40:39",
        updated_at: "2023-11-26 06:40:39",
      },
      App\Models\GoldMember {#6293
        id: 3,
        name: "Miss Anais Cruickshank Sr.",
        type: "Gold",
        created_at: "2023-11-26 06:41:01",
        updated_at: "2023-11-26 06:41:01",
      },
      App\Models\SilverMember {#6291
        id: 4,
        name: "Ruthie McLaughlin",
        type: "Silver",
        created_at: "2023-11-26 06:41:13",
        updated_at: "2023-11-26 06:41:13",
      },
      App\Models\BronzeMember {#6340
        id: 5,
        name: "Americo Kihn DDS",
        type: "Bronze",
        created_at: "2023-11-26 06:41:24",
        updated_at: "2023-11-26 06:41:24",
      },
    ],
  }

>

子クラスからデータを取得した場合も問題なく取得できましたね。

> use App\Models\NormalMember
> 
> NormalMember::all()
= Illuminate\Database\Eloquent\Collection {#6330
    all: [
      App\Models\NormalMember {#6321
        id: 1,
        name: "Louie Barrows V",
        type: "Normal",
        created_at: "2023-11-26 06:40:32",
        updated_at: "2023-11-26 06:40:32",
      },
      App\Models\NormalMember {#6254
        id: 2,
        name: "Jaren Beier I",
        type: "Normal",
        created_at: "2023-11-26 06:40:39",
        updated_at: "2023-11-26 06:40:39",
      },
    ],
  }

> 

まとめ

同じような構造のテーブルをたくさん作るよりは、1テーブルで関係できる方がすっきりするから個人的には好きな仕組みではある。
が、クラスの親子関係で表現したことが単一テーブル継承なのかはよくわかっていない...

子クラスをたくさん作ることで複雑化するかもしれないし、typeごとに分岐を書く方が使いやすいかもしれない
なので、このやり方を使いやすい規模などの見極めが大事かもしれない。

参考記事

https://bliki-ja.github.io/pofeaa/SingleTableInheritance

https://qiita.com/yebihara/items/9ecb838893ad99be0561

https://qiita.com/acro5piano/items/95ad47acd17f8a5a5b4c

https://qiita.com/grohiro/items/e78bd0e0ba79be4c29dc

脚注
  1. https://railsguides.jp/association_basics.html#単一テーブル継承-(sti) ↩︎

  2. https://laravel.com/docs/10.x/eloquent#global-scopes ↩︎

Discussion