Todo lo que siempre quiso saber sobre la agrupación

Al trabajar con datos en las plantillas, a menudo necesita agrupar elementos, dividirlos en lotes o recorrerlos según una condición. Latte ofrece tres herramientas para ello, cada una adecuada para una situación algo distinta.

El filtro |group agrupa los elementos por un criterio dado, el filtro |batch los divide en lotes de tamaño fijo y la etiqueta {iterateWhile} recorre los datos paso a paso y decide por sí misma cuándo interrumpir el bucle interior. Vamos a verlos uno por uno.

Filtro y función group

La herramienta se puede usar de dos formas: como filtro $items|group: … o como función group($items, …). Semánticamente son equivalentes: elija según la legibilidad.

Imagine una tabla de base de datos items cuyos elementos pertenecen a distintas categorías:

id categoryId name
1 1 Apple
2 1 Banana
3 2 PHP
4 3 Green
5 3 Red
6 3 Blue

Una lista sencilla de todos los elementos con una plantilla de Latte tendría este aspecto:

<ul>
{foreach $items as $item}
	<li>{$item->name}</li>
{/foreach}
</ul>

Pero si quisiéramos los elementos organizados en grupos por categoría, tenemos que dividirlos de modo que cada categoría tenga su propia lista. El resultado deseado sería este:

<ul>
	<li>Apple</li>
	<li>Banana</li>
</ul>

<ul>
	<li>PHP</li>
</ul>

<ul>
	<li>Green</li>
	<li>Red</li>
	<li>Blue</li>
</ul>

Esta tarea se resuelve de forma fácil y elegante con |group. Indicamos categoryId como parámetro, lo que significa que los elementos se repartirán en arrays más pequeños según el valor de $item->categoryId (si $item fuera un array, se usaría $item['categoryId']):

{foreach ($items|group: categoryId) as $categoryId => $categoryItems}
	<ul>
		{foreach $categoryItems as $item}
			<li>{$item->name}</li>
		{/foreach}
	</ul>
{/foreach}

Si quiere agrupar los elementos según criterios más complejos, puede usar una función como parámetro del filtro. La clave de cada grupo será entonces el valor de retorno de la función; por ejemplo, al agrupar por la longitud del nombre será el número de caracteres:

{foreach ($items|group: fn($item) => strlen($item->name)) as $length => $group}
	...
{/foreach}

Es importante señalar que cada grupo (incluido $categoryItems) no es un array corriente, sino un objeto que se comporta como un iterador, así que no puede acceder a los elementos por índice, por ejemplo $categoryItems[0]. Sí puede contar los elementos con count($categoryItems) y, para acceder al primer elemento del grupo, usar la función first().

Esta flexibilidad convierte a |group en una herramienta excepcionalmente útil para presentar datos.

Bucles anidados

Imaginemos que nuestra tabla de base de datos tiene además una columna subcategoryId, que define subcategorías para cada elemento. Queremos mostrar cada categoría principal en una lista <ul> aparte y cada subcategoría dentro de esa categoría principal en una lista <ol> anidada aparte:

{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}

Junto con Nette Database

Veamos cómo usar eficazmente la agrupación de datos en combinación con Nette Database. Supongamos que trabajamos con la tabla items del ejemplo introductorio, unida mediante la columna categoryId a esta tabla categories:

categoryId name
1 Fruits
2 Languages
3 Colors

Cargamos los datos de la tabla items con Nette Database Explorer mediante el comando $items = $db->table('items'). Al iterar sobre esos datos podemos acceder no solo a atributos como $item->name y $item->categoryId, sino también, gracias a la relación con la tabla categories, a la fila relacionada mediante $item->category. Esta relación permite aplicaciones interesantes:

{foreach ($items|group: category) as $category => $categoryItems}
	<h1>{$category->name}</h1>
	<ul>
		{foreach $categoryItems as $item}
			<li>{$item->name}</li>
		{/foreach}
	</ul>
{/foreach}

En este caso usamos el filtro |group para agrupar por la fila relacionada $item->category, no solo por la columna categoryId. Como resultado, la clave ($category) contiene directamente el objeto ActiveRow de esa categoría, lo que nos permite mostrar su nombre con {$category->name} y acceder a cualquier otra columna sin lanzar una consulta aparte a categories.

Filtro |batch

El filtro divide una lista de elementos en lotes de tamaño fijo. Resulta práctico para diseños en cuadrícula, disposiciones en columnas o cualquier tipo de agrupación visual.

Imaginemos que queremos mostrar los elementos en listas donde cada lista contenga como máximo tres elementos:

{foreach ($items|batch: 3) as $batch}
	<ul>
		{foreach $batch as $item}
			<li>{$item->name}</li>
		{/foreach}
	</ul>
{/foreach}

En este ejemplo, la lista $items se divide en grupos más pequeños, donde cada grupo ($batch) contiene hasta tres elementos. Cada lote se muestra después en una lista <ul> aparte.

Si el último grupo no contiene suficientes elementos para llegar al número deseado, el segundo parámetro del filtro permite definir con qué se completará ese grupo. Es ideal para alinear estéticamente los elementos allí donde una fila incompleta quedaría desordenada.

{foreach ($items|batch: 3, '—') as $batch}
	...
{/foreach}

Etiqueta {iterateWhile}

Vamos a resolver con la etiqueta {iterateWhile} las mismas tareas que abordamos con el filtro |group. La principal diferencia entre ambos enfoques es que |group procesa y agrupa primero todos los datos de entrada, mientras que {iterateWhile} controla el avance del bucle mediante una condición y la iteración avanza de forma secuencial.

Primero, rendericemos la tabla con las categorías usando {iterateWhile}:

{foreach $items as $item}
	<ul>
		{iterateWhile}
			<li>{$item->name}</li>
		{/iterateWhile $item->categoryId === $iterator->nextValue->categoryId}
	</ul>
{/foreach}

Mientras que {foreach} marca la parte exterior del ciclo, es decir, el dibujo de las listas de cada categoría, la etiqueta {iterateWhile} marca la parte interior, es decir, los elementos concretos. La condición de la etiqueta de cierre dice que la repetición continuará mientras el elemento actual y el siguiente pertenezcan a la misma categoría ($iterator->nextValue es el elemento siguiente; en el último elemento el bucle interior termina porque no hay elemento siguiente: Latte comprueba $iterator->hasNext() antes de evaluar la condición, así que nunca se llega a comparar con null).

Si la condición fuera siempre verdadera, todos los elementos se renderizarían dentro del primer <ul>:

{foreach $items as $item}
	<ul>
		{iterateWhile}
			<li>{$item->name}
		{/iterateWhile true}
	</ul>
{/foreach}

El resultado sería este:

<ul>
	<li>Apple</li>
	<li>Banana</li>
	<li>PHP</li>
	<li>Green</li>
	<li>Red</li>
	<li>Blue</li>
</ul>

¿Qué ventaja tiene usar {iterateWhile} así? Como el <ul> está dentro del {foreach} exterior, cuando la entrada está vacía no se renderiza absolutamente nada: ningún <ul></ul> solitario. Sin {iterateWhile} tendría que resolver ese mismo caso con un {if} antes de la etiqueta de apertura o con {foreachelse}.

Si indicamos la condición en la etiqueta de apertura {iterateWhile}, el comportamiento cambia: la condición (y el paso al elemento siguiente) se ejecuta al principio del ciclo interior, no al final. Así, mientras que en {iterateWhile} sin condición se entra siempre, en {iterateWhile $cond} se entra solo cuando se cumple la condición $cond. Y, al mismo tiempo, el elemento siguiente se escribe en $item.

Esto resulta útil cuando queremos renderizar el primer elemento de cada categoría de forma distinta a los demás, por ejemplo así:

<h1>Apple</h1>
<ul>
	<li>Banana</li>
</ul>

<h1>PHP</h1>
<ul>
</ul>

<h1>Green</h1>
<ul>
	<li>Red</li>
	<li>Blue</li>
</ul>

(El <ul></ul> vacío de la categoría PHP es solo una ilustración del mecanismo; en código real resolvería el renderizado del <ul> con un {if}.)

Modificamos el código original para renderizar primero el elemento como encabezado y usar después el bucle interior {iterateWhile} para renderizar como elementos de lista los siguientes elementos de la misma categoría:

{foreach $items as $item}
	<h1>{$item->name}</h1>
	<ul>
		{iterateWhile $item->categoryId === $iterator->nextValue?->categoryId}
			<li>{$item->name}</li>
		{/iterateWhile}
	</ul>
{/foreach}

Dentro de un mismo bucle podemos crear varios bucles interiores e incluso anidarlos. Así puede agrupar en varios niveles a la vez, por ejemplo subcategorías dentro de categorías.

Supongamos que la tabla tiene otra columna subcategoryId y que, además de estar cada categoría en un <ul> aparte, cada subcategoría estará en un <ol> aparte:

{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}
versión: 3.x