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.
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
Ca aurait été intéressant de montrer cela avec un :has_many, :through ;-)
En même temps ça fonctionne exactement de la manière qu'un modèle "normal" ...