Paingation в CakePHP с таблицами HABTM связями

Основная проблема для меня в CakePHP — это осуществление pagination штатными средствами для таблиц связанных HABTM.

Не знаю как лучше — назвать эту заметку переводом, или расширенным переводом. Я использую две статьи, плюс свои плюшки. Поэтому ссылки я проставлю, а перевод это, или компиляция — не столь важно, как мне кажется.

После недолгого шуршания по Интернету я нашёл решение вот по этому адресу: Pagination for HABTM. Хочу отметить что решение работает и для последней (на текущий момент) версии CakePHP 1.3.4.

Однако тут есть нюанс, который очень сильно портит малину. Если элемент связан с несколькими другими, то штатный педжинатор неправильно считает количество элементов, потому как они дублируются. Но и для этого есть решение DISTINCT in pagination.

Итак, переходим к задаче.

Что у нас есть на входе?

CakePHP 1.3.4 версии, MySQL 5.1.37

Есть категория товара («Принтеры лазерные», «Картриджи» и т.д.), есть производитель («HP», «Xerox»), есть виды характеристик («Формат печати», «Цветность»), есть сами эти характеристики — («А4», «А3» и, соответственно «Цветной», «Монохромный»), и есть сам товар, который лежит в одной из категорий, имеет одного производителя, может иметь характеристику (принтеры имеют формат печати в списке характеристик, а телефоны — нет), а характеристика, соответственно имеет свой список (для формта — «А4», «А3»).

Что нам необходимо? Необходимо чтобы делалась выборка (с работающей педжинацией) по следующему запросу (например): показать категорию товара 1, выбрать только производителя 2, показать товар, у которого есть в параметрах что-то из списка атрибутов («А4» или «А4» и «А3»).


Для начала, посмотрим как выглядят таблицы. (я сознательно опустил огромное количество полей, которые не влияют на результат — чтобы не загромождать и без того долгую статью)

Таблица производителей

CREATE TABLE `produces` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
 PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `produces` VALUES(1, 'Xerox');
INSERT INTO `produces` VALUES(2, 'Hewlett Packard');
INSERT INTO `produces` VALUES(3, 'Alcatel'); 


Таблица категорий

CREATE TABLE `categories` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
 PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `categories` VALUES(1, 'Принтеры лазерные');
INSERT INTO `categories` VALUES(2, 'Принтеры струйные');
INSERT INTO `categories` VALUES(3, 'Принтеры матричные');
INSERT INTO `categories` VALUES(4, 'Копировальные аппараты');


Таблица товаров

CREATE TABLE `goods` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `created` datetime NOT NULL,
 `model` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
 `category_id` bigint(20) NOT NULL,
 `produce_id` bigint(20) NOT NULL,
 PRIMARY KEY (`id`),
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 

INSERT INTO `goods` VALUES(8, '2010-12-14 00:29:33','model1', 1, 1);
INSERT INTO `goods` VALUES(7, '2010-12-14 00:21:59', 'model2', 1, 2);
INSERT INTO `goods` VALUES(9, '2010-12-14 13:50:51', 'model3', 2, 2);


Таблица типов характеристик

CREATE TABLE `orders` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
 PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `orders` VALUES(1, 'Цветопередача');
INSERT INTO `orders` VALUES(2, 'Формат');
INSERT INTO `orders` VALUES(3, 'Тип печати');


И таблица собственно характеристик

CREATE TABLE `ordernames` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `order_id` bigint(20) NOT NULL,
 `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
 PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `ordernames` VALUES(1, 1, 'цветные');
INSERT INTO `ordernames` VALUES(2, 1, 'монохромные');
INSERT INTO `ordernames` VALUES(3, 2, 'A3');
INSERT INTO `ordernames` VALUES(4, 2, 'A4');
INSERT INTO `ordernames` VALUES(5, 2, '10x15 cm');
INSERT INTO `ordernames` VALUES(6, 2, '42”');
INSERT INTO `ordernames` VALUES(7, 3, 'на термобумаге');
INSERT INTO `ordernames` VALUES(8, 3, 'на термоплёнке')
;

Есть ещё, табличка, которая связывает категории и order — потому как не у каждой категории товара есть уточняющий список характеристик. Эта табличка служит для формирования странички выдачи товара и сортировочного меню, поэтому считаю её вспомогательной и не требующей разъяснения или публикации.

Итак, таблички созданы, теперь необходимо связать товар с характеристиками. Для этого создаётся вспомогательная табличка:

CREATE TABLE `goods_ordernames` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `good_id` bigint(20) NOT NULL,
 `ordername_id` bigint(20) NOT NULL,
 PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;


Для связи конкретного товара с характеристикой (и не одной) из таблицы ordernames. Связываем таблички напрямую, без участия orders — зачем нам посредник?

Теперь пришла пора строить выборку

Отобрать товар по категории и производителю не представляет труда, ибо id-шник присутствует в таблице товара и нет необходимости делать какие-либо join-ы дополнительные. Тут всё просто.

Нас же интересует — как выбрать товар, обладающий характеристиками A4 и A3 одновременно, или только А3 (например).

Берём решение из первой ссылки, и прикручиваем внутрь контроллера:

public $paginate = array('Good' => array('limit' => 1, 'joins' => array( 
 array( 
 'table' => 'goods_ordernames', 
 'alias' => 'GoodsOrdername', 
 'type' => 'inner', 
 'conditions'=> array('GoodsOrdername.good_id = Good.id') 
 ), 
 array( 
 'table' => 'ordernames', 
 'alias' => 'Ordername', 
 'type' => 'inner', 
 'conditions'=> array( 
 'Ordername.id = GoodsOrdername.ordername_id', 
 'Ordername.id' => 3 
 ) 
 )),'contain' => array('GoodsOrdername','Ordername','Produce'),'conditions' => array('produce_id' => 1)));


Затем мы далее переопределим эту переменную, в зависимости от потребности. А потребностей у нас может быть две — первая нас интересуют характеристики товара, и вторая — нас не интересуют характеристики товара.

($named — массив характеристик).

if ($named != array()) {
$order = array('Ordername.id' => $named);
$this->paginate = array('Good' => array('limit' => 10, 'joins' => array( 
array( 
 'table' => 'goods_ordernames', 
 'alias' => 'GoodsOrdername', 
 'type' => 'inner', 
 'conditions'=> array('GoodsOrdername.good_id = Good.id') 
), 
array( 
 'table' => 'ordernames', 
 'alias' => 'Ordername', 
 'type' => 'inner', 
 'conditions'=> array( 
 'Ordername.id = GoodsOrdername.ordername_id',
 $order
 ) 
)),'order' => $sort,'fields' => array('DISTINCT Good.id','Good.*'),'conditions' => array('and' => array($produce,'Good.category_id' => $id))));

}
else {
 $this->paginate = array('Good' => array('limit' => 10, 'order' => $sort,'fields' => array('DISTINCT Good.id','Good.*'),'conditions' => array('and' => array($produce,'Good.category_id' => $id))));
}


Обратите внимание на две вещи, во первых нам не нужны дубли, и используем DISTINCT для отсекания повторов (а повторы будут, если товар обладает и «А4» и «А3» а мы выбираем обе характеристики). Второе — если массив характеристик не задан — мы не городим огород с join-ами. Логично? Логично. Параметр 'order' — тут вроде всё понятятно, $poduce — тоже ('Good.produce_id' => array(1,3,4...));

Считаем страницы

Всё? Не всё. Дело в том, что мы выбираем нужный нам товар — без дублирования. Но, при этом счётчик педжинации считает всё (если товар обладает характеристикой A3 и A4 — то, он будет посчитан дважды) — то есть имея на самом деле 4 единицы товара со множественными характеристиками, счётчик страниц покажет нам 2-3-4-5… страниц для листания, хотя на самом деле товара значительно меньше. Что делать? Открываем вторую ссылку приведенную в начале статьи, и дополняем модель good следующим:

/**
 * 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;
 }


Всё. Теперь всё работает так, как надо. В комментариях ко второй статье было указано — DISTINCT — зло. Но увы, я не знаю как по другому решить эту задачу. Если вам помогла эта статья — я рад, если вы поможете мне решить задачу ещё более простым способом — я буду счастлив.

P.S. Адрес сайта, где реализован данный механизм я приведу через неделю. Потому что клиент ещё находится на стадии «приёма» и я не хочу подвергать сайта хабраэффекту (если, конечно, таковой будет).


0 комментариев

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.