Resolviendo: accepts_nested_attributes_for rompe la validación de unicidad con scope

Si has usado accepts_nested_attributes_for con una validación de unicidad con scope al padre, es posible que hayas encontrado un bug frustrante: los registros duplicados pasan la validación. Este es un problema de Rails que existe desde hace mucho tiempo y afecta a muchos desarrolladores.

El Problema

Considera esta configuración común:

class Country < ActiveRecord::Base
  has_many :cities, dependent: :destroy
  accepts_nested_attributes_for :cities
end

class City < ActiveRecord::Base
  belongs_to :country
  validates :language, uniqueness: { scope: :country_id }
end

Ahora intenta crear un país con ciudades duplicadas:

country = Country.new(
  name: "USA",
  cities: [
    City.new(language: "en-US", name: "New York"),
    City.new(language: "en-US", name: "Los Angeles")  # ¡Mismo idioma!
  ]
)

country.save!  # ¡Esto tiene éxito cuando no debería!

Ambas ciudades se guardan, a pesar de tener el mismo language dentro del mismo country_id. La validación de unicidad no detectó el duplicado.

Por Qué Sucede Esto

Al crear registros anidados a través de accepts_nested_attributes_for, Rails valida los hijos antes de que el registro padre sea persistido. En el momento de la validación:

  1. El padre (Country) aún no tiene un ID
  2. El country_id en cada City es nil
  3. El validador de unicidad comprueba: “¿Existe otra ciudad con language: 'en-US' Y country_id: nil?”
  4. La consulta a la base de datos no encuentra nada (aún no hay registro confirmado)
  5. La validación pasa para ambas ciudades

La validación se ejecuta en memoria, pero la comprobación de unicidad consulta la base de datos donde los registros aún no existen.

Soluciones

Opción 1: Restricción en la base de datos (recomendado)

Añade un índice único a nivel de base de datos:

class AddUniquenessConstraintToCities < ActiveRecord::Migration[7.0]
  def change
    add_index :cities, [:country_id, :language], unique: true
  end
end

Esto captura los duplicados en el momento de la inserción y lanza ActiveRecord::RecordNotUnique. Querrás manejar esta excepción:

class CountriesController < ApplicationController
  def create
    @country = Country.new(country_params)
    @country.save!
  rescue ActiveRecord::RecordNotUnique
    @country.errors.add(:cities, "contienen idiomas duplicados")
    render :new, status: :unprocessable_entity
  end
end

Opción 2: Validación personalizada

Valida la unicidad dentro de la colección en memoria:

class Country < ActiveRecord::Base
  has_many :cities, dependent: :destroy
  accepts_nested_attributes_for :cities

  validate :cities_have_unique_languages

  private

  def cities_have_unique_languages
    languages = cities.reject(&:marked_for_destruction?).map(&:language)

    if languages.length != languages.uniq.length
      errors.add(:cities, "deben tener idiomas únicos")
    end
  end
end

Opción 3: Validar en la asociación

Usa validates_associated con un validador personalizado:

class City < ActiveRecord::Base
  belongs_to :country
  validates :language, uniqueness: { scope: :country_id }, on: :update

  validate :unique_language_in_siblings, on: :create

  private

  def unique_language_in_siblings
    return unless country

    siblings = country.cities.reject { |c| c.equal?(self) }
    if siblings.any? { |c| c.language == language }
      errors.add(:language, "ya ha sido tomado")
    end
  end
end

Opción 4: Hack que funciona

Una solución alternativa de la discusión del issue en GitHub usa el proc if para asignar la clave foránea temprano:

class City < ActiveRecord::Base
  belongs_to :country

  validates :language,
    uniqueness: { scope: :country_id },
    if: -> {
      self.country_id = country&.id if country.present? && country_id.nil?
      true
    }
end

Esto fuerza la asignación de country_id antes de que se ejecute la validación. No es elegante, pero funciona.

Prevención

  1. Siempre añade restricciones únicas a nivel de base de datos para validaciones de unicidad, especialmente las que tienen scope
  2. Prueba con atributos anidados: Escribe tests que intenten crear registros anidados duplicados
  3. Considera usar validates_with para validaciones complejas entre registros

Referencias

  • Issue de GitHub #20676 - Abierto desde 2015, más de 76 comentarios
  • Esto afecta a Rails 4.x hasta 7.x y probablemente también a 8.x