paranoiaを使っているモデルでユニーク制約のバリデーションを設定すると、論理削除済みのレコードは制約の対象から外れます。(論理削除済みのアイテムとは重複が許される)
その仕組みを理解するために少しgemのコードを読んで気づきがあったので記載します。
きっかけと概要
paranoiaを使っているモデルでバリデーションのユニーク制約をかけた際に、以下のことを知りました。
- paranoiaで論理削除しているとユニーク制約の対象外になります
class Book < ApplicationRecord validates :title, uniqueness: true acts_as_paranoid end
Book.all # [#<Book id: 1, title: "Ruby", deleted_at: Fri, 15 Mar 2024 ...>] Book.create!(title: 'Ruby') #=> success
- 一方で、
default_scopeだけだと対象範囲内です
class Book < ApplicationRecord default_scope -> { where.not(deleted_at: nil) } validates :title, uniqueness: true end
Book.all # [#<Book id: 1, title: "Ruby", deleted_at: Fri, 15 Mar 2024 ...>] Book.create!(title: 'Ruby') #=> Validation failed: Title has already been taken (ActiveRecord::RecordInvalid)
違いが生まれる仕組み
まず、Railsのユニーク制約のバリデーションは以下のコードです。
lib/active_record/validations/uniqueness.rbから一部抜粋
module ActiveRecord module Validations class UniquenessValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) finder_class = find_finder_class_for(record) value = map_enum_attribute(finder_class, attribute, value) relation = build_relation(finder_class, attribute, value) if record.persisted? if finder_class.primary_key relation = relation.where.not(finder_class.primary_key => record.id_in_database) else raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.") end end relation = scope_relation(record, relation) if options[:conditions] conditions = options[:conditions] relation = if conditions.arity.zero? relation.instance_exec(&conditions) else relation.instance_exec(record, &conditions) end end if relation.exists? error_options = options.except(:case_sensitive, :scope, :conditions) error_options[:value] = value record.errors.add(attribute, :taken, **error_options) end end private def build_relation(klass, attribute, value) relation = klass.unscoped comparison = relation.bind_attribute(attribute, value) do |attr, bind| return relation.none! if bind.unboundable? if !options.key?(:case_sensitive) || bind.nil? klass.connection.default_uniqueness_comparison(attr, bind) elsif options[:case_sensitive] klass.connection.case_sensitive_comparison(attr, bind) else # will use SQL LOWER function before comparison, unless it detects a case insensitive collation klass.connection.case_insensitive_comparison(attr, bind) end end relation.where!(comparison) end end end end
uniqueness設定のあるカラムの、今保存しようとしている値の条件で検索して一致するレコードがあればエラーを追加する、という処理の流れですbuild_relationの最初にunscopeしているので、default_scopeはここで一度リセットされます。(paranoiaを使っていても、論理削除済みレコードも関係なく取得される)
上記のうち、build_relationがポイントです。
paranoia側で、モジュールを用意していて、RailsのUniquenessValidatorの方にprependしているので、先にparanoia側のメソッドがメソッド探索で見つかるようになっています。
module ActiveRecord module Validations module UniquenessParanoiaValidator def build_relation(klass, *args) relation = super return relation unless klass.respond_to?(:paranoia_column) arel_paranoia_scope = klass.arel_table[klass.paranoia_column].eq(klass.paranoia_sentinel_value) if ActiveRecord::VERSION::STRING >= "5.0" relation.where(arel_paranoia_scope) else relation.and(arel_paranoia_scope) end end end class UniquenessValidator < ActiveModel::EachValidator prepend UniquenessParanoiaValidator end end end
流れとしては、superでRails側のbuild_relationでベースとなるリレーションを取得しつつ、relation.where(arel_paranoia_scope)で論理削除済みのレコードを除くクエリをかけています。
まとめ
uniquenessを使った場合、Rails側のバリデーションでdefault_scopeは無視するようにしています。- 一方、
paranoiaのacts_as_paranoidを使っているときは、paranoia独自のモジュールが作用することで、追加で論理削除済みのレコードはユニーク判定の対象から外す(重複を許す)よう意図して設計されています。
paranoia以外の論理削除gemはきっと違う動きをする(以前discardを使ったときは論理削除済みのレコードと重複できなかった気がする)と思うので、使っているgemによってきちんと確認する必要がありますね。