diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 89a3dc1b4d7..9b5247c0715 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -16,6 +16,8 @@ - Add `ExposeInTemplate` attribute to make non-public properties available in component templates directly. +- Add _Computed Properties_ system. + ## 2.0.0 - Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus` diff --git a/src/TwigComponent/src/ComponentRenderer.php b/src/TwigComponent/src/ComponentRenderer.php index a6a6ac58567..0bf74175dc9 100644 --- a/src/TwigComponent/src/ComponentRenderer.php +++ b/src/TwigComponent/src/ComponentRenderer.php @@ -48,6 +48,9 @@ public function render(MountedComponent $mounted): string // add the component as "this" ['this' => $component], + // add computed properties proxy + ['computed' => new ComputedPropertiesProxy($component)], + // add attributes ['attributes' => $mounted->getAttributes()], diff --git a/src/TwigComponent/src/ComputedPropertiesProxy.php b/src/TwigComponent/src/ComputedPropertiesProxy.php new file mode 100644 index 00000000000..f8edccc623f --- /dev/null +++ b/src/TwigComponent/src/ComputedPropertiesProxy.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent; + +/** + * @author Kevin Bond + * + * @experimental + */ +final class ComputedPropertiesProxy +{ + private array $cache = []; + + public function __construct(private object $component) + { + } + + public function __call(string $name, array $arguments): mixed + { + if ($arguments) { + throw new \InvalidArgumentException('Passing arguments to computed methods is not supported.'); + } + + if (isset($this->component->$name)) { + // try property + return $this->component->$name; + } + + if ($this->component instanceof \ArrayAccess && isset($this->component[$name])) { + return $this->component[$name]; + } + + $method = $this->normalizeMethod($name); + + if (isset($this->cache[$method])) { + return $this->cache[$method]; + } + + if ((new \ReflectionMethod($this->component, $method))->getNumberOfRequiredParameters()) { + throw new \LogicException('Cannot use computed methods for methods with required parameters.'); + } + + return $this->cache[$method] = $this->component->$method(); + } + + private function normalizeMethod(string $name): string + { + if (method_exists($this->component, $name)) { + return $name; + } + + foreach (['get', 'is', 'has'] as $prefix) { + if (method_exists($this->component, $method = sprintf('%s%s', $prefix, ucfirst($name)))) { + return $method; + } + } + + throw new \InvalidArgumentException(sprintf('Component "%s" does not have a "%s" method.', $this->component::class, $name)); + } +} diff --git a/src/TwigComponent/src/Resources/doc/index.rst b/src/TwigComponent/src/Resources/doc/index.rst index ff83f638ac6..a7ce6663c55 100644 --- a/src/TwigComponent/src/Resources/doc/index.rst +++ b/src/TwigComponent/src/Resources/doc/index.rst @@ -417,6 +417,10 @@ need to populate, you can render it with: Computed Properties ~~~~~~~~~~~~~~~~~~~ +.. versionadded:: 2.1 + + Computed Properties were added in TwigComponents 2.1. + In the previous example, instead of querying for the featured products immediately (e.g. in ``__construct()``), we created a ``getProducts()`` 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 ``this.products`` multiple times in your template, the query would be executed multiple times. -To make your ``getProducts()`` method act like a true computed property -(where its value is only evaluated the first time you call the method), -you can store its result on a private property: +To make your ``getProducts()`` method act like a true computed property, +call ``computed.products`` in your template. ``computed`` is a proxy +that wraps your component and caches the return of methods. If they +are called additional times, the cached value is used. -.. code-block:: diff +.. code-block:: twig - // src/Components/FeaturedProductsComponent.php - namespace App\Components; - // ... + {# templates/components/featured_products.html.twig #} - #[AsTwigComponent('featured_products')] - class FeaturedProductsComponent - { - private ProductRepository $productRepository; +
+

Featured Products

- + private ?array $products = null; + {% for product in computed.products %} + ... + {% endfor %} - // ... + ... + {% for product in computed.products %} {# use cache, does not result in a second query #} + ... + {% endfor %} +
- public function getProducts(): array - { - + if ($this->products === null) { - + $this->products = $this->productRepository->findFeatured(); - + } +.. note:: - - return $this->productRepository->findFeatured(); - + return $this->products; - } - } + Computed methods only work for component methods with no required + arguments. Component Attributes -------------------- diff --git a/src/TwigComponent/tests/Fixtures/Component/ComputedComponent.php b/src/TwigComponent/tests/Fixtures/Component/ComputedComponent.php new file mode 100644 index 00000000000..7761b6577e6 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/Component/ComputedComponent.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +#[AsTwigComponent('computed_component')] +final class ComputedComponent +{ + public $prop = 'value'; + private $count = 0; + + public function getCount() + { + return ++$this->count; + } +} diff --git a/src/TwigComponent/tests/Fixtures/Kernel.php b/src/TwigComponent/tests/Fixtures/Kernel.php index 50f145ff2e1..1cf1739a231 100644 --- a/src/TwigComponent/tests/Fixtures/Kernel.php +++ b/src/TwigComponent/tests/Fixtures/Kernel.php @@ -21,6 +21,7 @@ use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC; +use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComputedComponent; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithAttributes; use Symfony\UX\TwigComponent\Tests\Fixtures\Service\ServiceA; use Symfony\UX\TwigComponent\TwigComponentBundle; @@ -61,6 +62,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'template' => 'components/custom2.html.twig', ]); $c->register(WithExposedVariables::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(ComputedComponent::class)->setAutoconfigured(true)->setAutowired(true); if ('missing_key' === $this->environment) { $c->register('missing_key', ComponentB::class)->setAutowired(true)->addTag('twig.component'); diff --git a/src/TwigComponent/tests/Fixtures/templates/components/computed_component.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/computed_component.html.twig new file mode 100644 index 00000000000..cdec67fea1a --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/components/computed_component.html.twig @@ -0,0 +1,7 @@ +countDirect1: {{ this.getCount }} +countDirect2: {{ this.count }} +countComputed1: {{ computed.getCount }} +countComputed2: {{ computed.count }} +countComputed3: {{ computed.count }} +propDirect: {{ this.prop }} +propComputed: {{ computed.prop }} diff --git a/src/TwigComponent/tests/Fixtures/templates/template_a.html.twig b/src/TwigComponent/tests/Fixtures/templates/template_a.html.twig index 213a8a5b83a..e21d35495b6 100644 --- a/src/TwigComponent/tests/Fixtures/templates/template_a.html.twig +++ b/src/TwigComponent/tests/Fixtures/templates/template_a.html.twig @@ -1,3 +1,4 @@ {{ component('component_a', { propA: 'prop a value', propB: 'prop b value' }) }} {{ component('with_attributes', { prop: 'prop value 1', class: 'bar', style: 'color:red;', value: '', autofocus: null }) }} {{ component('with_attributes', { prop: 'prop value 2', attributes: { class: 'baz' }, type: 'submit', style: 'color:red;' }) }} +{{ component('computed_component') }} diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php index a73b467d840..c063e58caaf 100644 --- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -73,4 +73,17 @@ public function testRenderComponentWithExposedVariables(): void $this->assertStringContainsString('Prop2: prop2 value', $output); $this->assertStringContainsString('Prop3: prop3 value', $output); } + + public function testCanUseComputedMethods(): void + { + $output = self::getContainer()->get(Environment::class)->render('template_a.html.twig'); + + $this->assertStringContainsString('countDirect1: 1', $output); + $this->assertStringContainsString('countDirect2: 2', $output); + $this->assertStringContainsString('countComputed1: 3', $output); + $this->assertStringContainsString('countComputed2: 3', $output); + $this->assertStringContainsString('countComputed3: 3', $output); + $this->assertStringContainsString('propDirect: value', $output); + $this->assertStringContainsString('propComputed: value', $output); + } } diff --git a/src/TwigComponent/tests/Unit/ComputedPropertiesProxyTest.php b/src/TwigComponent/tests/Unit/ComputedPropertiesProxyTest.php new file mode 100644 index 00000000000..d317f30c8f5 --- /dev/null +++ b/src/TwigComponent/tests/Unit/ComputedPropertiesProxyTest.php @@ -0,0 +1,128 @@ + + */ +final class ComputedPropertiesProxyTest extends TestCase +{ + public function testProxyCachesGetMethodReturns(): void + { + $proxy = new ComputedPropertiesProxy(new class() { + private int $count = 0; + + public function getCount(): int + { + return ++$this->count; + } + }); + + $this->assertSame(1, $proxy->getCount()); + $this->assertSame(1, $proxy->getCount()); + $this->assertSame(1, $proxy->count()); + } + + public function testProxyCachesIsMethodReturns(): void + { + $proxy = new ComputedPropertiesProxy(new class() { + private int $count = 0; + + public function isCount(): int + { + return ++$this->count; + } + }); + + $this->assertSame(1, $proxy->isCount()); + $this->assertSame(1, $proxy->isCount()); + $this->assertSame(1, $proxy->count()); + } + + public function testProxyCachesHasMethodReturns(): void + { + $proxy = new ComputedPropertiesProxy(new class() { + private int $count = 0; + + public function hasCount(): int + { + return ++$this->count; + } + }); + + $this->assertSame(1, $proxy->hasCount()); + $this->assertSame(1, $proxy->hasCount()); + $this->assertSame(1, $proxy->count()); + } + + public function testCanProxyPublicProperties(): void + { + $proxy = new ComputedPropertiesProxy(new class() { + public $foo = 'bar'; + }); + + $this->assertSame('bar', $proxy->foo()); + } + + public function testCanProxyArrayAccess(): void + { + $proxy = new ComputedPropertiesProxy(new class() implements \ArrayAccess { + private $array = ['foo' => 'bar']; + + public function offsetExists(mixed $offset): bool + { + return isset($this->array[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->array[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } + }); + + $this->assertSame('bar', $proxy->foo()); + } + + public function testCannotProxyMethodsThatDoNotExist(): void + { + $proxy = new ComputedPropertiesProxy(new class() {}); + + $this->expectException(\InvalidArgumentException::class); + + $proxy->getSomething(); + } + + public function testCannotPassArgumentsToProxiedMethods(): void + { + $proxy = new ComputedPropertiesProxy(new class() {}); + + $this->expectException(\InvalidArgumentException::class); + + $proxy->getSomething('foo'); + } + + public function testCannotProxyMethodsWithRequiredArguments(): void + { + $proxy = new ComputedPropertiesProxy(new class() { + public function getValue(int $value): int + { + return $value; + } + }); + + $this->expectException(\LogicException::class); + + $proxy->getValue(); + } +}