この投稿は、
DMM WEBCAMP mentor Advent Calendar 2022
の投稿3日目のエントリーです。
2日目の @tomoaki-kimura の投稿、
【Rails6】コンソールでアソシエーションを理解しよう①(一対多) の続きとなります。
環境と前提
- ruby 3.1.1
- Rails 6.1.7
- yarn 1.22.18
前提として、
【Rails6】コンソールでアソシエーションを理解しよう①(一対多)
こちらの環境が作れていて、手を動かした状態からスタートとなります。
多対多のモデル
中間テーブルとは
中間テーブルは、今回の User
と Post
の関係を紐付ける為の手段で、今までの一対多のような
子モデル・投稿(多)
Post
- user_id
- message
ここの user_id
のように「誰の投稿」かを明確にするための紐付けをするデーターを格納するための仕組みの一つで、一対多では実現不可能な、
不特定多数の User
が Post
を 「いいね」 するようなケースに用いられます。
不特定多数が関連付ける 「いいね」 機能の場合、子モデルのテーブル自体に不特定多数の user_id
カラムを作る事が不可能なため、新たにテーブルを作成し、 User
と Post
が関連しているというカラムを持ったテーブルを作成します。
具体的には、
親モデル・ユーザー(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
一旦ここでマイグレーションファイルを見てみましょう。
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
属性を付け足しておきましょう。
全体のコードは、
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
モデルを確認しておきましょう。
class Favorite < ApplicationRecord
belongs_to :user
belongs_to :post
end
今回は belongs_to
が2つ存在しています。今回も特には触りません。
次に、 User
モデルにアソシエーションを書きにいきます。
現在は、
class User < ApplicationRecord
has_many :posts
end
このようになっていますので、
Favorite
モデルとのアソシエーションを追加して、
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
まず、モデルのコードを確認します。
class User < ApplicationRecord
has_many :posts
has_many :favorites
end
先程記述した中間テーブル、
has_many :favorites
を使ったアソシエーションを新たに書きたいので、 through
オプションを使って、
has_many :posts, through: :favorites
というモデル名の複数形を利用して書けば、勝手にRailsがモデルを辿ってくれはするのですが、今回は問題が起きます。
コード全体で見ると明確になりますが、
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
としてみましょう。
具体的には、
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
モデルを見に行くと代入する値を判断する事が出来ます。
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の方に目がいってしまいます。
全体では、
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 さんの
スレッドの購読やいいねもお願いします。
ここまで読んでいただきありがとうございます。