【Rails】uniquenessバリデーションに気をつける話

# はじめに

先日ユニーク制約関連で意図せぬエラーを出してしまっていたので、メモを残しておきます。

# どんな問題？
has_manyなモデルについて、
* 複数のインスタンスをbuild
* バリデーションチェック（チェック通過）
* 保存処理でDBのユニーク制約に引っかかりエラー発生
* 「？」

みたいはことが起こりました。

ユニークバリデーション設定してるのにモデルのバリデーションチェック通過しちゃうの？という疑問でした


# TL;DR
* uniquenessバリデーションは対象のインスタンスとDBに保存済みのレコードを比較しているよ
* 複数buildされたインスタンス間でのチェックはされないよ
* バリデーションチェックやエラーハンドリングは適切にやろうね


以降、サンプルを交えながら少し説明していきます。

# サンプルの説明
説明や検証をするためにサンプルを用意
今回はサンプルとして簡易的なモデルを用意しました。
本と著者、その中間テーブルを想定します。

ユニークになるのは、中間テーブルの`book_id`と`author_id`です。（複合ユニーク）

用意したschemaは以下になります。

```ruby:schema.rb
create_table "books", force: :cascade do |t|
  t.string "title", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

create_table "authors", force: :cascade do |t|
  t.string "name", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

create_table "book_authors", force: :cascade do |t|
  t.integer "book_id", null: false
  t.integer "author_id", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false

  t.index ["book_id"], name: "index_book_authors_on_book_id"
  t.index ["author_id"], name: "index_book_authors_on_author_id"
  t.index ["book_id", "author_id"], name: "index_book_id_and_author_id", unique: true
end

add_foreign_key "book_authors", "books", column: "book_id", name: "book_authors_books_fk"
add_foreign_key "book_authors", "authors", column: "author_id", name: "book_authors_authors_fk"
```

中間テーブルのモデルにはuniquenessバリデーションを設定しておきます

```ruby:book_author.rb
class BookAuthor < ApplicationRecord
  belongs_to :book
  belongs_to :author

  # validation
  validates :author_id, uniqueness: { scope: :book_id }
end
```

# 検証

検証環境は以下の通りです
* Ruby3.1.0
* Rails7.0.2.2
* PostgreSQL14.2


コンソールで試してみます
```bash
# bookのbuild
[1] pry(main)> book = Book.new(title: 'test')
=> #<Book:0x000000011231eee0 id: nil, title: "test", created_at: nil, updated_at: nil>

# 中間テーブルレコードのbuild(author_id: 1のレコードは既に登録済み)
[2] pry(main)> book.book_authors.build(author_id: 1)
=> #<BookAuthor:0x00000001123546f8 id: nil, book_id: nil, author_id: 1, created_at: nil, updated_at: nil>
[3] pry(main)> book.book_authors.build(author_id: 1)
=> #<BookAuthor:0x000000011239d420 id: nil, book_id: nil, author_id: 1, created_at: nil, updated_at: nil>

# バリデーション実行
[4] pry(main)> book.valid?
  BookAuthor Exists? (4.5ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" = $2 LIMIT $3  [["author_id", 1], ["book_id", 7], ["LIMIT", 1]]
  BookAuthor Exists? (0.2ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" = $2 LIMIT $3  [["author_id", 1], ["book_id", 7], ["LIMIT", 1]]
=> true

# 保存処理（DBのユニーク制約に引っかかり失敗）
[5] pry(main)> book.save
  TRANSACTION (0.2ms)  BEGIN
  Author Load (3.0ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  BookAuthor Exists? (0.7ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" IS NULL LIMIT $2  [["author_id", 1], ["LIMIT", 1]]
  Author Load (2.9ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  BookAuthor Exists? (0.3ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" IS NULL LIMIT $2  [["author_id", 1], ["LIMIT", 1]]
  Book Create (0.2ms)  INSERT INTO "books" ("title", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"  [["title", "test"], ["created_at", "2022-07-03 13:17:26.038383"], ["updated_at", "2022-07-03 13:17:26.038383"]]
  BookAuthor Exists? (0.2ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" = $2 LIMIT $3  [["author_id", 1], ["book_id", 7], ["LIMIT", 1]]
  BookAuthor Create (0.3ms)  INSERT INTO "book_authors" ("book_id", "author_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["book_id", 7], ["author_id", 1], ["created_at", "2022-07-03 13:17:26.039891"], ["updated_at", "2022-07-03 13:17:26.039891"]]
  BookAuthor Exists? (0.2ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" = $2 LIMIT $3  [["author_id", 1], ["book_id", 7], ["LIMIT", 1]]
  TRANSACTION (2.4ms)  ROLLBACK
=> false
```

バリデーションチェックは通ってますが、DBのユニーク制約に引っかかりrollbackされてますね

uniquenessバリデーションについて、Railsガイドで使用を確認してみましょう

# 仕様
https://railsguides.jp/active_record_validations.html#uniqueness

> このバリデーションは、その属性と同じ値を持つ既存のレコードがモデルのテーブルにあるかどうかを調べるSQLクエリを実行することで行われます。

つまりbuildされているActiveRecord間でのチェックはされず、build済みのレコードとDBに格納されているレコードとの間でのみチェックが走るということのようです。


# 対策してみる

カスタムバリデーションを追加してみます。（エラーメッセージをこのモデルに追加すべきかどうかという議論は本記事の本質とずれてしまうのでなしでお願いします。）
```diff_ruby:book.rb
class Book < ApplicationRecord
  has_many :book_authors, dependent: :restrict_with_error
  has_many :authors, through: :book_authors

+   # validation
+   validate :check_uniqueness_authors
+ 
+   def check_uniqueness_authors
+     author_ids = book_authors.map(&:author_id)
+     errors.add(:base, '著者が重複しています。') if author_ids.uniq.length != author_ids.length
+   end
end
```

余談ですが、配列の要素数を数えるならcountよりもlengthの方が速いみたいです
参考: https://qiita.com/motoki4917/items/ffc89d955e20b91d1014#comment-16d0082aa588ba5ce1c4


では、この状態でちゃんとバリデーションが効いてくれるか検証してみます
```bash
[1] pry(main)> book = Book.new(title: 'test')
=> #<Book:0x000000010b0208a8 id: nil, title: "test", created_at: nil, updated_at: nil>
[2] pry(main)> book.book_authors.build(author_id: 1)
=> #<BookAuthor:0x000000010ad6a488 id: nil, book_id: nil, author_id: 1, created_at: nil, updated_at: nil>
[3] pry(main)> book.book_authors.build(author_id: 1)
=> #<BookAuthor:0x000000010ac522a8 id: nil, book_id: nil, author_id: 1, created_at: nil, updated_at: nil>
[4] pry(main)> book.valid?
  Author Load (0.5ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  BookAuthor Exists? (0.7ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" IS NULL LIMIT $2  [["author_id", 1], ["LIMIT", 1]]
  Author Load (2.7ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  BookAuthor Exists? (0.3ms)  SELECT 1 AS one FROM "book_authors" WHERE "book_authors"."author_id" = $1 AND "book_authors"."book_id" IS NULL LIMIT $2  [["author_id", 1], ["LIMIT", 1]]
=> false
[5] pry(main)> book.errors.full_messages
=> ["著者が重複しています。"]
```

valid?の実行で重複を検知できるようになりました（よかった😃）

# 注意
上記の対策をしても、別々のクライアントが同時に保存処理をしようとした場合はモデルのバリデーションでは防げません。

なので、そのようなケースが想定される場合は`ActiveRecord::RecordNotUnique`の例外をrescueしてあげて、適切なメッセージを返すようにしてあげると良いかと思います。