Jointures multiples dans ActiveRecord grâce à include

Publié le 22/11/07 par Jean-Baptiste Escoyez | 3 commentaires

ActiveRecord facilite énormément la manipulation des relations entre les objets. Cependant, lorsque ces relations se complexifient, il n’est pas toujours évident de récupérer les données qui nous intéressent.

L’argument :include de la méthode find est souvent utilisé pour réduire le nombre de requêtes SQL à effectuer. Nous allons voir qu’en l’associant avec l’argument :conditions, elle permet également d’effectuer des requêtes complexes.

Rails est génial. Mais quand les choses se compliquent, ses détracteurs disent que le framework est limité. J’y ai cru un instant lorsque j’ai rencontré le problème que je vous décris ci-dessous. J’ai finalement constaté que Rails était génial même dans les situations compliquées.

Pour exposer le problème, rien de tel qu’un petit exemple:

L’application qui me pose problème est destinée à gérer un ensemble de bibliothèques (Library) contentant plusieurs livres (Book) qui eux-même ont un ou plusieurs auteurs (Author). Un auteur peut bien entendu avoir écrit plusieurs livres et un livre peut se trouver dans plusieurs bibliothèques (le lecteur attentif aura compris que nous ne parlons pas de l’exemplaire du livre mais bien de son édition).

Pour mettre en avant la cardinalité multiple, nous allons utiliser has_many :through. Nous créons donc deux tables de jointure : “propriété” (Ownership) pour joindre bibliothèque et livre et “autorat” (Authorship) pour joindre livre et auteur.

Le modèle est le suivant :


class Library < ActiveRecord::Base
  has_many :ownerships
  has_many :books, :through => :ownerships
end

class Book < ActiveRecord::Base
  has_many :ownerships
  has_many :libraries, :through => :ownerships

  has_many :authorships
  has_many :authors, :through => :authorships
end

class Author < ActiveRecord::Base
  has_many :authorships
  has_many :books, :through => :authorships
end

Et les tables de jointure:


class Ownership < ActiveRecord::Base
  belongs_to :library
  belongs_to :book
end

class Authorship < ActiveRecord::Base
  belongs_to :book
  belongs_to :author
end

Dans notre exemple, nous voulons simplement récupérer la liste de tous les auteurs d’une bibliothèque.


class Library < ActiveRecord::Base
  has_many :ownerships
  has_many :books, :through => :ownerships

  def find_all_authors
    books.inject([]){|authors,book| authors + book.authors}.uniq
  end
end

L’opération que nous faisons ici est potentiellement très lourde. En effet, elle va générer autant de requêtes SQL qu’il y a de livres dans la bibliothèque (lors de l’appel à book.authors).

En SQL, nous aurions fait une jointure multiple pour récupérer l’information recherchée. Allons-nous devoir utiliser find_by_sql? Lorsque je me suis retrouvé confronté à ce problème, j’ai bien pensé que j’allais devoir y passer. Mais, comme je le disais, Rails est génial.

L’option Activerecord utilisée ici est :include. D’habitude, :include est utilisée pour précharger les objets associé à un objet donné. Ce que réalise cette option est en réalité une jointure externe à droite. Il ne reste alors plus qu’à poser les bonnes :conditions pour obtenir le résultat désiré.


class Library < ActiveRecord::Base
  has_many :ownerships
  has_many :books, :through => :ownerships

  def find_all_authors
    Author.find(:all, :include => [:books => :libraries], 
                      :conditions => ["libraries.id = ?", self.id])
  end
end

Plusieurs remarques peuvent être faites à propos de ce exemple:

  • Nous utilisons la version imbriquée de include. En effet, la table Author sur laquelle nous faisons la requête ne contient aucune référence vers libraries.
  • Nous n’avons pas besoin de mentionner les classes d’associations (ownership et authorship) dans include, elle seront automatiquement ajoutées par rails.
  • Dans la condition, "libraries" est écrit au pluriel pour désigner le nom de la table.
  • Les imbrications du include peuvent être réalisées autant de fois que désiré et peuvent être combinées. Cependant, bien que cette technique permette de réaliser un nombre important de requêtes en une seule, il ne faut pas oublier que l’opération JOIN en SQL est lourde et qu’il faut donc essayer de l’éviter tant que possible.

Pour cloturer ce billet, je préciserai que je n’ai trouvé que très peu d’informations sur :include tant au niveau de la communauté anglophone que francophone. Ai-je raté un point important? Y a-t-il une solution plus élégante? Et vous, comment faites-vous?

3 Commentaires

Commentaire posté par AlSquire le 11/12/07

En fait, ce procédé porte un nom : le “eager loading”. Un p’tit coup de google sur ces deux mots + rails permet de satisfaire un peu sa curiosité sur le sujet je pense… mais en anglais. On parle même de “cascading eager loading” lorsque l’on demande des associations non directement liées au modèle qui appelle le find, comme dans l’exemple au dessus ( :include => [:books => :libraries] ).

Lorsque le eager loading est utilisé, il faut bien penser à préciser ses noms de tables dans les conditions pour éviter que la requête ne plante sur un nom de champ ambigu, c’est à dire un champ présent dans plusieurs tables, comme par exemple id évidemment, mais aussi created_at ou updated_at. D’ailleurs ce n’est pas une mauvaise idée de préciser la table même sans utiliser :include, pour éviter d’avoir des surprises si on en rajoute un plus tard (que ce soit dans le code du find, ou avec un with_scope par exemple).

Par contre je préciserais la syntaxe à utiliser. [:book => :libraries] est un tableau contenant en fait un hash où les accolades sont optionnelles car la syntaxe ruby le permet dans ce cas. Ce qu’il faut retenir c’est que le hash permet d’aller à un niveau plus profond dans les associations (en cascading donc) avec la key du hash étant le parent, alors qu’un array reste au même niveau. On peut donc se retrouver avec quelque chose comme :include => [:a, :b, { :c => :c_a }, { :d => [:d_a, :d_b, { :d_c => :d_c_a }] }, :e].

Autre précision un peu plus pointue, mais vu que :through est utilisé dans les exemples je me lance : le :include peut faire planter la requête si on y demande le modèle de jointure ayant servi au :through, car la requête va taper deux fois dans la même table et cafouille dans les alias à donner à ses tables dans ce cas (en tout cas dans ma version 1.2.3 … je sais je suis pas à jour :p d’ailleurs quelqu’un peut-il me dire si ça reste vrai dans les versions ultérieures ? ). Le has_and_belongs_to_many aurait le même problême.

Je pense aussi que has_many :authors, :through => :books est possible dans Library, ce qui éviterait de préciser l’id dans les conditions (utiliser un id dans une condition peut souvent être remplacé par une utilisation des associations), mais faire library.authors.find(:all, :include => [{ :books => :libraries }]) et on se retrouve dans le bug que j’évoque juste au dessus à priori.

Il est très instructif de regarder dans les logs les requêtes SQL faites par ActiveRecord avec l’utilisation du :include, notamment en conjonction de :limit. Personnellement j’y jette souvent un œil quand je demande à AR des choses un peu éxotique, et le eager loading est je crois la feature qui a achevé de me convaincre de passer à rails.

Commentaire posté par AlSquire le 11/12/07

Oh bah ! Je viens de le voir, la doc officielle vient d’être bien complétée sur le sujet, sûrement avec la sortie de Rails 2.0 :)

“Eager loading of associations” dans http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html

Commentaire posté par Jean-Baptiste Escoyez le 12/12/07

Merci AlSquire pour ton commentaire qui précise pas mal de choses importantes. Cependant, j’ai remarqué qu’il n’est pas possible de faire has_many :authors, :through => :books dans Library.

C’est d’ailleurs, l’objet du présent article : “Comment faire pour accéder à un modèle distant (en terme de relations)”.

Depuis que j’ai écrit cet article, j’ai trouvé une solution qui permet de faire cela : le plugin nested_has_many_through. Cette fonctionnalité suscite un débat au sein de la core team de rails car il permettrait de faire très simplement des requêtes SQL très couteuses (du style : :include => [:a, :b, { :c => :c_a }, { :d => [:d_a, :d_b, { :d_c => :d_c_a }] }, :e] :) ).

Enfin, pour ma part, je n’ai pas de problème lorsque je fais un :include sur la table du :through (par exemple : library.books.find(:all, :include => [:ownerships])).

Ajouter un commentaire

Vous devez être identifié pour poster un commentaire. Identifiez-vous, ou inscrivez-vous si ce n'est déjà fait.