Skip to content

Commit 772a0d2

Browse files
committed
[BUGFIX] Preserve traversable entries in ForViewHelper
Materialize traversables as key-value pairs when reverse iteration or iteration metadata requires looking ahead. This keeps duplicate yielded keys intact and allows iteration totals to work for non-countable traversables. Add coverage for non-countable traversables, generators, duplicate keys, and reverse rendering.
1 parent ef902a5 commit 772a0d2

2 files changed

Lines changed: 75 additions & 6 deletions

File tree

src/ViewHelpers/ForViewHelper.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,21 +99,39 @@ public function render(): string
9999
if (is_object($this->arguments['each']) && !$this->arguments['each'] instanceof \Traversable) {
100100
throw new InvalidArgumentValueException('ForViewHelper only supports arrays and objects implementing \Traversable interface', 1248728393);
101101
}
102+
$items = null;
102103
if ($this->arguments['reverse'] === true) {
103-
$this->arguments['each'] = array_reverse(iterator_to_array($this->arguments['each']), true);
104+
if (is_array($this->arguments['each'])) {
105+
$this->arguments['each'] = array_reverse($this->arguments['each'], true);
106+
} else {
107+
$items = [];
108+
foreach ($this->arguments['each'] as $keyValue => $singleElement) {
109+
$items[] = [$keyValue, $singleElement];
110+
}
111+
$items = array_reverse($items);
112+
}
104113
}
105114
if (isset($this->arguments['iteration'])) {
115+
if ($items === null && !is_countable($this->arguments['each'])) {
116+
$items = [];
117+
foreach ($this->arguments['each'] as $keyValue => $singleElement) {
118+
$items[] = [$keyValue, $singleElement];
119+
}
120+
}
106121
$iterationData = [
107122
'index' => 0,
108123
'cycle' => 1,
109-
'total' => count($this->arguments['each']),
124+
'total' => count($items ?? $this->arguments['each']),
110125
];
111126
}
112127
$globalVariableProvider = $this->renderingContext->getVariableProvider();
113128
$localVariableProvider = new StandardVariableProvider();
114129
$this->renderingContext->setVariableProvider(new ScopedVariableProvider($globalVariableProvider, $localVariableProvider));
115130
$output = '';
116-
foreach ($this->arguments['each'] as $keyValue => $singleElement) {
131+
foreach ($items ?? $this->arguments['each'] as $keyValue => $singleElement) {
132+
if ($items !== null) {
133+
[$keyValue, $singleElement] = $singleElement;
134+
}
117135
$localVariableProvider->add($this->arguments['as'], $singleElement);
118136
if (isset($this->arguments['key'])) {
119137
$localVariableProvider->add($this->arguments['key'], $keyValue);

tests/Functional/ViewHelpers/ForViewHelperTest.php

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,57 @@ public static function renderDataProvider(): \Generator
107107
. '</ul>',
108108
];
109109

110+
$value = new class () implements \IteratorAggregate {
111+
public function getIterator(): \Traversable
112+
{
113+
return new \ArrayIterator([
114+
'first' => 'foo',
115+
'second' => 'bar',
116+
]);
117+
}
118+
};
119+
yield 'iterator total works for non countable traversable' => [
120+
'<f:for each="{value}" as="item" iteration="iterator">{item}: {iterator.total}, </f:for>',
121+
['value' => $value],
122+
'foo: 2, bar: 2, ',
123+
];
124+
125+
yield 'iterator total works for generator' => [
126+
'<f:for each="{value}" as="item" iteration="iterator">{item}: {iterator.total}, </f:for>',
127+
static function () {
128+
$value = (static function (): \Generator {
129+
yield 'first' => 'foo';
130+
yield 'second' => 'bar';
131+
})();
132+
return ['value' => $value];
133+
},
134+
'foo: 2, bar: 2, ',
135+
];
136+
137+
yield 'iterator preserves duplicate keys from generator' => [
138+
'<f:for each="{value}" as="item" key="key" iteration="iterator">{key}: {item} / {iterator.total}, </f:for>',
139+
static function () {
140+
$value = (static function (): \Generator {
141+
yield 'duplicate' => 'foo';
142+
yield 'duplicate' => 'bar';
143+
})();
144+
return ['value' => $value];
145+
},
146+
'duplicate: foo / 2, duplicate: bar / 2, ',
147+
];
148+
149+
yield 'reverse preserves duplicate keys from generator' => [
150+
'<f:for each="{value}" as="item" key="key" reverse="true">{key}: {item}, </f:for>',
151+
static function () {
152+
$value = (static function (): \Generator {
153+
yield 'duplicate' => 'foo';
154+
yield 'duplicate' => 'bar';
155+
})();
156+
return ['value' => $value];
157+
},
158+
'duplicate: bar, duplicate: foo, ',
159+
];
160+
110161
$value = ['item'];
111162
yield 'iterator not available if not requested' => [
112163
'<f:for each="{value}" as="item">Total: {iterator.total}</f:for>',
@@ -200,16 +251,16 @@ public static function renderDataProvider(): \Generator
200251

201252
#[DataProvider('renderDataProvider')]
202253
#[Test]
203-
public function render(string $template, array $variables, string $expected): void
254+
public function render(string $template, array|callable $variables, string $expected): void
204255
{
205256
$view = new TemplateView();
206-
$view->assignMultiple($variables);
257+
$view->assignMultiple(is_callable($variables) ? $variables() : $variables);
207258
$view->getRenderingContext()->setCache(self::$cache);
208259
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($template);
209260
self::assertSame($expected, $view->render());
210261

211262
$view = new TemplateView();
212-
$view->assignMultiple($variables);
263+
$view->assignMultiple(is_callable($variables) ? $variables() : $variables);
213264
$view->getRenderingContext()->setCache(self::$cache);
214265
$view->getRenderingContext()->getTemplatePaths()->setTemplateSource($template);
215266
self::assertSame($expected, $view->render());

0 commit comments

Comments
 (0)