From 0d316e386b88399b35f27dc038fde36fbdfbd371 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Thu, 4 Apr 2024 15:00:34 +0200 Subject: [PATCH] allow to access drop public properties with camel or snake case name --- README.md | 4 +-- src/Attributes/Hidden.php | 2 +- src/Drop.php | 12 +++++-- src/Support/DropMetadata.php | 57 ++++++++++++++++++++++------------ tests/Integration/DropTest.php | 32 ++++++++++++++----- tests/Stubs/ProductDrop.php | 9 ++++-- 6 files changed, 81 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a3c4958..b7f2f92 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,11 @@ $stream = $template->stream($context); Liquid support almost any kind of object but in order to have a better control over the accessible data in the templates, you can pass your data as `Drop` objects and have a better control over the accessible data. Drops are standard php objects that extend the `Keepsuit\Liquid\Drop` class. -Each public method of the class will be accessible in the template as a property. +Public properties and public methods of the class will be accessible in the template as a property. You can also override the `liquidMethodMissing` method to handle undefined properties. Liquid provides some attributes to control the behavior of the drops: -- `Hidden`: Hide the method from the template, it cannot be accessed as a property. +- `Hidden`: Hide the method or the property from the template, it cannot be accessed from liquid. - `Cache`: Cache the result of the method, it will be called only once and the result will be stored in the drop. ```php diff --git a/src/Attributes/Hidden.php b/src/Attributes/Hidden.php index 2ce1e68..e474efb 100644 --- a/src/Attributes/Hidden.php +++ b/src/Attributes/Hidden.php @@ -8,7 +8,7 @@ * This attribute can be used to mark a drop method as hidden, * so it won't be exposed to the liquid context. */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] class Hidden { } diff --git a/src/Drop.php b/src/Drop.php index 2c7d94d..2e0c791 100644 --- a/src/Drop.php +++ b/src/Drop.php @@ -31,11 +31,17 @@ public function __get(string $name): mixed $invokableMethods = $this->getMetadata()->invokableMethods; $cacheableMethods = $this->getMetadata()->cacheableMethods; - $possibleNames = [ + $possibleNames = array_unique([ $name, Str::camel($name), Str::snake($name), - ]; + ]); + + foreach ($possibleNames as $propertyName) { + if (in_array($propertyName, $this->getMetadata()->properties)) { + return $this->{$propertyName}; + } + } foreach ($possibleNames as $methodName) { if (! in_array($methodName, $invokableMethods)) { @@ -66,7 +72,7 @@ public function __get(string $name): mixed } } - if ($this->context->strictVariables) { + if (isset($this->context) && $this->context->strictVariables) { throw new UndefinedDropMethodException($name); } diff --git a/src/Support/DropMetadata.php b/src/Support/DropMetadata.php index 8c265d1..452bbf5 100644 --- a/src/Support/DropMetadata.php +++ b/src/Support/DropMetadata.php @@ -7,8 +7,12 @@ use Keepsuit\Liquid\Drop; use ReflectionClass; use ReflectionMethod; +use ReflectionProperty; use Traversable; +/** + * @internal + */ final class DropMetadata { /** @@ -16,6 +20,13 @@ final class DropMetadata */ protected static array $cache = []; + public function __construct( + public readonly array $invokableMethods = [], + public readonly array $cacheableMethods = [], + public readonly array $properties = [], + ) { + } + public static function init(Drop $drop): DropMetadata { if (isset(self::$cache[get_class($drop)])) { @@ -31,32 +42,40 @@ public static function init(Drop $drop): DropMetadata $blacklist = [...$blacklist, 'current', 'next', 'key', 'valid', 'rewind']; } - $publicMethods = (new ReflectionClass($drop))->getMethods(ReflectionMethod::IS_PUBLIC); + $classReflection = new ReflectionClass($drop); - $visibleMethodNames = array_map( - fn (ReflectionMethod $method) => $method->getAttributes(Hidden::class) !== [] ? null : $method->getName(), + $publicMethods = array_filter( + $classReflection->getMethods(ReflectionMethod::IS_PUBLIC), + fn (ReflectionMethod $method) => $method->getAttributes(Hidden::class) === [] + && ! in_array($method->getName(), $blacklist) + && ! str_starts_with($method->getName(), '__') + ); + + $invokableMethods = array_map( + fn (ReflectionMethod $method) => $method->getName(), $publicMethods ); - $invokableMethods = array_values(array_filter( - array_diff($visibleMethodNames, $blacklist), - fn (?string $name) => $name !== null && ! str_starts_with($name, '__') - )); + $cacheableMethods = array_map( + fn (ReflectionMethod $method) => $method->getName(), + array_filter( + $publicMethods, + fn (ReflectionMethod $method) => $method->getAttributes(Cache::class) !== [] + ) + ); - $cacheableMethods = array_values(array_filter(array_map( - fn (ReflectionMethod $method) => $method->getAttributes(Cache::class) !== [] ? $method->getName() : null, - $publicMethods - ))); + $publicProperties = array_map( + fn (ReflectionProperty $property) => $property->getName(), + array_filter( + $classReflection->getProperties(ReflectionProperty::IS_PUBLIC), + fn (ReflectionProperty $property) => $property->getAttributes(Hidden::class) === [] + ) + ); return self::$cache[get_class($drop)] = new DropMetadata( - invokableMethods: $invokableMethods, - cacheableMethods: $cacheableMethods + invokableMethods: array_values($invokableMethods), + cacheableMethods: array_values($cacheableMethods), + properties: array_values($publicProperties) ); } - - public function __construct( - public readonly array $invokableMethods, - public readonly array $cacheableMethods - ) { - } } diff --git a/tests/Integration/DropTest.php b/tests/Integration/DropTest.php index 059c4b5..87f0fa8 100644 --- a/tests/Integration/DropTest.php +++ b/tests/Integration/DropTest.php @@ -2,6 +2,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tests\Stubs\CachableDrop; +use Keepsuit\Liquid\Tests\Stubs\CatchAllDrop; use Keepsuit\Liquid\Tests\Stubs\ContextDrop; use Keepsuit\Liquid\Tests\Stubs\EnumerableDrop; use Keepsuit\Liquid\Tests\Stubs\ProductDrop; @@ -20,19 +21,19 @@ }); test('text drop', function () { - expect(renderTemplate(' {{ product.texts.text }} ', ['product' => new ProductDrop()]))->toBe(' text1 '); + expect(renderTemplate(' {{ product.text.text }} ', ['product' => new ProductDrop()]))->toBe(' text1 '); }); test('catchall unknown method', function () { - expect(renderTemplate(' {{ product.catchall.unknown }} ', ['product' => new ProductDrop()]))->toBe(' catchall_method: unknown '); + expect(renderTemplate(' {{ product.catch_all.unknown }} ', ['product' => new ProductDrop()]))->toBe(' catchall_method: unknown '); }); test('catchall integer argument drop', function () { - expect(renderTemplate(' {{ product.catchall[8] }} ', ['product' => new ProductDrop()]))->toBe(' catchall_method: 8 '); + expect(renderTemplate(' {{ product.catch_all[8] }} ', ['product' => new ProductDrop()]))->toBe(' catchall_method: 8 '); }); test('text array drop', function () { - expect(renderTemplate('{% for text in product.texts.array %} {{text}} {% endfor %}', ['product' => new ProductDrop()]))->toBe(' text1 text2 '); + expect(renderTemplate('{% for text in product.text.array %} {{text}} {% endfor %}', ['product' => new ProductDrop()]))->toBe(' text1 text2 '); }); test('context drop', function () { @@ -108,16 +109,19 @@ test('drop metadata', function () { expect(invade(new ProductDrop())->getMetadata()) - ->invokableMethods->toBe(['texts', 'catchall', 'context']) - ->cacheableMethods->toBe([]); + ->invokableMethods->toBe(['text', 'catchAll', 'context']) + ->cacheableMethods->toBe([]) + ->properties->toBe(['productName']); expect(invade(new EnumerableDrop())->getMetadata()) ->invokableMethods->toBe(['size', 'first', 'count', 'min', 'max']) - ->cacheableMethods->toBe([]); + ->cacheableMethods->toBe([]) + ->properties->toBe([]); expect(invade(new CachableDrop())->getMetadata()) ->invokableMethods->toBe(['notCached', 'cached']) - ->cacheableMethods->toBe(['cached']); + ->cacheableMethods->toBe(['cached']) + ->properties->toBe([]); }); it('can cache drop method calls', function () { @@ -131,3 +135,15 @@ ->cached->toBe(0) ->cached->toBe(0); }); + +it('can access drop data with snake and camel cases', function () { + $drop = new ProductDrop(); + + expect($drop) + ->productName->toBe('Product') + ->product_name->toBe('Product'); + + expect($drop) + ->catchAll->toBeInstanceOf(CatchAllDrop::class) + ->catch_all->toBeInstanceOf(CatchAllDrop::class); +}); diff --git a/tests/Stubs/ProductDrop.php b/tests/Stubs/ProductDrop.php index 287c2b0..d2a5632 100644 --- a/tests/Stubs/ProductDrop.php +++ b/tests/Stubs/ProductDrop.php @@ -6,12 +6,17 @@ class ProductDrop extends Drop { - public function texts(): TextDrop + public function __construct( + public string $productName = 'Product' + ) { + } + + public function text(): TextDrop { return new TextDrop(); } - public function catchall(): CatchAllDrop + public function catchAll(): CatchAllDrop { return new CatchAllDrop(); }