はじめに
中間テーブル作成時に調べた内容をまとめます。
中間テーブルとは
多対多の関係を管理するためのテーブル
定義
- UserモデルとBookモデルを紐づける中間モデルUserBookの作成を想定
マイグレーションファイル
class CreateUserBooks < ActiveRecord::Migration[6.1]
def change
create_table :user_books do |t|
t.references :user, null: false, foreign_key: { on_delete: :cascade }
t.references :book, null: false, foreign_key: { on_delete: :cascade }
t.timestamps
end
add_index :user_books, [:user_id, :book_id], unique: true
end
end
モデルファイル
class User < ApplicationRecord
has_many :user_books, dependent: :destroy
has_many :books, through: :user_books
end
class Book < ApplicationRecord
has_many :user_books, dependent: :destroy
has_many :users, through: :user_books
end
class UserBook < ApplicationRecord
belongs_to :user
belongs_to :book
validates :user_id, uniqueness: { scope: :book_id }
end
dependent: :destroy
と foreign_key: {on_delete: :cascade}
-
dependent: :destroy
はアプリケーションレベルで、親オブジェクト削除時に関連する子オブジェクトのdestroyメソッドを呼び、コールバックも実行する -
foreign_key: {on_delete: :cascade}
はDBレベルで、親レコードが削除されると自動的にSQLのON DELETE CASCADE
が実行され関連する子レコードを削除することで、整合性が保証される
validates :user_id, uniqueness: { scope: :book_id }
と add_index :user_books, [:user_id, :book_id], unique: true
-
validates :user_id, uniqueness: { scope: :book_id }
はアプリケーションレベルで、ユーザが同じ本を重複して登録しようとした場合にバリデーションエラーを出す。バリデーションメッセージが表示される -
add_index :user_books, [:user_id, :book_id], unique: true
はDBレベルで、user_id と book_id の組み合わせの重複を防ぐための一意制約付きインデックスを追加する。これにより、同じユーザーが同じ本を複数回登録することをDBが拒否する
登録
UserBook.create(user: user, book: book, rating: 5)
取得
- userが評価5を付けた本を取得する場合を想定
方法①(SQLクエリは1回:最も効率的)
user.books.merge(UserBook.where(rating: 5))
-
user.books
:has_many: books, through: :user_books
のアソシエーションによりuserに紐づくbooksを取得 -
.merge(UserBook.where(rating: 5))
:user_booksテーブルのraiting=5の条件がJOINに適用される
発行されるSQL
SELECT books.*
FROM books
INNER JOIN user_books ON user_books.book_id = books.id
WHERE user_books.user_id = ? AND user_books.rating = 5
方法②(SQLクエリは2回)
UserBook.includes(:book).where(user: user, rating: 5).map(&:book)
-
UserBook.includes(:book)
:UserBookモデルの中で定義されたbelongs_to: book
を元に、関連するBookを先にまとめて取得する(イーガーローディング) -
.where(user: user, rating: 5)
- 該当するUserBookレコードを取得
発行されるSQL
SELECT "user_books".* FROM "user_books"
WHERE "user_books"."user_id" = ? AND "user_books"."rating" = 5
SELECT "books".* FROM "books"
WHERE "books"."id" IN (?, ?, ?, ...)
- IN句には先ほど取得したUserBookのbook_idがすべて入る
-
map(&:book)
:この時点でbookはすでにメモリ上に読み込まれているので追加のSQLは発行されない
方法③(N+1問題が発生)
UserBook.where(user: user, rating: 5).map(&:book)
-
UserBook.where(user: user, rating: 5)
:user_booksテーブルから該当レコードを取得(1クエリ) -
.map(&:book)
:各レコードに対して.book
を呼ぶたびにbooks
テーブルにアクセスする(Nクエリ)
発行されるSQL
SELECT "user_books".*
FROM "user_books"
WHERE "user_books"."user_id" = ? AND "user_books"."rating" = 5;
SELECT "books".*
FROM "books"
WHERE "books"."id" = ?
LIMIT 1;
- 1回目のSQLで取得した各UserBookレコードに対して
.book
を呼ぶたびにクエリが発行される
削除
- 中間レコードだけ削除する場合
user_book = UserBook.find_by(user: user, book: book)
user_book.destroy
user.books.delete(book)