PrestaShop. Про глюке в багаторівневій навігації

PrestaShop Blocklayered

Привіт Хабр! Я розумію, що історія, про яку я хочу розповісти зовсім звичайна. У кожного програміста, який працює з Open Source, таких випадків до десяти на день. Але я все одно вирішив про неї написати. Комусь вона реально допоможе, а кому-то може просто поліпшить настрій, що теж непогано.

Буде трохи реверс-інжинірингу, трохи філософських роздумів, і звичайно щасливий кінець. Кому важливо тільки виправлення глюку – можете не читати весь цей бред і відразу скопіювати хак з кінця статті. У будь-якому випадку, ласкаво просимо під кат.



Трохи про PrestaShop.
Почалося все з того, що начальство поставило завдання зробити Інтернет-магазин. Вибір був зроблений на користь PrestaShop 1.6 з наступних причин:

  • Написаний на PHP і може бути розгорнуто на нашому власному сервері
  • Адаптивний дизайн прямо з коробки
  • Непогано виглядає зі стандартною темою (в тому числі і на мобільних пристроях).
  • Добре впоралася з більш 50 000 завантажених товарів
  • З коробки є зручний і добре виглядає блок фільтрів (мовою PrestaShop він називається блок багаторівневої навігації)
З останнім пунктом через деякий час і виникло питання, що став предметом цієї статті.

У чому проявляється глюк
Коли товари були вже завантажені і я почав налаштовувати фільтри з'ясувалося, що в певних випадках модуль веде себе некоректно.

Навпроти кожного значення в блоці фільтрів показується число товарів (див. малюнок), яке буде відфільтровано, якщо Ви відзначите цю позицію. Сенс в тому, що при виборі різних значень у різних фільтрах усі кількості хитрим чином перераховуються, забезпечуючи зручну зворотний зв'язок і допомагаючи прийняти рішення про подальше відборі товарів.

Тут і закрався неадекватну поведінку. Якщо Ви зауважили одну властивість, то все працює і нові кількості з'являються. Якщо ви відзначили два і більше властивостей, то кількість навпроти позицій інших фільтрів скидається до початкових (як ніби жодна позиція не вибрано).

Тут потрібно відзначити, що в PrestaShop є поняття атрибутів і властивостей товару. Атрибути (attributes) – це характеристики товару, які беруть участь у формуванні різних версій одного і того ж товару (наприклад розмір взуття для однієї конкретної моделі взуття). Властивості (features) — це характеристики, загальні для всіх варіантів товару. У формуванні варіантів товару вони не беруть участь, а просто інформують користувача про споживчі властивості.

Помилка проявляється, якщо ви відзначаєте позиції в блоці фільтра за властивостями (features). В інших фільтрах (наприклад, по виробнику) цей ефект не проявляється.

Первинні припущення
Стало ясно, що

  • Це глюк (так як з однією позицією все працює, тобто логіка перерахунку код закладена)
  • Цей глюк розташований у модулі багаторівневої навігації (blocklayered)
  • Цей глюк швидше за все пов'язаний з неправильною побудовою умови SQL — запиту (пояснити я не можу, це більше на інтуїтивному рівні).
Пошуки в Інтернеті нічого не дали, тому стояв вибір:

  • Залишити все як є, погодившись, що це особливість функціонування магазину.
  • Залізти з головою в код і, проявивши ентузіазм, знайти причину глюка (і виправити).
Я прийняв інше рішення. Ентузіазм поменшало, коли я відкрив файл blocklayered.php. Він містив понад 3,5 тисяч рядків коду з яких 70% — багатоповерхові SQL-запити. Завдання стала походити на пошуки голки в стозі сіна. Спочатку я злякався, і навіть недобре подумав про творців PrestaShop. Але потім прикинув, як би я став програмувати непросту логіку роботи такого модуля і трохи поуспокоился. Завдання дійсно складна і, швидше за все, складність коду викликана об'єктивними причинами. Але все одно, при роботі з модулем не залишала думка, що можна зробити все це якось красивіше.

Інструменти та прийоми
При вирішенні проблеми будемо використовувати наступні інструменти:

WinSCP — надійний FTP-клієнт з безліччю функцій. Ні разу не підводив навіть на великих кількостях файлів і обсягах. Всі функції доступні в тому числі і з командного рядка, що робить його корисним при написанні скриптів.

UwAmp — проста в установці та налаштування WAMP-збірка. Будемо її використовувати для локального запуску досліджуваного коду.

Noteped++ — чудовий редактор для реверс-інжинірингу. Робота в різних кодуваннях і з різними кінцями рядків. Гарна підсвітка синтаксису. Відкриття великих файлів. Пошук рядків в тому числі і за файлів в каталогах. Працює дуже надійно.

HeidiSQL — GUI для MySQL. Безкоштовний графічний інструмент для баз даних. Іноді глючить, але в цілому працювати дуже зручно. Використовуємо його для вивчення вмісту бази даних при аналізі коду.

Основними прийомами буде дамп змінних і пошук по вихідного коду імен функцій і шматків коду. Оскільки нас цікавлять події відбуваються у тому числі і в ajax-запитів дамп змінних робимо в файл. Для цього там де потрібно вставляємо наступний код:

$f=fopen('headfire.txt','a+');
fwrite($f,$very_important_variable);
fwrite($f, PHP_EOL);
fclose($f);


headfire у назві файлу – мій псевдонім, я його використовую, там де треба позначити саме мій код або файл. Ви повинні використовувати свою кодову рядок. Важливо, щоб її легко було знайти і важко сплутати з іншими файлами або рядками коду.

Початок аналізу
Приблизно половина коду відповідає за BackOffice. Цей код відсіваємо відразу і намагаємося туди не заглядати.

Розкручування проблеми починаємо з пошуку tpl-файлу, відповідального за виведення наших фільтрів на сторінку. Шукати довго не довелося. Tpl-файл лежить в корені модуля і називається blocklayered.tpl. Заглянувши в нього переконуємося, що в ньому є рядок виводу кількості, яке у нас глючить.

<a href="{$value.link}" data-rel="{$value.rel}">{$value.name|escape:html:'UTF-8'}{if $layered_show_qties}<span> ({$value.nbr})</span>{/if}</a>


Краєм ока помічаємо, що кількість виводиться за умовою $layered_show_qties, а сама кількість має абревіатуру nbr. Може це пригодиться, а може ні.

Наступним кроком знаходимо місце, де викликається шаблон blocklayered.tpl. Це виявляється функція

public function generateFiltersBlock($selected_filters);


Для перевірки з'ясовуємо, що вона викликається два рази – один з хука лівої колонки, інший з ajax-запиту. Начебто схоже на правду. Сама функція невелика, але в ній є виклик функції, яка готує дані для шаблону

public function getFilterBlock($selected_filters = array())


Ця функція займає понад 800 рядків. В ній купа SQL-запитів. Швидше за все, тут зосереджена вся логіка формування фільтрів. Що примітно, що в модулі вона викликається 5 разів. Здається, що занадто накладно обчислювати стільки запитів 5 разів поспіль. Але потім помічаєш змінну

static $cache = null;


і розумієш, що це старий добрий трюк з кешуванням в статичної змінної. І ще розумієш, що писали код страшенні PHP-шники, які не зупиняться ні перед чим.

AND, OR і свята вода
Потрібно якось вивчити роботу функції. Глюк відбувається в момент, коли в фільтрі запалюється друга позначка. А це супроводжується Ajax-запитом. Тому використовуємо дамп змінної у файл.

З допомогою міцної кави і танців з бубнами навколо газової плити знаходимо місце, де надсилається основний запит для кожного блоку фільтра і вставляємо туди налагоджувальний код, що виводить цей запит у файл (змінна $sql_query):



// headfire debug begin
$f=fopen('headfire.txt','a+');
fwrite($f,print_r($sql_query,true));
fwrite($f, PHP_EOL);
fclose($f);
// headfire debug end

$products = false;
if (!empty($sql_query['from']))
{
$products = Db::getInstance()->executeS($sql_query['select']."\n".$sql_query['from']."\n".$sql_query['join']."\n".$sql_query['where']."\n".$sql_query['group']);
}


Зверніть увагу -$sql_query – масив. Це видно з коду, тому виводимо в дамп з допомогою print_r з прапором true.

Відразу перший же висновок у файл кричить нам про проблему:

Array
(
[select] => SELECT p.`id_product`, sa.`кількість`, sa.`out_of_stock` 
[from] => 
FROM ps_cat_restriction p
[join] => LEFT JOIN `ps_stock_available` sa
ON (sa.id_product = p.id_product AND sa.id_product_attribute=0 AND sa.id_shop = 1 AND sa.id_shop_group = 0 ) LEFT JOIN `ps_manufacturer` m ON (m.id_manufacturer = p.id_manufacturer) 
INNER JOIN `ps_layered_price_index` psi ON (psi.id_product = p.id_product AND psi.id_currency = 1
AND psi.price_min <= 3631136 AND psi.price_max >= 4618 AND psi.id_shop=1) 
[where] => 1 AND WHERE EXISTS (SELECT * FROM ps_feature_product fp WHERE fp.id_product = p.id_product AND fp.`id_feature_value` = 26634 OR fp.`id_feature_value` = 22096)
[group] => 
[second_query] => 
)


Зверніть увагу на умову[where]: там написано в одну сходинку AND і OR, причому OR знаходиться між однорідних умов і не виділено дужками.

fp.id_product = p.id_product AND fp.`id_feature_value` = 26634 OR fp.`id_feature_value` = 22096


Я переконаний, що всякий розсудлива програміст, помітивши, що в якомусь умови комбінуються AND і OR і при цьому не розставлено дужки, відразу повинен бігти за святою водою і окраплять їй монітор, вінчестер, і клавіатуру, щоб ця зараза не поширилася навколо.

Якщо серйозно, то навіть при побіжному погляді на умову і виходячи з характеру проблеми стає ясно, що помилка саме тут – OR забули взяти в дужки. Залишилося тільки знайти це місце і виправити. Але тут нас підстерігає маленький сюрприз.

Сюрприз на останок: динамічна диспетчеризація
Намагаємося знайти місце, де формується помилкове умова. Використовуємо для цього уривки з трасування виводу. Пошук по 'fp.`id_feature_value` наводить нас на функцію:

private static function getId_featureFilterSubQuery($filter_value, $ignore_join = false


Це те, що потрібно. Бачимо код, який формує умова без дужок і замінюємо його на правильний. Принагідно хочу зауважити на те, як вставляються OR – вони вставляються завжди, а останній OR откусывается (причому варварським методом).

foreach ($filter_value as $filter_val)
$query_filters .= 'fp.`id_feature_value` = '.(int)$filter_val.' OR ';
$query_filters = rtrim($query_filters, 'OR ').') ';


Вважаю це недоцільним. Тому переписую цей шматок коду в своєму стилі. Внизу наводиться вихідний і виправлений код функції.

//modules/blocklayered/block blocklayered.php

private static function getId_featureFilterSubQuery($filter_value, $ignore_join = false)
{
if (empty($filter_value))
return array();

$query_filters = ' AND EXISTS (SELECT * FROM '._DB_PREFIX_.'feature_product fp WHERE fp.id_product = p.id_product AND ';
foreach ($filter_value as $filter_val)
$query_filters .= 'fp.`id_feature_value` = '.(int)$filter_val.' OR ';
$query_filters = rtrim($query_filters, 'OR ').') ';

return array('where' => $query_filters);
}



//modules/blocklayered/block blocklayered.php

private static function getId_featureFilterSubQuery($filter_value, $ignore_join = false)
{
if (empty($filter_value))
return array();

//headfire hack begin
$query_filters = ' AND EXISTS (SELECT * FROM '._DB_PREFIX_.'feature_product fp WHERE fp.id_product = p.id_product AND ';
$query_filters1 = ";
foreach ($filter_value as $filter_val) {
if ($query_filters1) $query_filters1 .= 'OR ';
$query_filters1 .= 'fp.`id_feature_value` = '.(int)$filter_val;
}
$query_filters .= '( '.$query_filters1.' )'.')';
// headfire hack end

return array('where' => $query_filters);
}


А сюрприз полягає в тому, що в модулі ця функція прямо ніде не викликається. Коряве ім'я наводить на сумні думки про те, що воно десь формується програмно і викликається динамічно. Так воно і є, причому в інших подібних місцях зроблені чесні case. А тут вирішили пограти з динамічною диспетчеризацією.

Кінець історії
Виправлений Глюк. Фільтри стали працювати красиво. Можливо ця помилка вже вирішена в нових релізах PrestaShop. Ну а якщо немає і у Вас розкрилася схожа проблема – я радий якщо зміг Вам допомогти. І ще – не скупіться на дужки, навіть якщо порядок дій очевидний.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.