Gestion automatique d’une colonne de tri avec un Comportement

Nous avons souvent besoin d’une colonne de tri numérique dans une table, lorsque les données de cette table ne peuvent être classées selon un ordre standard, comme l’ordre alphabétique ou la date de création. C’est pourquoi nous avons créé un Comportement (Behavior) pour gérer automatiquement les opérations de maintenance de l’index de tri en cas d’ajout, modification ou suppression de données. Le Comportement ajoute également trois méthodes au Modèle associé, moveUp et moveDown, pour monter ou descendre un enregistrement de la table, et moveTo pour mettre à jour l’indice d’un enregistrement.

1. Installation

Mise à jour du 02/07/08 : le Comportement a été mis à jour pour être compatible avec la dernière version de CakePHP, qui introduit une nouvelle syntaxe pour les opérateurs SQL (depuis la RC1). Une simple condition comme :

$field => ' >= ' . $value

devient

"$field >= " => $value

Pour installer le Comportement, il suffit de télécharger le fichier ci-dessous dans le répertoire {app}/models/behaviors/ et de le renommer en ordonnable.php. Télécharger le Comportement OrdonnableBehavior Ou de recopier le code ci-dessous et de l’enregistrer sous {app}/models/behaviors/ordonnable.php :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
<?php
/**
 * Fichier de la classe OrdonnableBehavior - 16 mai 2008.
 *
 * @filesource
 * @author Pierre-Emmanuel Fringant
 * @link http://www.formation-cakephp.com/
 * @version	0.3
 * @license	http://www.opensource.org/licenses/mit-license.php The MIT License
 * @package app
 * @subpackage app.models.behaviors
 */
/**
 * Comportement de modèle pour prendre en charge automatiquement
 * une colone de tri numérique.
 *
 * @package app
 * @subpackage app.models.behaviors
 */
class OrdonnableBehavior extends ModelBehavior
{
	/**
	 * Initialisation du Comportement avec les options par défaut.
	 * 
	 * Options disponibles :
	 * - field :    nom du champ numérique de la base qui va contenir les valeurs de l'indice
	 * - addToEnd : booléen, si vrai un nouvel enregistrement sera ajouté au dernier rang de l'index d'ordre,
	 *              si faux le nouvel enregistrement sera ajouté au premier rang.
	 *
	 * @param object $model Le modèle sur lequel va être appliqué le Comportement
	 * @param array $config Les options définies dans le modèle (prioritaires sur les options par défaut)
	 */
	function setup(&$model, $config = array())
	{
		$settings = array_merge(
			array(
				'field' => 'ordre',
				'addToEnd' => true
			),
			(array)$config
		);
 
		$this->settings[$model->alias] = $settings;
	}
 
	/**
	 * Méthode appelée après chaque sauvegarde.
	 * Si c'est un ajout, on regarde la valeur de l'option $addToEnd
	 * et on met à jour la valeur de l'ordre de l'enregistrement concerné.
	 *
	 * @param object $model Le modèle relié au Comportement
	 * @param boolean $created Vrai s'il s'agit d'un ajout (INSERT), faux si c'est une mise à jour (UPDATE)
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 */
	function afterSave(&$model, $created)
	{
		extract($this->settings[$model->alias]);
 
		if(!$created)
		{
			return;
		}
 
		if($addToEnd)
		{
			// Indice le plus élevé
			list($order) = array_values(
				$model->find(
					'first',
					array(
						'conditions' => '1=1',
						'fields' => 'MAX(' . $field . ') AS ' . $field,
						'recursive' => -1
					)
				)
			);
 
			// Nouvel indice
			$new =
				empty($order[$field])
				? 1
				: $order[$field]+1;
 
		}
		else
		{
			// Décalage de tous les indices (+1 à tous)
			$this->__offset($model);
 
			// Nouvel indice
			$new = 1;
		}
 
		return $this->__updateOrder($model, $model->id, $new);
	}
 
	/**
	 * Méthode appelée avant chaque suppression.
	 * On décale d'un cran les enregistrements suivant celui à supprimer
	 *
	 * @param object $model Le modèle relié au Comportement
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 */
	function beforeDelete(&$model)
	{
		extract($this->settings[$model->alias]);
 
		$start = $model->field($field);
 
		return $this->__offset($model, $start, -1);
	}
 
	/**
	 * Monter d'un certain nombre de crans
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $id Id de l'enregistrement à monter
	 * @param integer $delta Nombre de crans
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 */
	function moveUp(&$model, $id = null, $delta = -1)
	{
		return $this->__move($model, $id, $delta);
	}
 
	/**
	 * Descendre d'un certain nombre de crans
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $id Id de l'enregistrement à descendre
	 * @param integer $delta Nombre de crans
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 */
	function moveDown(&$model, $id = null, $delta = +1)
	{
		return $this->__move($model, $id, $delta);
	}
 
	/**
	 * Affecter un nouvel indice à un enregistrement
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $id Id de l'enregistrement à descendre
	 * @param integer $new Nouvel indice
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 */
	function moveTo(&$model, $id = null, $new = 1)
	{
		extract($this->settings[$model->alias]);
 
		$model->id = $id;
		$old = $model->field($field);
 
		if($new <= 0)
		{
			$new = 1;
		}
 
		return $this->__move($model, $id, $new - $old);
	}
 
	/**
	 * Monter ou descendre d'un certain nombre de crans
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $id Id de l'enregistrement à modifier
	 * @param integer $delta Nombre de crans
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 * @access private
	 */
	function __move(&$model, $id = null, $delta = +1)
	{
		extract($this->settings[$model->alias]);
 
		$model->id = $id;
		$old = $model->field($field);
 
		$new = $old + $delta + ($delta > 0);
 
		if($new <= 0)
		{
			return false;
		}
 
		$return = $this->__offset($model, $new);
 
		if($return)
		{
			$return = $this->__updateOrder($model, $id, $new);	
		}
		if($return)
		{
			$return = $this->__reIndex($model);
		}
 
		return $return;
	}
 
	/**
	 * Appliquer un décalage de plusieurs lignes à partir d'un indice.
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $start Indice à partir duquel appliquer le décalage
	 * @param integer $offset Grandeur du décalage
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 * @access private
	 */
	function __offset(&$model, $start = 1, $offset = +1)
	{
		extract($this->settings[$model->alias]);
 
		return $model->updateAll(
			array(
				$model->escapeField($field) => $model->escapeField($field) . '+' . $offset
			),
			array(
				$model->escapeField($field) . " >= "  => $start
			)
		);
	}
 
	/**
	 * Réindexer la colonne de tri.
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $start Indice à partir duquel réinitialiser le compteur
	 * @return boolean Renvoie toujours vrai
	 * @access private
	 */
	function __reIndex(&$model, $start = 1)
	{
		extract($this->settings[$model->alias]);
 
		$fromStart = $model->find(
			'all',
			array(
				'fields' => array(
					$model->primaryKey,
					$field
				),
				'conditions' => array(
					"$field >= " => $start
				),
				'order' => array(
					$field => 'asc'
				),
				'recursive' => -1
			)
		);
 
		foreach($fromStart as $row)
		{
			if($row[$model->alias][$field] != $start)
			{
				$this->__updateOrder($model, $row[$model->alias][$model->primaryKey], $start);
			}
			$start++;
		}
 
		return true;
	}
 
 
	/**
	 * Echanger les ordres de deux enregistrements
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $one_id Id du premier enregistrement
	 * @param integer $two_id Id du deuxième enregistrement
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 * @access private
	 */
	function __switch(&$model, $one_id, $two_id)
	{
		extract($this->settings[$model->alias]);
 
		$one = $model->find(
			'first',
			array(
				'fields' => array(
					$model->primaryKey,
					$field
				),
				'conditions' => array(
					$model->primaryKey => $one_id
				),
				'recursive' => -1
			)
		);
 
		$two = $model->find(
			'first',
			array(
				'fields' => array(
					$model->primaryKey,
					$field
				),
				'conditions' => array(
					$model->primaryKey => $two_id
				),
				'recursive' => -1
			)
		);
 
		if(empty($one) or empty($two))
		{
			return false;
		}
 
		list($one) = array_values($one);
		list($two) = array_values($two);
 
		$return = $model->__updateOrder($model, $one[$model->primaryKey], $two[$field]);
 
		if($return)
		{
			$return = $model->__updateOrder($model, $two[$model->primaryKey], $one[$field]);
		}
 
		return $return;
	}
 
	/**
	 * Mise à jour de l'indice d'un enregistrement
	 *
	 * @param object $model Modèle relié au Comportement
	 * @param integer $id Id de l'enregistrement
	 * @param integer $order Nouvel indice
	 * @param boolean $validate Vrai si on doit valider les données
	 * @return boolean Renvoie vrai si succès, faux en cas d'erreur
	 */
	function __updateOrder(&$model, $id = null, $order = 0, $validate = false)
	{
		extract($this->settings[$model->alias]);
 
		$model->id = $id;
 
		return $model->saveField($field, $order, $validate);
	}
}
?>

2. Mise en pratique

Nous allons voir maintenant un exemple concret de l’utilisation de notre Comportement. Imaginons une gestion de diplômes : lorsque nous les affichons, nous souhaitons bien sûr les classer par ordre de niveau d’études, ce qui ne correspond ni à l’ordre de saisie ni à l’ordre alphabétique. Nous ajoutons donc une colonne de type numérique à notre table pour gérer l’ordre comme nous le souhaitons.

2.1. La table

Nous créons la table diplomes avec trois colonnes :
  • id : la clé primaire ;
  • libelle : le nom du diplôme ;
  • ordre : nombre entier non signé qui va servir à conserver la liste des diplômes dans l’ordre voulu.

CREATE TABLE `diplomes` (
  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `libelle` VARCHAR(50) NOT NULL,
  `ordre` INT(10) UNSIGNED NOT NULL DEFAULT '0',
  PRIMARY KEY  (`id`)
)

2.2. Le Modèle

Nous créons le fichier {app}/models/diplome.php :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// {app}/models/diplome.php
class Diplome extends AppModel
{
	var $name = 'Diplome';
 
	var $displayField = 'libelle';
 
	var $actsAs = array(
		'Ordonnable' => array(
			'field' => 'ordre',
			'addToEnd' => true
		)
	);
}

La variable $actsAs nous permet de dire au Modèle Diplome de se comporter comme étant « Ordonnable », et ce avec deux options :

  • field : la colonne de la table qui va conserver l’index de classement, ici notre colonne ‘ordre’. Vaut ‘ordre’ par défaut.
  • addToEnd : si vrai, un nouvel enregistrement sera placé à la fin, si faux il sera placé au début. Vrai par défaut.

2.3. Le Controller

Voyons les quatre actions qui nous intéressent : index (liste des diplômes par ordre voulu), monter (monter un diplôme d’un cran dans la liste), descendre (descendre un diplôme d’un cran dans la liste) et deplacer pour mettre à jour directement l’indice d’un diplôme :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// {app}/controllers/diplomes_controller.php
class DiplomesController extends AppController
{
	var $name = 'Diplomes';
 
	function index()
	{
		$diplomes = $this->Diplome->find(
			'all',
			array('order' => 'Diplome.ordre ASC')
		);
		$this->set(compact('diplomes'));
	}
 
	function monter($id = null)
	{
		if($id)
		{
			$this->Diplome->moveUp($id);
			$this->redirect('index');
		}
	}
 
	function descendre($id = null)
	{
		if($id)
		{
			$this->Diplome->moveDown($id);
			$this->redirect('index');
		}
	}
 
	function deplacer()
	{
		if(!empty($this->data))
		{
			$this->Diplome->moveTo(
				$this->data['Diplome']['id'],
				$this->data['Diplome']['indice']
			);
			$this->redirect('index');
		}
	}
}

Les méthodes monter et descendre appellent deux méthodes du Comportement OrdonnableBehavior, accessibles depuis le Modèle Diplome associé. Il ne reste au lecteur qu’à créer les actions d’ajout, d’édition et de suppression d’un diplôme, mais ces actions sont tout à fait classiques et nous ne les présenterons pas ici. Le Comportement se chargera automatiquement de maintenir l’index d’ordre à jour en cas d’ajout ou de suppression d’un enregistrement.

2.4. La Vue index

Finissons par la vue correspondant à l’action index du Contrôleur :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// {app}/views/diplomes/index.ctp
<?php
e($html->tableHeaders('Ordre actuel', 'Libellé', 'Monter', 'Descendre', 'Déplacer'));
foreach ($diplomes as $row): ?> 
<tr>
	<td><?php e($row['Diplome']['ordre']); ?></td>
 
	<td><?php e($row['Diplome']['libelle']); ?></td>
 
	<td><?php e($html->link("Monter", 'monter/'.$row['Diplome']['id'])); ?></td>
 
	<td><?php e($html->link("Descendre", 'descendre/'.$row['Diplome']['id'])); ?></td>
 
	<td>
	<?php e($form->create('Diplome', array('action' => 'deplacer'))); ?> 
	<?php e($form->input('id', array('value' => $row['Diplome']['id']))); ?> 
	<?php e($form->input('indice', array('label' => false, 'div' => false, 'size' => 3, 'value' => $row['Diplome']['ordre']))); ?> 
	<?php e($form->submit('ok', array('div' => false))); ?> 
	<?php e($form->end()); ?> 
	</td>
</tr>
<?php endforeach; ?> 
</table>
Pierre-Emmanuel Fringant

Articles connexes

Commentaires

Salut Kalt,

Cela faisait un petit moment que je n’avais pas utilisé l’un de tes excellents tutos. Et comme à chaque fois ou presque, j’ai une interrogation.

Serait-il envisageable de gérer « plusieurs » ordres de tri ?

Je m’explique : dans mon appli, j’ai besoin de gérer un ordre d’affichage des articles au sein d’une même page. Or, tous les articles, quelle que soit leur page parente sont dans une même table, du coup, si j’ai 12 articles, dont 6 appartiennent à la page 1, le premier article de la page 2 aura comme « indice » 7 et non pas 1.

Dans l’absolu ce n’est pas grave puisque le ORDER BY dans la vue placera mes articles dans l’ordre que je veux, mais d’un point de vue utilisation, c’est pas évident de se dire : « je déplace d’un cran le numéro 7 pour qu’il devienne le 2e article de la page 2″ !

En espérant avoir été clair et dans l’attente de te lire à nouveau…

Aurélien

Je ne pense pas qu’il soit bien compliqué de modifier mon Behavior pour que les méthodes de changement d’ordre acceptent en paramètre un array de conditions. Dans ton cas on pourrait ainsi appeler $model->moveUp($id, array('pageid' => $pageid));

Oui je le pense aussi, mais parfois j’ai encore du mal avec les Behaviors… qui sont tout de même des classes un peu particulières puisqu’elles impactent complètement le modèle avec lequel elles interagissent.

Je vais donc regarder de plus près ce que tu m’indiques et je ferais un retour si ça marche.

Merci de ta disponibilité

J’ai enfin pu faire quelques tests sur ma condition de page parente, en passant comme tu me le proposais, un tableau de conditions en plus de l’id.

J’ai réussi à modifier « moveUp », « moveDown » et « moveTo » et j’ai bien un ordre d’affichage en fonction de la page parente et non plus global.

Mais je ne sais pas trop comment passer la page parente aux méthodes « afterSave » (quand j’ajoute un nouvel article) et « beforeDelete »… A quel niveau dois-je agir pour que ces callbacks magiques connaissent ma variable ?

Bon en fait j’ai trouvé !

Je viens de comprendre l’utilisation de « $model->field($field) » et du coup j’ai ajouté une 3eme variable à ton tableau de config du behavior !

Et du coup, je me dis qu’en utilisant cela, je peux me dispenser d’ajouter le tableau de conditions lors des appels à « moveUp » et consorts, donc d’ajouter ce paramètre à toutes les méthodes. Si j’ai bien suivi, je peux l’obtenir dans chaque méthode grâce à la méthode « extract() ».

Mais bon, si on envisage d’avoir bien plus d’un champ conditionnel… il faudra créer autant de nouvelle variable dans le tableau de config. Pas génial !

Très bon tuto comme souvent.

Par contre, il faut préciser qu’il ne faut SURTOUT PAS choisir comme nom de champ ‘order’ (ordre en anglais).

'field' => 'order'

… en effet apparemment ce mot est réservé en SQL et la requête générée par le Behavior comporte alors une erreur de syntaxe.

Aussi, ne pas mettre le champ « ordre » dans le formulaire de création ou d’édition… car là aussi on obtient des erreurs.

Encore merci pour tous ces tutos, c’est un plaisir.

Participez

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