Meilleures validations contextuelles avec ActiveRecord

Younes Serraj
Younes Serraj25 nov. 2019

Meilleures validations contextuelles avec ActiveRecord

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.

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.

context/state -specific validations

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

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!

Partager
Younes Serraj
Younes Serraj25 nov. 2019

Blog de Capsens

Capsens est une agence spécialisée dans le développement de solutions fintech. Nous aimons les startups, la méthodologie scrum, le Ruby et le React.

Ruby Biscuit

La newsletter française des développeurs Ruby on Rails.
Retrouve du contenu similaire gratuitement tous les mois dans ta boîte mail !
S'inscrire