Les contextes de validation dans Rails

Les validateurs Rails sont expressifs et puissants, tout en étant faciles à utiliser et à étendre. Une fonctionnalité intéressante est que les validateurs peuvent s’exécuter uniquement lorsqu’une condition est remplie (en utilisant if: et unless:), ou dans des contextes sélectionnés, en utilisant on:. Mais saviez-vous que vous pouvez également définir des contextes personnalisés ? Voici comment le faire, et pourquoi cela pourrait être utile.

Tags : Ruby on Rails Validation

Publié le 26 août 2024

Les contextes de validation de base

En plus des conditions if: et unless:, Rails permet de limiter des validations à certains contextes seulement :

  1. on: :create - Validations qui ne s’exécutent qu’à la création d’un modèle. Équivalent à : if: :new_record?.
  2. on: :update - Validations qui ne s’exécutent qu’à la mise à jour d’un objet existant. Équivalent à : if: :persisted?.

Ces contextes peuvent être spécifiés dans les modèles comme ceci :

class User < ApplicationRecord
  validates :email, presence: true, on: :create
  validates :age, numericality: { greater_than: 18 }, on: :update
end

Dans cet exemple, la présence de l’email n’est vérifiée que lors de la création d’un nouvel utilisateur, tandis que la validation de l’âge ne s’applique que lors de la mise à jour d’un utilisateur. Les validations qui ne précisent pas l’option :on s’exécuteront toujours, quel que soit le contexte.

Les contextes de validation personnalisés

Au-delà des contextes intégrés, Rails permet de définir des contextes personnalisés pour des scénarios plus spécifiques :

class Article < ApplicationRecord
  validates :title, presence: true, on: :publish

  def publish
    return false unless valid?(:publish)
    # logique de publication
  end
end

Ici, le contexte personnalisé :publish est utilisé pour déclencher des validations qui doivent s’exécuter uniquement avant qu’un article ne soit publié. Si les vérifications nécessitent d’interroger la base de données par exemple, il est judicieux de ne les effectuer que lorsque c’est nécessaire.

Vous pouvez même définir plusieurs contextes en passant un tableau de symboles : on: [:create, :publish].

Attention : le contexte de validation est passé aux modèles associés

Une fonctionnalité non documentée est que lorsque vous utilisez des associations has_one ou has_many, le contexte de validation parent est automatiquement transmis aux modèles associés. Soyez conscient que ces modèles pourraient donc recevoir un contexte que vous ne vous attendiez pas à recevoir !

Dans le cas suivant :

class Author < ApplicationRecord
  has_many :books
  validates :name, presence: true, on: :publish
end

class Book < ApplicationRecord
  belongs_to :author
  validates :title, presence: true, on: :create
end

author = Author.new(books_attributes: [{}])
author.valid?(:publish)

Dans cette situation, l’appel valid?(:publish) sur l’auteur déclencher les validations de tous les livres associés, mais uniquement celles qui sont associées au contexte :publish, ou qui n’ont pas d’option :on. Ici, la validation sur le titre ne s’effectuera pas puisqu’elle est limitée au contexte :create. Attention aussi, il est possible de déclencher des boucles de validation infinies !

Bonnes pratiques autour des validations Rails

Valider au niveau du modèle

Il est préférable de contrôler la validité au niveau des modèles plutôt que dans les contrôleurs (skinny controllers, fat models). Cela permet notamment de s’assurer que les modèles associés effectuent les vérifications requises.

Éviter ou limiter les appels à la base de données

Pour éviter les N+1, essayez d’éviter les validations qui doivent communiquer avec la base de données, comme la vérification de la présence d’un modèle associé.

Dans l’une de mes applications par exemple, j’ai remarqué que Rails interrogeait la base de données pour vérifier l’unicité de l’email chaque fois que l’utilisateur était mis à jour, même si l’email n’avait pas changé. J’ai pu supprimer cette requête inutile en ajoutant on: :create puisque cette application ne permettait pas aux utilisateurs de changer leur email.

Dans un autre modèle, la validation d’unicité s’exécutait chaque fois que l’enregistrement était sauvegardé, même si l’attribut n’avait pas été modifié. Je l’ai modifié pour qu’il ne se produise que lorsque c’est nécessaire, en utilisant les dirty attributes. C’est maintenant devenu une habitude de vérifier l’unicité d’un attribut uniquement si l’attribut a changé.

validates_uniqueness_of :name, if: :name_changed?

Limiter la visibilité des méthodes de validation

Les méthodes de validation spécifiques (qui sont appelées via validate au lieu de validates) ont rarement besoin de faire partie de l’API publique d’un modèle, et devraient donc être privées. Il arrive néanmoins qu’on ait besoin de les appeler de l’extérieur, pour tester une logique métier importante, ou pour afficher conditionnellement un message par exemple. Cela devrait être évité mais il arrive qu’on soit obligé de les appeler de l’extérieur, soit parce que le code ne peut pas être modifié, ou lorsque l’ajout d’un présentateur serait excessif.

Envisager l’utilisation de POROs et de Form Objects

Les formulaires qui créent ou mettent à jour plusieurs modèles, ou associés à une logique de validation complexe sont souvent de bons candidats pour l’utilisation du pattern Form Object. N’oubliez pas que les POROs, y compris les objets de formulaire, peuvent inclure ActiveModel::Attributes et ActiveModel::Validations pour réutiliser l’API de validation de Rails. C’est une autre technique pour extraire les validations spécifiques à un seul contexte des définitions de modèles, ce qui rend le code beaucoup plus facile à lire.

Ne pas s’appuyer uniquement sur les validations

La validation par le navigateur (attribut required des balises input) est facile à contourner et ne devrait être utilisée que pour empêcher les utilisateurs de perdre du temps en envoyant des formulaires invalides.

De plus, un modèle peut avoir besoin d’être importé à partir d’un fichier (CSV par exemple), il est dans ce cas fréquent d’utiliser insert_all pour minimiser la pression sur la base de données. Il est donc important que la cohérence soit appliquée au niveau de la base de données, en utilisant des colonnes non nulles, des clés étrangères et des index uniques. La gemme Database Consistency peut être utilisée pour vérifier les divergences entre les validations du modèle et les contraintes de la base de données.

Enfin, je vous renvoie sur l’article de Thoughtbot qui compare validation, contraintes de base de données, ou les deux (en anglais) pour connaître les avantages et les inconvénients des deux options.