LoginSignup
3
2

More than 5 years have passed since last update.

Laravel(Eloquent)でSingle Table Inheritance (STI) を実現する

Posted at

LaravelにはSTIがない。ポリモーフィック関連(Polymorphic Relation:PR)はある。PRがあればSTIが不要だと考えているのかLaravel 4の頃からずっと提供されていない。ググればStackOverflowなどにLaravel + STIの質問が見つかるし、プラグインもいくつかGithubに公開されているのでそれなりのニーズはありそうなんだが。

STIがないと関連モデルをサブタイプにすることができない。

例えば

  1. [店舗]に複数の[会員]が所属している
  2. [店舗]に[スタッフ]が所属している
  3. [スタッフ]は自店舗の[会員]を閲覧できる
  4. [本社スタッフ]は全店舗の[会員]を閲覧できる

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() の変わりに絞り込みなしのクエリを返す
  }
}

ここまでくれば StoreCentralOffice を直接インスタンス化して希望通りの会員が取得できる。だが 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 と呼ばないのかもしれない。

3
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2