この記事は「GoQSystem Advent Calendar 2022」の2日目の記事です。
はじめに
多対多のリレーションは「1対1」や「1対多」のリレーション(※リレーションとは)と比べて若干複雑です。というのも多対多のリレーションには「中間テーブル」という存在があるからです。
まず「多対多」とは何か説明します(前置きが若干長いです)。
いいから本題だ!という方はこちらからどうぞ!
多対多とは
さて、そもそも多対多ってどういう関係やねんということですが、たとえば上のER図で示した「注文」と「商品」の関係を見てみます。
商品を3つ(りんご、みかん、ぶどう)扱っているものとします。
このような注文が3つ入ってきました。
これを見ていただいたら分かるように、「注文」は必ず1個以上の商品を含んでいます。
つまり、注文と商品は「1対多」の関係にあります。
逆に、「商品」については必ず0個以上の注文に属しています。
つまり、商品と注文は「1対多」の関係にあります。
「0個以上」というのはここでの「ぶどう」のように、必ずしも注文に属しているとは限らない商品も存在しているということです。
このように、関連するテーブルどちらからも1対多の関係にある関係性を「多対多」といいます。
なぜ中間テーブルが必要なのか
ではなぜ「多対多」の関係では中間テーブルが必要なのでしょうか。
お互いのテーブルに外部キーを持たせてこんな感じで定義すればよいのでは...?
しかしこれは基本的には避けるべき以下のアンチパターンになります。
- 主キーが重複してしまう
- 一つのカラムに複数の値が入ってしまう
主キーが重複してしまう
上記の「注文一覧」の注文1をこのテーブルで表現しようとすると、主キーが被ったレコードを作ることになってしまいます。
一つのカラムに複数の値が入ってしまう
もしくは一つのカラムに複数の値を入れざるを得ません
こうした事態を避けるために中間テーブルを作る必要があるということです。
注文一覧を中間テーブルを用いて表した図
多対多リレーションをLaravelで扱う
ここからLaravelでリレーションをどう扱うのかについて見ていきます。
環境
$ php --version
> PHP 8.1.11
$ php artisan --version
> Laravel Framework 9.40.1
注文一覧を中間テーブルを用いて表した図で確認したそれぞれのテーブルのデータはあらかじめ作成されているものとします。
ordersテーブルとproductsテーブルのスキーマは以下の通りです。
※order_productsについては以降で解説してます。
orders
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('orderer_name');
$table->timestamps();
});
}
products
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
基本的な記述
上記で使用した注文と商品を題材に考えます。
先に説明したように「多対多」は「1対多」と「1対多」の関係なので、Laravelで「1対多」を表すBelongsToMany
メソッドを使用します。
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class);
}
}
今回は注文IDと商品ID以外に数量(quantity
)も扱うため、中間テーブルのモデルを作成する必要があります。
以下のコマンドを使用しマイグレーションファイルとモデル(OrderProduct
)を作成します。
$ php artisan make:model OrderProduct -m
make:modelコマンドの -m オプションでmigrationファイルも同時に作成できます。
order_products
テーブルのスキーマ定義はこのようにしておきます。
public function up()
{
Schema::create('order_products', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Order::class)->constrained();
$table->foreignIdFor(Product::class)->constrained();
$table->integer('quantity');
});
}
foreignIdFor()
とconstrained()
については解説を省略しますがこちらの記事がわかりやすいです。
Laravel 外部キー制約の設定方法【2つの方法を解説】
基本的にLaravel(のコマンド)で作られるmigration
ファイルのテーブルはデフォルトでは複数形(order_products
)です。
しかしBelongsToMany
でLaravel
が想定しているテーブルは単数形(order_product
)なので、ここに一手間加える必要があります。
マイグレーションファイルを編集してテーブルを単数形(order_product
)にすることも可能ですが、今回はbelongsToMany
の第二引数にテーブル名を指定する方法で対処することにします。
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_products');
}
}
これでこのように記述することで関連したモデルを取得することができます。
Order::find(1)->products;
ここで取得されるのはモデル(App\Models\Product
)ではなくコレクション(Illuminate\Database\Eloquent\Collection
)です。
逆も同じでProduct
にorders
メソッドを追加することでproduct
側からも関連したモデルを取得することができます。
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Product extends Model
{
public function orders(): BelongsToMany
{
return $this->belongsToMany(Order::class);
}
}
Product::find(1)->orders;
中間テーブルにアクセスする
中間テーブル(order_products
)を取得するにはpivot
プロパティにアクセスします。
$order = Order::find(1);
foreach ($order->products as $product) {
$product->pivot->quantity;
}
先ほども触れましたが$order->products
で取得できるのはコレクションなため、まずモデルにアクセスするためループで回すか$order->first()
等でモデルを取得する必要があります。
ただこのままでは中間テーブルのquantity
にアクセスできません。
デフォルトでpivot
からアクセスできる値はモデルキー(order_id
やproduct_id
)のみであるためです。
モデルキー以外にアクセスしたいカラムがある場合はwithPivot
を定義する必要があります。
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_products')
->withPivot('quantity');
}
}
これで中間テーブルのquantityにアクセスできます。
$order = Order::find(1);
foreach ($order->products as $product) {
dd($product->pivot->quantity); // 1
}
プロパティの名前を変更する
このpivot
というプロパティはas
メソッドで任意の名前に変更できます。
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_products')
->as('purchase')
->withPivot('quantity');
}
}
$order = Order::find(1);
foreach ($order->products as $product) {
dd($product->purchase->quantity); // 1
}
中間テーブルの値で結果を絞り込む
where〜系のメソッドを用いて、中間テーブルの値で結果を絞り込むことができます。
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_products')
->as('purchase')
->withPivot('quantity')
->wherePivot('quantity', 3);
}
}
$order = Order::find(2);
foreach ($order->products as $product) {
dd($product->purchase->quantity); // 3
}
「注文一覧を中間テーブルを用いて表した図」によれば現在保持しているorder_id
が2
の中間テーブルでまず取得できるのはquantity
が2
の値レコードであるため、quantity
が3
という検索条件が適用されていることが分かります。
中間テーブルの値で結果を並べ替える
orderByPivot
メソッドを使うことで中間テーブルの値で結果を並べ替えることができます。
class Product extends Model
{
public function orders(): BelongsToMany
{
return $this->belongsToMany(Order::class, 'order_products')
->as('purchase')
->withPivot('quantity')
->orderByPivot('quantity', 'desc');
}
}
$product = Product::find(2);
foreach ($product->orders as $order) {
dd($order->purchase->quantity); // 10
}
カスタムピボットモデルを使って追加のメソッドを定義できるようにする
現状のままだと中間テーブルのモデルに新規でメソッドを定義しても使用することができません。
たとえばテーブル名をちょっと変化させたものを取得するメソッドを考えてみます。
class OrderProduct extends Model
{
public function showTableName()
{
return Str::title(Str::replace('_', ' ', $this->table));
}
}
このまま素直にこのメソッドの使用を試みると以下のエラーが発生します。
BadMethodCallException: Call to undefined method Illuminate\Database\Eloquent\Relations\Pivot::showTableName()
中間テーブルに自由にメソッドやプロパティを定義したい場合はカスタムピボットを使用する必要があるためです。
まず中間テーブルのモデルの継承をIlluminate\Database\Eloquent\Relations\Pivot
に差し替えます。
use Illuminate\Database\Eloquent\Relations\Pivot;
class OrderProduct extends Pivot // ← ここ
{
public function showTableName()
{
return Str::title(Str::replace('_', ' ', $this->table));
}
}
また、親モデルでusing
メソッドを使用して中間モデルを指定します。
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_products')
->using(OrderProduct::class);
}
}
class Product extends Model
{
public function orders(): BelongsToMany
{
return $this->belongsToMany(Order::class, 'order_products')
->using(OrderProduct::class);
}
}
これではじめに定義したshowTableName
メソッドが使えるようになりました。
$order = Order::find(1);
foreach ($order->products as $product) {
dd($product->pivot->shoTableName()); // Order Products
}
中間テーブルにレコードを挿入する
たとえば注文IDが「3」の注文にぶどう(商品IDが「3」)を3つ追加したい、となった場合attach
メソッドを使うことでそれが実現できます。
$order = Order::find(3);
$order->products()->attach(3, ['quantity' => 1]);
注文IDが「3」、商品IDが「3」、数量「1」のレコードが挿入されてます。
id | order_id | product_id | quantity |
---|---|---|---|
6 | 3 | 3 | 1 |
syncメソッドでリレーションを同期させる
最後にsync
メソッドを紹介して終わりにしたいと思います。
「リレーションを同期させる」とはどういうことかというと、リレーションの構築と削除を同時に行うということです。
たとえば現状の中間テーブルのデータはこのようになってます。
id | order_id | product_id | quantity |
---|---|---|---|
1 | 1 | 1 | 1 |
2 | 1 | 2 | 1 |
3 | 2 | 1 | 2 |
4 | 2 | 2 | 3 |
5 | 3 | 2 | 10 |
6 | 3 | 3 | 1 |
ここで、「注文ID1の注文はやっぱりぶどう5個だけにしたい」となった場合、このようにsync
メソッドを使うことでその状態を実現することができます。
$order = Order::find(1);
$order->products()->sync([3 => ['quantity' => 5]]);
結果
id | order_id | product_id | quantity |
---|---|---|---|
3 | 2 | 1 | 2 |
4 | 2 | 2 | 3 |
5 | 3 | 2 | 10 |
6 | 3 | 3 | 1 |
7 | 1 | 3 | 5 |
id1
、id2
のレコードが削除され、新たにid7
のレコードが挿入されていることが分かります。
つまり、「sync
メソッドで指定したid(ここでは3
)以外のレコード(ここでは主キーが1
と2
のレコード)は削除されて、逆に存在していないidを指定した場合、モデルキー以外のカラムも含めて新しくレコードが追加される」ということですね。
まとめ
こんな感じでLaravelには多対多のリレーションを自在に扱う術が豊富にあるので便利ですね。
最後に
GoQSystemでは一緒に働いてくれる仲間を募集中です!
ご興味がある方は以下リンクよりご確認ください。