Skip to content

Instantly share code, notes, and snippets.

@murdoch
Last active December 22, 2025 22:05
Show Gist options
  • Select an option

  • Save murdoch/6bd8c592e6b980b587c389ff714f465e to your computer and use it in GitHub Desktop.

Select an option

Save murdoch/6bd8c592e6b980b587c389ff714f465e to your computer and use it in GitHub Desktop.
# frozen_string_literal: true
# Validates that a model attribute is an array containing only unique elements.
# Optionally allows blank arrays and custom error messages.
#
# Usage:
# validates :tags, array_uniqueness: true
# validates :tags, array_uniqueness: { allow_blank: true, message: "has duplicates" }
#
class ArrayUniquenessValidator < ActiveModel::EachValidator
# Called by ActiveModel for each attribute using this validator
def validate_each(record, attribute, value)
# Do not validate nil values (consistent with most Rails validators)
return if value.nil?
# Ensure the value is actually an array
# If not, add an :invalid error and stop validation
unless value.is_a?(Array)
record.errors.add(attribute, :invalid, message: "must be an array")
return
end
# Skip validation for blank arrays if allow_blank is enabled
return if options[:allow_blank] && value.blank?
# Find any duplicate values in the array
duplicates = find_duplicates(value)
# No duplicates → validation passes
return if duplicates.empty?
# Build a helpful error message listing the duplicate values
message = options[:message] || "must contain unique elements"
formatted_duplicates = duplicates.map { |v| format_value(v) }.join(", ")
# Add a validation error with a symbolic error key (I18n-friendly)
record.errors.add(
attribute,
:not_unique,
message: "#{message}. Duplicate values: #{formatted_duplicates}"
)
end
private
# Returns an array of duplicate values, preserving the order
# in which the duplicates first appeared.
#
# Example:
# [1, 2, 1, 3, 2, 2] => [1, 2]
#
def find_duplicates(array)
seen = {} # Tracks values we have already encountered
duplicates = {} # Tracks duplicates without repetition
array.each do |item|
if seen[item]
# Mark the item as a duplicate (only once)
duplicates[item] ||= true
else
seen[item] = true
end
end
duplicates.keys
end
# Formats values for inclusion in error messages
# - nil => "nil"
# - "" => '""'
# - other => inspected value (useful for strings, symbols, hashes, etc.)
#
def format_value(value)
case value
when nil
"nil"
when ""
'""'
else
value.inspect
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment