Last active
December 22, 2025 22:05
-
-
Save murdoch/6bd8c592e6b980b587c389ff714f465e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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