LaravelにはSTIがない。ポリモーフィック関連(Polymorphic Relation:PR)はある。PRがあればSTIが不要だと考えているのかLaravel 4の頃からずっと提供されていない。ググればStackOverflowなどにLaravel + STIの質問が見つかるし、プラグインもいくつかGithubに公開されているのでそれなりのニーズはありそうなんだが。
STIがないと関連モデルをサブタイプにすることができない。
例えば
- [店舗]に複数の[会員]が所属している
- [店舗]に[スタッフ]が所属している
- [スタッフ]は自店舗の[会員]を閲覧できる
- [本社スタッフ]は全店舗の[会員]を閲覧できる
DBには staffs
テーブルと stores
テーブル、members
テーブルがある。
本社スタッフは[スタッフ]のID:1として登録されていて、本社は[店舗]のID:1として登録されている。本社スタッフと店舗スタッフは同じ[スタッフ]だが振る舞いが違うので継承して扱いたい。
以下のようなコードになるとうれしい。
// 本社スタッフは全会員を取得できる
$employee = Staff::find(1); // ID:1は本社スタッフ
$allMembers = $employee->store->members()->get();
// スタッフが所属する店舗の会員を全部取得
$staff = Staff::find(123); // ID:123は店舗スタッフ
$storeMembers = $staff->store->members()->get();
これをそのままモデルとして実装するとこうなる。
// スタッフモデル
class Staff extends Model
{
// スタッフは店舗に所属している
public function store() {
return $this->belongsTo('Store');
}
}
// 店舗モデル
class Store extends Model
{
// 店舗内の会員を取得する
public function members() {
return $this->hasMany('Member', 'store_id');
}
}
// 本店は店舗を継承する
class CentralOffice extends Store
{
public function members() { // override Store::members()
return Member::query(); // hasMany() の変わりに絞り込みなしのクエリを返す
}
}
ここまでくれば Store
と CentralOffice
を直接インスタンス化して希望通りの会員が取得できる。だが Staff
経由ではまだ取得できない。
// 全店舗の会員が取得できる
$allMembers = (new CentralOffice())->members()->get();
// 店舗ID:123の会員だけ取得できる
$members = (new Store(['id' => 123]))->members()->get();
// こっちはまだ動かない。$staff->storeがCentralOfficeモデルにならない。
$members = Staff::find(1)->store->members()->get();
$staff->store
のインスタンス化をフックして、店舗IDが1のときは CentralOffice
を、1以外のときは Store
のインスタンスを返すようにすればこのコードが動くようになる。
Eloquentは関連モデルのインスタンス化を Model::newFromBuilder()
に集約している。ここでIDの値を確認してインスタンス化するモデルを CentralOffice
に変更できる。
// Storeモデル
public function newFromBuilder($attributes = [], $connection = null)
{
$attributes = (array) $attributes;
if (isset($attributes['id']) && $attributes['id'] === 1) {
$model = (new CentralOffice())->newInstance([], true); // CentralOfficeを使う
} else {
$model = $this->newInstance([], true); // Storeを使う
}
// 親クラスからコピーした処理
$model->setRawAttributes($attributes, true);
$model->setConnection($connection ?: $this->getConnectionName());
return $model;
}
Store::newFromBuilder()
の処理を置き換えてやることで $staff->store
にアクセスしたときにIDに従って適切なクラスが設定されるようになった。
ここまでくれば以下のコードが動くようになる。
$allMembers = Staff::find(1)->members()->get();
$storeMembers = Staff::find(123)->members()->get();
今回はidフィールドの値によってモデルを切り替えたがこの判別はなんでもいい。type
フィールドを用意してクラス名を入れておけばRailsのSTIと同じようできる。
PoEAAの例のように Player クラスのサブクラスに別々のフィールドを持たせたいわけではないので厳密には STI と呼ばないのかもしれない。