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:
- El padre (
Country) aún no tiene un ID - El
country_iden cadaCityesnil - El validador de unicidad comprueba: “¿Existe otra ciudad con
language: 'en-US'Ycountry_id: nil?” - La consulta a la base de datos no encuentra nada (aún no hay registro confirmado)
- 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
- Siempre añade restricciones únicas a nivel de base de datos para validaciones de unicidad, especialmente las que tienen scope
- Prueba con atributos anidados: Escribe tests que intenten crear registros anidados duplicados
- Considera usar
validates_withpara 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