Hama Blog

Hama Blog

主にtech関連の記録

Railsで複数カラムの組み合わせでuniquenessバリデーションを指定するときの指定順について

概要

Railsuniqueness 1 バリデーションは単一のカラムだけでなく、複数のカラムに対しても、scope オプションを使うことで validates :book_id, uniqueness: { scope: :user_id } のように設定できるのだが、これってテーブルにも複合ユニーク制約を設定している場合は順番を同じにしないとインデックスが効かないよね?と疑問に思ったので調べてみた。

記事執筆時の環境

結論

少なくとも上記の環境・バージョンでは、
テーブルに実際に設定した複合ユニーク制約と順番を揃えなくてもよさそう。

例えば、以下のような場合は 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 の知識は浅いのでもうちょっとちゃんと調べたいところではある)

なので、少なくとも今回の調査環境では、指定順を気にしなくてよさそうではあったのだが、個人的には Railsuniqueness バリデーションでは以下の方針で書こうと思った。

  • できる限り、テーブルの複合ユニーク制約の指定順と同じ順番にする。

    今回の調査でわかった仕様を知らない人が見ても、同じような疑問を抱かないために。

  • バリデーションメッセージを工夫したい場合は、テーブルの複合ユニーク制約の指定順と異なる順番にしてもよい。

    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つまでしか投稿できません」というメッセージのほうが自然な文章な気がするので、上記は例が悪いが。。)