Mise en place du counterCache : un piège à éviter

Le counterCache est une fonctionnalité très intéressante pour économiser des requêtes dans une relation belongsTo entre deux modèles. Le principe est simple : il suffit d’ajouter une colonne dans la table parente pour enregistrer le nombre d’enfants. Dans le cadre d’un simple blog avec des Posts et des Commentaires, cela permet d’afficher le nombre de Commentaires d’un Post sans avoir à les compter, puisque le compte se fait automatiquement dans une colonne de la table parent, Post, à chaque ajout d’un enfant, un Commentaire.

ATTENTION : cet article n’est plus à jour. La méthode que nous décrivons ici a été implémentée dans le cœur de CakePHP le 17 déc. 2008, voir le ticket 5596

Mise en place

Voyons comment mettre en place le counterCache dans notre exemple : commençons par ajouter la colonne qui va accueillir le nombre de Commentaires dans la table Post. Un des principes de base de CakePHP étant « la convention au lieu de la configuration », nous nommons cette colonne commentcount (nom du modèle au singulier, suivi de count), de type entier. Si nous choisissons un autre nom, il faudra le définir explicitement dans le Modèle Comment.

Voyons ensuite la définition du CounterCache dans le Modèle Comment :

// {app}/models/comment.php
class Comment extends AppModel
{
	var $belongsTo = array('Post' => array(
		'counterCache' => true,
		'counterScope' => array('published' => 1)
	));
}

Nous activons donc le counterCache en ajoutant la clé 'counterCache' => true dans la définition de la relation belongsTo, et nous ajoutons une condition qui doit être remplie pour qu’un enregistrement enfant soit effectivement compté, à savoir que le Commentaire soit validé. Notons que si nous n’avions pas nommé la colonne de compte comme Cake l’attend, nous aurions du la passer dans la clé 'counterCache' à la place de 'true'.

C’est tout ce qu’il y a à faire, Cake se charge du reste, et nous constatons qu’à chaque validation d’un commentaire, le compteur de la table posts est mis à jour.

Un petit piège…

Tout se passe donc comme prévu. Mais étendons un peu notre petite application et ajoutons une gestion de Catégories de Posts, là aussi avec une relation Post belongsTo Category avec un counterCache dans la table des Catégories (nom de colonne attendu : post_count), pour afficher rapidement le nombre de Posts attachés à une Catégorie.

// {app}/models/post.php
class Post extends AppModel
{
	var $belongsTo = array('Category' => array(
		'counterCache' => true,
		'counterScope' => array('published' => 1)
	));
}

Comme tout à l’heure, cette simple déclaration suffit à mettre en place le counterCache, et nous voyons bien qu’à chaque publication d’un Post, la table des Catégories est mise à jour avec le bon nombre de Posts.

Mais que se passe-t-il lorsque nous éditons un Post existant et le changeons de Catégorie ? C’est un cas que nous n’avions pas envisagé dans la première partie, un Commentaire ne pouvant se rapporter qu’à un seul Post, sans possibilité d’en changer. Et bien là, le compteur de la Catégorie nouvellement reliée au Post édité est bien mis à jour, mais la Catégorie à laquelle était jusqu’ici attaché le Post n’est pas mise à jour ! Au bout de quelques éditions de Post, les compteurs ne sont plus d’aucune utilité puisque tous faux.

Nous allons remédier à cela dans le modèle Post, en plaçant notre logique dans les très utiles callbacks beforeSave et afterSave. Le beforeSave va se charger de récupérer l’ancien id de catégorie, et l’afterSave va mettre l’ancienne catégorie à jour grâce à cet id. Reprenons le modèle Post :

// {app}/models/post.php
class Post extends AppModel
{
	var $belongsTo = array('Category' => array(
		'counterCache' => true,
		'counterScope' => array('published' => 1)
	));
 
	// Variable qui va contenir l'ancien id de catégorie
	var $category_id_old = null;
 
	// Avant la sauvegarde
	function beforeSave()
	{
		// S'il ne s'agit pas d'un ajout...
		if($this->id)
		{
			// ... on stocke l'ancien category_id
			$this->category_id_old = $this->field('category_id');
		}
 
		// On doit retourner true pour que la sauvegarde continue
		return true;
	}
 
	// Après la sauvegarde
	function afterSave()
	{
		// Si on a un id et qu'il a changé...
		if($this->category_id_old && $this->category_id_old != $this->data['Post']['category_id'])
		{
			// ... on force la mise à jour du compteur de l'ancienne catégorie
			$this->updateCounterCache(array('category_id' => $this->category_id_old));
		}
	}
}

Cette fois nous sommes sûr que si un Post existant est affecté à une nouvelle Catégorie, non seulement le compteur de la nouvelle Catégorie sera bien augmenté de 1, mais aussi que le compteur de l’ancienne Catégorie sera diminué de 1.

Pierre-Emmanuel Fringant

Commentaires

Merci pour cette précision.

J’avais écrit quelques lignes sur le counterCache de CakePHP ici : http://blog.jaysalvat.com/articles/counter-caching-avec-cakephp.php

J’ai ajouté un lien vers cet article.

Je ne sais s’il existait avec la version que tu as utilisée pour ce tuto, mais il y a un bug avec le counterCache dans la version 1.2.2.8120 stable, relevé dans ce ticket : https://trac.cakephp.org/ticket/6270

En fait, à l’édition/mise à jour d’un Post, si on le dépublie (published => 0), c’est-à-dire que la clause « counterScope » n’est plus valide, le compteur de l’ancienne catégorie parente n’est alors pas mis à jour.

J’ai appliqué le patch proposé par mattcurry dans le ticket et cela fonctionne. En attendant un correctif officiel…

Correctif : la dernière release stable 1.2.3. prend en compte le patch de mattcurry !

Je vous invite donc à mettre à jour le cœur de Cake avec cette dernière révision, cela évite de devoir manipuler nous-même la classe mère Model.

Slt, J’ai un petit soucis, en fait chez moi ça ne marche pas, et je ne sais pas c’est dû à ma nomination de table. En fait chez j’ai deux tables, une nommée « posts » et l’autre « comments », comment devrais-je nommer la colonne dans la table « posts » svp!

La colonne de la table ‘posts’ devra s’appeler ‘comment_count’ pour respecter les conventions de CakePHP (il est cependant possible de choisir n’importe quel autre nom !). Ensuite il suffit de déclarer dans le modèle Comment :

// {app}/models/comment.php
class Comment extends AppModel
{
    var $belongsTo = array('Post' => array(
        'counterCache' => true
    ));
}

Participez

Pour insérer une portion de code, utilisez <pre lang="php">...</pre>