Laravelで単一テーブル継承の実装を試してみた
この記事は Laravel Advent Calendar 2023 2日目の記事です。
はじめに
同じような構造のテーブルを機能ごとに作っていると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.
コマンドで作ったあとにマイグレーションファイルは編集します。
<?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メソッドを経由するので、オーバーライドして、一部処理を書き換えます。
/**
* 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メソッドに実装します。
/**
* @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
に紐づく子クラスが作成できるようになったはずです。
子クラス
続いては先ほど登場した子クラスを作成します。
<?php
namespace App\Models;
class GoldMember extends Member
{
}
親になるMember
クラスを継承するだけで終わりだったりします。
ただ、これだけではtype
がGoldだけのMemberを取得ためには下記のようにwhereメソッドで指定する必要があります。
use App\Models\Member;
Member::query()->where('type', 'Gold')->get();
Goldで絞るならtype
に紐づく子クラス側で検索するだけにしたいので、Laravelの機能であれるグルーバルスコープ[2]を使えば解決しそうです。
さきほど作ったGoldMember
クラスにbooted
メソッドをオーバーライドしてaddGlobalScope
メソッドを使って登録します。
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
テーブルを参照するようになります。
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
は専用のメソッドを作成しておきます。
<?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',
]);
}
}
成果物
ここまでの流れでできた親子クラスは下記の通りです。
<?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,
];
}
}
<?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');
});
}
}
<?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');
});
}
}
<?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');
});
}
}
<?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
ごとに分岐を書く方が使いやすいかもしれない
なので、このやり方を使いやすい規模などの見極めが大事かもしれない。
参考記事
Discussion
X(旧Twitter)にて、ご指摘がありました。
私の方で動作確認まで行っていないですが、eager loadingを行う場合はバグを踏むかもですね!