【Rails】paranoiaで論理削除したレコードはユニーク制約バリデーションの対象外になる

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側で、モジュールを用意していて、RailsUniquenessValidatorの方に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

流れとしては、superRails側のbuild_relationでベースとなるリレーションを取得しつつ、relation.where(arel_paranoia_scope)で論理削除済みのレコードを除くクエリをかけています。

まとめ

  • uniquenessを使った場合、Rails側のバリデーションでdefault_scopeは無視するようにしています。
  • 一方、paranoiaacts_as_paranoidを使っているときは、paranoia独自のモジュールが作用することで、追加で論理削除済みのレコードはユニーク判定の対象から外す(重複を許す)よう意図して設計されています。

paranoia以外の論理削除gemはきっと違う動きをする(以前discardを使ったときは論理削除済みのレコードと重複できなかった気がする)と思うので、使っているgemによってきちんと確認する必要がありますね。