Pagination avec critères de filtrage complexes

Prenons l’exemple d’un blog classique, sur lequel nous désirons mettre en place un moteur de recherche pour faciliter l’accès aux articles à nos visiteurs. Nous ne souhaitons cependant pas créer de vue spécifique pour l’affichage des résultats mais simplement réutiliser une vue existante qui affiche la liste paginée des articles grâce au Paginator.

1. Modèle de données

Notre blog propose des articles (posts), composés d’un titre (title), d’un corps de message (content), rédigés par des auteurs respectifs (userid). Nous souhaitons proposer à nos visiteurs la possibilité d’effectuer une recherche à la fois sur le titre et sur l’auteur d’un article.

2. Formulaire de recherche

Supposons que notre blog dispose d’ores et déjà d’une page listant les articles à l’adresse suivante : /posts/archives, soit l’action archives du Contrôleur posts
controller. Nous souhaitons intégrer notre moteur de recherche au sein de cette vue, afin de retenir les critères de recherche entrés par le visiteur. Autrement dit, nous allons filtrer notre liste d’articles en fonction des critères de recherche.

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
// {app}/views/posts/archives.ctp
echo $form->create(
  'Article',
  array(
    'action' => 'search',
    'type' => 'GET'
  )
);
// Champ texte pour les mots clés
echo $form->input(
  'title',
  array(
    'label' => 'Entrez un terme',
    'value' => isset($this->params['named']['title']) ? $this->params['named']['title'] : null
  )
);
// Champ select avec la liste des auteurs
echo $form->input(
  'user',
  array(
    'label' => 'Selectionnez un auteur',
    'type' => 'select',
    'options' => $userList,
    'selected' => isset($this->params['named']['user']) ? $this->params['named']['user'] : null,
    'empty' => 'Selectionnez un auteur'
  )
);
echo $form->end('Lancer la recherche');

3. Traitement des critères

Récupérons à présent les données entrées dans le formulaire dans le Contrôleur PostsController :

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
// app/controllers/posts_controller.php
function search()
{
  // exclusion du 1er argument de $this->params['url'] (l'URL courante)
  array_shift($this->params['url']);
 
  $passedArgs = array();
 
  // exclusion des données nulles
  $criterias = array_filter(
    $this->params['url'],
    create_function(
      '$item',
      'return !empty($item);'
    )
  );
 
  // transformation des données au format du Paginator
  foreach($criterias as $key => $value)
  {
    array_push($passedArgs, $key . ':' . urlencode($value));
  }
 
  // redirect vers la Vue, en passant en argument les critères de recherche retenus.
  $this->redirect('archives/'.join('/', $passedArgs));
}

Les données entrées par l’utilisateur ont été transformées dans un format compréhensible par le Paginator puis redirigées vers l’action de notre choix listant déjà nos articles. Il faut à présent modifier la partie listant nos articles afin de prendre en compte les critères de recherche passés en argument.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//app/controller/posts_controller.php
function index()
{
  // Liste des auteurs d'article
  $this->set('userList', $this->Post->User->find('list'));
 
  // Nettoyage de la saisie
  App::import('Sanitize');
  $Sanitizer =& new Sanitize();
  $Sanitizer->clean(&$this->params['named']);
 
  // Utilisons le deuxième argument de la méthode Paginate
  // pour fournir un tableau de conditions que nous construirons
  // dans notre modèle à l’aide de la méthode createStatement
  $articles = $this->Paginate('Post', $this->Post->createStatement($this->params['named']));
  $this->set('articles', $articles);
}

Nous transmettons à une méthode de notre modèle les paramètres de recherche, celle-ci aura pour but de transformer les paramètres sous forme de tableau de critères SQL compréhensibles par CakePHP.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/models/Post.php
function createStatement($data)
{
  $statement = array();
 
  if(isset($data['title']))
  {
    array_push($statement, array("{$this->name}.title" => 'LIKE %' . $data['title'] . '%'));
  }
 
  if(isset($data['user']))
  {
    array_push($statement, array("{$this->name}.user_id" => $data['user']));
  }
 
  return $statement;
}

Nous disposons à présent d’une liste d’articles filtrés selon les critères entrés par l’utilisateur !

Reste à imposer à notre vue ( /app/views/posts/archives.ctp ) de retenir les critères entrés lors de l’utilisation du Helper Paginator. Pour cela, il suffit simplement de transmettre l’URL courante comme paramètre.

Au début du fichier app/views/posts/archives.ctp ajoutons :

1
2
//app/views/posts/archives.ctp
options(array('url' => isset($this->params['named']) ? $this->params['named'] : array() ));

Voilà, nous disposons à présent d’un moteur de recherche utilisable par le Paginator, nous pouvons présenter les résultats sur plusieurs pages et les trier.

L’utilisation de cette méthode présente trois avantages :

  • Nous sommes libres de masquer les champs de recherche dans l’URL, contrairement aux URLs générées par le Paginator. Pour reprendre notre exemple, nous aurions très bien pu remplacer l’URL suivante : /posts/archives/user:3/title:un+super+article par /posts/archives/author:3/message:un+super+article et simplement changer les tableau de correspondance dans la fonction createStatement() du modèle.
  • Les recherches entrées peuvent très bien être mises en favoris. Imaginez l’url suivante : /articles/sort:price/direction:desc/currency:dollar/title:dvd+collector
  • Nous aurions tout à fait pu adapter et deplacer les méthodes search() et createStatement() respectivement dans appcontroller.php et appmodel.php pour proposer une fonction recherche sur chacun de nos modèles !

Christophe Cholot

Articles connexes

Commentaires

Bonjour Christophe,

Comment utilises-tu les paramètres passés dans l’url avec le paginator ? Ton dernier exemple manque de détail dans archives.ctp. Merci de ton retour :)

Bonjour, L’intérêt de cette méthode est justement de pas avoir à modifier les vues existantes tout en y appliquant des critères de filtrage complexe. Dans « archive.ctp » il suffit simplement , comme precisé dans le tutorial d’ajouter la ligne suivante : $paginator->options(array('url' => isset($this->params['pass'])?$this->params['pass']:array())); pour profiter des fonctions habituelles du Paginator (pagination, statistiques, tri..). Cependant il est toujours possible de manipuler les variables passées dans l’URL à l’aide de $this->params['named'].

Merci beaucoup, Ce qui ne parait pas évident, c’est de retrouver toutes les conditions passées par le paginator avec $paginator->next ou $paginator->prev

J’ai bien essayé un : echo $paginator->next(‘Suivant’, array(‘url’ => isset($this->params['named']) ? $this->params['named']:array()) );

Mais il va me renvoyer vers : controller/adminindex/lesarguments au lieu de me renvoyer vers : controller/index/les_arguments

Si tu utilises une route admin, il faut préciser au paginator d’utiliser l’action en cours sans le prefix admin.
Ce « problème » avait été signalé à l’équipe de développement par l’intermédiaire du Trac. En attendant, il est possible de résoudre ce problème très simplement en « forcant » la main au paginator :

Dans la vue concernée, il suffit d’ajouter :

$paginator->options(array(‘url’ => isset($this->params['named']) ? $this->params['named'] : array())); $paginator->params['action'] = strreplace(Configure::read(‘Routing.admin’) . ‘‘,  », $this->action);

Super Christophe, je te remercie de ton aide :)

Salut Christophe,

Je reviens vers vous après plusieurs tentatives infructueuses, également du côté du Bakery ou de google groupes. Mais cela pourrait s’avérer utile à d’autres, aussi je formule le problème si vous aviez le temps de le voir;

Vous avez ici un modèle Post tel que ; $belongsTo = array (‘User’ => array(‘className’ => ‘User’,'foreignKey’=>’user_id’));

Votre méthode createStatement va fournir un tableau de conditions liés au modèle Post : $this->Paginate(‘Post’,…

Si j’ai maintenant des posts qui ont une ou plusieurs catégories, tel que : var $hasAndBelongsToMany = array(‘Categorie’ => array(‘className’ => ‘Categorie’, ‘joinTable’ => ‘postscategories’, ‘foreignKey’ => ‘postid’, ‘associationForeignKey’=> ‘categorie_id’, ‘conditions’ =>  », ‘order’ =>  », ‘limit’ =>  », ‘unique’ => true, ‘finderQuery’ =>  », ‘deleteQuery’ =>  », ‘with’ => ‘PostsCategorie’ )

Je vais pouvoir filtrer par catégorie sans passer par createStatement tel que : $articles = $this->paginate(‘PostsCategorie’,array(‘PostsCategorie.categorie_id’ => intval($this->params['named']['Categorie'])));

Ca fonctionne bien, mais si je dois ajouter un filtre par catégorie et par user, la méthode paginate ne peut s’appliquer aux deux. Bref, je ne vois pas comment filtrer dans cet exemple par catégorie (et plus) en utilisant la méthode createStatement ?

J’ai bien regardé aussi la méthode du bakery qui n’est pas viable pour les multiples HATBM : http://bakery.cakephp.org/articles/view/pagination

Hello,

Il est possible déplacer la méthode createStatement dans le modèle PostsCategorie :

//app/models/posts_categories.php

function createStatement($data){ $statement = array();

if(isset($data['category'])){ arraypush($statement, array("{$this->name}.categoryid" => intval($data['category'])); return $statement; }

et d’appeler $this->PostsCategorie->createStatement($this->params['named']), ce qui devrait répondre au besoin imminent. Par contre, pour joindre le modèle User il faudra, à mon avis, passer par une requete intermediaire et combiner les résultats à l’aide de la fonction array_combine() et de la classe Set, soit écrire les requetes manuellement. :)

Bonjour Christophe ,

Merci beaucoup pour l’article sur la pagination.

Ce que je veux faire est simple mais ça ne fonctionne pas, lorsque je soumets une recherche j’obtiens rien comme donnée dans mon tableau. Au départ, le tableau est populé par les données de la table sans problème.

Voici l’url qui j’obtiens: http://localhost/sriinventaire/fonds/index/fondlibelle:Courant

J’ai une table fonds: CREATE TABLE fonds ( id mediumint(8) unsigned NOT NULL autoincrement, fond_libelle varchar(50) collate utf8unicodeci NOT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB AUTOINCREMENT=12 DEFAULT CHARSET=utf8 COLLATE=utf8unicodeci;

Voici le formulaire de recherche qui est dans // {app}/views/fonds/index.ctp

Ceci est au début du fichier index.ctp: options(array(‘url’ => isset($this->params['named'])?$this->params['named']:array()));?>

create( ‘Fond’,
array( ‘action’ => ‘search’, ‘type’ => ‘GET’ ) );

    echo $form->input(
    'fond_libelle',
    array(
    'label' => 'Entrez un fond_libelle',
    'value' => isset($this->params['named']['fond_libelle']) ? $this->params['named']['fond_libelle'] : null

) ); echo $form->end(‘Lancer la recherche’); ?>

Voici la fin de la function search() qui est dans // app/controllers/fonds_controller.php (seulement la fin est différente de la vôtre)

// redirect vers la Vue, en passant en argument les critères de recherche retenus. $this->redirect(‘index/’.join(‘/’, $passedArgs));

Voici la fin de la function index() qui est dans // app/controllers/fonds_controller.php $fonds = $this->Paginate(‘Fond’, $this->Fond->createStatement($this->params['named'])); $this->set(‘fonds’, $fonds);

Voici le createStatement qui est dans // app/models/Fond.php:

function createStatement($data) { $statement = array(); if(isset($data['fondlibelle'])) { arraypush($statement, array(« {$this->name}.fondlibelle » => ‘LIKE %’ . $data['fondlibelle'] . ‘%’));
} return $statement; }

Merci pour votre aide

Salut Christophe,

j’ai trouvé !!!

Voici la correction que j’ai fait dans la fonction createStatement:

arraypush($statement, array($this->name. ‘.’ .’fondlibelle’.’ LIKE ‘=> ‘%’ . $data['fond_libelle'] . ‘%’));

Merci beaucoup pour les articles de formation. C’est très très utile.

Bonjour et merci pour cet article.

Tout d’abord, j’ai une question : est-ce que ces lignes

App::import('Sanitize');
  $Sanitizer =& new Sanitize();
  $Sanitizer->clean(&$this->params['named']);
sont encore valables pour une version 1.2 (et +) ? sinon, que faudrait-il écrire ?

ensuite j’ai en souci : ma fonction createStatement me donne bien le tableau de conditions que je veux mais avec LIKE, la requête générée dans le controller ressemble à ça

WHERE <code>Post</code>.<code>title</code> = 'LIKE \'%essai%\''
évidemment ça ne donne rien de bon (mais ça ne génère pas d’erreur sql, ce qui est surprenant)

quid de ce like ? le fonctionnement aurait-il changé depuis la rédaction de cet article ?

merci d’avance

Salut Christophe,

avec cette technique je rencontre un problème quand je rajoute

Router::parseExtensions(‘rss’);

dans app/config/routes.php

l’url du résultat de la recherche sans Router::parseExtensions : http://ultim.dev/project1/posts/recherche/catgorie:16/marque:21/sort:created/direction:desc

l’url du résultat de la recherche avec Router::parseExtensions : http://ultim.dev/project1/posts/recherche/url:posts%2Ffilter/catgorie:16/marque:21/sort:created/direction:desc

t’aurais pas une combine pour ce genre de cas ?

merci pour ce post très clair et bien rédigé Je me méfierais de la portion

array_shift($this->params['url']);

en effet, comme indiqué, cette méthode retourne le premier couple (clé / valeur) de $this->params['url'] et l’enlève du tableau

Hors, la (clé-valeur) retirée ne correspond pas forcement à l’url courante (dans mon cas (cakephp 1.2), la valeur ‘décalée’ était ma valeur de filtrage)

il me semble préférable de filtrer certaines clés de this->params['url']

merci encore Christophe Chapron

Concernant la question que je posais en avril suite au problème avec Router::parseExtensions(’rss’);

La solution :

supprimer url et ext de $criterias

$criterias = array<em>filter(
            $this->params['url'],
            create</em>function(
            '$item',
            'return !empty($item);'
            )
        );</p>
 
<p>unset($criterias['url']);
unset($criterias['ext']);

Bonjour, merci pour le tutoriel :)

Je me permet d’ajouter une petite précision au niveau sécurité, en filtrant dans le controller:

$this-&gt;params['url']

J’ai donc ajouter la méthode Sanitize de manière a prévenir toute faille xss

App::import('Sanitize');
$this-&gt;params['url'] = Sanitize::stripAll($this-&gt;params['url']);

Bonjour christophe,

j’explique mieux ma question. En fait, d’apres l’introduction, tu dis que la fonction archive doit afficher les posts. Mais, dans la vue de cette fonction, il existe seulement le formulaire. Cependant, au cours de l’explication, j’ai compris que tu as fais l’affichage au niveau de la vue index. Peux tu mieux m expliquer quelle est la logique que tu fais? Comment le code de recuperation de resultat se trouve dans index alors que tu dis que c’est la foction archive qui va afficher. Merci d’avance

Participez

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