Nous avons souvent besoin d'une validation contextuelle dans les modèles ActiveRecord et nous ne trouvons pas de moyen idéal pour le faire. Cet article présente une solution élégante, légère et sans dépendance.
Tout d'abord, gardons à l'esprit deux études de cas qui illustrent parfaitement cette problématique :
Machines à états : vous devez exécuter différentes validations en fonction de l'état actuel de l'enregistrement.
Achèvement progressif : on ne peut remplir le formulaire à l'étape n que si toutes les étapes précédentes sont achevées.
Quels sont les outils fournis par ActiveRecord pour faire face à ce problème ?
Créez un STI et modifiez le type de votre enregistrement.
— Avantage : vous pouvez exécuter des validations "toujours requises" et des validations "spécifiques au type".
— Inconvénient : vous ne pouvez pas exécuter des validations "spécifiques au type" pour plusieurs types en même temps, vous devriez muter votre objet pour chaque type et fusionner les erreurs.
Utiliser contextes.
— Avantage : vous pouvez exécuter des validations "toujours requises" et des validations "spécifiques au type".
— Inconvénient : vos modèles restent désordonnés et lourds et vous devez appeler valid? pour chaque contexte à valider.
Vous pouvez également utiliser d'autres outils tels que la validation sèche.
— Avantages : outil puissant.
— Inconvénient : ajouter encore une autre dépendance, apprendre encore un autre outil. Vous sortez également d'ActiveRecord et perdez de nombreux avantages (toutes les erreurs dans record.errors, i18n lazy lookup, ...). C'est un excellent outil mais plutôt pour des cas complexes.
Rien de ce qui précède ne correspond à mes attentes : une solution modulaire, légère, qui ne nécessite aucune dépendance supplémentaire et qui repose uniquement sur ActiveModel::Validations.
Construisez un objet avec uniquement le comportement requis pour le contexte en question.
Autrement dit : au lieu d'avoir un modèle avec toutes les validations et de n'exécuter que celles qui sont nécessaires, ayez un modèle avec seulement les validations toujours nécessaires et étendez-le avec des validations spécifiques au contexte/état.
Avantages:
Aucune dépendance supplémentaire
Entièrement modulaire
Pas de DSL supplémentaire à apprendre
Allège vos modèles
Inconvénients:
Je suis toujours à la recherche d'un.
Exemple de machine à états
Supposons que nous ayons un modèle Article
avec un title
, un subtitle
, un content
et un state
(valeurs : %i[draft published]
).
Bien que l'article soit un draft
, il suffit qu'un titre ou un sous-titre soit présent.
Pour être
published
, l'article doit avoir un titre, un sous-titre et un contenu présent. Le titre doit également être unique parmi les articles publiés.
app/models/article.rb
class Article < ApplicationRecord
validates :state, inclusion: { in: %i[draft published] }
end
app/validations/articles/draft_validation.rb
module Articles::DraftValidation
def self.extended(obj)
obj.class_eval do
validates :title, presence: true, unless: ->{ subtitle.present? }
end
end
end
app/validations/articles/published_validation.rb
module Articles::PublishedValidation
def self.extended(obj)
obj.class_eval do
validates :title, presence: true, uniqueness: { conditions: ->{ where(status: 'published') } }
validates :subtitle, presence: true
validates :content, presence: true
end
end
end
Maintenant, voici comment valider un article en fonction de son état :
class ArticlesController < ApplicationController
def update
@article.assign_attributes(article_params)
case @article.state
when :draft
@article.extend(Articles::DraftValidation)
when :published
@article.extend(Articles::PublishedValidation)
end
if @article.save
redirect_to @article, notice: 'Article was successfully updated.'
else
render :edit
end
end
end
Il s'agit d'une manière simplifiée à l'extrême, mais fonctionnelle, de construire un article avec juste les bonnes validations pour son état actuel.
Exemple d'achèvement progressif
Supposons que nous ayons un modèle de Profile
et que, pour des raisons d'ergonomie, nous ayons décidé de diviser le processus d'enregistrement en 4 étapes :
Informations personnelles (nom de famille, prénom, date de naissance, ...)
Situation familiale (situation matrimoniale, nombre d'enfants, ...)
Attentes professionnelles (pays, salaire attendu, ...)
Situation financière (salaire actuel, montant de la dette actuelle, ...)
Puisque j'ai démontré précédemment comment écrire des modules qui contiennent des validations spécifiques au contexte et à l'état, nous supposerons que les 4 modules suivants existent :
Profiles::PersonalInformationValidation
Profiles::FamilySituationValidation
Profiles::WorkExpectationsValidation
Profiles::FinancialSituationValidation
Pour pouvoir remplir le formulaire à l'étape N, il faut d'abord que toutes les étapes précédentes soient correctement remplies.
Voici un exemple de la manière dont on pourrait construire un remplissage progressif de formulaire :
class ProfilesController < ApplicationController
STEPS = %w[personal_information family_situation work_expectations financial_situation].freeze
def update
@profile = Profile.find(params[:id])
previous_incomplete_step = find_previous_incomplete_step
if previous_incomplete_step
redirect_to edit_profiles_controller(id: @profile.id, current_step: previous_incomplete_step), notice: "Please complete this form first"
return
end
@profile.assign_attributes(profile_params)
@profile.extend(step_validation_module(current_step))
if @profile.save
redirect_to_next_step
else
render :edit
end
end
private
def current_step
params[:current_step]
end
def redirect_to_next_step
if current_step == STEPS.last
redirect_to root_path, notice: "Profile successfully completed!"
else
next_step = STEPS[STEPS.find_index(current_step) + 1]
edit_profiles_controller(id: @profile.id, current_step: next_step)
end
end
def find_previous_incomplete_step
STEPS.each do |step|
break if step == current_step
@profile.extend(step_validation_module(step))
return step unless @profile.valid?
end
nil
end
def step_validation_module(step)
"Profiles::#{step.to_s.camelize}Validation".constantize
end
end
C'est à dessein que je n'écris pas toutes les méthodes et contrôles de sécurité nécessaires dans cet exemple de code, afin de le rendre aussi facile à lire que possible. Le but ici est évidemment d'illustrer une technique, et non d'écrire du code prêt à la production.
Une dernière chose à propos de cette approche : vous pouvez composer une validation faite d'autres validations.
Supposons que vous ayez besoin, à plusieurs endroits, de valider que le profil est complet. Allez-vous continuer à étendre votre enregistrement avec les validations des 4 étapes distinctes à chaque fois ? Non, vous écrirez simplement un autre module de validation qui sera lui-même composé des validations des 4 étapes. En tant que tel :
module Profiles::FullValidation
def self.extended(obj)
obj.class_eval do
extend Profiles::PersonalInformationValidation
extend Profiles::FamilySituationValidation
extend Profiles::WorkExpectationsValidation
extend Profiles::FinancialSituationValidation
end
end
end
Merci d'avoir lu!