Manipuler ses données avec dry-transformer

Publié le 16 décembre 2021 par Émilie Podczaszy | ruby

Cet article est publié sous licence CC BY-NC-SA

La manipulation de données est une tâche que nous faisons quotidiennement, renommer les paramètres d’une requête entrante dans un contrôleur, exporter des tableaux de données diverses en CSV, etc. La liste est longue et selon la complexité des structures à manipuler ou du résultat attendu, la tâche peut vite devenir ardue.

Après avoir entendu parler de la gem dry-transformer, j’ai décidé de la tester et contre toute attente, elle répond parfaitement à la problématique tout en étant simple d’utilisation. Seul point négatif, son manque de documentation et d’exemples, c’est pourquoi je vous propose de voir ensemble comment elle fonctionne et s’utilise.

Dry-transformer, qu’est-ce que c’est ?

Cette petite bibliothèque Ruby, qui fait partie de la collection des gems dry-rb, est inspirée de la programmation fonctionnelle : les données passent à travers plusieurs fonctions “stateless” qui chacune renvoie une nouvelle représentation des données d’origine à la fonction suivante. Elle propose donc une approche plus orientée donnée qu’orientée objet.

Il est aussi bon d’ajouter que la gem n’a aucune dépendance.

Exemple d’utilisation

Suite à un appel API, nous recevons cette liste de villes :

input = [
  {
    "nom" => "Lille",
    "surface" => 34.8,
    "population" => 232741,
    "coordonnées" => {
      "latitude" => 50.62925,
      "longitude" => 3.057256
    },
  },
  {
    "nom" => "Amiens",
    "surface" => 49.46,
    "population" => 132874,
    "coordonnées" => {
      "latitude" => 49.894067,
      "longitude" => 2.295753
    }
  },
  {
    "nom" => "Arras",
    "surface" => 11.63,
    "population" => 40721,
    "coordonnées" => {
      "latitude" => 50.291002,
      "longitude" => 2.777535
    }
  },

]

Et souhaitons travailler un peu ce tableau pour obtenir :

[
  {
    name: "LILLE",
    density: 6687.96,
    latitude: 50.62925,
    longitude: 3.057256
  },
  {
    name: "ARRAS",
    density: 3501.38,
    latitude: 50.291002,
    longitude: 2.777535
  },
  {
    name: "AMIENS",
    density: 2686.49,
    latitude: 49.894067,
    longitude: 2.295753
  },
]

On veut donc appliquer les modifications suivantes :

  1. transformer toutes les clefs, mêmes celles imbriquées, en symboles ;
  2. renommer la clef nom par name ;
  3. mettre en majuscule le nom des villes ;
  4. extraire la latitude et la longitude des coordonnées ;
  5. calculer la densité de population ;
  6. ne garder que les paires clef-valeur qui nous intéresse ;
  7. trier le tableau par densité.

Tout d’abord, nous allons utiliser le DSL de la gem pour retranscrire chacune de ces étapes :

class Mapper < Dry::Transformer::Pipe
  import Dry::Transformer::ArrayTransformations
  import Dry::Transformer::HashTransformations

  define! do
    map_array do
      deep_symbolize_keys                             # 1.
      rename_keys nom: :name                          # 2.
      map_value :name, -> value { value.upcase }      # 3.
      unwrap :coordonnees, %i[latitude longitude]     # 4.
      accept_keys %i[name density latitude longitude] # 6.
    end
  end
end

Importer les modules ArrayTransformations et HashTransformations mis à disposition par la gem permet d’appeler un certains nombres de fonctions de transformation. Pour notre exemple, presque tout est couvert, il ne reste que les étapes de calcul de la densité (5.) ainsi que le tri (7.).

Nous allons donc devoir créer notre propre module et pour cela rien de plus simple, il suffit d’étendre le module Registry et d’ajouter nos méthodes :

module CustomTransformations
  extend Dry::Transformer::Registry

  def self.add_key(hash, key, fn)
    hash.merge(key => fn[hash])
  end

  def self.sort_by(array, key)
    array.sort_by { |v| v[key] }
  end
end

La méthode :sort_by est somme toute assez explicite, alors voyons plus en détails :add_key. Cette méthode prend en entrée notre hash, la clef à ajouter ainsi qu’un Proc qui sera invoqué avec :[] comme il est d’usage dans cette gem. Le tout sera fusionné avec le hash en entrée. Cela suit le même mode de fonctionnement que map_value utilisé plus haut.

Intégré dans notre mapper, cela donne :

define! do
  map_array do
    add_key :density, -> city { city[:population] / city[:surface] }
  end
end

À noter qu’il y a d’autres manières d’utiliser un Proc :

compute_density = Proc.new { |city| city[:population] / city[:surface] }
add_key :density, compute_density

# ou encore

class DensityComputation
  def self.to_proc
    proc { |city| city[:population] / city[:surface] }
  end
end
add_key :density, DensityComputation.to_proc

Un calcul, aussi simple soit-il, semble être un bon candidat pour une abstraction supplémentaire alors, gardons la classe DensityComputation.

Nous avons toutes nos transformations, il ne reste plus qu’à les ajouter dans le mapper et voilà :

class Mapper < Dry::Transformer::Pipe
  import Dry::Transformer::ArrayTransformations
  import Dry::Transformer::HashTransformations
  import CustomTransformations

  define! do
    map_array do
      deep_symbolize_keys                             # 1.
      rename_keys nom: :name                          # 2.
      map_value :name, -> value { value.upcase }      # 3.
      unwrap :coordonnees, %i[latitude longitude]     # 4.
      add_key :density, DensityComputation.to_proc    # 5.
      accept_keys %i[name density latitude longitude] # 6.
    end
    sort_by :density                                  # 7.
  end
end

Mapper.new.call(input)
=> [
  { name: "AMIENS", density: 2686.49, latitude: 49.894067, longitude: 2.295753 },
  { name: "ARRAS",  density: 3501.38, latitude: 50.291002, longitude: 2.777535 },
  { name: "LILLE",  density: 6687.96, latitude: 50.62925,  longitude: 3.057256 },
]

Chaque type de transformation est encapsulé dans sa méthode, ce qui rend la composition de plusieurs transformations facile et grâce au DSL le tout se lit simplement.

Un dernier pour la route

Si vous n’êtes toujours pas convaincu·e, voici un autre exemple.

On vous demande d’apporter des modifications dans un projet existant, vous préférez tomber sur cette méthode ?

def group_categories(array)
  array.group_by{ |h| h[:name] }.values.map do |hs|
    hs.first.merge({ categories: hs.map{ |h| h[:category] } }).except(:category)
  end
end

Ou cette classe ?

class Mapper < Dry::Transformer::Pipe
  import Dry::Transformer::ArrayTransformations
  import Dry::Transformer::HashTransformations

  define! do
    group :categories, [:category]
    map_array do
      map_value :categories do
        extract_key :category
      end
    end
  end
end

Personnellement le choix est vite vu, dry-transformer rend le code bien plus compréhensible et maintenable.

Ressources


L’équipe Synbioz.
Libres d’être ensemble.