Le piège du counterCache avec un modèle en arbre : le CounterBehavior

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 modèle dont les enregistrements n’ont aucune relation entre eux.

Mais si le modèle Category est construit comme un arbre hiérarchique, avec des sous-catégories sur plusieurs niveaux, l’astuce ne met à jour que le compteur de la branche finale à laquelle est attaché le post. Si nous avions besoin d’afficher le nombre total d’enregistrements de tous les enfants d’une branche parente, il faudrait faire une requête ou un traitement PHP spécifique pour le calculer et le passer à la vue.

Nous allons donc créer un Behavior, nommé tout simplement Counter, pour exécuter automatiquement la mise à jour du counterCache des branches parentes, à chaque nouvel enregistrement créé ou déplacé dans l’arborescence. Nous en profiterons pour y inclure l’astuce vue dans le dernier tutoriel, ce qui évitera de la déclarer dans tous les modèles qui utilisent le counterCache : il suffira d’ajouter le behavior au tableau $actsAs du modèle concerné et de le paramétrer.

Mise en place dans les modèles

Le modèle Category se comporte donc comme un arbre hiérarchique grâce au TreeBehavior livré avec CakePHP.

class Category extends AppModel {
	var $actsAs = array('Tree');
}

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

class Post extends AppModel {
	// Behaviors utilisés
	public $actsAs = array
	(
		// Met à jour les compteurs de posts des catégories de rattachement
		'Counter' => array
		(
			'field' => 'category_id',
			'tree_model' => 'Category'
		)
	);
 
	// Associations
	public $belongsTo = array
	(
			'Category' => array
			(
				'className' => 'Category',
				'foreignKey' => 'category_id',
				'conditions' => '',
				'fields' => '',
				'order' => '',
				'counterCache' => true,
				'counterScope' => array('published' => '1')
			),
	);
}

Le CounterBehavior

/**
 * Comportement Counter
 *
 * Met à jour les compteurs gérés
 * par <em>counterCache</em> dans les modèles
 * liés par <em>belongsTo</em>, lorsqu'un élément change
 * de catégorie parente.
 *
 * Il permet aussi de mettre à jour
 * les compteurs des branches parentes
 * de celle dont le <em>counterCache</em> est mis à jour,
 * dans un modèle utilisant le <em>TreeBehavior</em>
 *
 * @filesource
 * @author Aurélien Vairet 
 * @version 0.1
 * @date 2009-02
 */
class CounterBehavior extends ModelBehavior {
	// Identifiant de la branche parente de l'enregistrement en cours de traitement
	var $old_id = 0;
 
	/**
	 * Initialise le comportement
	 * en lui passant les paramètres
	 * de config issus du modèle.
	 *
	 * Paramètres possibles :
	 * - 'field' : le nom de la clé étrangère représentant l'association belongsTo (par exemple 'category_id')
	 * - 'tree_model' : le nom du modèle lié par belongsTo et se comportant en arbre (par exemple 'Category')
	 *
	 * @access public
	 * @param object $model Modèle lié au comportement
	 * @param array $config	Paramètres d'initialisation définis dans le modèle
	 */
	function setup(&$model,$config = array()) {
		$settings = (array)$config;
		$this->settings[$model->alias] = $settings;
	}
 
 
	/**
	 * Sauvegarde la valeur du champ
	 * lié par <em>counterCache</em>
	 * avant tout enregistrement
	 *
	 * @access public
	 * @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]);
		// Si on met à jour un enregistrement, on enregistre l'id de sa branche d'origine
		if (isset($model->data[$model->alias][$model->primaryKey]) && !empty($field)) {
			$this->old_id = $model->field($field);	// ici $field est issu du extract() et correspond au paramètre défini dans le modèle
		}
		return true;
	}
 
 
	/**
	 * Exécuté après chaque sauvegarde,
	 * pour mettre à jour les compteurs
	 * des branches parentes de celle à laquelle
	 * appartient l'enregistrement qui vient d'être effectué.
	 * Si l'enregistrement était un update et que l'enregistrement a changé
	 * de branche, la méthode met à jour, en plus, le compteur de l'ancienne branche
	 * et de ses branches parentes.
	 *
	 * @access public
	 * @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'enregistrement est un ajout et qu'il existe un modèle à structure en arbre
		if ($created && !empty($field) && !empty($tree_model)) {
			// On met à jour les compteurs des éléments parents de la branche à laquelle est ajouté l'élément
			$this->__updateParentCounter($model,$model->$tree_model,$model->data[$model->alias][$field]);
			return true;
		}
		// Si c'est un update
		elseif (!empty($field)) {
			// et si l'enregistrement a changé de branche
			if ($this->old_id > 0 && $this->old_id != $model->data[$model->alias][$field]) {
				// on met à jour le compteur de l'ancienne branche
				$model->updateCounterCache(array($field => $this->old_id));
				// s'il existe un modèle à structure en arbre...
				if (!empty($tree_model)) {
					// on met à jour le compteur des parents de l'ancienne branche
					$this->__updateParentCounter($model,$model->$tree_model,$this->old_id,false);
					// on met à jour le compteur des parents de la nouvelle branche
					$this->__updateParentCounter($model,$model->$tree_model,$model->data[$model->alias][$field]);
				}
			}
		}
		return true;
	}
 
 
	/**
	 * Avant toute suppression d'un élément,
	 * si le modèle lié par <em>belongsTo</em> a une structure
	 * en arbre, on met à jour les compteurs des branches
	 * parentes de celle qui va perdre un élément.
	 *
	 * @access public
	 * @param object $model Modèle lié au comportement
	 * @return bool Renvoie toujours vrai pour permettre la poursuite de la suppression
	 */
	function beforeDelete(&$model) {
		extract($this->settings[$model->alias]);
		if (!empty($field) && !empty($tree_model)) {
			// On récupère les données de l'enregistrement en cours de suppression, afin de trouver l'id de la branche
			// dont les compteurs des parents doivent être mis à jour
			$current = $model->read(null,$model->field($model->primaryKey));
			$this->__updateParentCounter($model,$model->$tree_model,$current[$model->alias][$field],false);
		}
		return true;
	}
 
 
	/**
	 * Met à jour les compteurs
	 * des branches parentes de la branche
	 * dont le <em>counterCache</em> a changé
	 *
	 * @access private
	 * @param object $model Modèle lié au comportement
	 * @param object $model_to_count Modèle en arbre dont on met à jour les compteurs des branches
	 * @param int $id Identifiant de la branche dont on cherche les parents
	 * @param bool $add Indique si on doit incrémenter (true) ou décrémenter (false) les compteurs
	 * @return void
	 */
	function __updateParentCounter(&$model,$tree_model,$id,$add = true) {
		// On détermine le champ affecté par le counterCache
		$field_name = strtolower($model->alias).'_count';
		// On récupère la hiérarchie de la branche
		$parents = $tree_model->getpath($id);
		// On enlève le dernier élément de la hiérarchie qui correspond à la branche courante
		$current = array_pop($parents);
 
		// On boucle sur les parents pour ajouter ou retrancher 1 à chaque champ représentant le counterCache
		$nb = -1;
		if ($add) {
			$nb = +1;
		}
		foreach ($parents as $parent) {
			$current_counter = $parent[$tree_model->alias][$field_name];
			$new_counter = $current_counter + $nb;
			$tree_model->id = $parent[$tree_model->alias][$tree_model->primaryKey];
			$tree_model->saveField($field_name,$new_counter);
		}
	}
}

Que se passe-t-il ?

1- Le modèle Post initialise le CounterBehavior en lui passant comme paramètres :

  • le nom de la clé étrangère représentant l’association belongsTo (category_id dans notre exemple)
  • le nom du modèle associé qui se comporte comme un arbre (Category)

2- Dans la méthode beforeSave du behavior, on détecte si la sauvegarde concerne une mise à jour (on teste la présence de la clé primaire dans le tableau de données du modèle $model->data) et si le paramètre $field (défini dans le tableau $actsAs pour le CounterBehavior et récupéré par la fonction extract()) est bien renseigné.

if (isset($model->data[$model->alias][$model->primaryKey]) && !empty($field))

On sauvegarde alors la valeur de $field (donc l’id de la catégorie d’origine) dans la propriété $old_id de notre classe CounterBehavior.

3- Dans la méthode afterSave du behavior, on va appliquer l’astuce vu dans le tutoriel précédent et mettre à jour, si besoin, les catégories parentes.

Le premier cas est celui de l’ajout d’un post (la variable $created est présente) à l’une des catégories, on ne fait donc que la mise à jour des compteurs des catégories parentes de celle qui est choisie pour ce nouveau post.

Le second cas se produit lorqu’on met à jour un post : on va alors détecter s’il a changé ou pas de catégorie parente. S’il a changé, la valeur $old_id enregistrée lors du beforeSave est donc différente de la valeur présente dans le tableau de données du modèle. Nous allons donc appliquer l’astuce habituelle pour mettre à jour le compteur de sa catégorie d’origine.

$model->updateCounterCache(array($field => $this->old_id));

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

Ensuite, si la propriété $tree_model a été définie dans la configuration du CounterBehavior, nous appliquons notre méthode privée __updateParentCounter pour mettre à jour le compteur des catégories parentes de l’ancienne et de la nouvelle catégorie.

Notez que si l’on ne déclare pas de « tree_model » dans l’initialisation du behavior, la mise à jour des parents est inutile.

if (!empty($tree_model))

Ce test permet d’utiliser le CounterBehavior avec des modèles sans structure en arbre, comme le faisait l’astuce vue dans l’autre tutoriel.

4- Un cas à ne pas oublier : la suppression d’un post. Le counterCache met à jour le compteur de la catégorie à laquelle était attaché le post supprimé, mais pas celui de ses catégories parentes ! Nous ajoutons donc une fonction beforeDelete à notre behavior.

Conclusion

A partir d’une astuce liée à un modèle et de l’utilisation d’un behavior natif, 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.)

Aurélien Vairet

Articles connexes

Commentaires

A noter : 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.8166 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.

Participez

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