Laravel Eloquentで複合主キーのテーブルとリレーションシップを扱う
Eloquentの制約を克服し、複合主キーを使いこなすための具体例


この記事は Laravel Advent Calendar 2024 7日目の投稿です。

概要: Eloquentと複合主キーの課題

Eloquentは複合主キーに対応していない。

Laravel日本語ドキュメント: Eloquentの準備 - 「コンポジット」主キー

Eloquentは、それぞれのモデルがその主キーとして役立つことができる、少なくとも1つの一意に識別される「ID」を持つ必要があります。Eloquentモデルは「コンポジット」主キーをサポートしていません。

既存データベースをLaravelで扱いたいが、Eloquentが複合主キーに対応していないために困ったことはないだろうか?この記事では、複合主キーを使ったデータベースをEloquentから扱う場合の課題を解決する方法を、具体的に説明する。

ナチュラルキーとサロゲートキー

まず「複合主キー」「『コンポジット』主キー」の意味を説明する。既にご存じの方は読み飛ばして構わない。

例題として、コメント機能つきの電子書籍サイトを想像して欲しい。次のようなモデルを考える:

例題: 電子書籍サイトのデータベース設計

  • 著者: Author は複数の 書籍: Book を持つ
  • 書籍: Book は複数の コメント: Comment を持つ

最近のやり方で設計すると次のようになる:

ER図

«authors»AuthorPK:idint«books»BookPK:idintFK: author_id intauthor_idreferences authors«comments»CommentPK:idintFK book_id intbook_idreferences booksBook HasMany CommentAuthor HasMany Book

IDが示すものは何か?

上のモデルでは「著者」「書籍」「コメント」に、それぞれ一意のIDが付与されている。このIDの要件(採番体型)を、例えば次のように変更することを考える:

  • 変更前: 書籍ID全書籍を通じて 一意
  • 変更後: 書籍ID著者ごとに 一意

変更後の体型では、著者が異なれば異なる書籍に同じ書籍IDを付与できる。しかし、この体型は上のモデルでは対応できない:

著者ID PK: 書籍ID 備考
1 1 OK
1 2 OK
2 3 OK
3 1 一意制約違反

books テーブルは id 列単独で主キーのため、たとえ著者が異なっても books.id に2件の 1 は存在できない。

複合主キー

「複合主キー」はこのケースで利用する。次のような設計になる:

«authors»AuthorPK: author_id int«books»BookPK: author_id intPK: book_id intforeign key (author_id)references authors«comments»CommentPK: author_id intPK: book_id intPK: comment_id intforeign key (author_id, book_id)references booksBook HasMany CommentAuthor HasMany Book

PK:主キー を複数列に跨ぐことで、「単独の列で一意」ではなく「2列の組み合わせで一意」を可能とした。これで「書籍ID著者ごとに 一意」を実現できる。コメントも同じように「3列の組み合わせで一意」とした。

現代では、「組み合わせで一意」という要件の場合、組み合わせに対する複合一意制約とは別で id という列を表の先頭に設け、それを単独で主キーとすることが多い。要件に応じて 自然 に決まる「ナチュラル キー」とは別で、主キー用に 代理 の「サロゲート キー」を用意する。

今やこれが常識だが、ORM普及以前は必ずしもそうでなかった。このパターンを、Eloquentは扱えない。

LaravelとEloquentでの複合主キー

Laravelで複合主キーを扱う方法

複合主キー非対応はEloquentの話であり、Laravelとしては扱える。Comment の場合マイグレーションは次のようになる:

// 複合主キーを設定したマイグレーション例
Schema::create('comments', function (Blueprint $table) {
  $table->bigInteger('author_id');
  $table->bigInteger('book_id');
  $table->bigInteger('comment_id');

  // 複合主キーの対象列を配列で指定
  $table->primary(['author_id', 'book_id', 'comment_id']);

  // 複合外部キーの対象列を配列で指定
  $table->foreign(['author_id', 'book_id'])
    ->references(['author_id', 'book_id'])->on('books');
});

クエリビルダも問題ない。PHPコードとして表現した通りのSQLが実行される。

Eloquentで複合主キーに対応する実装例

ここまでは問題なかったが、これらをテーブルではなくEloquentモデルとして扱おうとした際に問題が発生する。発生する幾つかの問題と解決策をそれぞれ説明する。

id 列の名前

$author = Author::find(42);
// Illuminate\Database\QueryException  SQLSTATE[HY000]: General error:
//   1 no such column: authors.id
//   (Connection: sqlite, SQL: select * from "authors" where "authors"."id" = 42 limit 1).

authors は単独主キーで本来問題ないが、authors.id という存在しない列を参照しようとしてエラー。Eloquentは主キーのカラム名を id と仮定しているので、そうでない場合は追加の設定が必要になる:

app/Models/Author.php
class Author extends Model { // 追加: 主キーのカラム名を明示的に設定 protected $primaryKey = 'author_id'; }

複合主キーとfind()

複合主キーを対象とした find() は利用不可として諦める。決してやってなはいけない例として、次のような実装がある:

app/Models/Book.php
class Book extends Model { // NG例: 主キーの一部だけを設定 protected $primaryKey = 'book_id'; }

Book モデルなので book_id が主キー」という妥当に見えるコード。一見きちんと動いている ように見えてしまう 点が厄介だ。結果は次のようになる。

$book = Book::find(1); // book_id = 1 の書籍を取得
// SQL: select * from "books" where "books"."book_id" = 1 limit 1
// 返却:
// App\Models\Book {#5308
//   author_id: 1,
//   book_id: 1,
// }

book_idは複合主キーの一部でしかないことを思い出して欲しい:

PK: 著者ID PK: 書籍ID 書籍名
1 1 SQLアンチパターン
1 2 テスト駆動開発
2 3 人月の神話
3 1 失敗から学ぶ RDBの正しい歩き方

この例で Book::find(1) を実行した場合、「SQLアンチパターン」「失敗から学ぶ RDBの正しい歩き方」どちらが返却されるかは不定だ。事故の元なので find() 自体を禁止し、適切(?)なエラーメッセージと共にクラッシュさせてしまおう。私は次のようコードを推奨している:

app/Models/Book.php
class Book extends Model { // 主キーに「あり得ない列名」を設定 protected $primaryKey = 'このモデルには主キーがありません'; // 主キーの自動増分を無効化(あり得ない列名のため) public $incrementing = false; }

非対応を破った実装に対するフールプルーフやエラーメッセージはLaravel自体には用意されていない。そこで「絶対にエラーが発生するSQL」をEloquentに実行させその文字列を出力させている。「カラム名 = エラーメッセージ」は人間が読めれば何でも良い:

Comment::find(1)
// Illuminate\Database\QueryException  SQLSTATE[HY000]: General error:
//   1 no such column: comments.複合キーのため主キー未設定
//   (Connection: sqlite, SQL: select * from "comments" where "comments"."複合キーのため主キー未設定" = 1 limit 1).

HasManyの動作

HasManyも同じ理由でそのままでは正常動作しないが、これは工夫で解決できる。次のような表を考えよう:

PK: 著者ID PK: 書籍ID PK: コメントID 書籍名 コメント
1 1 1 SQLアンチパターン 体系的でした!
3 1 1 失敗から学ぶ RDBの正しい歩き方 実践的でした!
// OK: 1 - 和田卓人
$author = Author::find(1);

// OK: 1,1 - SQLアンチパターン
$book = $author->books->first();

// NG: 1,1,1: 「体系的でした!」 ← ここが問題になる
$comments = $book->comments;

まず誤った例。book_id というカラム名だけに注目すると、次のようなリレーションシップを書きたくなる。

app/Models/Book.php
public function comments(): HasMany { return $this->hasMany(Comment::class, 'book_id', 'book_id'); }

この実装の問題点と解決方法を、Laravelのドキュメントの次の一節を踏まえ説明する:

Laravel日本語ドキュメント: リレーション - リレーションのクエリ

リレーションではLaravelクエリビルダメソッドのどれでも使用できるので、クエリビルダのドキュメントを調べ、使用可能な全メソッドを習んでください。

クエリビルダのデバッグと同じように、リレーションシップが実行するSQLを調べる:

$book->comments()->toRawSql();
// select * from "comments" where
//   "comments"."book_id" = 1 and
//   "comments"."book_id" is not null

find() と同じ問題が発生している。book_id だけでは絞り込みが不十分のため「SQLアンチパターン」へのコメントを取得する際、同じ 書籍ID:1 を持つ「失敗から学ぶ RDBの正しい歩き方」のコメントも取得されてしまう。

この問題は、リレーションではLaravelクエリビルダメソッドのどれでも使用できる ことを利用し、次のように解決する:

app/Models/Book.php
public function comments(): HasMany { return $this->hasMany(Comment::class, 'book_id', 'book_id') // 追加: 著者IDを絞り込み条件に追加 ->where('author_id', $this->author_id); }
$comments = $book->comments()->toRawSql();
// select * from "comments" where
//   "comments"."book_id" = 1 and
//   "comments"."book_id" is not null and
//   "author_id" = 1 // 追加された

これで (*,1):(複数の書籍) ではなく (1,1):SQLアンチパターン のコメントだけを取得できるようになった。

BelongsToの動作

BelongsToも同じ問題があるが、同じやり方で解決できる。SQLはほぼ同じなので結論のコードだけ示す:

app/Models/Comment.php
public function book(): BelongsTo { return $this->belongsTo(Book::class, 'book_id', 'book_id') // 追加: 著者IDを絞り込み条件に追加 ->where('author_id', $this->author_id); }

ルートモデル結合

複合主キーを持つモデルは、Eloquent以前にURL体型で制約事項がある。次のルートを考えてみよう:

Route::get(
  'books/{book}',
  [BookController::class, 'show']
);

// GET books/{book}

次のようなHTTPリクエストを送ったとする:

GET /books/1

// ???

やはり find() と同じ問題が発生する。このURLでは「SQLアンチパターン」「失敗から学ぶ RDBの正しい歩き方」どちらを示すのかは特定できない。複合主キーを持つモデルへのルートモデル結合の場合:

Route::get(
  'authors/{author}/books/{book}',
  [BookController::class, 'show']]
);

// GET authors/{author}/books/{book}
GET /authors/3/books/1

// 失敗から学ぶRDBの正しい歩き方

親の情報もURLに含まている必要がある。

ここまでは当然の話だが、Eloquentでこれを扱う場合やはり追加のコードが必要となる。ここから先はネストしたリソースとして設定された次の例を考える:

Route::resource(
  'authors.books.comments',
  CommentController::class
);

// GET authors/{author}/books/{book}/comments/{comment}
GET /authors/3/books/1/comments/1

// 404 not found

予想に反し 404 not found が返却されてしまった。これは BookComment の主キーが、前述のコードでいう 複合キーのため主キー未設定 であることが理由だ。Laravelがルートモデル結合のために実行しているSQLを調べてみる:

\DB::enableQueryLog();

$response = $this->json(
  Request::METHOD_GET,
  'authors/3/books/1/comments/1'
);

dump(\DB::getRawQueryLog());
-- OK: Author: 単独で主キーのため問題ない
select * from "authors" where "author_id" = '2' limit 1

-- NG: Book: `find()` を利用禁止した際のエラーSQLが実行される
select * from "books" where "複合キーのため主キー未設定" = '11' limit 1
-- (設定していなかった場合、親子関係の異なる誤った書籍が返却されてしまう)

-- NG: Comment: 何も実行されない
-- Bookが取得できなかった時点でモデルの解決は中断される

主キーは設定できないがルートモデル結合の解決キーは指定する必要がある。キーのカスタマイズを使って、今回は次のように指定する:

app/Models/Book.php
// 解決キーを明示的に指定 public function getRouteKeyName() { return 'book_id'; }
app/Models/Comment.php
// 解決キーを明示的に指定 public function getRouteKeyName() { return 'comment_id'; }

ここまでで 404 not found は解消されるが、絞り込みが不十分なため 親子関係の異なる誤った リソースが返却される可能性は残る。そこでルートに次のような記述を追加する:

Route::resource(
  'authors.books.comments',
  CommentController::class
)->scoped(); // 追加: ネストしたリソースのスコープを明示的に宣言

以上の対応で問題なく動作する。

GET /authors/3/books/1

// 失敗から学ぶRDBの正しい歩き方

今回はモデル側をカスタマイズしルートは通常のやり方で指定したが、ルート側をカスタマイズする方法もある。後述のサンプルコードには一通りの実装が含まれているので、興味があれば参照して欲しい。

find()の代替

find() 非対応は一見するとダメージが大きそうだが、実はそんなことはない。上に述べた通り HasMany, BelongsTo は問題なく利用できるため:

  • 適切なURL体型とルートモデル結合での取得
  • 探したいモデルの子から BelongsTo で取得
  • 探したいモデルの親から HasMany で取得

このような方法で容易にモデルを一意に特定できる。

コード例

この記事で解説した設計とコードを筆者のGitHubリポジトリで公開した:

https://github.com/KentarouTakeda/example-eloquent-composite-primary-key/pull/2

コードには次のものが含まれる:

  • 記事中のテーブル設計を再現するマイグレーション
  • 複合主キーに対応する HasMany, BelongsTo の実装
  • ルートモデル結合の設定例
  • 意図通りの動作であることを確認するテストコード

必要に応じて参照して欲しい。

まとめ

この記事では以下の点について解説した:

  • 複合主キーが必要な状況とその設計例
  • Laravelで複合主キーを扱うためのマイグレーション
  • Eloquentモデルでの制約と解決策

備考

この記事ではファクトリの作成方法について触れていない。単に、筆者がまだ検証していないことが理由だ。良い実装を見つけ次第、追記する予定。

このやり方は、筆者が手探りで考案したものだ。紹介した手法より良い方法をご存じの方は、是非ともXなどで共有して欲しい。

更新履歴
  • 2024年12月6日
    • ルートモデル結合の説明を追加

SNSへシェア
はてなブックマーク