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によってきちんと確認する必要がありますね。