From d9b986c84906b29c2d66e1a357779997ec3a077a Mon Sep 17 00:00:00 2001 From: Matthias Vogel Date: Wed, 1 Jul 2026 14:34:17 +0200 Subject: [PATCH] [BUGFIX] Handle generators correctly 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. --- src/ViewHelpers/ForViewHelper.php | 15 +- .../ViewHelpers/ForViewHelperTest.php | 147 +++++++++++++++++- 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/ViewHelpers/ForViewHelper.php b/src/ViewHelpers/ForViewHelper.php index f6d336697..8f3da82ec 100644 --- a/src/ViewHelpers/ForViewHelper.php +++ b/src/ViewHelpers/ForViewHelper.php @@ -99,8 +99,18 @@ public function render(): string if (is_object($this->arguments['each']) && !$this->arguments['each'] instanceof \Traversable) { throw new InvalidArgumentValueException('ForViewHelper only supports arrays and objects implementing \Traversable interface', 1248728393); } + $isKeyValuePair = false; + if ($this->arguments['reverse'] === true || (isset($this->arguments['iteration'])) && !is_countable($this->arguments['each'])) { + $isKeyValuePair = true; + $traversable = $this->arguments['each']; + $this->arguments['each'] = []; + foreach ($traversable as $key => $singleElement) { + // unpack the traversable, but keep duplicate keys. + $this->arguments['each'][] = [$key, $singleElement]; + } + } if ($this->arguments['reverse'] === true) { - $this->arguments['each'] = array_reverse(iterator_to_array($this->arguments['each']), true); + $this->arguments['each'] = array_reverse($this->arguments['each'], true); } if (isset($this->arguments['iteration'])) { $iterationData = [ @@ -114,6 +124,9 @@ public function render(): string $this->renderingContext->setVariableProvider(new ScopedVariableProvider($globalVariableProvider, $localVariableProvider)); $output = ''; foreach ($this->arguments['each'] as $keyValue => $singleElement) { + if ($isKeyValuePair === true) { + [$keyValue, $singleElement] = $singleElement; + } $localVariableProvider->add($this->arguments['as'], $singleElement); if (isset($this->arguments['key'])) { $localVariableProvider->add($this->arguments['key'], $keyValue); diff --git a/tests/Functional/ViewHelpers/ForViewHelperTest.php b/tests/Functional/ViewHelpers/ForViewHelperTest.php index 741de873b..c1c1a3ddf 100644 --- a/tests/Functional/ViewHelpers/ForViewHelperTest.php +++ b/tests/Functional/ViewHelpers/ForViewHelperTest.php @@ -107,6 +107,147 @@ public static function renderDataProvider(): \Generator . '', ]; + $value = new class () implements \IteratorAggregate { + public function getIterator(): \Traversable + { + return new \ArrayIterator([ + 'first' => 'foo', + 'second' => 'bar', + ]); + } + }; + yield 'iterator total works for non countable traversable' => [ + '{item}: {iterator.total}, ', + ['value' => $value], + 'foo: 2, bar: 2, ', + ]; + + yield 'generator gets traversed' => [ + '{item} ', + static function () { + $value = (static function (): \Generator { + yield 'first' => 'foo'; + yield 'second' => 'bar'; + })(); + return ['value' => $value]; + }, + 'foo bar ', + ]; + + yield 'iterator total works for generator' => [ + '{item}: {iterator.total}, ', + static function () { + $value = (static function (): \Generator { + yield 'first' => 'foo'; + yield 'second' => 'bar'; + })(); + return ['value' => $value]; + }, + 'foo: 2, bar: 2, ', + ]; + + yield 'iterator last works for generator' => [ + '{item}{f:if(condition: iterator.isLast, then: \'!\', else: \', \')}', + static function () { + $value = (static function (): \Generator { + yield 'Lucas'; + yield 'Helene'; + yield 'Thomas'; + })(); + return ['value' => $value]; + }, + 'Lucas, Helene, Thomas!', + ]; + + yield 'generator preserves duplicate keys without key argument' => [ + '{item} ', + static function () { + $value = (static function (): \Generator { + yield 'duplicate' => 'foo'; + yield 'duplicate' => 'bar'; + })(); + return ['value' => $value]; + }, + 'foo bar ', + ]; + + yield 'iterator preserves duplicate keys from generator' => [ + '{key}: {item} / {iterator.total}, ', + static function () { + $value = (static function (): \Generator { + yield 'duplicate' => 'foo'; + yield 'duplicate' => 'bar'; + })(); + return ['value' => $value]; + }, + 'duplicate: foo / 2, duplicate: bar / 2, ', + ]; + + yield 'empty generator with iteration returns empty string' => [ + '{item}: {iterator.total}, ', + static function () { + $value = (static function (): \Generator { + yield from []; + })(); + return ['value' => $value]; + }, + '', + ]; + + yield 'empty generator with reverse returns empty string' => [ + '{item} ', + static function () { + $value = (static function (): \Generator { + yield from []; + })(); + return ['value' => $value]; + }, + '', + ]; + + yield 'reverse preserves duplicate keys from generator' => [ + '{key}: {item}, ', + static function () { + $value = (static function (): \Generator { + yield 'duplicate' => 'foo'; + yield 'duplicate' => 'bar'; + })(); + return ['value' => $value]; + }, + 'duplicate: bar, duplicate: foo, ', + ]; + + yield 'reverse and iteration work for generator' => [ + '{item}: {iterator.total} / {iterator.isFirst} / {iterator.isLast}, ', + static function () { + $value = (static function (): \Generator { + yield 'first' => 'foo'; + yield 'second' => 'bar'; + })(); + return ['value' => $value]; + }, + 'bar: 2 / 1 / , foo: 2 / / 1, ', + ]; + + yield 'reverse and iteration preserve duplicate keys from generator' => [ + '{key}: {item} / {iterator.total}, ', + static function () { + $value = (static function (): \Generator { + yield 'duplicate' => 'foo'; + yield 'duplicate' => 'bar'; + })(); + return ['value' => $value]; + }, + 'duplicate: bar / 2, duplicate: foo / 2, ', + ]; + + $value = new \ArrayObject(['foo', 'bar']); + yield 'iterator metadata works for countable traversable' => [ + '{item}: {iterator.total} / {iterator.isFirst} / {iterator.isLast}, ', + ['value' => $value], + 'foo: 2 / 1 / , bar: 2 / / 1, ', + ]; + $value = ['item']; yield 'iterator not available if not requested' => [ 'Total: {iterator.total}', @@ -200,16 +341,16 @@ public static function renderDataProvider(): \Generator #[DataProvider('renderDataProvider')] #[Test] - public function render(string $template, array $variables, string $expected): void + public function render(string $template, array|callable $variables, string $expected): void { $view = new TemplateView(); - $view->assignMultiple($variables); + $view->assignMultiple(is_callable($variables) ? $variables() : $variables); $view->getRenderingContext()->setCache(self::$cache); $view->getRenderingContext()->getTemplatePaths()->setTemplateSource($template); self::assertSame($expected, $view->render()); $view = new TemplateView(); - $view->assignMultiple($variables); + $view->assignMultiple(is_callable($variables) ? $variables() : $variables); $view->getRenderingContext()->setCache(self::$cache); $view->getRenderingContext()->getTemplatePaths()->setTemplateSource($template); self::assertSame($expected, $view->render());