【Rails６】コンソールでアソシエーションを理解しよう②（多対多）

この投稿は、
[DMM WEBCAMP mentor Advent Calendar 2022](https://qiita.com/advent-calendar/2022/infratop_mentor)
の投稿3日目のエントリーです。

2日目の @tomoaki-kimura の投稿、
[【Rails６】コンソールでアソシエーションを理解しよう①（一対多）](https://qiita.com/tomoaki-kimura/items/165fdcf94c179e8e08b4) の続きとなります。

## 環境と前提

- ruby 3.1.1
- Rails 6.1.7
- yarn 1.22.18

前提として、

[【Rails６】コンソールでアソシエーションを理解しよう①（一対多）](https://qiita.com/tomoaki-kimura/items/165fdcf94c179e8e08b4)

こちらの環境が作れていて、手を動かした状態からスタートとなります。

## 多対多のモデル
### 中間テーブルとは

中間テーブルは、今回の `User` と `Post` の関係を紐付ける為の手段で、今までの一対多のような

子モデル・投稿（多）
```
Post
  - user_id
  - message
```

ここの `user_id` のように「誰の投稿」かを明確にするための紐付けをするデーターを格納するための仕組みの一つで、一対多では実現不可能な、

不特定多数の `User` が `Post` を 「いいね」 するようなケースに用いられます。

不特定多数が関連付ける 「いいね」 機能の場合、子モデルのテーブル自体に不特定多数の `user_id` カラムを作る事が不可能なため、新たにテーブルを作成し、 `User` と `Post` が関連しているというカラムを持ったテーブルを作成します。

具体的には、

親モデル・ユーザー（１）
```
User
  - name
```

中間モデル・お気に入り（多）
```
Favorite
  - user_id
  - post_id
```

このようなカラムを持ったテーブルを作る事によって、双方のテーブルを関連付けます。

この `Favorite` モデルのインスタンスを登録する事によって、「いいね」のレコードが完了する事になります。

では、該当モデルを作ってみましょう。

### Favoriteモデル

では、ターミナルより、

```bash
$ rails g model Favorite user:references post:references
```

を実行します。

```bash
$ rails g model Favorite user:references post:references
Running via Spring preloader in process 12844
      invoke  active_record
      create    db/migrate/xxxxxxxxxxxxxx_create_favorites.rb
      create    app/models/favorite.rb
      invoke    test_unit
      create      test/models/favorite_test.rb
      create      test/fixtures/favorites.yml
```

一旦ここでマイグレーションファイルを見てみましょう。

```ruby:vi db/migrate/xxxxxxxxxxxxxx_create_favorites.rb
class CreateFavorites < ActiveRecord::Migration[6.1]
  def change
    create_table :favorites do |t|
      t.references :user, null: false, foreign_key: true
      t.references :post, null: false, foreign_key: true

      t.timestamps
    end
  end
end
```

ここで一つポイントですが、「いいね」は同じユーザーと同じ投稿では１件しか登録出来ないようにしておく方が良いですので、一意の制約を書いておこうと思います。

```ruby
t.index [:user_id, :post_id], unique: true
```

このようなインデックスを作成し、 `user_id` `post_id` カラムの組み合わせに対して、 `unique` 属性を付け足しておきましょう。

全体のコードは、

```ruby:vi db/migrate/xxxxxxxxxxxxxx_create_favorites.rb
class CreateFavorites < ActiveRecord::Migration[6.1]
  def change
    create_table :favorites do |t|
      t.references :user, null: false, foreign_key: true
      t.references :post, null: false, foreign_key: true

      t.timestamps
      t.index [:user_id, :post_id], unique: true
    end
  end
end
```

と、なります。

この時点でマイグレーションも行っておきましょう。

```bash
% rails db:migrate
Running via Spring preloader in process 14158
== xxxxxxxxxxxxxx CreateFavorites: migrating ==================================
-- create_table(:favorites)
   -> 0.0052s
== xxxxxxxxxxxxxx CreateFavorites: migrated (0.0052s) =========================
```

無事、通りました。

### アソシエーション

`Favorite` モデルを確認しておきましょう。

```ruby:app/models/favorite.rb
class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :post
end
```

今回は `belongs_to` が２つ存在しています。今回も特には触りません。

次に、 `User` モデルにアソシエーションを書きにいきます。

現在は、

```ruby:app/models/favorite.rb
class User < ApplicationRecord
  has_many :posts
end
```

このようになっていますので、

`Favorite` モデルとのアソシエーションを追加して、

```ruby:app/models/favorite.rb
class User < ApplicationRecord
  has_many :posts
  has_many :favorites
end
```

このようにしておきましょう。

ここで、コンソールで確認しておきます。

```bash
$ rails c
Running via Spring preloader in process 14315
Loading development environment (Rails 6.1.7)
irb(main):001:0>           
```

いいねの登録が出来る所まで確認しておきましょう。

```irb
irb(main):001:0> User.first
   (1.1ms)  SELECT sqlite_version(*)
  User Load (0.5ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                            
=>                                                              
#<User:0x00000001069da3f8                                       
 id: 1,                                                         
 name: "taro",                                                  
 created_at: Thu, 01 Dec 2022 17:37:46.597540000 UTC +00:00,    
 updated_at: Thu, 01 Dec 2022 17:37:46.597540000 UTC +00:00>   
```

ユーザーの存在確認

```irb
irb(main):002:0> User.first.favorites
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                            
  Favorite Load (0.5ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ?  [["user_id", 1]]                                                
=> []    
```

アソシエーションが実行出来る事の確認

```irb
irb(main):003:0> User.first.favorites.build
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                            
=>                                                                              
#<Favorite:0x0000000106e45c30                                                   
 id: nil,                                                                       
 user_id: 1,                                                                    
 post_id: nil,                                                                  
 created_at: nil,                                                               
 updated_at: nil>  
```

`user_id` が存在する状態でのインスタンスの作成

```irb
irb(main):004:0> User.first.favorites.build(post_id: Post.last.id)
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                            
  Post Load (0.8ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                           
=>                                                                              
#<Favorite:0x000000010750ec38                                                   
 id: nil,                                                                       
 user_id: 1,                                                                    
 post_id: 6,                                                                    
 created_at: nil,                                                               
 updated_at: nil>    
```

`post_id` に値を入れてインスタンスを作成

```irb
irb(main):005:0> User.first.favorites.build(post_id: Post.last.id).save
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                            
  Post Load (0.1ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                           
  TRANSACTION (0.1ms)  begin transaction                                        
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?  [["id", 6], ["LIMIT", 1]]                                                    
  Favorite Create (2.0ms)  INSERT INTO "favorites" ("user_id", "post_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 1], ["post_id", 6], ["created_at", "2022-12-02 18:57:49.507193"], ["updated_at", "2022-12-02 18:57:49.507193"]]                                                                         
  TRANSACTION (2.3ms)  commit transaction                                       
=> true  
```

これで登録完了です。

さらに確認として、もう一度同じレコードを登録してみます。

```irb
irb(main):006:0> User.first.favorites.build(post_id: Post.last.id).save
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                            
  Post Load (0.0ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                           
  TRANSACTION (0.1ms)  begin transaction                                        
  Post Load (0.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?  [["id", 6], ["LIMIT", 1]]                                                    
  Favorite Create (0.7ms)  INSERT INTO "favorites" ("user_id", "post_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 1], ["post_id", 6], ["created_at", "2022-12-02 19:03:48.294591"], ["updated_at", "2022-12-02 19:03:48.294591"]]                                                                         
  TRANSACTION (0.1ms)  rollback transaction                                     
/Users/xxxxxxxxx/xxxxx/xxxxxx/ruby/3.1.1/lib/ruby/gems/3.1.0/gems/sqlite3-1.5.4-arm64-darwin/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: UNIQUE constraint failed: favorites.user_id, favorites.post_id (ActiveRecord::RecordNotUnique)
/Users/xxxxxxxxx/xxxx/xxxxxx/ruby/3.1.1/lib/ruby/gems/3.1.0/gems/sqlite3-1.5.4-arm64-darwin/lib/sqlite3/statement.rb:108:in `step': UNIQUE constraint failed: favorites.user_id, favorites.post_id (SQLite3::ConstraintException)
```

このようにSQL側からのエラーが出ていて、 `unique` 属性に引っかかった事が読み取れます。これで重複したレコードを防ぐ事が出来ています。

最後にレコードの確認です。

```irb
irb(main):007:0> User.first.favorites
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                            
  Favorite Load (0.2ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ?  [["user_id", 1]]                                                
=>                                                                              
[#<Favorite:0x0000000107044f68                                                  
  id: 1,                                                                        
  user_id: 1,                                                                   
  post_id: 6,                                                                   
  created_at: Fri, 02 Dec 2022 19:03:46.145331000 UTC +00:00,                   
  updated_at: Fri, 02 Dec 2022 19:03:46.145331000 UTC +00:00>]  
```

無事、取り出す事が出来ています。

### 最終的に参照したいモデル

ただ、この `Favorite` モデルは、双方のモデルの `id` しか管理していないために、このデーターから、直接「いいね」 した `message` は参照できません。
一旦 `post_id` の `値` を用いて、目的のレコード（今回で言えば、`Post` モデルのオブジェクト） を参照する必要があります。

ですので、構造としては、

親モデル・ユーザー（１）
```
User
  - name
```

中間モデル・お気に入り（多）
```
Favorite
  - user_id
  - post_id
```

子モデル・投稿（１）
```
Post
  - message
```

の順序で、「いいね」した `Post` 一覧を参照する必要があります。
（すでに存在する `Post` モデルの `user_id` は投稿したユーザーを特定する為のものなので、上記では便宜上記載はしていません。）

そして、この `Post` を見に行くための、アソシエーションを書いておくと参照が便利です。

## アソシエーションの命名

ここで、中間テーブルから `Post` を参照する為のアソシエーションを書いてみましょう。

### `through`

まず、モデルのコードを確認します。

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :favorites
end
```

先程記述した中間テーブル、

```ruby
has_many :favorites
```

を使ったアソシエーションを新たに書きたいので、 `through` オプションを使って、

```ruby
has_many :posts, through: :favorites
```

というモデル名の複数形を利用して書けば、勝手にRailsがモデルを辿ってくれはするのですが、今回は問題が起きます。

コード全体で見ると明確になりますが、

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :favorites
  has_many :posts, through: :favorites
end
```

`has_many` はメソッドを作成するためのショートハンドでしたので、そのメソッド名 `posts` が２箇所存在するとなると、メソッドが上書きされてしまいます。
上記のコードの結果は、１つ目の `has_many` が無効化されてしまう結果になります。（つまり投稿のアソシエーションが使えなくなります。）

### `source`

この現象を防ぐ為に、命名規則を自由にするための、 `source` オプションを使います。
すると、 `has_many` の引数をモデル名以外のものを自由に使う事が出来ます。

今回は、 `has_many :favorite_posts` としてみましょう。

具体的には、

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :favorites
  has_many :favorite_posts, through: :favorites, source: :post
end
```

このように書きます。

`source: :post` の `post` は `:favorites` で参照される、 `Favorite` モデルに記載されている `belongs_to` の値を指定しますので。

`Favorite` モデルを見に行くと代入する値を判断する事が出来ます。

```ruby:app/models/favorite.rb
class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :post
end
```
ここまでのアソシエーションを確認しましょう。再びコンソールを開きましょう。

```irb
$ rails c                  
Running via Spring preloader in process 15949
Loading development environment (Rails 6.1.7)
irb(main):001:0> 
```

そして、 `has_many: :favorites`　を使って、

```irb
irb(main):001:0> User.first.favorites
   (0.5ms)  SELECT sqlite_version(*)
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
  Favorite Load (0.4ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ?  [["user_id", 1]]
=> 
[#<Favorite:0x0000000106d75cb0
  id: 1,
  user_id: 1,
  post_id: 6,
  created_at: Fri, 02 Dec 2022 19:03:46.145331000 UTC +00:00,
  updated_at: Fri, 02 Dec 2022 19:03:46.145331000 UTC +00:00>]
```

をして、 `Favorite` モデルのインスタンスが参照されている事を確認。

続いて、`has_many: :favorite_posts`　を使って、

```irb
irb(main):002:0> User.first.favorite_posts
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]                          
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" INNER JOIN "favorites" ON "posts"."id" = "favorites"."post_id" WHERE "favorites"."user_id" = ?  [["user_id", 1]]                                           
=>                                            
[#<Post:0x0000000106cddd20                    
  id: 6,                                      
  user_id: 2,
  message: "hello5",
  created_at: Thu, 01 Dec 2022 18:29:02.902544000 UTC +00:00,
  updated_at: Thu, 01 Dec 2022 18:29:02.902544000 UTC +00:00>]
```

`Post` モデルのインスタンスが参照されていることを確認して下さい。

これによって、コントローラーの実装等では、

```ruby
#このコードは本記事と関係ありません。
@user = User.find.params[:id]
@posts = @user.favorite_posts
```

のような形で、簡単にループで表示したいデーターを参照する事が出来ます。

## 実際のいいね機能

ここまで、書けたらいいね機能としては十分です。
ただし、実際は重複したデーターを入れさせないような処理等をしておかなくては、現状ではSQLエラーが出る恐れがあります。

そこで、もう少しきちんとしたコードを考えてみましょう。

### find_or_create_by でレコードの登録時の重複を防ぐ

`find_or_create_by` を使うと、レコードが存在した時はそのオブジェクトを返し、存在がない場合のみ `create` する動きになります。

今度は `User.last` にいいねして貰いましょう。

```irb
irb(main):003:0> User.last.favorites.find_or_create_by(post_id: Post.last.id)
  User Load (0.8ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                           
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                           
  Favorite Load (0.2ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ? AND "favorites"."post_id" = ? LIMIT ?  [["user_id", 2], ["post_id", 6], ["LIMIT", 1]]                                                            
  TRANSACTION (1.3ms)  begin transaction                                        
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = ? LIMIT ?  [["id", 6], ["LIMIT", 1]]                                                    
  Favorite Create (1.9ms)  INSERT INTO "favorites" ("user_id", "post_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["user_id", 2], ["post_id", 6], ["created_at", "2022-12-02 20:11:58.012118"], ["updated_at", "2022-12-02 20:11:58.012118"]]                                                                         
  TRANSACTION (0.8ms)  commit transaction                                       
=> 
#<Favorite:0x0000000107015a10
 id: 2,
 user_id: 2,
 post_id: 6,
 created_at: Fri, 02 Dec 2022 20:11:58.012118000 UTC +00:00,
 updated_at: Fri, 02 Dec 2022 20:11:58.012118000 UTC +00:00>
```

`INSERT` が実行され `commit transaction ` と出ていますので登録出来たようです。
登録出来たら、もう一度同じコードを実行します。

```irb
irb(main):004:0> User.last.favorites.find_or_create_by(post_id: Post.last.id)
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  Favorite Load (0.1ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ? AND "favorites"."post_id" = ? LIMIT ?  [["user_id", 2], ["post_id", 6], ["LIMIT", 1]]
=> 
#<Favorite:0x0000000107304960
 id: 2,
 user_id: 2,
 post_id: 6,
 created_at: Fri, 02 Dec 2022 20:11:58.012118000 UTC +00:00,
 updated_at: Fri, 02 Dec 2022 20:11:58.012118000 UTC +00:00>
```

今度は、 `SELECT` だけで止まっています。
SQLエラーは回避出来ました。

### レコードがある場合のみ削除する

削除側には、 `find_or_create_by` のような便利メソッドがありませんので、少し工夫が必要です。

まずは、 `find_by` を使ってレコードを変数に格納します。

```ruby
User.last.favorites.find_by(post_id: Post.last.id)
```
`find_by` は見つからない場合 `nil` を返すので、以降の処理につなぐ事が出来ます。 

```irb
irb(main):001:0> favorite = User.last.favorites.find_by(post_id: Post.last.id)
   (0.6ms)  SELECT sqlite_version(*)
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                    
  Post Load (0.1ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]                                                    
  Favorite Load (0.4ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ? AND "favorites"."post_id" = ? LIMIT ?  [["user_id", 2], ["post_id", 6], ["LIMIT", 1]]                                                     
=>                                                                       
#<Favorite:0x0000000106a38de0                                            
...                 
```

存在がありますので、オブジェクトが返ってきています。

そして、 変数 `favorite` が存在する場合のみ `destroy` を実行します。

```irb
irb(main):002:0> favorite.destroy if favorite
  TRANSACTION (0.2ms)  begin transaction
  Favorite Destroy (2.3ms)  DELETE FROM "favorites" WHERE "favorites"."id" = ?  [["id", 2]]                                                              
  TRANSACTION (1.1ms)  commit transaction                        
=>                                                               
#<Favorite:0x0000000106a38de0                                    
 id: 2,                                                          
 user_id: 2,                                                     
 post_id: 6,                                                     
 created_at: Fri, 02 Dec 2022 20:11:58.012118000 UTC +00:00,     
 updated_at: Fri, 02 Dec 2022 20:11:58.012118000 UTC +00:00>   
```

`commit transaction ` ですので、削除出来たようです。

もう一度同じ事を行ってみます。

```irb
irb(main):003:0> favorite = User.last.favorites.find_by(post_id: Post.last.id)
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]                                            
  Post Load (0.1ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT ?  [["LIMIT", 1]]
  Favorite Load (0.1ms)  SELECT "favorites".* FROM "favorites" WHERE "favorites"."user_id" = ? AND "favorites"."post_id" = ? LIMIT ?  [["user_id", 2], ["post_id", 6], ["LIMIT", 1]]
=> nil
```

今度はレコードが見つからず、 `nil` となっています。

```irb
irb(main):004:0> favorite.destroy if favorite
=> nil
```

`destroy` も実行しませんでしたので、これでエラー回避が出来た事となります。

これらをインスタンスメソッドとしてモデルに書くとするなら、

```ruby
def favorite(post)
  self.favorites.find_or_create_by(post_id: post.id)
end

def unfavorite(post)
  favorite = self.favorites.find_by(post_id: post.id)
  favorite.destroy if favorite
end
```

このようなコードが考えられるでしょう。

```ruby
  favorite = self.favorites.find_by(post_id: post.id)
  favorite.destroy if favorite
```

この部分は、 `&`(ボッチ演算子)を使うと、
```ruby
self.favorites.find_by(post_id: post.id)&.destroy
```

と、１行で書くことが出来ます。
が、メソッド部分（英文の動詞にあたる部分）が後ろにあると、可読性が下がる可能性はあります。どちらかというとfind_byの方に目がいってしまいます。

全体では、

```ruby:app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :favorites
  has_many :favorite_posts, through: :favorites, source: :post

  def favorite(post)
    self.favorites.find_or_create_by(post_id: post.id)
  end

  def unfavorite(post)
    favorite = self.favorites.find_by(post_id: post.id)
    favorite.destroy if favorite
  end  
end 
```

このようになります。

ここまで書けたら、最後の検証として、コンソールで

```ruby
User.first.unfavorite(Post.first)
User.first.favorite(Post.first)
User.first.favorite(Post.first)
User.first.unfavorite(Post.first)
User.first.unfavorite(Post.first)
```

などのように、登録・削除を交互に行ったり連続で行ったりして、エラーが出ない事を確認しておきましょう。
エラーがでなければ、モデルの実装はOKでしょう。

## 結論

コンソールは実装の現在地を把握するためにとても有用です。
コンソールを使ってモデルを先に検証した上で次の実装に進むと、DB側に問題がない状態を前提とする事が出来ます。
面倒なようですが、トラブルが減ったり、調べる箇所が減る分、実装スピードは上がりますので是非試してみて下さい。

## おわりに

[【Rails６】コンソールでアソシエーションを理解しよう①（一対多）](https://qiita.com/tomoaki-kimura/items/165fdcf94c179e8e08b4)に続いて、 ２回に渡りアソシエーションとコンソールを触ってみました。
内容自体は今更感のあるものですが、逐一コンソールで確認する事の大事さをご理解いただけると幸いです。

次回 4日目の投稿は、@takumi3488 さんの

[【Ruby】カッコつけたコードを書いてみよう](https://qiita.com/advent-calendar/2022/infratop_mentor) です。

スレッドの購読やいいねもお願いします。

ここまで読んでいただきありがとうございます。