Exploiter des données avec HStore et Ruby on Rails

Publié le 21 août 2013 par Martin Catty | back

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

À l’heure (la mode) du Big Data, l’exploitation de données a le vent en poupe.

Aujourd’hui, quand on pense exploitation de données volumineuses et pas forcément structurées, on pense aussi généralement NoSQL.

Pourtant, des bases de données relationnelles comme PostgreSQL offrent des outils très intéressants tels que l’extension HStore.

Cerise sur le gâteau, notre framework préféré, Ruby on Rails, gère HStore via ActiveRecord depuis la version 4.

Présentation de HStore

À l’heure actuelle, pour stocker des données non structurées on pouvait utiliser le serialize de Ruby on Rails. Cette solution offre l’avantage d’être indépendante du SGBD.

De son côté HStore est une extension de PostgreSQL permettant de stocker des données non structurées sous la forme clé / valeur dans une colonne d’une table classique. Cette colonne est donc de type HStore.

Contrairement à un outil comme redis, ce store peut être interrogé en SQL. Il est par contre dépendant de PostgreSQL.

Usage du HStore

Voici notre cas d’usage: nous avons plusieurs fichiers CSV contenant des informations utilisateurs sur lesquelles nous aimerions réaliser quelques traitements.

Par exemple, nous voudrions faire une jointure de ces deux fichiers pour ne conserver que les lignes avec des emails communs.

D’un CSV à l’autre les données ne sont pas forcément structurées de la même façon (ordre des colonnes…).

Création de l’application

En partant de rails 4, générons une application et configurons la pour utiliser pg.

Cette application est disponible sur notre github.

HStore étant une extension, nous allons l’activer avec une migration.

bin/rails g migration enable_hstore

class SetupHstore < ActiveRecord::Migration
  def self.up
    enable_extension "hstore"
  end

  def self.down
    disable_extension "hstore"
  end
end

Nous allons ensuite créer un modèle représentant notre datastore.

Ce modèle ne comportera qu’une colonne, stockant le path de notre fichier CSV.

bin/rails g model datastore path:string

class CreateDatastores < ActiveRecord::Migration
  def change
    create_table :datastores do |t|
      t.string :path

      t.timestamps
    end
  end
end

Nous allons maintenant créer un modèle Line contenant les différents enregistrements, lié à un Datastore.

bin/rails g model line datastore:references data:hstore
class CreateLines < ActiveRecord::Migration
  def change
    create_table :lines do |t|
      t.references :datastore, index: true
      t.hstore :data

      t.timestamps
    end
  end
end

Notre modèle datastore possède une méthode de classe pour créer un datastore depuis un path.

require 'csv'

class Datastore < ActiveRecord::Base
  has_many :lines

  #
  # Load datastore from CSV file.
  # @param  path [String] path to the file. Must be related to rails root.
  #
  # @return [Datastore] return the loaded datastore.
  def self.load(path)
    app_path = Rails.root.join(path)
    if File.exists?(app_path)
      ary     = CSV.read(app_path)
      headers = ary.shift
      store   = Datastore.create(path: path)

      ary.each do |cols|
        datas = Hash[headers.zip(cols)]
        store.lines.create data: datas
      end

      store
    else
      false
    end
  end


  #
  # Return duplicated lines of the store, base on the key.
  # @param  store [Datastore] Datastore to compare with
  # @param  key [String] Key for comparing value
  #
  # @return [Array] Lines
  def duplicated_lines(store, key)
    self.lines.
      joins("join lines as l on l.datastore_id = #{store.id}").
      where("lines.data -> :key = l.data -> :key", key: key).
      all
  end
end

Il n’est pas recommandé de lire tout le fichier CSV en mémoire, ici je le fais parce que le nombre de lignes n’est pas très important.

Une fois lu, j’extrais les headers, puis j’itère sur les lignes que je zip avec ces headers.

Le zip va nous permettre de mixer les éléments deux à deux ; puis nous transformons le tableau résultant en hash pour le stocker dans le HStore.

À noter que le code contient 2 CSV dans le répertoire datas pour nos tests. Les tests unitaires sont dans test/models.

C’est la méthode duplicated_lines qui nous intéresse le plus car elle illustre ce que nous pouvons faire avec HStore.

Nous allons faire une jointure de la table ligne sur elle même en filtrant sur la clé.

Nous retournons le tout sous forme d’un tableau de lignes.

Hormis le requêtage légèrement différent, le reste est tout à fait classique.

Voici quelques exemples de requêtes possibles:

# nombre de lignes qui n'ont pas la clé email
Line.where.not("data ? :key", key: 'email').count

# ligne avec des emails en .us
Line.where("data -> :key LIKE :value", key: 'email', value: '%.us')

Le gros avantage du HStore est d’être à la fois souple et performant.

À l’inverse, les données sérialisées sont très lourdes à manipuler, et même si cela reste simple de les comparer en ruby, c’est bien plus lent que d’utiliser directement PostgreSQL pour cela.

Dernier point, et non des moindre, il est parfaitement possible d’indexer les champs de type HStore pour en accélérer le requêtage. Le gain n’est effectif que sur certains opérateurs.

Pour aller plus loin, je vous encourage à consulter l’excellente documentation en ligne de PostgreSQL.

L’équipe Synbioz.

Libres d’être ensemble.