Résoudre : accepts_nested_attributes_for casse la validation d'unicité avec scope
Si vous avez utilisé accepts_nested_attributes_for avec une validation d’unicité scopée au parent, vous avez peut-être rencontré un bug frustrant : les enregistrements dupliqués passent la validation. C’est un problème Rails de longue date qui affecte de nombreux développeurs.
Le Problème
Considérez cette configuration courante :
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
Maintenant, essayez de créer un pays avec des villes dupliquées :
country = Country.new(
name: "USA",
cities: [
City.new(language: "en-US", name: "New York"),
City.new(language: "en-US", name: "Los Angeles") # Même langue !
]
)
country.save! # Ça réussit alors que ça ne devrait pas !
Les deux villes sont sauvegardées, bien qu’ayant la même language au sein du même country_id. La validation d’unicité n’a pas détecté le doublon.
Pourquoi Cela Se Produit
Lors de la création d’enregistrements imbriqués via accepts_nested_attributes_for, Rails valide les enfants avant que l’enregistrement parent ne soit persisté. Au moment de la validation :
- Le parent (
Country) n’a pas encore d’ID - Le
country_idsur chaqueCityestnil - Le validateur d’unicité vérifie : “Y a-t-il une autre ville avec
language: 'en-US'ETcountry_id: nil?” - La requête en base de données ne trouve rien (il n’y a pas encore d’enregistrement validé)
- La validation passe pour les deux villes
La validation s’exécute en mémoire, mais la vérification d’unicité interroge la base de données où les enregistrements n’existent pas encore.
Solutions
Option 1 : Contrainte en base de données (recommandé)
Ajoutez un index unique au niveau de la base de données :
class AddUniquenessConstraintToCities < ActiveRecord::Migration[7.0]
def change
add_index :cities, [:country_id, :language], unique: true
end
end
Cela attrape les doublons au moment de l’insertion et lève ActiveRecord::RecordNotUnique. Vous voudrez gérer cette exception :
class CountriesController < ApplicationController
def create
@country = Country.new(country_params)
@country.save!
rescue ActiveRecord::RecordNotUnique
@country.errors.add(:cities, "contiennent des langues en doublon")
render :new, status: :unprocessable_entity
end
end
Option 2 : Validation personnalisée
Validez l’unicité au sein de la collection en mémoire :
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, "doivent avoir des langues uniques")
end
end
end
Option 3 : Valider sur l’association
Utilisez validates_associated avec un validateur personnalisé :
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, "est déjà pris")
end
end
end
Option 4 : Un hack qui fonctionne
Une solution de contournement de la discussion de l’issue GitHub utilise le proc if pour assigner la clé étrangère tôt :
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
Cela force l’assignation de country_id avant l’exécution de la validation. Ce n’est pas élégant, mais ça fonctionne.
Prévention
- Ajoutez toujours des contraintes uniques au niveau base de données pour les validations d’unicité, surtout celles avec scope
- Testez avec les attributs imbriqués : Écrivez des tests qui tentent de créer des enregistrements imbriqués en doublon
- Considérez utiliser
validates_withpour les validations complexes inter-enregistrements
Références
- Issue GitHub #20676 - Ouvert depuis 2015, plus de 76 commentaires
- Cela affecte Rails 4.x jusqu’à 7.x et probablement aussi 8.x