Skip to content

Commit 040ad56

Browse files
committed
[Twig] add computed method system
1 parent b355eb9 commit 040ad56

File tree

10 files changed

+273
-23
lines changed

10 files changed

+273
-23
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 Properties_ system.
17+
1618
## 2.0.0
1719

1820
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 ComputedPropertiesProxy
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+
if (isset($this->component->$name)) {
34+
// try property
35+
return $this->component->$name;
36+
}
37+
38+
if ($this->component instanceof \ArrayAccess && isset($this->component[$name])) {
39+
return $this->component[$name];
40+
}
41+
42+
$method = $this->normalizeMethod($name);
43+
44+
if (isset($this->cache[$method])) {
45+
return $this->cache[$method];
46+
}
47+
48+
if ((new \ReflectionMethod($this->component, $method))->getNumberOfRequiredParameters()) {
49+
throw new \LogicException('Cannot use computed methods for methods with required parameters.');
50+
}
51+
52+
return $this->cache[$method] = $this->component->$method();
53+
}
54+
55+
private function normalizeMethod(string $name): string
56+
{
57+
if (method_exists($this->component, $name)) {
58+
return $name;
59+
}
60+
61+
foreach (['get', 'is', 'has'] as $prefix) {
62+
if (method_exists($this->component, $method = sprintf('%s%s', $prefix, ucfirst($name)))) {
63+
return $method;
64+
}
65+
}
66+
67+
throw new \InvalidArgumentException(sprintf('Component "%s" does not have a "%s" method.', $this->component::class, $name));
68+
}
69+
}

src/TwigComponent/src/MountedComponent.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ public function getVariables(): array
4646
{
4747
return array_merge(
4848
['this' => $this->component, 'attributes' => $this->attributes],
49-
get_object_vars($this->component)
49+
get_object_vars($this->component),
50+
['computed' => new ComputedPropertiesProxy($this->component)],
5051
);
5152
}
5253
}

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

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ need to populate, you can render it with:
363363
Computed Properties
364364
~~~~~~~~~~~~~~~~~~~
365365

366+
.. versionadded:: 2.1
367+
368+
Computed Properties were added in TwigComponents 2.1.
369+
366370
In the previous example, instead of querying for the featured products
367371
immediately (e.g. in ``__construct()``), we created a ``getProducts()``
368372
method and called that from the template via ``this.products``.
@@ -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
--------------------
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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\Tests\Fixtures\Component;
13+
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
#[AsTwigComponent('computed_component')]
17+
final class ComputedComponent
18+
{
19+
public $prop = 'value';
20+
private $count = 0;
21+
22+
public function getCount()
23+
{
24+
return ++$this->count;
25+
}
26+
}

src/TwigComponent/tests/Fixtures/Kernel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA;
2121
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB;
2222
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC;
23+
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComputedComponent;
2324
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithAttributes;
2425
use Symfony\UX\TwigComponent\Tests\Fixtures\Service\ServiceA;
2526
use Symfony\UX\TwigComponent\TwigComponentBundle;
@@ -59,6 +60,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
5960
'key' => 'component_d',
6061
'template' => 'components/custom2.html.twig',
6162
]);
63+
$c->register(ComputedComponent::class)->setAutoconfigured(true)->setAutowired(true);
6264

6365
if ('missing_key' === $this->environment) {
6466
$c->register('missing_key', ComponentB::class)->setAutowired(true)->addTag('twig.component');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
countDirect1: {{ this.getCount }}
2+
countDirect2: {{ this.count }}
3+
countComputed1: {{ computed.getCount }}
4+
countComputed2: {{ computed.count }}
5+
countComputed3: {{ computed.count }}
6+
propDirect: {{ this.prop }}
7+
propComputed: {{ computed.prop }}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{{ component('component_a', { propA: 'prop a value', propB: 'prop b value' }) }}
22
{{ component('with_attributes', { prop: 'prop value 1', class: 'bar', style: 'color:red;', value: '', autofocus: null }) }}
33
{{ component('with_attributes', { prop: 'prop value 2', attributes: { class: 'baz' }, type: 'submit', style: 'color:red;' }) }}
4+
{{ component('computed_component') }}

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,17 @@ 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+
$this->assertStringContainsString('propDirect: value', $output);
78+
$this->assertStringContainsString('propComputed: value', $output);
79+
}
6780
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
namespace Symfony\UX\TwigComponent\Tests\Unit;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Symfony\UX\TwigComponent\ComputedPropertiesProxy;
7+
8+
/**
9+
* @author Kevin Bond <[email protected]>
10+
*/
11+
final class ComputedPropertiesProxyTest extends TestCase
12+
{
13+
public function testProxyCachesGetMethodReturns(): void
14+
{
15+
$proxy = new ComputedPropertiesProxy(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 testProxyCachesIsMethodReturns(): void
30+
{
31+
$proxy = new ComputedPropertiesProxy(new class() {
32+
private int $count = 0;
33+
34+
public function isCount(): int
35+
{
36+
return ++$this->count;
37+
}
38+
});
39+
40+
$this->assertSame(1, $proxy->isCount());
41+
$this->assertSame(1, $proxy->isCount());
42+
$this->assertSame(1, $proxy->count());
43+
}
44+
45+
public function testProxyCachesHasMethodReturns(): void
46+
{
47+
$proxy = new ComputedPropertiesProxy(new class() {
48+
private int $count = 0;
49+
50+
public function hasCount(): int
51+
{
52+
return ++$this->count;
53+
}
54+
});
55+
56+
$this->assertSame(1, $proxy->hasCount());
57+
$this->assertSame(1, $proxy->hasCount());
58+
$this->assertSame(1, $proxy->count());
59+
}
60+
61+
public function testCanProxyPublicProperties(): void
62+
{
63+
$proxy = new ComputedPropertiesProxy(new class() {
64+
public $foo = 'bar';
65+
});
66+
67+
$this->assertSame('bar', $proxy->foo());
68+
}
69+
70+
public function testCanProxyArrayAccess(): void
71+
{
72+
$proxy = new ComputedPropertiesProxy(new class() implements \ArrayAccess {
73+
private $array = ['foo' => 'bar'];
74+
75+
public function offsetExists(mixed $offset): bool
76+
{
77+
return isset($this->array[$offset]);
78+
}
79+
80+
public function offsetGet(mixed $offset): mixed
81+
{
82+
return $this->array[$offset];
83+
}
84+
85+
public function offsetSet(mixed $offset, mixed $value): void
86+
{
87+
}
88+
89+
public function offsetUnset(mixed $offset): void
90+
{
91+
}
92+
});
93+
94+
$this->assertSame('bar', $proxy->foo());
95+
}
96+
97+
public function testCannotProxyMethodsThatDoNotExist(): void
98+
{
99+
$proxy = new ComputedPropertiesProxy(new class() {});
100+
101+
$this->expectException(\InvalidArgumentException::class);
102+
103+
$proxy->getSomething();
104+
}
105+
106+
public function testCannotPassArgumentsToProxiedMethods(): void
107+
{
108+
$proxy = new ComputedPropertiesProxy(new class() {});
109+
110+
$this->expectException(\InvalidArgumentException::class);
111+
112+
$proxy->getSomething('foo');
113+
}
114+
115+
public function testCannotProxyMethodsWithRequiredArguments(): void
116+
{
117+
$proxy = new ComputedPropertiesProxy(new class() {
118+
public function getValue(int $value): int
119+
{
120+
return $value;
121+
}
122+
});
123+
124+
$this->expectException(\LogicException::class);
125+
126+
$proxy->getValue();
127+
}
128+
}

0 commit comments

Comments
 (0)