Boire ou coder ... Pourquoi choisir?
Publié le 29 juin 2010 22:00

Rails : Modèles polymorphes

Supposons que vous soyez en train de développer un blog (du genre de celui-ci ;-) ). Vous avez en conséquent avoir des billets qui peuvent avoir des commentaires. Cependant ces commentaires, eux, peuvent être présent dans un billet ou bien dans une page. Afin de rendre notre code propre, nous ne désirons pas avoir plusieurs types de commentaires. Que vous postiez un commentaire sur une page ou sur un billet, il s'agit d'une entrée dans la même base de données et du même modèle.

Dans le modèle

Pour cela, notre modèle Commentaire sera lié à une page ou un billet de manière polymorphe.

class Comment < ActiveRecord::Base
    belongs_to :commentable, :polymorphic => true
end

class Post < ActiveRecord::Base
    has_many :comments, :as => :commentable
end

class Page < ActiveRecord::Base
    has_many :comments, :as => :commentable
end

Vous pouvez donc assigner un commentaire à un billet ou à une page (mais pas aux deux évidemment). Pour fonctionner, une relation polymorphe requiert tout de même que vous ajoutiez un champ "type" à la base comments.

Ainsi dans vos migrations :

class CreateComments < ActiveRecord::Migration
    def self.up
        create_table :comments do |t|
            t.integer  :commentable_id
            t.string    :type

            t.timestamps
        end
    end

    def self.down
        drop_table :comments
    end
end

Le type correspondra à Post ou Page et permettra à rails de savoir quel est le type de relation du commentaire. Ainsi, depuis votre commentaire, vous pouvez faire :

@comment.commentable

Ce qui retournera l'objet commentable. Une page ou un billet, en fonction du type donné.

Et depuis la page et le billet, vous pouvez faire :

@object.comments

Ce qui retournera tous les commentaires relatifs à cette page ou à ce billet.

Dans le contrôleur

Nos modèles gèrent assez aisément les relations polymorphes. Mais qu'en est-il de notre contrôleur ? Il n'existe pas de solution native. Mais en bidouillant un peu, nous pouvons obtenir quelque chose de propre, nous permettant d'avoir un seul contrôleur pour tous nos commentaires.

Dans le répertoire lib de notre application, créons un module que nous nommerons ParentObject. Votre fichier doit par conséquent être nommé parent_object.rb

module ParentObject

    def self.included(klass)
        klass.class_eval do
            class << self
                attr_reader :parents

                protected
                def parent_resources(*parents)
                    @parents = parents
                end
            end

            def parent_id(parent)
                params["#{parent.to_s}_id"]
            end

            def parent_type
                self.class.parents.detect { |parent| parent_id(parent) }
            end

            def parent_class
                parent_type && parent_type.to_s.classify.constantize
            end

            def parent_object
                parent_class && parent_class.find(parent_id(parent_type))
            end
        end
    end
end

Nous créons un module ruby que nous devrons par la suite inclure dans notre contrôleur. Pour les détails, je vous invite à vous renseigner sur ce en quoi consiste la méta programmation en ruby. Nous n'allons pas sortir du sujet ici.

class << self
    attr_reader :parents

    protected
    def parent_resources(*parents)
        @parents = parents
    end
end

Ici, nous définissons une méthode qui sera accessible à la base de notre contrôleur (en dehors des actions) et qui nous permettra de définir la liste de tous les objets qui pourront être parents de nos commentaires.

def parent_id(parent)
    params["#{parent.to_s}_id"]
end

Cette méthode, à laquelle nous transmettons un type de parent (par exemple "post"), nous retournera l'identifiant de l'élément passé en paramètre. Celui-ci doit être nommé "parent_id" (par exemple "post_id").

def parent_type
    self.class.parents.detect { |parent| parent_id(parent) }
end

Cette méthode parcourera tous les parents transmis et cherchera pour chacun d'eux si un paramètre y est présent. Dès qu'elle en trouvera un, elle retournera ce parent. Ainsi, nous savons si nous avons affaire à un post ou une page.

def parent_class
    parent_type && parent_type.to_s.classify.constantize
end

Cette méthode, en se basant sur la méthode précédente (qui retourne une chaine de caractère) nous retournera la classe de l'objet parent.

def parent_object
    parent_class && parent_class.find(parent_id(parent_type))
end

Enfin cette méthode prends la classe de l'objet parent et effectue la requête appropriée dans la base de données afin d'en retourner l'objet.

Nous n'avons plus qu'à utiliser ceci dans notre application !

class ApplicationController < ActionController::Base
    include ParentObject
end

Dans notre contrôleur principal, nous incluons notre module dernièrement créé, afin de pouvoir l'utiliser dans n'importe quel contrôleur.

class CommentsController < ApplicationController
    parent_resources :post, :page

     def new
         @parent = parent_object
         @comment = Comment.new
     end

     def create
         @parent = parent_object
         @comment = @parent.comments.build params[:comment]

         if @comment.valid? and @comment.save
             redirect_to @parent
         else
             render :new
         end
     end
end

Assez simple non ? :-) Que faisons-nous ?

parent_resources :post, :page

Nous définissons, pour ce contrôleur, la liste de tous les parents possibles. Puis dans les actions new et create nous récupérons l'objet parent et permettons de poster un nouveau commentaire relatif à cet objet.

Les routes

Lorsque nous créons nos routes relatives à ces commentaires, nous utilisons bien évidemment les ressources. Et c'est là le seul endroit ou nous avons un peu de répétition de code à faire.

resources :posts do
    resources :comments
end
resources :pages do
    resources :comments
end

Pour chaque post et chaque page, nous ajoutons des ressources de commentaires.

Ces routes fonctionneront avec rails3. Adaptez les en fonction pour une application rails 2 !

Tester ceci

Vu que j'ai utilisé ceci dans un cas réel avant d'écrire cet article, j'ai également écrit des tests. Vous pouvez visualiser et utiliser ceux-ci comme bon vous semble. Vous les trouverez sur gist@github

Et nos contrôleurs ?

Lorsque vous allez écrire des tests fonctionnels pour vos contrôleurs, vous allez vouloir tester chacun des différents types d'objets parents. Il est cependant inutile de répéter 50 fois votre floppée de tests. Cela ne serait pas très DRY non plus.

require 'spec_helper'
describe CommentsController do

    [:post, :page].each do |parent|

        before :each do
            @parent = Factory(parent)
        end

        describe 'new' do
            it 'should display the form page' do
                get :new, "#{parent}_id".to_sym => @parent.id
            end
        end

        describe 'create' do
            it 'should create a new comment' do
                lambda do
                    post :create,
                        :comment => Factory.attributes_for(:comment),
                        "#{parent}_id".to_sym => @parent.id
                end
            end
        end
    end
end

Que faisons-nous ici ?

[:post, :page].each do |parent|
    ...
end

Nous définissons un tableau contenant tous les types de parents que nous pouvons avoir pour ce contrôleur. Et nous parcourons ce tableau. Tous nos tests seront effectués à l'intérieur de la boucle. Ainsi ils seront effectués pour chacun des éléments parents.

before :each do
    @parent = Factory(parent)
end

Avant chaque test, nous définissons l'objet parent. Ici, nous utilisons factory girl et chaque modèle a sa factory du même nom.

Enfin nous créons nos tests.

get :new, "#{parent}_id".to_sym => @parent.id

Lorsque nous faisons un appel à une page, nous devons transmettre le paramètre qui correspond au parent donné.

Conclusion

Comme vous pouvez le constater ici, il est assez simple d'éviter d'inutiles répétitions de code lorsque vos commentaires sont tous similaires. Si ce n'était pas le cas, peut-être qu'utiliser Single Table Inheritance serait plus utile et modulaire.

Commentaires

jakikiller
jakikiller dit: 30 août 2010 17:18

Ca aurait été intéressant de montrer cela avec un :has_many, :through ;-)

Damien
Damien dit: 30 août 2010 18:11 Site web

En même temps ça fonctionne exactement de la manière qu'un modèle "normal" ...

Postez un commentaire

Markdown activé