Skip to content

Instantly share code, notes, and snippets.

@JamesAndresCM
Last active July 20, 2025 05:15
Show Gist options
  • Save JamesAndresCM/ee9ba8e9381bc03b5a771b966647326a to your computer and use it in GitHub Desktop.
Save JamesAndresCM/ee9ba8e9381bc03b5a771b966647326a to your computer and use it in GitHub Desktop.

Validate nested uniqueness associations

  • Imagine you have 2 models (city and country) with nested attributes feature, then the model country has n cities, the principal validation is that a for each record, the language is unique

Steps:

  • create unique index rails g migration AddIndexToCity
class AddIndexToCity < ActiveRecord::Migration[5.2]
  def change
    add_index :cities, [:country_id, :language], unique: true
  end
end
  • Creates a custom validator concern
module NestedUniquenessValidator
  extend ActiveSupport::Concern

  class_methods do
    def validates_nested_uniqueness_of(association_name, fields:)
      validate do
        records = send(association_name).reject(&:marked_for_destruction?)

        duplicates = records.group_by { |r| fields.map { |f| r.send(f) } }
                            .select { |_k, v| v.size > 1 }

        duplicates.each do |values, _|
          human_fields = fields.zip(values).map { |f, v| "#{f}: #{v}" }.join(', ')
          errors.add(association_name, "contains duplicates by #{human_fields}")
        end
      end
    end
  end
end
  • Create city model
class City < ApplicationRecord
  belongs_to :country, inverse_of: :cities
end
  • Create country model
class Country < ApplicationRecord
  include NestedUniquenessValidator
  has_many :cities, dependent: :destroy, inverse_of: :country
  accepts_nested_attributes_for :cities, allow_destroy: true
  validates_nested_uniqueness_of :cities, fields: [:language]
end
  • Transactions
country = Country.new(description:'abc', language: 'en-US', cities:[City.new(language: 'en-US', text: 'abc'), City.new(language: 'en-US', text: 'abc')]).save!
   (0.2ms)  BEGIN
   (0.2ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):1
ActiveRecord::RecordInvalid (Validation failed: Cities contains duplicates by language: en-US)
irb(main):002:0> country = Country.new(description:'abc', language: 'en-US', cities:[City.new(language: 'en-US', text: 'abc')]).save!
   (1.3ms)  BEGIN
  Country Create (5.5ms)  INSERT INTO "countries" ("description", "language", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["description", "abc"], ["language", "en-US"], ["created_at", "2025-07-20 00:17:22.495271"], ["updated_at", "2025-07-20 00:17:22.495271"]]
  City Create (2.3ms)  INSERT INTO "cities" ("language", "text", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["language", "en-US"], ["text", "abc"], ["country_id", 20], ["created_at", "2025-07-20 00:17:22.503756"], ["updated_at", "2025-07-20 00:17:22.503756"]]
   (0.5ms)  COMMIT
=> true
irb(main):003:0> country = Country.first
  Country Load (0.3ms)  SELECT  "countries".* FROM "countries" ORDER BY "countries"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<Country id: 20, description: "abc", language: "en-US", created_at: "2025-07-20 00:17:22", updated_at: "2025-07-20 00:17:22">
irb(main):004:0> country.cities.create!(language: "en-US")
   (0.2ms)  BEGIN
  City Create (1.1ms)  INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["language", "en-US"], ["country_id", 20], ["created_at", "2025-07-20 00:17:29.624148"], ["updated_at", "2025-07-20 00:17:29.624148"]]
   (0.1ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):4
ActiveRecord::RecordNotUnique (PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_cities_on_country_id_and_language")
DETAIL:  Key (country_id, language)=(20, en-US) already exists.
: INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"
irb(main):005:0> country.update!(cities_attributes: [{language: "en-US"}])
   (0.2ms)  BEGIN
  City Load (0.5ms)  SELECT "cities".* FROM "cities" WHERE "cities"."country_id" = $1  [["country_id", 20]]
   (0.1ms)  ROLLBACK
Traceback (most recent call last):
        2: from (irb):5
        1: from (irb):5:in `rescue in irb_binding`
ActiveRecord::RecordInvalid (Validation failed: Cities contains duplicates by language: en-US)
irb(main):006:0> country.cities
=> #<ActiveRecord::Associations::CollectionProxy [#<City id: 38, language: "en-US", text: "abc", country_id: 20, created_at: "2025-07-20 00:17:22", updated_at: "2025-07-20 00:17:22">, #<City id: nil, language: "en-US", text: nil, country_id: 20, created_at: nil, updated_at: nil>]>
irb(main):007:0> country.cities.reload
  City Load (0.3ms)  SELECT "cities".* FROM "cities" WHERE "cities"."country_id" = $1  [["country_id", 20]]
=> #<ActiveRecord::Associations::CollectionProxy [#<City id: 38, language: "en-US", text: "abc", country_id: 20, created_at: "2025-07-20 00:17:22", updated_at: "2025-07-20 00:17:22">]>
irb(main):008:0> country.update!(cities_attributes: [{id: 38, _destroy: true}, {language: "en-US"}])
   (0.7ms)  BEGIN
  City Destroy (0.7ms)  DELETE FROM "cities" WHERE "cities"."id" = $1  [["id", 38]]
  City Create (0.3ms)  INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["language", "en-US"], ["country_id", 20], ["created_at", "2025-07-20 00:18:24.025572"], ["updated_at", "2025-07-20 00:18:24.025572"]]
   (0.8ms)  COMMIT
=> true

country.update!(cities_attributes: [{id: 40, _destroy: true}, {language: "en-US"}, {language: "es-CL"}])
   (0.2ms)  BEGIN
  City Destroy (0.4ms)  DELETE FROM "cities" WHERE "cities"."id" = $1  [["id", 40]]
  City Create (0.4ms)  INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["language", "en-US"], ["country_id", 20], ["created_at", "2025-07-20 00:19:05.545679"], ["updated_at", "2025-07-20 00:19:05.545679"]]
  City Create (0.3ms)  INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["language", "es-CL"], ["country_id", 20], ["created_at", "2025-07-20 00:19:05.546573"], ["updated_at", "2025-07-20 00:19:05.546573"]]
   (0.6ms)  COMMIT
=> true
country.update!(cities_attributes: [{id: 41, _destroy: true}, {language: "en-US"}, {id: 42, language: "es-CL2"}, {language: "pt"}])
   (0.6ms)  BEGIN
  City Destroy (0.9ms)  DELETE FROM "cities" WHERE "cities"."id" = $1  [["id", 41]]
  City Update (0.7ms)  UPDATE "cities" SET "language" = $1, "updated_at" = $2 WHERE "cities"."id" = $3  [["language", "es-CL2"], ["updated_at", "2025-07-20 00:19:47.770235"], ["id", 42]]
  City Create (0.7ms)  INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["language", "en-US"], ["country_id", 20], ["created_at", "2025-07-20 00:19:47.771789"], ["updated_at", "2025-07-20 00:19:47.771789"]]
  City Create (0.2ms)  INSERT INTO "cities" ("language", "country_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["language", "pt"], ["country_id", 20], ["created_at", "2025-07-20 00:19:47.772967"], ["updated_at", "2025-07-20 00:19:47.772967"]]
   (0.5ms)  COMMIT
=> true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment