dev @ purchease

comment nous codons chez purchease

..

Les mutations avec rails

Publié par david le 12/02/2020 - rails

Intro

On commence un projet la fleur au fusil avec des modèles légers, leurs test unitaires et quelques méthodes qui nous permettent le le manipuler. Au fil du temps, on ajoute des méthodes pour régir le comportement du modèle, et puis d’autres et puis d’autres. On se réveille un jour avec des fichiers de 1000 lignes illisibles. Pour éviter cela, on peut étendre son modèle avec des Concerns dans rails, permettant de répartir du code attaché dans des fichiers différents touchant à des aspects différents du modèle. Mais pour les opérations plus compliquées, qui peuvent manipuler plusieurs modèles, il faut autre chose.

Mutations

Les mutations sont des classes rendues disponibles par la gem mutations. On l’ajoute au Gemfile et peut commencer à l’utiliser. Pour précision, les mutations sont une implementation ruby d’un design pattern de commande.

Utilisation par l’exemple

L’exemple

Disons qu’on a très envie d’écrire une commande permettant de récupérer l’évolution des montants dépensés sur des achats caractérisés par une expression régulière dans leur libellé.

Pour la suite on considérera le modèle suivant :

class Purchase < ApplicationRecord
    belongs_to :user_id
    belongs_to :line 

    attr_accessible :unit_price, :quantity, :user_id
class Line < ApplicationRecord
    belongs_to :denomination

    attr_accessible :value

Notre but : pour une expression régulière donnée, représenter l’évolution de la part dépenses des utilisateurs sur leurs achats dont le libellé match cette expression régulière.

Allons-y !

On commence facile, on déclare la classe. Attention, pas si facile que ca : c’est une commande, on va donc utiliser un verbe pour la nommer ! Le point d’entrée est la méthode execute, dont on fera l’implémentation plus tard

class RetrieveSpendingsByMonth < Mutations::command
    def execute
    end
end

Ensuite, on aime bien les mutations parce qu’elle nous permettent de définir les arguments et d’assurer qu’ils sont valides et fournis lors de l’appel : dans notre cas, on a envie d’une regexp, d’une date de début et d’une date de fin.

class RetrieveSpendingsByMonth < Mutations::command
    required do 
        string :reg_exp_as_string
        string :start_date_as_string
        string :end_date_as_string
    end
end

Note : la gem supporte de passer des types plus élaborés, voire des modèles activeRecord. Cependant, comme on le verra plus bas, on peut avoir envie d’utiliser ces mutations de manière asynchrone. Comme on n’a pas envie de prendre de risque au moment de la serialisation, on va utiliser des string et le faire savoir grâce au nommage

Des methodes explicites… avec memoization

Alors, ce qui suit n’est pas inhérent aux mutations, mais d’une part c’est une manière élégante de coder les choses et, comme on le verra dans la partie debug, ca va nous être très utile pour repérer les boulettes.

Comme on a utilisé des strings, on a envie d’avoir les ‘vrais objets’, visibles dans toute la mutation :


  def start_date
    @start_date ||= start_date_as_string.to_date
  end

  def end_date
    @end_date ||= end_date_as_string.to_date
  end

On pourra alors joyeusement utiliser les méthodes reg_ex / start_date / end_date à notre gré dans les méthodes de notre mutation. Le code de traduction ne sera executé qu’une fois !

Les requêtes

On va chercher à récuperer l’ensemble des dépenses sur les libellés selectionnés, et on voudra normaliser par la somme des achats enregistrés sur la même période :

   def spendings_by_month
     @spendings_by_user_id ||= Purchase.includes(:receipt).joins([:receipt, :line]).where("receipts.created_at between ? and ?", start_date, end_date).where('lines.denomination_id in (?)', denom_ids).each_with_object(Hash.new(0)) do |purchase, h|
       h[purchase.bought_at.strftime('%Y-%m')] += purchase.unit_price_value * purchase.quantity
    end
  end

  def denom_ids
    @denom_ids ||= Denomination.where('value ~* ?', regular_expression).pluck(:id)
  end


  def total_recorded_by_user_id
    @total_recorded_by_user_id ||= Receipt.where(' created_at between ? and ? ', start_date, end_date).select('total, created_at').each_with_object(Hash.new(0)) do |rcpt, h|
      h[rcpt.created_at.strftime('%Y-%m')] += (rcpt.total || 0).to_f
    end
  end

L’execution

On fait le minumum dans la méthode principale pour être lisible :

  def execute
    share = {}
    total_recorded_by_user_id.each do |m, spent|
      share[m] = spendings_by_month[m] / spent
    end
    share
  end

Et on lance :

RetrieveSpendingsByMonth.run! start_date: '2018-06-01', end_date: '2019-06-01', reg_exp_as_string: '\\mGILE?T?\\M.*(REFL|ORA|JAU|SECU)'

Au passage, qu’apprend-on ? Que les ventes de gilets jaunes ont curieusement augmenté en novemebre / décembre 2018 !

gilets_evo

Icing on the cake : Debug

Mainteant, pourquoi cet effort de design paie-t-il ? Grâce à la memoization et au pattern de commande isolé dans une classe, je peux très facilement tester de manière unitaire mon code : ( ici avec pry , mais irb fonctionnerait aussi )

pry RetrieveSpendingsByMonth.new start_date: '2018-06-01', end_date: '2019-06-01', reg_exp_as_string:  '\\mGILE?T?\\M.*(REFL|ORA|JAU|SECU)'
>

Je suis dans le contexte de ma mutation, je peux appeler mes méthodes :

pry RetrieveSpendingsByMonth.new start_date: '2018-06-01', end_date: '2019-06-01', reg_exp_as_string: '\\mGILE?T?\\M.*(REFL|ORA|JAU|SECU)'
> start_date

> spendings_by_month

Inutile de jouer des initialisations, le pattern de memoization initialize ce dont j’ai besoin lors du premier appel.

Cherry on the iced cake : AsyncMutation

Parfois ces commandes peuvent être des calculs et des requêtes couteuses qu’on a envie d’exécuter en background (sur un job asynchrone).

class MutationsAsyncCommand < Mutations::Command
  @queue = :asynctasks

    def run_async!(*args)
      validate_yield!(*args) { Resque.enqueue(self, *args) }
    end

    def self.perform(*args)
        run!(*args)
    end
    private
    def validate_yield!(*args)
      validation_outcome = validate(*args)
      if validation_outcome.success?
        yield
      else
        raise Mutations::ValidationException, validation_outcome.errors
      end
    end

end

avec la légère modification suivante :

class RetrieveSpendingsByMonth < MutationsAsyncCommand
 #
end