Skip to content

Commit 8bfea5f

Browse files
committed
[Twig] add computed method system
1 parent bb5b7c7 commit 8bfea5f

File tree

8 files changed

+172
-25
lines changed

8 files changed

+172
-25
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
- Add `PreRenderEvent` to intercept/manipulate twig template/variables before rendering.
1515

16+
- Add _Computed Method_ system.
17+
1618
## 2.0.0
1719

1820
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ public function render(object $component, ComponentMetadata $metadata): string
4040
$event = new PreRenderEvent(
4141
$component,
4242
$metadata,
43-
array_merge(['this' => $component], get_object_vars($component))
43+
array_merge(
44+
['this' => $component],
45+
get_object_vars($component),
46+
['computed' => new ComputedMethodProxy($component)],
47+
)
4448
);
4549

4650
$this->dispatcher->dispatch($event);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent;
13+
14+
/**
15+
* @author Kevin Bond <[email protected]>
16+
*
17+
* @experimental
18+
*/
19+
final class ComputedMethodProxy
20+
{
21+
private array $cache = [];
22+
23+
public function __construct(private object $component)
24+
{
25+
}
26+
27+
public function __call(string $name, array $arguments): mixed
28+
{
29+
if ($arguments) {
30+
throw new \InvalidArgumentException('Passing arguments to computed methods is not supported.');
31+
}
32+
33+
$method = $this->normalizeMethod($name);
34+
35+
if (isset($this->cache[$method])) {
36+
return $this->cache[$method];
37+
}
38+
39+
if ((new \ReflectionMethod($this->component, $method))->getNumberOfRequiredParameters()) {
40+
throw new \LogicException('Cannot use computed methods for methods with required parameters.');
41+
}
42+
43+
return $this->cache[$method] = $this->component->$method();
44+
}
45+
46+
private function normalizeMethod(string $name): string
47+
{
48+
if (method_exists($this->component, $name)) {
49+
return $name;
50+
}
51+
52+
if (method_exists($this->component, $getter = sprintf('get%s', ucfirst($name)))) {
53+
return $getter;
54+
}
55+
56+
throw new \InvalidArgumentException(sprintf('Component "%s" does not have a "%s" or "%s" method.', $this->component::class, $name, $getter));
57+
}
58+
}

src/TwigComponent/src/Resources/doc/index.rst

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,12 @@ need to populate, you can render it with:
360360
means that you can safely render the same component multiple times with
361361
different data because each component will be an independent instance.
362362

363-
Computed Properties
364-
~~~~~~~~~~~~~~~~~~~
363+
Computed Methods
364+
~~~~~~~~~~~~~~~~
365+
366+
.. versionadded:: 2.1
367+
368+
Computed Methods were added in TwigComponents 2.1.
365369

366370
In the previous example, instead of querying for the featured products
367371
immediately (e.g. in ``__construct()``), we created a ``getProducts()``
@@ -378,35 +382,32 @@ But there's no magic with the ``getProducts()`` method: if you call
378382
``this.products`` multiple times in your template, the query would be
379383
executed multiple times.
380384

381-
To make your ``getProducts()`` method act like a true computed property
382-
(where its value is only evaluated the first time you call the method),
383-
you can store its result on a private property:
385+
To make your ``getProducts()`` method act like a true computed property,
386+
call ``computed.products`` in your template. ``computed`` is a proxy
387+
that wraps your component and caches the return of methods. If they
388+
are called additional times, the cached value is used.
384389

385-
.. code-block:: diff
390+
.. code-block:: twig
386391
387-
// src/Components/FeaturedProductsComponent.php
388-
namespace App\Components;
389-
// ...
392+
{# templates/components/featured_products.html.twig #}
390393
391-
#[AsTwigComponent('featured_products')]
392-
class FeaturedProductsComponent
393-
{
394-
private ProductRepository $productRepository;
394+
<div>
395+
<h3>Featured Products</h3>
395396
396-
+ private ?array $products = null;
397+
{% for product in computed.products %}
398+
...
399+
{% endfor %}
397400
398-
// ...
401+
...
402+
{% for product in computed.products %} {# use cache, does not result in a second query #}
403+
...
404+
{% endfor %}
405+
</div>
399406
400-
public function getProducts(): array
401-
{
402-
+ if ($this->products === null) {
403-
+ $this->products = $this->productRepository->findFeatured();
404-
+ }
407+
.. note::
405408

406-
- return $this->productRepository->findFeatured();
407-
+ return $this->products;
408-
}
409-
}
409+
Computed methods only work for component methods with no required
410+
arguments.
410411

411412
Component Attributes
412413
--------------------

src/TwigComponent/tests/Fixtures/Component/ComponentA.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ final class ComponentA
2424

2525
private $propB;
2626
private $service;
27+
private $count = 0;
2728

2829
public function __construct(ServiceA $service)
2930
{
@@ -44,4 +45,9 @@ public function getPropB()
4445
{
4546
return $this->propB;
4647
}
48+
49+
public function getCount()
50+
{
51+
return ++$this->count;
52+
}
4753
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
propA: {{ propA }}
22
propB: {{ this.propB }}
33
service: {{ this.service.value }}
4+
countDirect1: {{ this.getCount }}
5+
countDirect2: {{ this.count }}
6+
countComputed1: {{ computed.getCount }}
7+
countComputed2: {{ computed.count }}
8+
countComputed3: {{ computed.count }}

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,15 @@ public function testCanRenderComponentWithAttributes(): void
6464
$this->assertStringContainsString('Component Content (prop value 2)', $output);
6565
$this->assertStringContainsString('<button class="foo baz" type="submit" style="color:red;">', $output);
6666
}
67+
68+
public function testCanUseComputedMethods(): void
69+
{
70+
$output = self::getContainer()->get(Environment::class)->render('template_a.html.twig');
71+
72+
$this->assertStringContainsString('countDirect1: 1', $output);
73+
$this->assertStringContainsString('countDirect2: 2', $output);
74+
$this->assertStringContainsString('countComputed1: 3', $output);
75+
$this->assertStringContainsString('countComputed2: 3', $output);
76+
$this->assertStringContainsString('countComputed3: 3', $output);
77+
}
6778
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace Symfony\UX\TwigComponent\Tests\Unit;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Symfony\UX\TwigComponent\ComputedMethodProxy;
7+
8+
/**
9+
* @author Kevin Bond <[email protected]>
10+
*/
11+
final class ComputedMethodProxyTest extends TestCase
12+
{
13+
public function testProxyCachesMethodReturns(): void
14+
{
15+
$proxy = new ComputedMethodProxy(new class() {
16+
private int $count = 0;
17+
18+
public function getCount(): int
19+
{
20+
return ++$this->count;
21+
}
22+
});
23+
24+
$this->assertSame(1, $proxy->getCount());
25+
$this->assertSame(1, $proxy->getCount());
26+
$this->assertSame(1, $proxy->count());
27+
}
28+
29+
public function testCannotProxyMethodsThatDoNotExist(): void
30+
{
31+
$proxy = new ComputedMethodProxy(new class() {});
32+
33+
$this->expectException(\InvalidArgumentException::class);
34+
35+
$proxy->getSomething();
36+
}
37+
38+
public function testCannotPassArgumentsToProxiedMethods(): void
39+
{
40+
$proxy = new ComputedMethodProxy(new class() {});
41+
42+
$this->expectException(\InvalidArgumentException::class);
43+
44+
$proxy->getSomething('foo');
45+
}
46+
47+
public function testCannotProxyMethodsWithRequiredArguments(): void
48+
{
49+
$proxy = new ComputedMethodProxy(new class() {
50+
public function getValue(int $value): int
51+
{
52+
return $value;
53+
}
54+
});
55+
56+
$this->expectException(\LogicException::class);
57+
58+
$proxy->getValue();
59+
}
60+
}

0 commit comments

Comments
 (0)