はじめに
先日ユニーク制約関連で意図せぬエラーを出してしまっていたので、メモを残しておきます。
どんな問題?
has_manyなモデルについて、
- 複数のインスタンスをbuild
- バリデーションチェック(チェック通過)
- 保存処理でDBのユニーク制約に引っかかりエラー発生
- 「?」
みたいはことが起こりました。
ユニークバリデーション設定してるのにモデルのバリデーションチェック通過しちゃうの?という疑問でした
TL;DR
- uniquenessバリデーションは対象のインスタンスとDBに保存済みのレコードを比較しているよ
- 複数buildされたインスタンス間でのチェックはされないよ
- バリデーションチェックやエラーハンドリングは適切にやろうね
以降、サンプルを交えながら少し説明していきます。
サンプルの説明
説明や検証をするためにサンプルを用意
今回はサンプルとして簡易的なモデルを用意しました。
本と著者、その中間テーブルを想定します。
ユニークになるのは、中間テーブルのbook_id
とauthor_id
です。(複合ユニーク)
用意したschemaは以下になります。
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バリデーションを設定しておきます
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
コンソールで試してみます
# 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ガイドで使用を確認してみましょう
仕様
このバリデーションは、その属性と同じ値を持つ既存のレコードがモデルのテーブルにあるかどうかを調べるSQLクエリを実行することで行われます。
つまりbuildされているActiveRecord間でのチェックはされず、build済みのレコードとDBに格納されているレコードとの間でのみチェックが走るということのようです。
対策してみる
カスタムバリデーションを追加してみます。(エラーメッセージをこのモデルに追加すべきかどうかという議論は本記事の本質とずれてしまうのでなしでお願いします。)
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
では、この状態でちゃんとバリデーションが効いてくれるか検証してみます
[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してあげて、適切なメッセージを返すようにしてあげると良いかと思います。