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 (user_id). 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 app_controller.php et app_model.php pour proposer une fonction recherche sur chacun de nos modèles !

Christophe Cholot

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/admin_index/les_arguments
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'] = str_replace(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’ => ‘posts_categories’,
‘foreignKey’ => ‘post_id’,
‘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'])){
array_push($statement, array(”{$this->name}.category_id” => 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. :)

Participez