グループ化について知りたかったことのすべて

テンプレートでデータを扱うとき、項目をグループにまとめたり、一定の大きさの塊に分けたり、条件にもとづいて繰り返したりしたくなることがよくあります。Latte にはそのための道具が 3 つあり、それぞれ少しずつ違う場面に向いています。

|group フィルタは指定した基準で項目をグループ化し、|batch フィルタは決まった大きさの塊に分け、{iterateWhile} タグはデータを一歩ずつ辿りながら内側のループをいつ抜けるかを自分で判断します。順に見ていきましょう。

フィルタと関数 group

この道具は 2 つの形で使えます。フィルタ $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 = $db->table('items') というコマンドで、Nette Database Explorer を使って 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}

この場合、categoryId カラムではなく、関連する行 $item->category|group フィルタによるグループ化を行っています。その結果、キー($category)にはそのカテゴリの ActiveRow オブジェクトが直接入るので、{$category->name} で名前を表示でき、categories への別のクエリなしにほかのカラムにもアクセスできます。

フィルタ |batch

このフィルタは項目のリストを決まった大きさの塊に分けます。グリッドレイアウト、段組み、そのほか見た目のグループ分けに便利です。

各リストに最大 3 項目までを入れて表示したいとしましょう。

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

この例では、$items のリストが小さなグループに分けられ、各グループ($batch)には最大 3 項目が入ります。それぞれの塊が別々の <ul> リストとして表示されます。

最後のグループが目的の数に足りない場合、フィルタの第 2 パラメータでそのグループを何で埋めるかを指定できます。要素が欠けた行が乱れて見えるのを避け、見た目を整えるのに最適です。

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

タグ {iterateWhile}

|group フィルタで解いたのと同じ課題を、{iterateWhile} タグで解いてみましょう。2 つのやり方の主な違いは、|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>

(PHP カテゴリの空の <ul></ul> は仕組みを示すための例です。実際のコードでは {if}<ul> の出力を制御することになるでしょう。)

もとのコードを書き換えて、まず項目を見出しとして描き、続いて内側の {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}
バージョン: 3.x