Un concern réutilisable pour filtrer les modèles dans Ruby on Rails

Je voulais implémenter un système de filtrage générique pour des modèles dans Ruby on Rails, en procédant un peu différemment de ce que Justin Weiss et Fabio Pitino proposent.

Tags : Ruby on Rails Concerns

Publié le 28 juin 2022

Justin Weiss a jeté les bases d’un concern en 2016 dans son article Search and Filter Rails Models Without Bloating Your Controller (Chercher et filtrer parmi des modèles Rails sans alourdir les contrôleurs).

Fabio Pitino a poussé la technique plus loin grâce à une nouvelle macro nommée search_scope dans Enhanced Filterable concern for Rails models. Le nom indique clairement quels scopes sont utilisés pour le filtrage, et simplifie la gestion des paramètres autorisés.

Cependant, le code de Fabio ne fonctionne plus avec Rails 6 (la macro search_scope n’est pas initialisée à temps), et le nom ne refléte pas vraiment ce qui se passe.

Voici ce que j’ai fini par mettre en place, en m’appuyant sur le travail de Justin et Fabio.

  1. Ajuster le nom et la syntaxe du concern pour que ce soit plus explicite :

    # app/models/concerns/filterable.rb
    module Filterable
      extend ActiveSupport::Concern
    
      class_methods do
        attr_reader :filter_scopes
    
        def filter_scope(name, *args)
          scope name, *args
          @filter_scopes ||= []
          @filter_scopes << name
        end
    
        def filter_by(params)
          filtered = all
          params.permit(*filter_scopes).each do |key, value|
            filtered = filtered.public_send(key, value) if value.present?
          end
          filtered
        end
      end
    end
    
  2. Inclure ce concern dans tous les modèles héritant d’ApplicationRecord :

    # app/models/application_record.rb
    class ApplicationRecord
      include Filterable
    end
    
  3. Ajouter la macro filter_scope dans tous les modèles qui doivent être filtrés :

    # app/models/widget.rb
    class Widget < ApplicationRecord
      filter_scope :foo, { where(foo: "bar") }
    end
    
  4. Appliquer le filtre dans le contrôleur :

    # app/controllers/widgets_controller.rb
    class WidgetsController < ApplicationRecord
      def index
        @widgets = Widget.filter_by(params)
      end
    end
    

Je ne suis toujours pas entièrement satisfait de cette solution car elle semble un peu grossière.

Dans la même veine, il existe la gemme has_scope de HeartCombo mais je trouve dommage qu’il faille autoriser les scope dans chaque contrôleur, même si c’est plus sécurisé.

Je vois plusieurs améliorations possibles :

  • Automatiser le filtrage avec un before_action dans les contrôleurs, comme le fait CanCanCan avec load_and_authorize_resource.
  • Créer un FormObject que form_for pourrait utiliser pour présélectionner les valeurs du formulaire à partir de la requête actuelle.
  • Renforcer la sécurité, ce que has_scope fait de manière plus préventive, pour empêcher les injections SQL.
  • Simplifier les requêtes sur les scopes booléens et ceux qui prennent plus d’un paramètre (date de début/fin par exemple).