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 :

  1. Le parent (Country) n’a pas encore d’ID
  2. Le country_id sur chaque City est nil
  3. Le validateur d’unicité vérifie : “Y a-t-il une autre ville avec language: 'en-US' ET country_id: nil ?”
  4. La requête en base de données ne trouve rien (il n’y a pas encore d’enregistrement validé)
  5. 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

  1. Ajoutez toujours des contraintes uniques au niveau base de données pour les validations d’unicité, surtout celles avec scope
  2. Testez avec les attributs imbriqués : Écrivez des tests qui tentent de créer des enregistrements imbriqués en doublon
  3. Considérez utiliser validates_with pour 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