Pagination of data from a HABTM relationship
Recently I got asked how you could paginate data from a HABTM (hasAndBelongsToMany) relationship. As the answer could be interesting for others, too, I will show a potential solution here.
Let’s say you have the two models Category and Product, and you want to paginate the Products of a certain Category. Your table definition looks like:
create table categories (
id int(11) not null auto_increment,
name varchar(255) not null,
primary key (id)
);
create table products (
id int(11) not null auto_increment,
name varchar(255) not null,
primary key (id)
);
create table categories_products (
category_id int(11) not null,
product_id int(11) not null
);
And the models are:
// app/models/category.php
class Category extends AppModel {
var $hasAndBelongsToMany = array('Product' );
}
respectively
// app/models/product.php
class Product extends AppModel {
var $hasManyAndBelongsToMany = array('Category');
}
Before we move on, let’s add some test data to the tables:
insert into categories (id, name) values (1, 'PHP'); insert into categories (id, name) values (2, 'Framework'); insert into products (id, name) values (1, 'CakePHP'); insert into products (id, name) values (2, 'Zend'); insert into products (id, name) values (3, 'Symfony'); insert into products (id, name) values (4, 'Rails'); insert into products (id, name) values (5, 'Django'); insert into categories_products (category_id, product_id) values (1, 1); insert into categories_products (category_id, product_id) values (1, 2); insert into categories_products (category_id, product_id) values (1, 3); insert into categories_products (category_id, product_id) values (2, 1); insert into categories_products (category_id, product_id) values (2, 2); insert into categories_products (category_id, product_id) values (2, 3); insert into categories_products (category_id, product_id) values (2, 4); insert into categories_products (category_id, product_id) values (2, 5);
Now we want to paginate all products in the category “PHP”. For this purpose we write the following code:
// app/controllers/categories_controller.php
class CategoriesController extends AppController {
var $paginate = array('limit' => 2);
function index() {
$data = $this->paginate('Category', array('Category.id' => 1));
debug($data);
exit;
}
}
The output of this code is:
Array
(
[0] => Array
(
[Category] => Array
(
[id] => 1
[name] => PHP
)
[Product] => Array
(
[0] => Array
(
[id] => 1
[name] => CakePHP
)
[1] => Array
(
[id] => 2
[name] => Zend
)
[2] => Array
(
[id] => 3
[name] => Symfony
)
)
)
)
Obviously the wrong approach as we get three products instead of two. Hm, maybe we have to use the Product model in the paginate statement. Let’s try it:
// app/controllers/categories_controller.php
class CategoriesController extends AppController {
var $paginate = array('limit' => 2);
function index() {
$data = $this->paginate('Product', array('Category.id' => 1));
debug($data);
exit;
}
}
This causes the following error:
SQL Error: 1054: Unknown column 'Category.id' in 'where clause'
Hm. Couldn’t we use somehow the mapping table required for our HABTM association? Sure, we can create a model for it:
// app/model/categories_product.php
class CategoriesProduct extends AppModel {
var $belongsTo = array('Category', 'Product');
}
And use this model in the paginate statement:
// app/controllers/categories_controller.php
class CategoriesController extends AppController {
var $paginate = array('limit' => 2);
function index() {
$data = $this->paginate('CategoriesProduct', array('Category.id' => 1));
debug($data);
exit;
}
}
This time we get the expected result:
Array
(
[0] => Array
(
[CategoriesProduct] => Array
(
[category_id] => 1
[product_id] => 1
)
[Category] => Array
(
[id] => 1
[name] => PHP
)
[Product] => Array
(
[id] => 1
[name] => CakePHP
)
)
[1] => Array
(
[CategoriesProduct] => Array
(
[category_id] => 1
[product_id] => 2
)
[Category] => Array
(
[id] => 1
[name] => PHP
)
[Product] => Array
(
[id] => 2
[name] => Zend
)
)
)
If there is a better solution, please let me know ;-)




Man… I can’t thank you enough for the help. I was not sure if I could make a model for the Join table. This helps a ton. Back to bakin….
Thank you so much… that is exactly what I need for my tags. You are great.
Now if you could make this happen for Cake 1.1, that would be peachy keen.
I think you can still solve this using:
$this->paginate(’Category’, array(’Category.id’ => 1));
ALSO using a jointable model AND the “with” key supported by CakePHP 1.2 used in HABTM assocations.
Never mind the article, check out any posts mentioning the “with” key.
http://www.thinkingphp.org/2006/10/26/modeling-relationships-in-cakephp-faking-rails-throughassociation/
It comes down to providing the jointable model in the with key for the Category:
var $hasAndBelongsToMany = array(
‘Product’ => array(
‘with’ => ‘CategoriesProduct’,
‘className’ => ‘Product’…
Every Category record returned by any find or a findAll will contain ALL products for THAT Category record, provided that $this->Category->recursive equals 1 or more.
I have not tested it myself if it works for this specific case (with pagination and all), but I think it should work as I have used the with key in my own project. While it’s more Cakelike to use this (sometimes very handy!) feature, your more basic solution works as well for this case, and is probably less confusing to people just starting with CakePHP.
Okay wait, I guess the “with” approach will not work in THIS case because it will limit the number of categories instead of products, but it can be very usefull in other situations obviously. (Maybe paginating lists of posts along with their own tags?)
You don’t need to modelize the join table, you can use CakePHP’s built in one: Modelizing HABTM join tables in CakePHP 1.2: with and auto-with models
@all: Thanks for your comments!
@Daniel, snowdog: I am glad it helps you guys!
@airrob: I have to disappoint you, I don’t know how to do that with CakePHP 1.1.x.x.
@Tim: I tried it with “with”, but without luck…
@Mariano: I tried the approach shown in your article. It works fine with findAll combined with bindModel. But for some reason bindModel does not affect paginate, and so I get only data from CategoriesProduct in my result set.
@cakebaker: yeah, that’s probably because paginate() will issue a findCount first, which would then reset the dynamic models before it does the actual findAll.
@cakebaker
I encountered the same issue of findCount() resetting associations with Controller::paginate() and I found that setting the 2nd param of (un)?bindModel() to false persisted the associations and didn’t reset them.
Model::bindModel($params, $reset = true);
@ Mariano:
IMO, Model::findCount() should be refractored because;
1. The reason mentioned herein. Since you are not returning associative data recursively with findCount(), resetting the associations after it’s execution doesn’t make sense.
2. findCount() sends $results to the afterFind() callback in both the Model and each Behavior. I don’t think I have ever come into a situation where I need to modify the results from a findCount() :)
[...] cakebaker ยป Pagination of data from a HABTM relationship The trick is making a model for the join table (tags: cakephp habtm pagination) Posted by Richard@Home Filed in 15 [...]
@Brandon: you could define your own method to do the count in the model, which receives two parameters: ($conditions, $recursive). Inside that method you could do your bindModel() right after you have done your own findCount. Call that method paginateCount and thats it:
Model {
function paginateCount($conditons, $recursive) {
$count = $this->findCount($conditions, $recursive);
// My bindModels here, before the findAll()
// paginate executes
return $count;
}
}
@Mariano, Brandon: Thanks for your comments!
@Brandon: Yes, with the second param set to false it works fine (and you can then omit the CategoriesProduct model)
I don’t know about the paginateCount function. It feels like it was put in as a workaround for pagination issues, so I don’t really like it. I think it would take a lot of people (such as myself) a long time before they really know how to use it.
Suppose you’re a beginner and you need to overcome this HABTM paginationproblem.
By the time you have the solution, you’ll know how bindModel works, that findCount is run before pagination, that this call resets the assocations, that you can override the paginateCount method (I know it by digging in the sourcecode for an hour or so, and finding some snippet by phpnut), how to override this paginateCount method etc etc…
That’s just too much if all you want is pagination where a HABTM relation is involved.
@Mariano, I have read your HABTM auto with article but for some reason it does not modelize my jointable automatically. Is the automodelizing feature a recent thing? (I’m using the 1.2 July release from the site)
I agree with Tim that the Model::paginateCount() seems like nothing more than a bandaid that just adds another layer of configuration to make the default functionality of Controller::paginate() work as it should.
Why not just take the time and refractor Model::findCount() to correct these issues?
Also, I think that bringing “pagination” functionality into the Model is bad practice since pagination is Controller specific functionality.
I have 98 more cents to go on this topic :)
@Tim: mm strange I can’t remember when the auto model was added but always try to keep your local 1.2 branch UP TO DATE. For example if you are running on 1.2 alpha from july you are looking at a completely diff 1.2 release in regards to core.php and other issues :) Try updating the branch and test those with models again, they should be working out of the box for hasAndBelongsToMany bindings
@Brandon, yeah, never looked at it that way, but I think you are right that any paginationstuff does not belong in a Model for that reason.
However, I don’t think that findCount should be refactored. Because it is using findAll (like any other non-custom query) it’s functionality can be altered by using callbacks. Even though afterFind is not really usefull, beforeFind might be, since you can add/alter conditions.
I think it is up to the pagination functionality itself to make sure everything works as expected. Maybe the developer should be able to tell how the results should be counted in one (easy) way or another when paginating. For example, count either the number of unique product id’s, or the number of unique category id’s when paginating HABTM…which is actually possible without using findCount or even hitting the database. Also, when the the database is not hit by using findCount, the associations would not be reset…just a thought.
please describe properly
@aditya: What is unclear?
[...] tempdocs.cakephp.org - Pagination http://www.littlehart.net - CakePHP Pagination With A HABTM Relationship cakebaker.42dh.com - Pagination of data from a HABTM relationship [...]
@cakebaker
But what if you have now a Model named ‘Theme’ like in your Categories Model :
var $belongsTo = array (’Theme’ => array(
‘className’ => ‘Theme’,
‘foreignKey’=>’theme_id’)
);
A ‘theme’ can be for instance “web programming” or “football”, it is just for the example.
And now how do you retreive the datas of the Theme model with your method ?
$data = $this->paginate(’CategoriesProduct’, array(’Category.id’ => 1));
debug($data);
$data will never return any datas from theme ?
[apology]I have a very bad english accent, I know that…[/apology]
@Fred: Hm, I’m not sure I understand you correctly. But if you have a “Theme” model instead of the “Category” model I used, you simply have to replace “Category” respectively “Categories” with “Theme” and “Themes”.
Hope that helps!
If not, please describe the scenario you try to implement in more detail (you can also send me an email).
@cakebaker
There is a misunderstanding. The problem is not to change the name Category for Theme.
Keep the structure you gave in example and add a new model named Theme with the $belongsTo I gave.
In your example, a category can be ‘php’ or ‘framework’ or ‘ruby’, ok ?
Now, I add a Theme model where a theme can be ‘web programming’ or ‘football’, because you can also have categories depending to the theme ‘football”.
And if I try to paginate my categories by theme and do a debug($data), I don’t have any results of Theme in my array.
@Fred: Thanks for the clarification!
At the moment I don’t see a way to get the Theme data directly. A workaround could be to extract the theme_id from the result, and then to get the theme data with an extra query.
Hope that helps.
Hello Cakebaker,
For all who wish to be interested to have pagination of data(s) from a HATBM, you can also read this excellent article (in french) from :
http://www.formation-cakephp.com/30/pagination-avec-criteres-de-filtrage-complexes
This method allows to combine multi-params from a post (or anything you want), and you can have pagination with date, or with date + authors, or date + authors + books, etc.
In fact, I used their method, whitch get $this->param['named'] in the URL, and combine it with a personnal “cooking” method (PCM) to get results if a model is like I described in my previous post.
This “PCM” can be performed better with time, because I’m not enought cakemaker to know if there is a better way now. I get the results of Theme data (remember my previous post) in the view by using requestAction method.
I’am sorry if my english is not very clear ; hope you can understand anyway :)
@Fred: Thanks for the link (even though I don’t understand everything there as I’m still a French beginner)!
It’s good to hear you found a solution for your problem :)
Anyway, I’m a new to CakePHP and this helped me a lot. Thanks for the article.
@Keerthi: I’m glad this article was useful for you!
wow.. it great.. thanks for articles :D
Great article. I had been struggling with this exact problem, with categories > products even :)
I did run into an issue on the view side though. Since category and product each have the ‘name’ field I had to be sure to set the paginator->sort() to specifically use the correct field ( Product.name ) but even so this column will not automatically switch between ASC and DESC.
@Corie: Hm, what do you mean with “will not automatically switch between ASC and DESC”? It seems I’m slow on the uptake ;-)
@cakebaker: In your view if you sort by a field like Model.field the helper will always set the direction to ASC.
I found a workaround here: http://bakery.cakephp.org/articles/view/advanced-pagination-1-2 (comment #7) [...] as a workaround I copied paginator.php to my app/views/helpers directory and changed the line
return preg_replace(’/.*\./’, ”, key($options['order']));
in the function sortKey to
return preg_replace( sprintf( ‘/%s\./’, $this->defaultModel()), ”, key($options['order'])); [...]
This does seem to fix the issue.
@Corie: Ah, thanks for the explanation, now I got it :) And I’m glad you found a fix for the issue!