概要
Rails の uniqueness
1 バリデーションは単一のカラムだけでなく、複数のカラムに対しても、scope オプションを使うことで validates :book_id, uniqueness: { scope: :user_id }
のように設定できるのだが、これってテーブルにも複合ユニーク制約を設定している場合は順番を同じにしないとインデックスが効かないよね?と疑問に思ったので調べてみた。
記事執筆時の環境
Rails: 7.0.4
PostgreSQL: 15.1
MySQL: 5.7.43
結論
少なくとも上記の環境・バージョンでは、
テーブルに実際に設定した複合ユニーク制約と順番を揃えなくてもよさそう。
例えば、以下のような場合は validates :book_id, uniqueness: { scope: :user_id }
でも validates :user_id, uniqueness: { scope: :book_id }
でもインデックスが効く。
- BookReview という書籍に対するレビューを投稿できるモデルがある。
- 書籍1つにつき1人1レビューまでという制約をつけたい。
- book_reviews テーブルには
book_id, user_id
の順番で複合ユニーク制約を設定している。
class BookReview < ApplicationRecord belongs_to :book belongs_to :user validates :book_id, uniqueness: { scope: :user_id } # validates :user_id, uniqueness: { scope: :book_id } でもインデックスが効く end
調査内容
uniqueness
バリデーションの実行ログ確認
まず、上記の例をもとに、uniqueness
バリデーションで2パターンの指定をしたときの実行ログを確認してみた。
(前提) BookReview
に既に book_id = 1, user_id = 1 のレコードが存在する。
validates :book_id, uniqueness: { scope: :user_id }
のとき[7] pry(main)> BookReview.create!(book_id: 1, user_id: 1, title: 'テスト') TRANSACTION (0.7ms) BEGIN Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] BookReview Exists? (0.5ms) SELECT 1 AS one FROM "book_reviews" WHERE "book_reviews"."book_id" = $1 AND "book_reviews"."user_id" = $2 LIMIT $3 [["book_id", 1], ["user_id", 1], ["LIMIT", 1]] TRANSACTION (0.3ms) ROLLBACK
validates :user_id, uniqueness: { scope: :book_id }
のとき[9] pry(main)> BookReview.create!(book_id: 1, user_id: 1, title: 'テスト') TRANSACTION (0.5ms) BEGIN Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]] BookReview Exists? (0.5ms) SELECT 1 AS one FROM "book_reviews" WHERE "book_reviews"."user_id" = $1 AND "book_reviews"."book_id" = $2 LIMIT $3 [["user_id", 1], ["book_id", 1], ["LIMIT", 1]] TRANSACTION (0.3ms) ROLLBACK
BookReview Exists?
を確認すると、
前者は WHERE "book_reviews"."book_id" = $1 AND "book_reviews"."user_id" = $2
後者は WHERE "book_reviews"."user_id" = $1 AND "book_reviews"."book_id" = $2
となっており、validates に指定した順番のとおりに WHERE 句が指定されている。
SQL 実行計画の確認
book_reviews テーブルに対して WHERE 句に book_id と user_id を指定して、SELECT 文を EXPLAIN してみた。
PostgreSQL
book_id, user_id の指定順のとき
user_id, book_id の指定順のとき
どちらも Index Scan となっている⁉️
MySQL
book_id, user_id の指定順のとき
user_id, book_id の指定順のとき
こちらも、両方とも type が const
なので、ユニークインデックスがちゃんと効いていた。
所感
てっきり、複合キーの指定順に WHERE 句でも順番を指定しないとインデックスが効かないものだと思っていたので驚いた。
(DB の知識は浅いのでもうちょっとちゃんと調べたいところではある)
なので、少なくとも今回の調査環境では、指定順を気にしなくてよさそうではあったのだが、個人的には Rails の uniqueness
バリデーションでは以下の方針で書こうと思った。
できる限り、テーブルの複合ユニーク制約の指定順と同じ順番にする。
今回の調査でわかった仕様を知らない人が見ても、同じような疑問を抱かないために。
バリデーションメッセージを工夫したい場合は、テーブルの複合ユニーク制約の指定順と異なる順番にしてもよい。
Rails の仕様的に、カラム名がバリデーションメッセージの主語になるはずなので、今回の例だと主語を「ユーザー」にしたい場合は以下のように指定する。
validates :user_id, uniqueness: { scope: :book_id, message: 'は1つの書籍につき1つのレビューしか投稿できません' } # 「ユーザーは1つの書籍につき1つのレビューしか投稿できません」というメッセージになる。
(上記だとバリデーションメッセージとしては少し不自然な気がするのと、普通に
validates :book_id, uniqueness: { scope: :user_id, message: '1つにつき、1人1つまでしか投稿できません' }
として、「書籍1つにつき、1人1つまでしか投稿できません」というメッセージのほうが自然な文章な気がするので、上記は例が悪いが。。)