【Laravel】多対多のリレーションでsyncメソッドを使って中間テーブルを更新する

# はじめに

業務で「多対多（Many to Many）」のリレーションを持つテーブルおよび、リレーションの管理のための中間テーブルを用いる機会があった。

そこで、Laravelで中間テーブルを扱う方法や、その際に便利な `attach` メソッドや`sync` メソッド、また`sync`メソッドの注意点など学んだことをまとめておく。

# 事前準備

## テーブル構成

例として以下のようなテーブル構成の場合について説明する。

blogsとtagsは多対多の関係であり、blog_tagテーブルが中間テーブルとなる。

![ER図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1208065/e4a54cb9-a5aa-cd41-d59d-5999e273f846.png)

## 多対多の関係となるモデルとマイグレーションファイルを作成

まずは下記コマンドを実行し、blogsテーブルとtagsテーブルについてのモデルとマイグレーションファイルを作成する。

```bash
$ php artisan make:model Blog --migration
$ php artisan make:model Tag --migration
```



```php:app/Models/Blog.php
<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
 
class Blog extends Model
{
    use HasFactory;
 
    protected $fillable = [
        'title',
    ];
 
    public function tags()
    {
        return $this->belongsToMany('App\Models\Tag')->withTimestamps();
    }
}
```


```php:app/Models/Tag.php
<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
 
class Tag extends Model
{
    use HasFactory;
 
    protected $fillable = [
        'name',
    ];
 
    public function blogs()
    {
        return $this->belongsToMany('App\Models\Blog')->withTimestamps();
    }
}
```

#### ポイント
リレーションの記述の際に `->withTimestamps()` を書くこと。
これにより中間テーブルのタイムスタンプを更新することが出来る。
　
 
また `database/migrations`ディレクトリに2つのマイグレーションファイルが生成されているので、編集する。



```php:2022_04_14_055438_create_blogs_table.php
<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
 
class CreateBlogsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('blogs', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->timestamps();
        });
    }
 
    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('blogs');
    }
}
```


```php:2022_04_13_055461_create_tags_table.php
<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
 
class CreateTagsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tags', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }
 
    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('tags');
    }
}
```

## 中間テーブルを作成する

次に中間テーブルのマイグレーションファイルを作成する。

```bash
$ php artisan make:migration create_blog_tag_table
```

※基本的には中間テーブルの名前は、関係するモデル名（blogとtag）をアルファベット順に並べた「blog_tag」とする。


```php:2022_04_13_060221_create_blog_tag_table.php
<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
 
class CreateBookTagTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('blog_tag', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('blog_id');
            $table->unsignedBigInteger('tag_id');
            $table->foreign('blog_id')->references('id')->on('blogs')->onDelete('cascade');
            $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
            $table->timestamps();
        });
    }
 
    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('blog_tag');
    }
}
```

#### ポイント
以下の部分で外部キー制約を設定している。
これにより、blogsテーブルからレコード削除した場合には中間テーブルからもレコード削除する（tagも同様）というようになる。

```php
$table->foreign('blog_id')->references('id')->on('blogs')->onDelete('cascade');
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
```

 
 
 
これでテーブルの用意ができた。

シーダーファイルやビューについてはここでは書かないが、blogsテーブル、tagsテーブルに適当なサンプルデータがあるものとして、次に `attach` メソッド・`detach`メソッド・`sync`メソッドの使い方を紹介していく。

# リレーションの紐付け・解除

## attachメソッド（新たに紐付けする場合）

 `attach` メソッドを使うと、新たに紐付けの登録を行うことができる。

### 使い方

例えばブログに対してタグを紐づける場合は、ブログのモデルからリレーションを呼び出し、 `attach` メソッドで紐付けたいタグのIDを指定する。

以下のように、 `attach` メソッドには配列で複数のidを渡すこともできる。

```php
// tagsテーブルのid=1のデータをブログに紐づける場合
Blog::first()->tags()->attach(1);

// tagsテーブルのid=1,2,3のデータをブログに紐づける場合
Blog::first()->tags()->attach([1, 2, 3]);
```

### attachメソッドにより発行されるSQL

 `attach` メソッドがどのようなSQLを実行しているのか出力してみると、以下の通り一度の`INSERT`が行われていた。

紐付け登録したいタグのidを複数渡したとしても、一度の `INSERT` で登録してくれている。

```php
// attachメソッドに、ブログに紐付け対タグのタグのidを2つ渡した場合
insert into `blog_tag` (`created_at`, `blog_id`, `tag_id`, `updated_at`) values (?, ?, ?, ?), (?, ?, ?, ?)
```

 `sync` メソッド実行時のSQLについても後述するが、仮に `sync` メソッドを使って同じことを行うと、紐付けしたいタグの回数分 `INSERT` が実行されていたので、単純に新たに紐付けを登録するだけの場合は `attach` メソッドの方が良いだろう。

## detachメソッド（紐付けを解除する場合）

 `detach` メソッドは `attach` メソッドの逆で、紐付けを解除するもの。

実行すると中間テーブルからレコードが削除される。

### 使い方

使い方は `attach` と同様。

配列で複数のidを渡すこともできる。

```php
Blog::first()->tags()->detach(1);
Blog::first()->tags()->detach([1, 2, 3]);
```

## syncメソッド（紐付けと紐付け解除をまとめて行う場合）

 `sync` メソッドは、紐づけるidのリストを引数として渡すと、中間テーブルに登録されていない紐付けについては新たに紐付け登録を行い、引数に渡したidリストに無い紐付けは中間テーブルから削除を行う。

引数に渡したidリストでまるっと紐付けを上書きするイメージなので、引数に渡したid以外の紐付け登録は自動的に解除されることに留意。

※紐付け解除はせずに `sync` を行う `syncWithoutDetaching` というメソッドもある。

### 使い方

使い方は `attach` メソッドや `detach` メソッドと同様。

以下のように書くと、タグのid=1,2,3 の紐付けを登録し、これ以外の紐付けは中間テーブルから削除する。

```php
Blog::first()->tags()->sync([1, 2, 3]);
```

### syncメソッドにより発行されるSQL

  `sync` メソッドがどんなSQLを実行しているのか出力してみると、流れは以下の通りだった。

- 中間テーブル（folder_templateテーブル）から該当のテンプレートのtemplate_idを持つレコード取得（既存の紐付け登録状況を確認する）

- 紐付けを解除する`delete`実行（一度のdeleteで削除したいフォルダをまとめて削除する）

- 紐付けを新規登録する`insert`実行（登録したいフォルダの数だけinsertを行う）

※紐付けが維持されるフォルダに対しては`delete`,`insert`は行われない。

例えば、元々あるブログに対してid=1,2のタグが紐づけられているときに、 `sync([3, 4])` を実行した場合は以下の通りのSQLが実行される。

```php
// 既存の紐付け登録状況を確認する
select * from `blog_tag` where `blog_tag`.`blog_id` = ?

// 一度のdeleteで削除したいタグをまとめて削除する
delete from `blog_tag` where `blog_tag`.`blog_id` = ? and `blog_tag`.`folder_id` in (?, ?)

// 登録したいタグの数だけinsertを行う
insert into `blog_tag` (`created_at`, `blog_id`, `folder_id`, `updated_at`) values (?, ?, ?, ?)
insert into `blog_tag` (`created_at`, `blog_id`, `folder_id`, `updated_at`) values (?, ?, ?, ?)
```

限られた規模感のアプリケーションだったり、紐付け登録する数に限度が見えているならば問題無いだろうが、紐付け登録したい回数分の `INSERT` が行われているということには留意。

### 注意点
紐付けを便利に行ってくれる `sync`メソッドだが、多対多の双方から紐付けを行う場合は注意が必要になる。
例えば以下のような動作を順に行うケースを考えてみる。

①ブログ側からブログAとタグBをsync （ブログAに紐づくタグは、タグBのみとなる）
②タグ側からタグCとブログAをsync （タグCに紐づくブログは、ブログAのみとなる）
③ブログ側からブログAとタグDをsync (ブログAに紐づくタグは、タグDのみとなる)

この場合、②までは問題なく行うことができるが、③を行うと②で行ったタグCとブログAの紐付けは解除されてしまうこととなる。

なので上記のように双方から紐付け登録を行いたい場合は、
・ブログ側からの紐付け： `sync` メソッドを使用する
・タグ側からの紐付け：条件分岐で `attach` メソッドと `detach` メソッドを使用して手動で紐付けの登録と解除を行う
などの方法を検討した方が良さそうだ。

# さいごに

 `Laravel` の恩恵を上手く利用すれば簡単に中間テーブルを扱うことが出来る反面、その挙動や発行されるSQLをきちんと把握して注意する必要があると学んだ。

 `Laravel`のメソッドがどのような挙動をしているのか、どのようなSQLが発行されているのかを理解しつつ、便利なものを上手く使っていきたいと思う。

## 参考文献

https://readouble.com/laravel/5.8/ja/eloquent-relationships.html#many-to-many-polymorphic-relations

https://noumenon-th.net/programming/2020/11/05/many-to-many/

https://biz.addisteria.com/syncwithoutdetaching/#toc6

https://engineering.mobalab.net/2019/06/21/laravel%E3%81%AE%E5%A4%9A%E5%AF%BE%E5%A4%9A%E3%81%AE%E3%83%AA%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6/#%E6%96%B0%E3%81%9F%E3%81%AB%E7%B4%90%E3%81%A5%E3%81%91%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AFattach()%E3%82%92%E4%BD%BF%E3%81%86