Hey folks,
this post is going to deal with the pretty common problem of paginating a search with the CakePHP framework.
The Problem
The problem is that a user inputs some search criteria into a form, the resultset exceeds 30 rows for example and the user must be able to browse through the resultset via different pages. The search criteria has to be the same everytime, the form prepopulated with the used search keywords/data and the resultset still has to match the input conditions everytime a new page is clicked.
This problem itself is in fact not much of a problem. We just need to store the form conditions somewhere and then hack it together. So what we are going to do is that we raise the difficulty bar a lot more by trying to get the pagination work over a HABTM relation.
Battlefield Briefing
We agreed we need to store the search criteria somewhere, so we can access it later. No we won't use the DB for that as it is overkill. We will also not use files, since many users may use the search at the same time screwing our hd. :P Yes, we will use the session for that as it is made exactly for these things.
For the example code we are going to use the "Advanced Search" of Flashfun247, a flashgame site which is one of my freetime projects. If you want to see some more of its code, feel free to ask.
We want to search for games based on some input conditions. The Game model is related to the GameCategory model over a HABTM relation, so the same game can be in many categories and a category contains many games. CakePHP's paginator cannot handle pagination over a HABTM so well in its current version. The incident here is that we want every game listed only once in the resultset - and not n times, where n is the number of categories it belongs to.
So we must at some point include a group by statement. However, the paginator will use that group by statement for its internal find('count') call as well, which it does to determine the size of the resultset. This will in fact corrupt the page count screwing us all over. We will see that we can trick the paginator, though. ; )
The View
To get us started, let's have a look at the view in /views/searches/advanced.ctp, which is very simple:
<h1><?php
echo $this->
pageTitle =
'Advanced Game Search';
?></h1>
<?php
$html->
addCrumb($this->
pageTitle);
echo $form->
create('Search',
array('action' =>
'advanced'));
if (isset($formData) && !
empty($formData)) {
$form->
data =
$formData;
}
echo $form->
input('game_category_id',
array('label' =>
'Category:',
'options' =>
$searchCategories,
'empty' =>
'All Categories'));
echo $form->
input('keywords',
array('label' =>
'Text from game name, description or instructions:'));
echo $form->
input('tags',
array('label' =>
'Is tagged with (separate tags by comma):'));
$orderOptions =
array(
'Game.name' =>
'Name',
'GameCategory.name' =>
'Game Category',
'Game.avg_rating' =>
'Game Rating',
'Game.clicks' =>
'Number of Plays',
);
echo $form->
input('order_by',
array('label' =>
'Order Results By:',
'options' =>
$orderOptions));
echo $form->
input('order_dir',
array('label' =>
'Direction:',
'options' =>
array('asc' =>
'Ascending',
'desc' =>
'Descending')));
?>
<div class="clear"></div>
<?php echo $form->
end('Search',
array('action' =>
'search'))?>
<?php if (isset($games)) :
?>
<div class="clear"></div>
<?php if (!
empty($games)) :
?>
<?php echo $this->
element('../games/list',
array('games' =>
$games,
'hilite' =>
$query))?>
<div class="clear"></div>
<?php echo $this->
element('paging',
array('model' =>
'GameCategoriesGame'))?>
<?php else :
?>
<p class="error-message">Sorry, your search returned no results</p>
<?php endif;
?>
<?php endif;
?>
It should be pretty straightforward. The only weird thing here is that $formData array. It is basically the placeholder for our search criteria that the user originally typed into the search form field. The view only needs to know where the form data is and not where it comes from. We simply assign the data to the form helper so it can prepopulate the fields for us (line 7).
The user can input here a substring of the name/description/instructions of a game and he can pick a category where the game must be in. Notice the different order options as well as the string "All Categories" for the empty option of the select tag. One other remarkable thing is that we have both isset($games) and !empty($games) calls there. This is to differentiate if the user has submitted the form already ( isset($games) ) and, if he did, the resultset is not empty which allows us to display that "Nothing found" message.
Here is the /views/games/list.ctp view just so you have the complete code:
<?php
$short =
isset($short) ?
$short :
false;
$class =
$short ?
' short' :
'';
?>
<div class="games-list">
<?php foreach ($games as $game) :
?>
<div class="game
<?php echo $class ?>">
<div class="game-image">
<?php echo $this->
element('game_image',
array('game' =>
$game,
'thumb' =>
true))?>
</div>
<div class="game-descr">
<?php
$name =
$game['Game']['name'];
if (isset($hilite)) {
$name =
$text->
highlight($game['Game']['name'],
$hilite);
}
echo $html->
link($name, Game::
url($game),
null,
false,
false);
?>
<?php if (!
$short) :
?>
<?php echo $game['Game']['short_desc'] ?>
<div class="plays"><span>Plays:</span>
<?php echo $game['Game']['game_playing_count']?></div>
<?php endif;
?>
</div>
</div>
<?php endforeach;
?>
</div>
?>
Straightforward... Let's move on to the paging element:
<?php
if (!
isset($model) ||
$paginator->
params['paging'][$model]['pageCount'] >
1) :
?>
<div class="paging">
<?php echo $paginator->
prev('« Previous',
array('escape' =>
false,
'class' =>
'prev'),
null,
array('class'=>
'disabled'));?>
<?php echo $paginator->
numbers();?>
<?php echo $paginator->
next('Next »',
array('escape' =>
false,
'class' =>
'next'),
null,
array('class'=>
'disabled'));?>
</div>
<?php endif;
?>
Notice the different checks at the start in order to figure out if we need to display a div at all.. This is called in the advanced.ctp view and the model GameCategoriesGame is supplied, which is a convenience HABTM model which belongsTo both Game and GameCategory.
The controller action
The controller action might appear a little big at first glance. However, every line has its purpose. This is in a SearchesController. You could have your own search() method though in about any controller.
function advanced
() {
$searchCategories =
$this->
Game->
GameCategory->
find('list',
compact('conditions'));
$this->
set(compact('searchCategories'));
$page =
1;
if (isset($this->
params['named']['page'])) {
$page =
$this->
params['named']['page'];
}
$formData =
array();
$sessionKey =
'advanced_search_query';
if (isset($this->
data['Search']['keywords'])) {
$formData =
$this->
data;
$this->
Session->
write($sessionKey,
$formData);
} elseif ($this->
Session->
check($sessionKey)) {
$formData =
$this->
Session->
read($sessionKey);
} else {
Assert::
true(false,
'404');
}
$this->
set(compact('formData'));
if (!
empty($formData)) {
$query =
$formData['Search']['keywords'];
$useQuery =
trim(low
($query));
$conditions =
array();
if (!
empty($formData['Search']['game_category_id'])) {
$conditions['GameCategoriesGame.game_category_id'] =
$formData['Search']['game_category_id'];
}
$conditions = am
($conditions,
array(
'Game.published' =>
'1',
'or' =>
array(
'LOWER(Game.name) LIKE' =>
"%{$useQuery}%",
'LOWER(Game.short_desc) LIKE' =>
"%{$useQuery}%",
'LOWER(Game.long_desc) LIKE' =>
"%{$useQuery}%",
'LOWER(Game.instructions) LIKE' =>
"%{$useQuery}%"
)
));
$this->
GameCategoriesGame->
forcePaginateCount =
$this->
GameCategoriesGame->
paginatorCount(
'game_categories_games',
$conditions,
array('Game')
);
$contain =
array('GameCategory',
'Game.Tag');
$order =
array('Game.name' =>
'asc');
if (!
empty($formData['Search']['order_by'])) {
$order =
array($formData['Search']['order_by'] =>
$formData['Search']['order_dir']);
}
$this->
paginate['GameCategoriesGame'] =
array(
'conditions' =>
$this->
GameCategoriesGame->
paginatorConditions('game_categories_games',
$conditions),
'contain' =>
$contain,
'order' =>
$order,
'limit' =>
12
);
$games =
$this->
paginate('GameCategoriesGame');
$this->
set(compact('games',
'query'));
}
}
So we are first loading all our game categories to populate the select tag. Then we check if there is a named parameter "page" given. If so, the user clicked on the Previous/Next/Numbered links. If it is not present, we might as well start at page 1. ; ]
Now comes the tricky part. We check if the form was submitted via empty($this->data). If it is submitted, we store all the form data in the session. If the form is not submitted we try to recover the form data from the session. If both the form is not submitted and there is no data in the session, but it still a Get request, something bad happened and we fire the user by asserting the yummyness of his cake.
The rest should be familiar - some processing of the $formData array to extract the proper conditions and order stuff. The most interesting stuff now is that call to $this->GameCategoriesGame->paginatorCount('game_categories_games', $conditions, array('Game'));. This enables us to paginate over the HABTM relation (Game HABTM GameCategory). Here is the code from the GameCategoriesGame model:
<?php
class GameCategoriesGame
extends AppModel
{
var $name =
'GameCategoriesGame';
var $belongsTo =
array('GameCategory',
'Game');
/**
* Return count for given pagination
*
* @param string $paginator Pagination name
* @param array $conditions Conditions to use
* @return mixed Count, or false
* @access public
*/
function paginatorCount
($paginator,
$conditions =
array(),
$contain =
array()) {
$Db = ConnectionManager::
getDataSource($this->
useDbConfig);
if (!
empty($contain)) {
$related = ClassRegistry::
init($contain[0]);
}
$sql =
'SELECT
COUNT(DISTINCT ' .
$this->
alias .
'.' .
$this->
belongsTo['Game']['foreignKey'] .
') count
FROM ' .
$Db->
fullTableName($this->
table) .
' ' .
$Db->
name($this->
alias) .
' ';
if (!
empty($contain)) {
$sql .=
' INNER JOIN ' .
$Db->
fullTableName($related->
table) .
' ' .
$Db->
name($related->
alias) .
' ';
}
$sql .=
$Db->
conditions($this->
paginatorConditions($paginator,
$conditions,
'count'));
$count =
$this->
query($sql);
if (!
empty($count)) {
$count =
$count[0][0]['count'];
}
return $count;
}
/**
* Build conditions for given pagination
*
* @param string $paginator Pagination name
* @param array $extraConditions Extra conditions to use
* @param string $method 'count', or 'find'
* @return array Conditions
* @access public
*/
function paginatorConditions
($paginator,
$extraConditions =
array(),
$method =
null) {
$Db = ConnectionManager::
getDataSource($this->
useDbConfig);
$conditions =
null;
if (empty($extraConditions)) {
$extraConditions =
array('1=1');
}
switch (strtolower($paginator)) {
case 'game_categories_games':
if ($method !=
'count') {
$conditions =
array_merge($extraConditions,
array('1=1 GROUP BY ' .
$this->
alias .
'.' .
$this->
belongsTo['Game']['foreignKey']));
} else {
$conditions =
$extraConditions;
}
break;
}
return $conditions;
}
/**
* Executed by the paginator to get the count. Overriden to allow
* forcing a count (through var $forcePaginateCount)
*
* @param array $conditions Conditions to use
* @param int $recursive Recursivity level
* @return int Count
* @access public
*/
function paginateCount
($conditions,
$recursive) {
if (isset($this->
forcePaginateCount)) {
$count =
$this->
forcePaginateCount;
unset($this->
forcePaginateCount);
} else {
$count =
$this->
find('count',
compact('conditions',
'recursive'));
}
return $count;
}
}
?>
To make a long story short: You see we build up the count query on our on and then force Cake to use our calculated count via our own forcePaginateCount property of the model. The Group BY is already in there, we can supply extra conditions and have different queries for different types (see the switch statement in paginatorConditions).
Alas, we have to build the sql on our own for the JOINs, which can become a headache for more complex problems. Anyway, this code gives us enough flexibility to build the right pagination for every problem. :) If you can think of a problem this code cannot be used for, please let me know and we discuss.
The paginateCount() method could go into your AppModel, I just put it here to have the code in one place to keep it simpler.
Conclusion
The method presented has some advantages and disadvantages, as always. The advantages would definitely include that we don't have to extend the controller's paginate() method in our app controller. This is what many people do and what I did in the past as well. However, as always, it's not good manners to hack the core.
Another advantage is the flexibility of the code - with just one line, we can calculate pagination counts for almost every occasion, and even if we paginate over two or three HABTM relations (I can show you later).
Disadvantages include some bloat in your models and the need to write sql again (*sigh*), which can become very complex if you have to supply all the JOINS yourself for more complex problems. Apart from that the code does not yet have full integration of the containable behavior. However, that I can add later.
I hope you liked the article and can put it to some use. Credits go to mariano for the original idea for this. If you guys are interested in seeing how I coupled the "Save search" feature from here with all of this, feel free to ask and we can have some nice discussion.