😺

Eloquentに複合プライムキーを使う(と少し雑談)

2021/08/09に公開

とあるプロジェクトの話ですが、商品のテーブルのPKが二つのフィールドの複合キーとなっています。
一応マイグレーションファイルの書き方を貼ります:

 Schema::create('products', function (Blueprint $table) {
      $table->bigInteger('id');
      $table->unsignedTinyInteger('area_id');
      $table->foreign('area_id')->references('id')->on('areas');
      // 複合PK
      $table->primary(['id', 'area_id']);
      //...
 }     

EloquentにはModel::find(id)とか便利なAPIがありますが、これがどうやら複合プライムキーのケースに対応していないらしい。それで解決策を色々とGoogle先生に聞いて、次のようなコードにたどり着きました。

まずは複合プライムキーを使うtraitを作ります。このtraitを持つモデルには、setKeysForSaveQuerygetKeyForSaveQueryfindメソッドを書き換えます。

trait HasCompositePrimaryKey
{
    /**
     * Set the keys for a save update query.
     *
     * @param  mixed  $query
     * @return \Illuminate\Database\Eloquent\Builder
     */
    protected function setKeysForSaveQuery($query)
    {
        $keys = $this->getKeyName();
        if (!is_array($keys)) {
            return parent::setKeysForSaveQuery($query);
        }

        foreach ($keys as $keyName) {
            $query->where($keyName, '=', $this->getKeyForSaveQuery($keyName));
        }

        return $query;
    }

    /**
     * Get the primary key value for a save query.
     *
     * @param mixed $keyName
     * @return mixed
     */
    protected function getKeyForSaveQuery($keyName = null)
    {
        if (is_null($keyName)) {
            $keyName = $this->getKeyName();
        }

        if (isset($this->original[$keyName])) {
            return $this->original[$keyName];
        }

        return $this->getAttribute($keyName);
    }

    public static function find($ids, $columns = ['*'])
    {
        $me = new self;
        $query = $me->newQuery();
        foreach ($me->getKeyName() as $key) {
            $query->where($key, '=', $ids[$key]);
        }
        return $query->first($columns);
    }
}

これでモデルでは次のように導入します。

class Product extends Model
{
    use HasCompositePrimaryKey;
    protected $primaryKey = ['id', 'area_id'];
    public $incrementing = false;
    // ...
}

// PKを確認する
MItem::first()->getKeyName();
// findにkey-valueペアを入れる
MItem::find(['id'=> 1111,'area_id'=> 2222]);

Githubでは議題として出されていますが未だに更新されていないようです。メンテナーが言っているように変えるためにコストが大きいからなかなか動きが取れないと。その反面stack overflowでこんなコメントもありました:

ORM frameworks like Ruby on Rails started out with the phrase "opinionated software" about PK design being id only, but this is like saying that you won't support functions with more than one argument. In versions after the first, RoR supports compound primary keys. All frameworks eventually come to the same conclusion. If anyone is still using an ORM that doesn't support compound PK's, you need to upgrade.

このコメントは2014年のものなのでだいぶ経っていました。Bill Karwinさんの履歴を見ればSQLの専門家でもあり、確かにその例をみる説得力が感じられます。ただEloquent ORMに頼るならidオンリーになってしまうのも確かですよね。LaravelもどちらかというとidだけのPKを前提にしている様子。

結局その次のアンサーのように、これは「意見」、「好き嫌い」の問題になるのであは?

This question is dangerously close to asking for opinions, which can generate religious wars.

個人的には本来idオンリー派ですが、今回のプロジェクトの状況でいうと、idというのは確かに商品を分別する標識ではありますが、それと同時に同じ商品が違う地域に販売されているから、それを見分けるために、地域コードと複合PKに、他のメンバーが提案しました。

であれば、idを商品コードに変えて、別の商品コード+販売地域で作られるidというコラムを設けておけば良いのでは?としたいところですが、実は商品コードは別のシステムから作られるので、またルールが違います。また、この商品コードは、本番の販売になっていない限り、採番されないので、商品コードのない商品もあるから、PKとして働くことができません。こういった諸事情があり、結局商品id+販売地域の複合PKとなりました。これってもしかしたら複合PKが良いケースではないか??

どうだろうな。別の観点からですが、以前ECサイトのプロジェクトの時に少し類似する概念がありました。商品は基本的にSPU(Standard Product Unit)とSKU(stock keeping unit)に分けられていて、例えば「iphone 12」というのがSPUで、「iphone 12 シルバー 256G」がSKUとなります。ジャンル・カテゴリー(例えば携帯電話とか)との概念は違い、SPUはこれが同じ商品だと判断するもので、SKUがスペックなどを含めて物理的に最小単位を持つ在庫商品の番号となります。SPUをみれば、これはiphone 12かgalaxy S20かわかり、SKUをみれば、カラー、容量までわかります。

今回のプロジェクトに適応すれば、SPUが商品idとなり、SKUが地域を見分ける地域番号をつけた、ユニークな番号となります。ただ、SKUをそのままプライムキーにする必要があるかどうかは、そうでもなく、単純にautoincrementのidコラムを残っておくのも特に違和感はないでしょう。ORM事情を考えるとむしろこちらの方がやりやすいかもしれません。と考えると、今回のプロジェクトは別に複合キーでないといけないことはありません。

といったところですので、やはりidオンリーの方が良いのではないかなと。ただ、もし今後「複合PKを使わなければならない」というケースとあったら、idオンリー派から改宗するかもしれません(笑)。

Discussion