37
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Laravel】Eloquentで多対多のリレーションを使い倒す

Last updated at Posted at 2022-12-01

この記事は「GoQSystem Advent Calendar 2022」の2日目の記事です。

はじめに

多対多のリレーションは「1対1」や「1対多」のリレーション(※リレーションとは)と比べて若干複雑です。というのも多対多のリレーションには「中間テーブル」という存在があるからです。

sample.drawio.png

まず「多対多」とは何か説明します(前置きが若干長いです)。
いいから本題だ!という方はこちらからどうぞ!

多対多とは

さて、そもそも多対多ってどういう関係やねんということですが、たとえば上のER図で示した「注文」と「商品」の関係を見てみます。

商品を3つ(りんご、みかん、ぶどう)扱っているものとします。

商品一覧
商品ID:1 (2).png

このような注文が3つ入ってきました。

注文一覧
new.drawio.png

これを見ていただいたら分かるように、「注文」は必ず1個以上の商品を含んでいます
つまり、注文と商品は「1対多」の関係にあります。
new.drawio (1).png

逆に、「商品」については必ず0個以上の注文に属しています
つまり、商品と注文は「1対多」の関係にあります。

「0個以上」というのはここでの「ぶどう」のように、必ずしも注文に属しているとは限らない商品も存在しているということです。

商品ID:1 (6).png

このように、関連するテーブルどちらからも1対多の関係にある関係性を「多対多」といいます。

なぜ中間テーブルが必要なのか

ではなぜ「多対多」の関係では中間テーブルが必要なのでしょうか。
お互いのテーブルに外部キーを持たせてこんな感じで定義すればよいのでは...?

test.drawio.png

しかしこれは基本的には避けるべき以下のアンチパターンになります。

  • 主キーが重複してしまう
  • 一つのカラムに複数の値が入ってしまう

主キーが重複してしまう

上記の「注文一覧」の注文1をこのテーブルで表現しようとすると、主キーが被ったレコードを作ることになってしまいます。

注文1
new.drawio.png
orders
new2.drawio.png

一つのカラムに複数の値が入ってしまう

もしくは一つのカラムに複数の値を入れざるを得ません

orders
new2.drawio.png

こうした事態を避けるために中間テーブルを作る必要があるということです。

注文一覧を中間テーブルを用いて表した図

new2.drawio.png

多対多リレーションを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メソッドを使用します。

Order.php
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)です。

しかしBelongsToManyLaravelが想定しているテーブルは単数形(order_product)なので、ここに一手間加える必要があります。

マイグレーションファイルを編集してテーブルを単数形(order_product)にすることも可能ですが、今回はbelongsToManyの第二引数にテーブル名を指定する方法で対処することにします。

Order.php
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)です。

逆も同じでProductordersメソッドを追加することでproduct側からも関連したモデルを取得することができます。

Product.php
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_idproduct_id)のみであるためです。
モデルキー以外にアクセスしたいカラムがある場合はwithPivotを定義する必要があります。

Order.php
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メソッドで任意の名前に変更できます。

Order.php
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〜系のメソッドを用いて、中間テーブルの値で結果を絞り込むことができます。

Order.php
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_id2の中間テーブルでまず取得できるのはquantity2の値レコードであるため、quantity3という検索条件が適用されていることが分かります。

中間テーブルの値で結果を並べ替える

orderByPivotメソッドを使うことで中間テーブルの値で結果を並べ替えることができます。

Product.php
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
}

カスタムピボットモデルを使って追加のメソッドを定義できるようにする

現状のままだと中間テーブルのモデルに新規でメソッドを定義しても使用することができません。
たとえばテーブル名をちょっと変化させたものを取得するメソッドを考えてみます。

OrderProduct.php
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に差し替えます。

OrderProduct.php
use Illuminate\Database\Eloquent\Relations\Pivot;

class OrderProduct extends Pivot // ← ここ
{
    public function showTableName()
    {
        return Str::title(Str::replace('_', ' ', $this->table));
    }
}

また、親モデルでusingメソッドを使用して中間モデルを指定します。

Order.php
class Order extends Model
{
    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'order_products')
            ->using(OrderProduct::class);
    }
}
Product.php
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)以外のレコード(ここでは主キーが12のレコード)は削除されて、逆に存在していないidを指定した場合、モデルキー以外のカラムも含めて新しくレコードが追加される」ということですね。

まとめ

こんな感じでLaravelには多対多のリレーションを自在に扱う術が豊富にあるので便利ですね。

最後に

GoQSystemでは一緒に働いてくれる仲間を募集中です!

ご興味がある方は以下リンクよりご確認ください。

37
35
0

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
37
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?