17
4

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.

DMM WEBCAMP mentor Advent Calendar 2022

Day 3

【Rails6】コンソールでアソシエーションを理解しよう②(多対多)

Last updated at Posted at 2022-12-02

この投稿は、
DMM WEBCAMP mentor Advent Calendar 2022
の投稿3日目のエントリーです。

2日目の @tomoaki-kimura の投稿、
【Rails6】コンソールでアソシエーションを理解しよう①(一対多) の続きとなります。

環境と前提

  • ruby 3.1.1
  • Rails 6.1.7
  • yarn 1.22.18

前提として、

【Rails6】コンソールでアソシエーションを理解しよう①(一対多)

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

多対多のモデル

中間テーブルとは

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

子モデル・投稿(多)

Post
  - user_id
  - message

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

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

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

具体的には、

親モデル・ユーザー(1)

User
  - name

中間モデル・お気に入り(多)

Favorite
  - user_id
  - post_id

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

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

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

Favoriteモデル

では、ターミナルより、

$ rails g model Favorite user:references post:references

を実行します。

$ 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

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

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

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

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

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

全体のコードは、

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

と、なります。

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

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

無事、通りました。

アソシエーション

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

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

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

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

現在は、

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

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

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

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

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

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

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

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

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(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(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(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(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(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(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 モデルのオブジェクト) を参照する必要があります。

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

親モデル・ユーザー(1)

User
  - name

中間モデル・お気に入り(多)

Favorite
  - user_id
  - post_id

子モデル・投稿(1)

Post
  - message

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

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

アソシエーションの命名

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

through

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

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

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

has_many :favorites

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

has_many :posts, through: :favorites

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

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

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

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

source

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

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

具体的には、

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

このように書きます。

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

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

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

ここまでのアソシエーションを確認しましょう。再びコンソールを開きましょう。

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

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

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(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 モデルのインスタンスが参照されていることを確認して下さい。

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

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

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

実際のいいね機能

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

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

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

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

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

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(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 を使ってレコードを変数に格納します。

User.last.favorites.find_by(post_id: Post.last.id)

find_by は見つからない場合 nil を返すので、以降の処理につなぐ事が出来ます。

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(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(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(main):004:0> favorite.destroy if favorite
=> nil

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

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

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

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

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

この部分は、 &(ボッチ演算子)を使うと、

self.favorites.find_by(post_id: post.id)&.destroy

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

全体では、

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 

このようになります。

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

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側に問題がない状態を前提とする事が出来ます。
面倒なようですが、トラブルが減ったり、調べる箇所が減る分、実装スピードは上がりますので是非試してみて下さい。

おわりに

【Rails6】コンソールでアソシエーションを理解しよう①(一対多)に続いて、 2回に渡りアソシエーションとコンソールを触ってみました。
内容自体は今更感のあるものですが、逐一コンソールで確認する事の大事さをご理解いただけると幸いです。

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

【Ruby】カッコつけたコードを書いてみよう です。

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

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

17
4
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
17
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?