Le piège du counterCache : créons un behavior !

Dans le tutoriel précédent : Mise en place du counterCache : un piège à éviter, nous avions vu comment mettre en place le counterCache entre deux modèles associés en belongsTo et comment mettre à jour le compteur d’origine, lorsqu’un enregistrement change de parent.

L’astuce consistait à placer notre logique dans les callbacks beforeSave et afterSave du modèle Post, dans lequel est déclarée l’association belongsTo avec le modèle Category. Tout cela fonctionne bien pour un seul modèle, mais si l’application a plusieurs counterCache actifs, il faut dupliquer l’astuce dans tous les modèles, ce qui est peu productif et ne permet pas de réutiliser l’astuce dans d’autres projets.

Nous allons donc créer un Behavior, nommé tout simplement Counter, pour exécuter automatiquement la mise à jour du counterCache lorsque des enregistrements change de parent.

Introduction

Les Behaviors ou Comportements sont des classes qui permettent aux modèles de se comporter (to behave) d’une certaine manière lorsqu’ils manipulent les données ou d’accéder à des fonctionnalités évoluées et partagées entre modèles. Cela évite aussi de surcharger les modèles métiers avec des mécanismes génériques et répétitifs.

Les Comportements sont chargés avec les modèles dès qu’ils sont initialisés dans la propriété $actsAs. Ils peuvent se configurer en passant un tableau d’option.

class MonModel extends AppModel {
   var $actsAs = array('MonComportement'=>array('param1'=>'toto'));
}

Dans la plupart des cas, on cherche à éviter de produire du code supplémentaire dans les modèles, le behavior utilisera donc principalement les callbacks pour agir automatiquement en arrière plan des méthodes save(), delete() ou find(). On y trouvera ainsi les méthodes de modèle connues, comme beforeSave() ou beforeValidate(). Celles-ci agissent selon un schéma de ce type :

  1. beforeDelete du modèle éventuellement
  2. beforeDelete des behaviors attachés
  3. delete du modèle
  4. afterDelete des behaviors attachés
  5. afterDelete du modèle éventuellement

Vous aurez noté que nous parlons DES behaviors et non d’un seul. En effet, si un modèle utilise plusieurs comportements, il faut bien garder à l’esprit que TOUTES les méthodes de callbacks des behaviors seront appelées successivement ! Attention aux effets de bord…

Dans notre exemple autour de counterCache, notre behavior ne contiendra donc que deux méthodes utiles : beforeSave() et afterSave().

Mise en place dans le modèle

Tout se passe dans le modèle Post : déclaration du counterCache, appel et configuration éventuelle du behavior.

class Post extends AppModel {
 
	var $actsAs = array('Counter');
 
	var $belongsTo = array
	(
		'Category' => array
		(
			'counterCache' => true,
			'counterScope' => array('published' => '1')
		),
	);
}

Le CounterBehavior

/**
 * Comportement Counter (CounterBehavior)
 *
 * @filesource
 * @author Aurélien Vairet 
 * @package app
 * @subpackage app.models.behaviors
 * @version 0.2
 */
class CounterBehavior extends ModelBehavior {
	// Identifiant du nœud parent de l'enregistrement en cours de traitement
	var $old_id = 0;
 
	// Paramètres de configuration par défaut : si on respecte ces conventions,
	// inutile de passer les params dans le modèle qui utilise le behavior
	var $default_settings = array(
		'parent_field'	=> 'category_id'
	);
 
	/**
	 * Méthode d'initialisation du comportement qui
	 * récupère les éventuels paramètres de configuration
	 * précisés dans le modèle appelant et les merge
	 * avec les paramètres par défaut
	 * 
	 */
	function setup(&$model,$config = array()) {
		$settings = (array)$config;
		$this->settings[$model->alias] = am($this->default_settings,$settings);
	}
 
	/**
	 * Avant toute sauvegarde, s'il s'agit d'un UPDATE,
	 * conserve l'id du nœud parent de l'enregistrement en cours
	 * dans la propriété old_id
	 *
	 * @param object $model Modèle lié au comportement
	 * @return bool Renvoie toujours vrai pour permettre la poursuite du processus de sauvegarde
	 */
	function beforeSave(&$model) {
		// Ajoute les valeurs des paramètres d'initialisation dans la table des symboles
		extract($this->settings[$model->alias]);
		if (isset($model->data[$model->alias][$model->primaryKey])) {
			$this->old_id = $model->field($parent_field);
		}
		return true;
	}
 
	/**
	 * Exécuté après chaque sauvegarde pour mettre à jour si besoin,
	 * le compteur de l'ancien nœud de rattachement
	 *
	 * @param object $model Modèle lié au comportement
	 * @param bool $created Indique s'il s'agit d'un enregistrement créé (true) ou mis à jour (false)
	 * @return bool Renvoie toujours vrai pour permettre la poursuite de l'action après sauvegarde
	 */
	function afterSave(&$model,$created) {
		extract($this->settings[$model->alias]);
		// Si l'id du nœud parent est bien présent dans les données
		if (!empty($model->data[$model->alias][$parent_field])) {
			$new_id = $model->data[$model->alias][$parent_field];
			// Si on a changé de nœud parent...
			if ($this->old_id > 0 && $this->old_id != $new_id) {
				// on met à jour le compteur de l'ancienne branche
				$model->updateCounterCache(array($parent_field => $this->old_id));
			}
		}
		return true;
	}
}

Rien de très compliqué dans cette classe :

  1. On définit 2 propriétés, la première old_id stocke l’identifiant de l’ancien nœud parent, la seconde default_settings est un tableau de paramètres de configuration optionnels. Il ne comporte pour l’instant qu’un seul paramètre parent_field, qui est le nom de la clé étrangère servant de support au counterCache et comme on aime bien les conventions dans Cake, on suppose que le modèle qui a des compteurs est habituellement Category et que la clé étrangère adéquate est category_id ! Ainsi, dans le modèle on a juste à préciser « Counter » dans le tableau $actsAs pour que toute la magie opère… Mais on peut très bien changer cette valeur par défaut dans la classe ou lors de l’initialisation du behavior dans le modèle :
    var $actsAs = array('Counter'=>array('parent_field' => 'tartempion_id'));
  2. une méthode setup() qui permet d’initialiser le behavior et de lui passer éventuellement des paramètres de configuration. Dans ce dernier cas, on fait un merge avec les paramètres par défaut.
    $this->settings[$model->alias] = am($this->default_settings,$settings);
  3. les deux méthodes de callbacks qui seront appelées juste avant et juste après le save du modèle et qui reprennent simplement la syntaxe de l’astuce vue dans le dernier tutoriel.

Que se passe-t-il ?

  1. Le modèle Post initialise le CounterBehavior en lui passant éventuellement comme paramètre, le nom de la clé étrangère représentant l’association belongsTo.
  2. <li>Dans la méthode <code>beforeSave</code> du <em>behavior</em>, on détecte si la sauvegarde concerne une mise à jour, en testant la présence de la clé primaire dans le tableau de données du modèle <code>$model->data</code>. Si elle n'y est pas c'est qu'on ajoute un nouvel enregistrement n'ayant pas encore d'identifiant et donc pas de nœud parent.
    
    if (isset($model->data[$model->alias][$model->primaryKey]))

    On sauvegarde alors la valeur de $parentfield (dans notre cas, c’est l’id de la catégorie d’origine) dans la propriété $oldid.

    <li>Dans la méthode <code>afterSave</code> du <em>behavior</em>, on va appliquer l’astuce vu dans le tutoriel précédent. Lorqu’on met à jour un post, on détecte s’il a changé ou pas de catégorie parente. S’il a changé, la valeur <code>$old_id</code>, enregistrée lors du <code>beforeSave</code>, est donc différente de la valeur présente dans le tableau de données du modèle. Nous mettons à jour le compteur de sa catégorie d’origine :
    
    $model->updateCounterCache(array($field => $this->old_id));</li>

Notez que le compteur de la nouvelle catégorie est mis à jour automatiquement par le counterCache, inutile de s’en soucier.

Conclusion

A partir d’une astuce liée à un modèle, nous avons construit un behavior personnalisé simple qui permet de comprendre comment coder les comportements et comment ils interagissent avec le modèle qui leur est lié. Beaucoup de behaviors utilisent l’encapsulation des méthodes de callbacks des modèles. Vous pouvez ainsi vous exercer à leur construction sur des bases que vous connaissez, avant de vous lancer dans des opérations plus complexes. Il faut bien regarder les classes mères Model et Behavior, ainsi que les cas de tests fournis avec Cake pour comprendre comment utiliser les méthodes et propriétés génériques dans les behaviors ($model->alias, $model->field, $model->primaryKey, etc.)

Pour aller plus loin

En poussant notre exploration du counterCache, nous nous sommes aperçus d’un autre problème de mise à jour des compteurs : si le modèle Category se comporte comme un arbre par représentation intervallaire grâce au TreeBehavior et que l’on souhaite afficher les compteurs des branches parentes, le CounterBehavior tel qu’imaginé dans ce tutoriel ne met à jour que les branches terminales auxquelles sont attachés les Posts !

Dans ce cas, en déplaçant plusieurs posts ou en changeant carrément une catégorie de parent, les compteurs s’affolent illico !

Nous verrons dans un prochain tutoriel comment améliorer notre CounterBehavior pour qu’il puisse à la fois corriger le piège du counterCache et mettre à jour les branches parentes de l’arbre transversal.

Aurélien Vairet

Articles connexes

Participez

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