Всё, что вы всегда хотели знать о группировке
Работая с данными в шаблонах, вы часто сталкиваетесь с необходимостью сгруппировать элементы, разбить их на порции или обойти по условию. Latte предлагает для этого три инструмента, каждый из которых подходит для немного разной ситуации.
Фильтр |group группирует элементы по заданному критерию, фильтр
|batch разбивает их на порции фиксированного размера, а тег
{iterateWhile} обходит данные шаг за шагом и сам решает, когда прервать
внутренний цикл. Разберём их по очереди.
Фильтр и функция group
Инструмент можно использовать в двух формах: как фильтр
$items|group: … или как функцию group($items, …). Семантически они
равнозначны, выбирайте по читаемости.
Представьте таблицу базы данных items, элементы которой
относятся к разным категориям:
| id | categoryId | name |
|---|---|---|
| 1 | 1 | Яблоко |
| 2 | 1 | Банан |
| 3 | 2 | PHP |
| 4 | 3 | Зелёный |
| 5 | 3 | Красный |
| 6 | 3 | Синий |
Простой вывод всех элементов с помощью шаблона Latte выглядел бы так:
<ul>
{foreach $items as $item}
<li>{$item->name}</li>
{/foreach}
</ul>
Но если бы мы хотели упорядочить элементы по группам-категориям, нам нужно разделить их так, чтобы у каждой категории был свой список. Желаемый результат выглядел бы так:
<ul>
<li>Яблоко</li>
<li>Банан</li>
</ul>
<ul>
<li>PHP</li>
</ul>
<ul>
<li>Зелёный</li>
<li>Красный</li>
<li>Синий</li>
</ul>
Эта задача легко и изящно решается с помощью |group. В качестве
параметра мы указываем categoryId, то есть элементы будут разделены
на меньшие массивы по значению $item->categoryId (если бы $item
был массивом, использовалось бы $item['categoryId']):
{foreach ($items|group: categoryId) as $categoryId => $categoryItems}
<ul>
{foreach $categoryItems as $item}
<li>{$item->name}</li>
{/foreach}
</ul>
{/foreach}
Если вы хотите группировать элементы по более сложному критерию, в параметре фильтра можно передать функцию. Ключом каждой группы тогда станет возвращаемое значение функции: например, при группировке по длине имени это будет число символов:
{foreach ($items|group: fn($item) => strlen($item->name)) as $length => $group}
...
{/foreach}
Важно помнить, что каждая группа (включая $categoryItems) – это не
обычный массив, а объект, ведущий себя как итератор, поэтому обратиться
к элементам по индексу, например $categoryItems[0], нельзя. Зато вы
можете посчитать элементы через count($categoryItems), а для доступа к
первому элементу группы используйте функцию first().
Такая гибкость делает |group исключительно полезным
инструментом для представления данных.
Вложенные циклы
Представим, что в нашей таблице базы данных есть дополнительный
столбец subcategoryId, задающий подкатегорию каждого элемента. Мы
хотим вывести каждую главную категорию в отдельном списке
<ul>, а каждую подкатегорию внутри этой главной категории – в
отдельном вложенном списке <ol>:
{foreach ($items|group: categoryId) as $categoryItems}
<ul>
{foreach ($categoryItems|group: subcategoryId) as $subcategoryItems}
<ol>
{foreach $subcategoryItems as $item}
<li>{$item->name}
{/foreach}
</ol>
{/foreach}
</ul>
{/foreach}
Вместе с Nette Database
Покажем, как эффективно использовать группировку данных вместе с Nette
Database. Предположим, мы работаем с таблицей items из вводного
примера, связанной через столбец categoryId вот с этой таблицей
categories:
| categoryId | name |
|---|---|
| 1 | Фрукты |
| 2 | Языки |
| 3 | Цвета |
Данные из таблицы items мы загружаем с помощью Nette Database Explorer
командой $items = $db->table('items'). При обходе этих данных мы можем
обращаться не только к атрибутам вроде $item->name и
$item->categoryId, но и, благодаря связи с таблицей categories, к
связанной записи через $item->category. Эта связь открывает
интересные возможности:
{foreach ($items|group: category) as $category => $categoryItems}
<h1>{$category->name}</h1>
<ul>
{foreach $categoryItems as $item}
<li>{$item->name}</li>
{/foreach}
</ul>
{/foreach}
В этом случае мы используем фильтр |group для группировки по
связанной записи $item->category, а не только по столбцу categoryId.
В результате ключ ($category) прямо содержит объект ActiveRow
соответствующей категории, поэтому мы можем вывести её имя через
{$category->name} и обратиться к любому другому столбцу без
отдельного запроса к categories.
Фильтр |batch
Фильтр разбивает список элементов на порции фиксированного размера. Это удобно для сеточных раскладок, разбиения по колонкам или любой визуальной группировки.
Представим, что мы хотим вывести элементы в списках, каждый из которых содержит максимум три элемента:
{foreach ($items|batch: 3) as $batch}
<ul>
{foreach $batch as $item}
<li>{$item->name}</li>
{/foreach}
</ul>
{/foreach}
В этом примере список $items разделяется на меньшие группы,
каждая из которых ($batch) содержит до трёх элементов. Затем каждая
порция выводится в отдельном списке <ul>.
Если в последней группе не хватает элементов до нужного количества, второй параметр фильтра позволяет задать, чем эта группа будет дополнена. Это идеально подходит для эстетичного выравнивания элементов там, где неполный ряд выглядел бы неаккуратно.
{foreach ($items|batch: 3, '—') as $batch}
...
{/foreach}
Тег {iterateWhile}
Покажем те же задачи, которые мы решали фильтром |group, с помощью
тега {iterateWhile}. Главное отличие двух подходов в том, что
|group сначала обрабатывает и группирует все входные данные, тогда
как {iterateWhile} управляет ходом цикла через условие, и обход идёт
последовательно.
Сначала выведем таблицу с категориями с помощью {iterateWhile}:
{foreach $items as $item}
<ul>
{iterateWhile}
<li>{$item->name}</li>
{/iterateWhile $item->categoryId === $iterator->nextValue->categoryId}
</ul>
{/foreach}
Пока {foreach} обозначает внешнюю часть цикла, то есть отрисовку
списков для каждой категории, тег {iterateWhile} обозначает
внутреннюю часть, то есть отдельные элементы. Условие в закрывающем
теге говорит, что повторение продолжится, пока текущий и следующий
элементы относятся к одной категории ($iterator->nextValue – это следующий элемент; для последнего
элемента внутренний цикл заканчивается, потому что следующего
элемента нет – Latte проверяет $iterator->hasNext() перед вычислением
условия, поэтому до сравнения с null дело не доходит).
Если бы условие всегда было истинным, все элементы отрисовались бы
внутри первого <ul>:
{foreach $items as $item}
<ul>
{iterateWhile}
<li>{$item->name}
{/iterateWhile true}
</ul>
{/foreach}
Результат выглядел бы так:
<ul>
<li>Яблоко</li>
<li>Банан</li>
<li>PHP</li>
<li>Зелёный</li>
<li>Красный</li>
<li>Синий</li>
</ul>
В чём же польза такого применения {iterateWhile}? В том, что
<ul> находится внутри внешнего {foreach}, и при пустом вводе
не отрисуется вообще ничего – никакого одинокого <ul></ul>.
Без {iterateWhile} тот же случай пришлось бы обрабатывать через
{if} перед открывающим тегом или через {foreachelse}.
Если указать условие в открывающем теге {iterateWhile}, поведение
изменится: условие (и переход к следующему элементу) выполняется в
начале внутреннего цикла, а не в конце. Таким образом, в {iterateWhile}
без условия вы входите всегда, а в {iterateWhile $cond} – только тогда,
когда условие $cond выполнено. И при этом в $item записывается
следующий элемент.
Это полезно в ситуациях, когда мы хотим отрисовать первый элемент каждой категории иначе, чем остальные, например вот так:
<h1>Яблоко</h1>
<ul>
<li>Банан</li>
</ul>
<h1>PHP</h1>
<ul>
</ul>
<h1>Зелёный</h1>
<ul>
<li>Красный</li>
<li>Синий</li>
</ul>
(Пустой <ul></ul> для категории PHP – лишь иллюстрация
механики, в реальном коде отрисовку <ul> вы бы обработали
через {if}.)
Изменим исходный код так, чтобы сначала отрисовать элемент как
заголовок, а затем внутренним циклом {iterateWhile} отрисовать
последующие элементы той же категории как пункты списка:
{foreach $items as $item}
<h1>{$item->name}</h1>
<ul>
{iterateWhile $item->categoryId === $iterator->nextValue?->categoryId}
<li>{$item->name}</li>
{/iterateWhile}
</ul>
{/foreach}
Внутри одного цикла мы можем создать несколько внутренних циклов и даже вложить их друг в друга. Так можно группировать сразу на нескольких уровнях, например подкатегории внутри категорий.
Предположим, что в таблице есть ещё один столбец subcategoryId и что
помимо отдельного <ul> для каждой категории каждая
подкатегория будет в отдельном <ol>:
{foreach $items as $item}
<ul>
{iterateWhile}
<ol>
{iterateWhile}
<li>{$item->name}
{/iterateWhile $item->subcategoryId === $iterator->nextValue->subcategoryId}
</ol>
{/iterateWhile $item->categoryId === $iterator->nextValue->categoryId}
</ul>
{/foreach}