Skip to content

Commit 2c1bc0b

Browse files
committed
[Twig] add computed method system
1 parent c27fa19 commit 2c1bc0b

File tree

10 files changed

+274
-22
lines changed

10 files changed

+274
-22
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
- Add `ExposeInTemplate` attribute to make non-public properties available in component
1717
templates directly.
1818

19+
- Add _Computed Properties_ system.
20+
1921
## 2.0.0
2022

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

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ public function render(MountedComponent $mounted): string
4848
// add the component as "this"
4949
['this' => $component],
5050

51+
// add computed properties proxy
52+
['computed' => new ComputedPropertiesProxy($component)],
53+
5154
// add attributes
5255
['attributes' => $mounted->getAttributes()],
5356

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/Resources/doc/index.rst

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@ need to populate, you can render it with:
417417
Computed Properties
418418
~~~~~~~~~~~~~~~~~~~
419419

420+
.. versionadded:: 2.1
421+
422+
Computed Properties were added in TwigComponents 2.1.
423+
420424
In the previous example, instead of querying for the featured products
421425
immediately (e.g. in ``__construct()``), we created a ``getProducts()``
422426
method and called that from the template via ``this.products``.
@@ -432,35 +436,32 @@ But there's no magic with the ``getProducts()`` method: if you call
432436
``this.products`` multiple times in your template, the query would be
433437
executed multiple times.
434438

435-
To make your ``getProducts()`` method act like a true computed property
436-
(where its value is only evaluated the first time you call the method),
437-
you can store its result on a private property:
439+
To make your ``getProducts()`` method act like a true computed property,
440+
call ``computed.products`` in your template. ``computed`` is a proxy
441+
that wraps your component and caches the return of methods. If they
442+
are called additional times, the cached value is used.
438443

439-
.. code-block:: diff
444+
.. code-block:: twig
440445
441-
// src/Components/FeaturedProductsComponent.php
442-
namespace App\Components;
443-
// ...
446+
{# templates/components/featured_products.html.twig #}
444447
445-
#[AsTwigComponent('featured_products')]
446-
class FeaturedProductsComponent
447-
{
448-
private ProductRepository $productRepository;
448+
<div>
449+
<h3>Featured Products</h3>
449450
450-
+ private ?array $products = null;
451+
{% for product in computed.products %}
452+
...
453+
{% endfor %}
451454
452-
// ...
455+
...
456+
{% for product in computed.products %} {# use cache, does not result in a second query #}
457+
...
458+
{% endfor %}
459+
</div>
453460
454-
public function getProducts(): array
455-
{
456-
+ if ($this->products === null) {
457-
+ $this->products = $this->productRepository->findFeatured();
458-
+ }
461+
.. note::
459462

460-
- return $this->productRepository->findFeatured();
461-
+ return $this->products;
462-
}
463-
}
463+
Computed methods only work for component methods with no required
464+
arguments.
464465

465466
Component Attributes
466467
--------------------
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
@@ -21,6 +21,7 @@
2121
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA;
2222
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB;
2323
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC;
24+
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComputedComponent;
2425
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithAttributes;
2526
use Symfony\UX\TwigComponent\Tests\Fixtures\Service\ServiceA;
2627
use Symfony\UX\TwigComponent\TwigComponentBundle;
@@ -61,6 +62,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
6162
'template' => 'components/custom2.html.twig',
6263
]);
6364
$c->register(WithExposedVariables::class)->setAutoconfigured(true)->setAutowired(true);
65+
$c->register(ComputedComponent::class)->setAutoconfigured(true)->setAutowired(true);
6466

6567
if ('missing_key' === $this->environment) {
6668
$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
@@ -73,4 +73,17 @@ public function testRenderComponentWithExposedVariables(): void
7373
$this->assertStringContainsString('Prop2: prop2 value', $output);
7474
$this->assertStringContainsString('Prop3: prop3 value', $output);
7575
}
76+
77+
public function testCanUseComputedMethods(): void
78+
{
79+
$output = self::getContainer()->get(Environment::class)->render('template_a.html.twig');
80+
81+
$this->assertStringContainsString('countDirect1: 1', $output);
82+
$this->assertStringContainsString('countDirect2: 2', $output);
83+
$this->assertStringContainsString('countComputed1: 3', $output);
84+
$this->assertStringContainsString('countComputed2: 3', $output);
85+
$this->assertStringContainsString('countComputed3: 3', $output);
86+
$this->assertStringContainsString('propDirect: value', $output);
87+
$this->assertStringContainsString('propComputed: value', $output);
88+
}
7689
}
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)