# frozen_string_literal: true module ActiveRecord module Validations class UniquenessValidator < ActiveModel::EachValidator # :nodoc: def initialize(options) if options[:conditions] && !options[:conditions].respond_to?(:call) raise ArgumentError, "#{options[:conditions]} was passed as :conditions but is not callable. " \ "Pass a callable instead: `conditions: -> { where(approved: true) }`" end unless Array(options[:scope]).all? { |scope| scope.respond_to?(:to_sym) } raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \ "Pass a symbol or an array of symbols instead: `scope: :user_id`" end super @klass = options[:class] end def validate_each(record, attribute, value) finder_class = find_finder_class_for(record) value = map_enum_attribute(finder_class, attribute, value) return if record.persisted? && !validation_needed?(finder_class, record, attribute) 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 # The check for an existing value should be run from a class that # isn't abstract. This means working down from the current class # (self), to the first non-abstract class. Since classes don't know # their subclasses, we have to build the hierarchy between self and # the record's class. def find_finder_class_for(record) class_hierarchy = [record.class] while class_hierarchy.first != @klass class_hierarchy.unshift(class_hierarchy.first.superclass) end class_hierarchy.detect { |klass| !klass.abstract_class? } end def validation_needed?(klass, record, attribute) return true if options[:conditions] || options.key?(:case_sensitive) scope = Array(options[:scope]) attributes = scope + [attribute] attributes = resolve_attributes(record, attributes) return true if attributes.any? { |attr| record.attribute_changed?(attr) || record.read_attribute(attr).nil? } !covered_by_unique_index?(klass, record, attribute, scope) end def covered_by_unique_index?(klass, record, attribute, scope) @covered ||= self.attributes.map(&:to_s).select do |attr| attributes = scope + [attr] attributes = resolve_attributes(record, attributes) klass.connection.schema_cache.indexes(klass.table_name).any? do |index| index.unique && index.where.nil? && (Array(index.columns) - attributes).empty? end end @covered.include?(attribute.to_s) end def resolve_attributes(record, attributes) attributes.flat_map do |attribute| reflection = record.class._reflect_on_association(attribute) if reflection.nil? attribute.to_s elsif reflection.polymorphic? [reflection.foreign_key, reflection.foreign_type] else reflection.foreign_key end end end 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 def scope_relation(record, relation) Array(options[:scope]).each do |scope_item| scope_value = if record.class._reflect_on_association(scope_item) record.association(scope_item).reader else record.read_attribute(scope_item) end relation = relation.where(scope_item => scope_value) end relation end def map_enum_attribute(klass, attribute, value) mapping = klass.defined_enums[attribute.to_s] value = mapping[value] if value && mapping value end end module ClassMethods # Validates whether the value of the specified attributes are unique # across the system. Useful for making sure that only one user # can be named "davidhh". # # class Person < ActiveRecord::Base # validates_uniqueness_of :user_name # end # # It can also validate whether the value of the specified attributes are # unique based on a :scope parameter: # # class Person < ActiveRecord::Base # validates_uniqueness_of :user_name, scope: :account_id # end # # Or even multiple scope parameters. For example, making sure that a # teacher can only be on the schedule once per semester for a particular # class. # # class TeacherSchedule < ActiveRecord::Base # validates_uniqueness_of :teacher_id, scope: [:semester_id, :class_id] # end # # It is also possible to limit the uniqueness constraint to a set of # records matching certain conditions. In this example archived articles # are not being taken into consideration when validating uniqueness # of the title attribute: # # class Article < ActiveRecord::Base # validates_uniqueness_of :title, conditions: -> { where.not(status: 'archived') } # end # # To build conditions based on the record's state, define the conditions # callable with a parameter, which will be the record itself. This # example validates the title is unique for the year of publication: # # class Article < ActiveRecord::Base # validates_uniqueness_of :title, conditions: ->(article) { # published_at = article.published_at # where(published_at: published_at.beginning_of_year..published_at.end_of_year) # } # end # # When the record is created, a check is performed to make sure that no # record exists in the database with the given value for the specified # attribute (that maps to a column). When the record is updated, # the same check is made but disregarding the record itself. # # Configuration options: # # * :message - Specifies a custom error message (default is: # "has already been taken"). # * :scope - One or more columns by which to limit the scope of # the uniqueness constraint. # * :conditions - Specify the conditions to be included as a # WHERE SQL fragment to limit the uniqueness constraint lookup # (e.g. conditions: -> { where(status: 'active') }). # * :case_sensitive - Looks for an exact match. Ignored by # non-text columns. The default behavior respects the default database collation. # * :allow_nil - If set to +true+, skips this validation if the # attribute is +nil+ (default is +false+). # * :allow_blank - If set to +true+, skips this validation if the # attribute is blank (default is +false+). # * :if - Specifies a method, proc, or string to call to determine # if the validation should occur (e.g. if: :allow_validation, # or if: Proc.new { |user| user.signup_step > 2 }). The method, # proc or string should return or evaluate to a +true+ or +false+ value. # * :unless - Specifies a method, proc, or string to call to # determine if the validation should not occur (e.g. unless: :skip_validation, # or unless: Proc.new { |user| user.signup_step <= 2 }). The # method, proc, or string should return or evaluate to a +true+ or +false+ # value. # # === Concurrency and integrity # # Using this validation method in conjunction with # {ActiveRecord::Base#save}[rdoc-ref:Persistence#save] # does not guarantee the absence of duplicate record insertions, because # uniqueness checks on the application level are inherently prone to race # conditions. For example, suppose that two users try to post a Comment at # the same time, and a Comment's title must be unique. At the database-level, # the actions performed by these users could be interleaved in the following manner: # # User 1 | User 2 # ------------------------------------+-------------------------------------- # # User 1 checks whether there's | # # already a comment with the title | # # 'My Post'. This is not the case. | # SELECT * FROM comments | # WHERE title = 'My Post' | # | # | # User 2 does the same thing and also # | # infers that their title is unique. # | SELECT * FROM comments # | WHERE title = 'My Post' # | # # User 1 inserts their comment. | # INSERT INTO comments | # (title, content) VALUES | # ('My Post', 'hi!') | # | # | # User 2 does the same thing. # | INSERT INTO comments # | (title, content) VALUES # | ('My Post', 'hello!') # | # | # ^^^^^^ # | # Boom! We now have a duplicate # | # title! # # The best way to work around this problem is to add a unique index to the database table using # {connection.add_index}[rdoc-ref:ConnectionAdapters::SchemaStatements#add_index]. # In the rare case that a race condition occurs, the database will guarantee # the field's uniqueness. # # When the database catches such a duplicate insertion, # {ActiveRecord::Base#save}[rdoc-ref:Persistence#save] will raise an ActiveRecord::StatementInvalid # exception. You can either choose to let this error propagate (which # will result in the default \Rails exception page being shown), or you # can catch it and restart the transaction (e.g. by telling the user # that the title already exists, and asking them to re-enter the title). # This technique is also known as # {optimistic concurrency control}[https://en.wikipedia.org/wiki/Optimistic_concurrency_control]. # # The bundled ActiveRecord::ConnectionAdapters distinguish unique index # constraint errors from other types of database errors by throwing an # ActiveRecord::RecordNotUnique exception. For other adapters you will # have to parse the (database-specific) exception message to detect such # a case. # # The following bundled adapters throw the ActiveRecord::RecordNotUnique exception: # # * ActiveRecord::ConnectionAdapters::Mysql2Adapter. # * ActiveRecord::ConnectionAdapters::TrilogyAdapter. # * ActiveRecord::ConnectionAdapters::SQLite3Adapter. # * ActiveRecord::ConnectionAdapters::PostgreSQLAdapter. def validates_uniqueness_of(*attr_names) validates_with UniquenessValidator, _merge_attributes(attr_names) end end end end