From c2796009739a961554b0a9725e54d08acb52611b Mon Sep 17 00:00:00 2001 From: George Steel Date: Tue, 10 Dec 2024 23:14:45 +0000 Subject: [PATCH 1/2] Refactor `FilterChain` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors filter chain to require the plugin manager as a constructor dependency and… - Drops `setOptions` - Removes inheritance - Implement newly added `FilterChainInterface` - Remove the constant `DEFAULT_PRIORITY` - Drops getter and setter for the plugin manager - Drops the `plugin` method - Drops `getFilter` - Stops pretending that chains can be serialized Introduces `ImmutableFilterChain` which is effectively the same as `FilterChain` but immutable and lacks the `merge` method which has limited use cases. `ImmutableFilterChain` and `FilterChainInterface` can be backported to 2.x so that libs can switch to it (input-filter, form etc) without breaking BC _(Possibly)_ Signed-off-by: George Steel --- psalm-baseline.xml | 14 -- src/FilterChain.php | 224 +++++------------------ src/FilterChainFactory.php | 27 +++ src/FilterChainInterface.php | 38 ++++ src/FilterPluginManager.php | 2 + src/ImmutableFilterChain.php | 106 +++++++++++ src/ImmutableFilterChainFactory.php | 30 +++ test/FilterChainFactoryTest.php | 46 +++++ test/FilterChainTest.php | 102 ++++------- test/ImmutableFilterChainFactoryTest.php | 46 +++++ test/ImmutableFilterChainTest.php | 148 +++++++++++++++ 11 files changed, 525 insertions(+), 258 deletions(-) create mode 100644 src/FilterChainFactory.php create mode 100644 src/FilterChainInterface.php create mode 100644 src/ImmutableFilterChain.php create mode 100644 src/ImmutableFilterChainFactory.php create mode 100644 test/FilterChainFactoryTest.php create mode 100644 test/ImmutableFilterChainFactoryTest.php create mode 100644 test/ImmutableFilterChainTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index da102fc6..bd64e939 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -199,20 +199,6 @@ - - - - - - - - - - - - - - diff --git a/src/FilterChain.php b/src/FilterChain.php index d908fb7e..4d1dea34 100644 --- a/src/FilterChain.php +++ b/src/FilterChain.php @@ -6,18 +6,11 @@ use Countable; use IteratorAggregate; -use Laminas\ServiceManager\ServiceManager; use Laminas\Stdlib\PriorityQueue; +use Psr\Container\ContainerExceptionInterface; use Traversable; -use function call_user_func; use function count; -use function get_debug_type; -use function is_array; -use function is_callable; -use function is_string; -use function sprintf; -use function strtolower; /** * @psalm-type InstanceType = FilterInterface|callable(mixed): mixed @@ -32,82 +25,42 @@ * priority?: int, * }> * } - * @extends AbstractFilter * @implements IteratorAggregate + * @implements FilterChainInterface */ -final class FilterChain extends AbstractFilter implements Countable, IteratorAggregate +final class FilterChain implements FilterChainInterface, Countable, IteratorAggregate { - /** - * Default priority at which filters are added - */ - public const DEFAULT_PRIORITY = 1000; - - /** @var FilterPluginManager|null */ - protected $plugins; + /** @var PriorityQueue */ + private PriorityQueue $filters; /** - * Filter chain - * - * @var PriorityQueue + * @param FilterChainConfiguration $options + * @throws ContainerExceptionInterface If any filter cannot be retrieved from the plugin manager. */ - protected $filters; - - /** - * Initialize filter chain - * - * @param FilterChainConfiguration|Traversable|null $options - */ - public function __construct($options = null) - { - $this->filters = new PriorityQueue(); - - if (null !== $options) { - $this->setOptions($options); - } - } - - /** - * @param FilterChainConfiguration|Traversable $options - * @return $this - * @throws Exception\InvalidArgumentException - */ - public function setOptions($options) - { - if (! is_array($options) && ! $options instanceof Traversable) { - throw new Exception\InvalidArgumentException(sprintf( - 'Expected array or Traversable; received "%s"', - get_debug_type($options) - )); + public function __construct( + private readonly FilterPluginManager $plugins, + array $options = [], + ) { + /** @var PriorityQueue $priorityQueue */ + $priorityQueue = new PriorityQueue(); + $this->filters = $priorityQueue; + + $callbacks = $options['callbacks'] ?? []; + foreach ($callbacks as $spec) { + $this->attach( + $spec['callback'], + $spec['priority'] ?? self::DEFAULT_PRIORITY, + ); } - foreach ($options as $key => $value) { - switch (strtolower($key)) { - case 'callbacks': - foreach ($value as $spec) { - $callback = $spec['callback'] ?? false; - $priority = $spec['priority'] ?? static::DEFAULT_PRIORITY; - if (is_callable($callback) || $callback instanceof FilterInterface) { - $this->attach($callback, $priority); - } - } - break; - case 'filters': - foreach ($value as $spec) { - $name = $spec['name'] ?? false; - $options = $spec['options'] ?? []; - $priority = $spec['priority'] ?? static::DEFAULT_PRIORITY; - if (is_string($name) && $name !== '') { - $this->attachByName($name, $options, $priority); - } - } - break; - default: - // ignore other options - break; - } + $filters = $options['filters'] ?? []; + foreach ($filters as $spec) { + $this->attachByName( + $spec['name'], + $spec['options'] ?? [], + $spec['priority'] ?? self::DEFAULT_PRIORITY, + ); } - - return $this; } /** Return the count of attached filters */ @@ -116,85 +69,27 @@ public function count(): int return count($this->filters); } - /** Get plugin manager instance */ - public function getPluginManager(): FilterPluginManager + public function attach(FilterInterface|callable $callback, int $priority = self::DEFAULT_PRIORITY): self { - $plugins = $this->plugins; - if (! $plugins instanceof FilterPluginManager) { - $plugins = new FilterPluginManager(new ServiceManager()); - $this->setPluginManager($plugins); - } - - return $plugins; - } + $this->filters->insert($callback, $priority); - /** - * Set plugin manager instance - * - * @return self - */ - public function setPluginManager(FilterPluginManager $plugins) - { - $this->plugins = $plugins; return $this; } - /** - * Retrieve a filter plugin by name - * - * @template T of FilterInterface - * @param class-string|string $name - * @return ($name is class-string ? T : InstanceType) - */ - public function plugin(string $name, array $options = []) + public function attachByName(string $name, array $options = [], int $priority = self::DEFAULT_PRIORITY): self { - return $this->getPluginManager()->build($name, $options); - } + /** @psalm-var FilterInterface $filter */ + $filter = $this->plugins->build($name, $options); - /** - * Attach a filter to the chain - * - * @param InstanceType $callback A Filter implementation or valid PHP callback - * @param int $priority Priority at which to enqueue filter; defaults to 1000 (higher executes earlier) - * @throws Exception\InvalidArgumentException - * @return self - */ - public function attach(FilterInterface|callable $callback, int $priority = self::DEFAULT_PRIORITY) - { - if (! is_callable($callback)) { - if (! $callback instanceof FilterInterface) { - throw new Exception\InvalidArgumentException(sprintf( - 'Expected a valid PHP callback; received "%s"', - get_debug_type($callback) - )); - } - $callback = [$callback, 'filter']; - } - $this->filters->insert($callback, $priority); - return $this; - } - - /** - * Attach a filter to the chain using a short name - * - * Retrieves the filter from the attached plugin manager, and then calls attach() - * with the retrieved instance. - * - * @param class-string|string $name - * @param int $priority Priority at which to enqueue filter; defaults to 1000 (higher executes earlier) - * @return self - */ - public function attachByName(string $name, array $options = [], int $priority = self::DEFAULT_PRIORITY) - { - return $this->attach($this->plugin($name, $options), $priority); + return $this->attach($filter, $priority); } /** * Merge the filter chain with the one given in parameter * - * @return self + * @return $this */ - public function merge(FilterChain $filterChain) + public function merge(FilterChain $filterChain): self { foreach ($filterChain->filters->toArray(PriorityQueue::EXTR_BOTH) as $item) { $this->attach($item['data'], $item['priority']); @@ -203,58 +98,27 @@ public function merge(FilterChain $filterChain) return $this; } - /** - * Get all the filters - * - * @return PriorityQueue - */ - public function getFilters() - { - return $this->filters; - } - - /** - * Returns $value filtered through each filter in the chain - * - * Filters are run in the order in which they were added to the chain (FIFO) - * - * @psalm-suppress MixedAssignment values are always mixed - */ public function filter(mixed $value): mixed { - $valueFiltered = $value; foreach ($this as $filter) { - if ($filter instanceof FilterInterface) { - $valueFiltered = $filter->filter($valueFiltered); - - continue; - } - - $valueFiltered = call_user_func($filter, $valueFiltered); + /** @var mixed $value */ + $value = $filter($value); } - return $valueFiltered; + return $value; } - /** - * Clone filters - */ - public function __clone() + public function __invoke(mixed $value): mixed { - $this->filters = clone $this->filters; + return $this->filter($value); } /** - * Prepare filter chain for serialization - * - * Plugin manager (property 'plugins') cannot - * be serialized. On wakeup the property remains unset - * and next invocation to getPluginManager() sets - * the default plugin manager instance (FilterPluginManager). + * Prevent clones from mutating the composed priority queue */ - public function __sleep() + public function __clone() { - return ['filters']; + $this->filters = clone $this->filters; } /** @return Traversable */ diff --git a/src/FilterChainFactory.php b/src/FilterChainFactory.php new file mode 100644 index 00000000..99c499ff --- /dev/null +++ b/src/FilterChainFactory.php @@ -0,0 +1,27 @@ +get(FilterPluginManager::class); + assert($pluginManager instanceof FilterPluginManager); + + return new FilterChain($pluginManager, $options); + } +} diff --git a/src/FilterChainInterface.php b/src/FilterChainInterface.php new file mode 100644 index 00000000..34c1ca48 --- /dev/null +++ b/src/FilterChainInterface.php @@ -0,0 +1,38 @@ + + * @psalm-type InstanceType = FilterInterface|callable(mixed): mixed + */ +interface FilterChainInterface extends FilterInterface +{ + public const DEFAULT_PRIORITY = 1000; + + /** + * Attach a filter to the chain + * + * @param InstanceType $callback A Filter implementation or valid PHP callback + * @param int $priority Priority at which to enqueue filter; defaults to 1000 (higher executes earlier) + */ + public function attach(FilterInterface|callable $callback, int $priority = self::DEFAULT_PRIORITY): self; + + /** + * Attach a filter to the chain using an alias or FQCN + * + * Retrieves the filter from the composed plugin manager, and then calls attach() + * with the retrieved instance. + * + * @param class-string|string $name + * @param array $options Construction options for the desired filter + * @param int $priority Priority at which to enqueue filter; defaults to 1000 (higher executes earlier) + * @throws ContainerExceptionInterface If the filter cannot be retrieved from the plugin manager. + */ + public function attachByName(string $name, array $options = [], int $priority = self::DEFAULT_PRIORITY): self; +} diff --git a/src/FilterPluginManager.php b/src/FilterPluginManager.php index 76978db7..402c6bb0 100644 --- a/src/FilterPluginManager.php +++ b/src/FilterPluginManager.php @@ -47,8 +47,10 @@ final class FilterPluginManager extends AbstractPluginManager File\Rename::class => InvokableFactory::class, File\RenameUpload::class => InvokableFactory::class, File\UpperCase::class => InvokableFactory::class, + FilterChain::class => FilterChainFactory::class, ForceUriScheme::class => InvokableFactory::class, HtmlEntities::class => InvokableFactory::class, + ImmutableFilterChain::class => ImmutableFilterChainFactory::class, Inflector::class => InvokableFactory::class, ToFloat::class => InvokableFactory::class, MonthSelect::class => InvokableFactory::class, diff --git a/src/ImmutableFilterChain.php b/src/ImmutableFilterChain.php new file mode 100644 index 00000000..a427717a --- /dev/null +++ b/src/ImmutableFilterChain.php @@ -0,0 +1,106 @@ +, + * options?: array, + * priority?: int|null, + * }>, + * callbacks?: list, + * } + * @implements FilterChainInterface + */ +final class ImmutableFilterChain implements FilterChainInterface +{ + /** @var PriorityQueue */ + private readonly PriorityQueue $filters; + + /** @param PriorityQueue|null $filters */ + private function __construct( + private readonly FilterPluginManager $pluginManager, + PriorityQueue|null $filters, + ) { + /** @var PriorityQueue $default */ + $default = new PriorityQueue(); + $this->filters = $filters ?? $default; + } + + public function filter(mixed $value): mixed + { + foreach ($this->filters as $filter) { + /** @var mixed $value */ + $value = $filter($value); + } + + return $value; + } + + public function __invoke(mixed $value): mixed + { + return $this->filter($value); + } + + public static function empty(FilterPluginManager $pluginManager): self + { + return new self($pluginManager, null); + } + + /** + * Construct a filter chain from a specification + * + * @param ChainSpec $spec + * @throws ContainerExceptionInterface If any named filters cannot be retrieved from the plugin manager. + */ + public static function fromArray(array $spec, FilterPluginManager $pluginManager): self + { + /** @psalm-var PriorityQueue $queue */ + $queue = new PriorityQueue(); + $chain = new self($pluginManager, $queue); + + $callables = $spec['callbacks'] ?? []; + foreach ($callables as $set) { + $chain = $chain->attach($set['callback'], $set['priority'] ?? self::DEFAULT_PRIORITY); + } + + $filters = $spec['filters'] ?? []; + foreach ($filters as $filter) { + $chain = $chain->attachByName( + $filter['name'], + $filter['options'] ?? [], + $filter['priority'] ?? self::DEFAULT_PRIORITY, + ); + } + + return $chain; + } + + public function attach(FilterInterface|callable $callback, int $priority = self::DEFAULT_PRIORITY): self + { + $filters = clone $this->filters; + $filters->insert($callback, $priority); + + return new self($this->pluginManager, $filters); + } + + public function attachByName(string $name, array $options = [], int $priority = self::DEFAULT_PRIORITY): self + { + /** @psalm-var FilterInterface $filter */ + $filter = $this->pluginManager->build($name, $options); + $filters = clone $this->filters; + $filters->insert($filter, $priority); + + return new self($this->pluginManager, $filters); + } +} diff --git a/src/ImmutableFilterChainFactory.php b/src/ImmutableFilterChainFactory.php new file mode 100644 index 00000000..c1aac68b --- /dev/null +++ b/src/ImmutableFilterChainFactory.php @@ -0,0 +1,30 @@ +get(FilterPluginManager::class); + assert($pluginManager instanceof FilterPluginManager); + + return ImmutableFilterChain::fromArray($options, $pluginManager); + } +} diff --git a/test/FilterChainFactoryTest.php b/test/FilterChainFactoryTest.php new file mode 100644 index 00000000..4720b5e1 --- /dev/null +++ b/test/FilterChainFactoryTest.php @@ -0,0 +1,46 @@ + [ + FilterPluginManager::class => FilterPluginManagerFactory::class, + ], + ]); + + $this->pluginManager = $serviceManager->get(FilterPluginManager::class); + } + + public function testAFilterChainCanBeRetrievedFromThePluginManager(): void + { + $chain = $this->pluginManager->get(FilterChain::class); + + self::assertInstanceOf(FilterChain::class, $chain); + } + + public function testAChainCanBeBuiltWithOptions(): void + { + $chain = $this->pluginManager->build(FilterChain::class, [ + 'callbacks' => [ + ['callback' => new StringToLower()], + ], + ]); + + self::assertSame('foo', $chain->filter('FOO')); + } +} diff --git a/test/FilterChainTest.php b/test/FilterChainTest.php index 5c496d3f..d04c5cc9 100644 --- a/test/FilterChainTest.php +++ b/test/FilterChainTest.php @@ -4,37 +4,45 @@ namespace LaminasTest\Filter; -use ArrayIterator; use Laminas\Filter\FilterChain; +use Laminas\Filter\FilterPluginManager; use Laminas\Filter\PregReplace; use Laminas\Filter\StringToLower; use Laminas\Filter\StringTrim; use Laminas\Filter\StripTags; +use Laminas\ServiceManager\ServiceManager; use LaminasTest\Filter\TestAsset\StrRepeatFilterInterface; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use function count; use function iterator_to_array; -use function serialize; use function strtolower; use function strtoupper; use function trim; -use function unserialize; -/** @psalm-import-type FilterChainConfiguration from FilterChain */ +/** + * @psalm-import-type FilterChainConfiguration from FilterChain + */ class FilterChainTest extends TestCase { + private FilterPluginManager $plugins; + + protected function setUp(): void + { + $this->plugins = new FilterPluginManager(new ServiceManager()); + } + public function testEmptyFilterChainReturnsOriginalValue(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $value = 'something'; self::assertSame($value, $chain->filter($value)); } public function testFiltersAreExecutedInFifoOrder(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(new TestAsset\LowerCase()) ->attach(new TestAsset\StripUpperCase()); $value = 'AbC'; @@ -44,7 +52,7 @@ public function testFiltersAreExecutedInFifoOrder(): void public function testFiltersAreExecutedAccordingToPriority(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(new TestAsset\StripUpperCase()) ->attach(new TestAsset\LowerCase(), 100); $value = 'AbC'; @@ -54,7 +62,7 @@ public function testFiltersAreExecutedAccordingToPriority(): void public function testAllowsConnectingArbitraryCallbacks(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(static fn(string $value): string => strtolower($value)); $value = 'AbC'; self::assertSame('abc', $chain->filter($value)); @@ -62,22 +70,21 @@ public function testAllowsConnectingArbitraryCallbacks(): void public function testAllowsConnectingViaClassShortName(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attachByName(StringTrim::class, [], 100) ->attachByName(StripTags::class) ->attachByName(StringToLower::class, ['encoding' => 'utf-8'], 900); - $value = ' ABC '; + $value = ' ABC '; $valueExpected = 'abc'; self::assertSame($valueExpected, $chain->filter($value)); } public function testAllowsConfiguringFilters(): void { - $config = $this->getChainConfig(); - $chain = new FilterChain(); - $chain->setOptions($config); - $value = ' abc '; + $config = $this->getChainConfig(); + $chain = new FilterChain($this->plugins, $config); + $value = ' abc '; $valueExpected = 'ABC ABC '; self::assertSame($valueExpected, $chain->filter($value)); } @@ -85,25 +92,15 @@ public function testAllowsConfiguringFilters(): void public function testAllowsConfiguringFiltersViaConstructor(): void { $config = $this->getChainConfig(); - $chain = new FilterChain($config); - $value = ' abc '; - $valueExpected = 'ABCABC'; - self::assertSame($valueExpected, $chain->filter($value)); - } - - public function testConfigurationAllowsTraversableObjects(): void - { - $config = $this->getChainConfig(); - $config = new ArrayIterator($config); - $chain = new FilterChain($config); - $value = ' abc '; + $chain = new FilterChain($this->plugins, $config); + $value = ' abc '; $valueExpected = 'ABCABC'; self::assertSame($valueExpected, $chain->filter($value)); } public function testCanRetrieveFilterWithUndefinedConstructor(): void { - $chain = new FilterChain([ + $chain = new FilterChain($this->plugins, [ 'filters' => [ ['name' => 'int'], ], @@ -142,7 +139,7 @@ public static function staticUcaseFilter(string $value): string #[Group('Laminas-412')] public function testCanAttachMultipleFiltersOfTheSameTypeAsDiscreteInstances(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attachByName(PregReplace::class, [ 'pattern' => '/Foo/', 'replacement' => 'Bar', @@ -153,7 +150,7 @@ public function testCanAttachMultipleFiltersOfTheSameTypeAsDiscreteInstances(): ]); self::assertSame(2, count($chain)); - $filters = $chain->getFilters(); + $filters = iterator_to_array($chain); $compare = null; foreach ($filters as $filter) { self::assertNotSame($compare, $filter); @@ -165,7 +162,7 @@ public function testCanAttachMultipleFiltersOfTheSameTypeAsDiscreteInstances(): public function testClone(): void { - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $clone = clone $chain; $chain->attachByName(StripTags::class); @@ -173,47 +170,32 @@ public function testClone(): void self::assertCount(0, $clone); } - public function testCanSerializeFilterChain(): void - { - $chain = new FilterChain(); - $chain->attach(new TestAsset\LowerCase()) - ->attach(new TestAsset\StripUpperCase()); - $serialized = serialize($chain); - - $unserialized = unserialize($serialized); - self::assertInstanceOf(FilterChain::class, $unserialized); - self::assertSame(2, count($unserialized)); - $value = 'AbC'; - $valueExpected = 'abc'; - self::assertSame($valueExpected, $unserialized->filter($value)); - } - public function testMergingTwoFilterChainsKeepFiltersPriority(): void { $value = 'AbC'; $valueExpected = 'abc'; - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(new TestAsset\StripUpperCase()) ->attach(new TestAsset\LowerCase(), 1001); self::assertSame($valueExpected, $chain->filter($value)); - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(new TestAsset\LowerCase(), 1001) ->attach(new TestAsset\StripUpperCase()); self::assertSame($valueExpected, $chain->filter($value)); - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(new TestAsset\LowerCase(), 1001); - $chainToMerge = new FilterChain(); + $chainToMerge = new FilterChain($this->plugins); $chainToMerge->attach(new TestAsset\StripUpperCase()); $chain->merge($chainToMerge); self::assertSame(2, $chain->count()); self::assertSame($valueExpected, $chain->filter($value)); - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach(new TestAsset\StripUpperCase()); - $chainToMerge = new FilterChain(); + $chainToMerge = new FilterChain($this->plugins); $chainToMerge->attach(new TestAsset\LowerCase(), 1001); $chain->merge($chainToMerge); self::assertSame(2, $chain->count()); @@ -225,7 +207,7 @@ public function testThatIteratingOverAFilterChainDirectlyYieldsExpectedFilters() $filter1 = new StringToLower(); $filter2 = new StripTags(); - $chain = new FilterChain(); + $chain = new FilterChain($this->plugins); $chain->attach($filter1, 10); $chain->attach($filter2, 20); @@ -236,19 +218,11 @@ public function testThatIteratingOverAFilterChainDirectlyYieldsExpectedFilters() ], $filters); } - public function testThatIteratingOverGetFiltersYieldsExpectedFilters(): void + public function testTheFilterChainIsInvokable(): void { - $filter1 = new StringToLower(); - $filter2 = new StripTags(); + $chain = new FilterChain($this->plugins); + $chain->attach(new StringToLower()); - $chain = new FilterChain(); - $chain->attach($filter1, 10); - $chain->attach($filter2, 20); - - $filters = iterator_to_array($chain->getFilters()); - self::assertEquals([ - 0 => $filter1, - 1 => $filter2, - ], $filters); + self::assertSame('foo', $chain->__invoke('FOO')); } } diff --git a/test/ImmutableFilterChainFactoryTest.php b/test/ImmutableFilterChainFactoryTest.php new file mode 100644 index 00000000..1a46ec48 --- /dev/null +++ b/test/ImmutableFilterChainFactoryTest.php @@ -0,0 +1,46 @@ + [ + FilterPluginManager::class => FilterPluginManagerFactory::class, + ], + ]); + + $this->pluginManager = $serviceManager->get(FilterPluginManager::class); + } + + public function testAFilterChainCanBeRetrievedFromThePluginManager(): void + { + $chain = $this->pluginManager->get(ImmutableFilterChain::class); + + self::assertInstanceOf(ImmutableFilterChain::class, $chain); + } + + public function testAChainCanBeBuiltWithOptions(): void + { + $chain = $this->pluginManager->build(ImmutableFilterChain::class, [ + 'callbacks' => [ + ['callback' => new StringToLower()], + ], + ]); + + self::assertSame('foo', $chain->filter('FOO')); + } +} diff --git a/test/ImmutableFilterChainTest.php b/test/ImmutableFilterChainTest.php new file mode 100644 index 00000000..2a312942 --- /dev/null +++ b/test/ImmutableFilterChainTest.php @@ -0,0 +1,148 @@ +plugins = new FilterPluginManager(new ServiceManager()); + } + + public function testThatFiltersWillBeRetrievedFromThePluginManager(): void + { + $chain = ImmutableFilterChain::empty($this->plugins) + ->attachByName(StringToLower::class); + + self::assertSame('foo', $chain->__invoke('Foo')); + } + + public function testFiltersWillOperateInFifoOrderWhenPrioritiesAreEqual(): void + { + $makeFoo = static fn (): string => 'Foo'; + $makeBar = static fn (): string => 'Bar'; + + $chain = ImmutableFilterChain::empty($this->plugins) + ->attach($makeFoo, 10) + ->attach($makeBar, 10); + + self::assertSame('Bar', $chain->filter('Baz')); + } + + public function testFiltersWillOperateInPriorityOrder(): void + { + $makeFoo = static fn (): string => 'Foo'; + $makeBar = static fn (): string => 'Bar'; + + $chain = ImmutableFilterChain::empty($this->plugins) + ->attach($makeFoo, 10) + ->attach($makeBar, 20); + + self::assertSame('Foo', $chain->filter('Baz')); + + $chain = ImmutableFilterChain::empty($this->plugins) + ->attach($makeFoo, 20) + ->attach($makeBar, 10); + + self::assertSame('Bar', $chain->filter('Baz')); + } + + public function testTheFilterProducesTheSameResultForMultipleExecutions(): void + { + $makeFoo = static fn (): string => 'Foo'; + $makeBar = static fn (): string => 'Bar'; + + $chain = ImmutableFilterChain::empty($this->plugins) + ->attach($makeFoo) + ->attach($makeBar); + + self::assertSame('Bar', $chain->filter('Baz')); + self::assertSame('Bar', $chain->filter('Bat')); + } + + public function testAnEmptyFilterChainWillReturnTheUnfilteredValue(): void + { + self::assertSame( + 'Foo', + ImmutableFilterChain::empty($this->plugins)->filter('Foo'), + ); + } + + public function testSpecificationWithCallables(): void + { + $spec = [ + 'callbacks' => [ + [ + 'callback' => static fn (string $in): string => str_replace(' ', '_', $in), + 'priority' => 10, + ], + [ + 'callback' => static fn (string $in): string => implode(' ', str_split($in)), + ], + ], + ]; + + $chain = ImmutableFilterChain::fromArray($spec, $this->plugins); + + self::assertSame('F_o_o', $chain->filter('Foo')); + } + + public function testSpecificationWithFilters(): void + { + $spec = [ + 'filters' => [ + [ + 'name' => StringPrefix::class, + 'options' => [ + 'prefix' => 'Foo', + ], + 'priority' => 10, + ], + [ + 'name' => StringToLower::class, + ], + ], + ]; + + $chain = ImmutableFilterChain::fromArray($spec, $this->plugins); + + self::assertSame('Foofoo', $chain->filter('Foo')); + } + + public function testSpecificationWithFilterInstancesInCallbacks(): void + { + $spec = [ + 'callbacks' => [ + [ + 'callback' => new StringPrefix([ + 'prefix' => 'Foo', + ]), + 'priority' => 10, + ], + [ + 'callback' => new StringToLower(), + ], + ], + ]; + + $chain = ImmutableFilterChain::fromArray($spec, $this->plugins); + + self::assertSame('Foofoo', $chain->filter('Foo')); + } +} From eb02bec9512fd0ed99afbb0acdeb16b5858bd16c Mon Sep 17 00:00:00 2001 From: George Steel Date: Tue, 17 Dec 2024 23:20:03 +0000 Subject: [PATCH 2/2] Re-write documentation on Filter Chains and update `writing-filters` Signed-off-by: George Steel --- docs/book/v3/filter-chains.md | 194 +++++++++++++++++++++----------- docs/book/v3/writing-filters.md | 40 +++++-- 2 files changed, 156 insertions(+), 78 deletions(-) diff --git a/docs/book/v3/filter-chains.md b/docs/book/v3/filter-chains.md index a7690583..faf1b713 100644 --- a/docs/book/v3/filter-chains.md +++ b/docs/book/v3/filter-chains.md @@ -1,93 +1,155 @@ # Filter Chains -> MISSING: **Installation Requirements** -> The following examples use the [`Alpha` filter from the `laminas/laminas-i18n`](https://docs.laminas.dev/laminas-i18n/filters/alpha/). -> Make sure to install the required package before running the examples. -> -> ```bash -> $ composer require laminas/laminas-i18n -> ``` - -Often, multiple filters should be applied to some value in a particular order. -For example, a login form accepts a username that should be lowercase and -contain only alphabetic characters. - -`Laminas\Filter\FilterChain` provides a simple method by which filters may be -chained together. The following code illustrates how to chain together two -filters for the submitted username and fulfill the above requirements: +Filter chains provide a way of grouping multiple filters together. +They operate on the input by fulfilling the `FilterInterface` contract in the same way as all the other filters. + +You can enqueue as many filters as you like via a straight-forward api, attaching either ready-to-use filter instances, arbitrary callables or by using a declarative array format. + +## Getting a Filter Chain Instance + +The filter chain has a constructor dependency on the `FilterPluginManager`, so whilst you can manually create a filter chain from scratch, it is normally easier to retrieve one from the plugin manager ```php -// Create a filter chain and add filters to the chain -$filterChain = new Laminas\Filter\FilterChain(); -$filterChain - ->attach(new Laminas\I18n\Filter\Alpha()) - ->attach(new Laminas\Filter\StringToLower()); +assert($container instanceof Psr\Container\ContainerInterface); +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); -// Filter the username -$username = $filterChain->filter($_POST['username']); -``` +// Create a Filter Chain instance manually: +$chain = new Laminas\Filter\FilterChain($pluginManager); -Filters are run in the order they are added to the filter chain. In the above -example, the username is first removed of any non-alphabetic characters, and -then any uppercase characters are converted to lowercase. +// Fetch an empty filter chain from the plugin manager +$chain = $pluginManager->get(Laminas\Filter\FilterChain::class); -Any object that implements `Laminas\Filter\FilterInterface` may be used in a -filter chain. +// Build a ready-to-use filter chain via the plugin manager +$chain = $pluginManager->build(Laminas\Filter\FilterChain::class, [ + // ...(Filter Chain Configuration)) +]); +``` -## Setting Filter Chain Order +## Adding Filters to the Chain -For each filter added to the `FilterChain`, you can set a priority to define -the chain order. Higher values indicate higher priority (execute first), while -lower and/or negative values indicate lower priority (execute last). The default value is `1000`. +The following examples provide 3 ways of building a chain that performs the following: -In the following example, any uppercase characters are converted to lowercase -before any non-alphabetic characters are removed. +- Trim the input string using `Laminas\Filter\StringTrim` +- Make the string lowercase using `Laminas\Filter\StringToLower` +- Reverse the string using a closure + +### 1. Attaching Filter Instances to the Chain ```php -// Create a filter chain and add filters to the chain -$filterChain = new Laminas\Filter\FilterChain(); -$filterChain - ->attach(new Laminas\I18n\Filter\Alpha()) - ->attach(new Laminas\Filter\StringToLower(), 500); +use Laminas\Filter\FilterChain; +use Laminas\Filter\StringToLower; +use Laminas\Filter\StringTrim; + +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); + +$chain = $pluginManager->get(FilterChain::class); + +$chain->attach(new StringTrim()); // directly instantiate the filter +$chain->attach($pluginManager->get(StringToLower::class)); +$chain->attach(static fn (string $value): string => strrev($value)); + +print $chain->filter(' OOF '); // 'foo' ``` -## Using the Plugin Manager +### 2. Building the Chain with Array Configuration -A `FilterPluginManager` is attached to every `FilterChain` instance. Every filter -that is used in a `FilterChain` must be known to the `FilterPluginManager`. +```php +use Laminas\Filter\FilterChain; +use Laminas\Filter\StringToLower; +use Laminas\Filter\StringTrim; + +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$chain = $pluginManager->build(FilterChain::class, [ + 'filters' => [ + ['name' => StringTrim::class], + ['name' => StringToLower::class], + ], + 'callbacks' => [ + ['callback' => static fn (string $value): string => strrev($value)], + ], +]); + +print $chain->filter(' OOF '); // 'foo' +``` -To add a filter to the `FilterChain`, use the `attachByName()` method. The -first parameter is the name of the filter within the `FilterPluginManager`. The -second parameter takes any options for creating the filter instance. The third -parameter is the priority. +### 3. Adding Filters to the Chain by Name ```php -// Create a filter chain and add filters to the chain -$filterChain = new Laminas\Filter\FilterChain(); -$filterChain - ->attachByName('alpha') - ->attachByName('stringtolower', ['encoding' => 'utf-8'], 500); +use Laminas\Filter\FilterChain; +use Laminas\Filter\StringToLower; +use Laminas\Filter\StringTrim; + +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); + +$chain = $pluginManager->get(FilterChain::class); + +// `attachByName` retrieves the filter from the composed plugin manager: +$chain->attachByName(StringTrim::class); +$chain->attachByName(StringToLower::class); +// We must still use `attach` to add the closure: +$chain->attach(static fn (string $value): string => strrev($value)); + +print $chain->filter(' OOF '); // 'foo' ``` -The following example shows how to add a custom filter to the `FilterPluginManager` and the -`FilterChain`: +By default, filters execute in the order they are added to the filter chain. +In the above examples, the input is trimmed first, then converted to lowercase and finally reversed. + +## Types of Attachable Filters + +Any object that implements `Laminas\Filter\FilterInterface` may be used in a filter chain. +Additionally, you can provide any type of `callable`, however it should match the signature `fn (mixed $value): mixed`. You are free to narrow the types from mixed, but given the simple contract of `FilterInterface` it is often better practice to [write a custom filter](writing-filters.md) and register it with the plugin manager. + +## Setting Filter Chain Order + +For each filter added to the `FilterChain`, you can set a priority to define the chain order. +Higher values indicate higher priority (execute first), while lower and/or negative values indicate lower priority (execute last). +The default priority is `1000`. + +In the following example, an uppercase prefix is applied after the input has been converted to lower case, even though the prefix filter is added to the chain first: ```php -$filterChain = new Laminas\Filter\FilterChain(); -$filterChain - ->getPluginManager() - ->setInvokableClass('myNewFilter', 'MyCustom\Filter\MyNewFilter'); -$filterChain - ->attachByName('alpha') - ->attachByName('myNewFilter'); +// Create a filter chain and add filters to the chain +$filterChain = $pluginManager->get(Laminas\Filter\FilterChain::class); +$filterChain->attach(new Laminas\Filter\StringPrefix(['prefix' => 'FOO: '])); +$filterChain->attach(new Laminas\Filter\StringToLower(), 500); + +print $filterChain->filter('BAR'); // 'FOO: bar' ``` -You can also add your own `FilterPluginManager` implementation: +## Array Configuration + +As previously noted, you can define filter chains using a configuration array. +The exact specification of this array is as follows: ```php -$filterChain = new Laminas\Filter\FilterChain(); -$filterChain->setPluginManager(new MyFilterPluginManager()); -$filterChain - ->attach(new Laminas\I18n\Filter\Alpha()) - ->attach(new MyCustom\Filter\MyNewFilter()); +$filterChainConfig = [ + 'filters' => [ + [ + 'name' => SomeFilter::class, // Required. Must be an alias or a FQCN registered in the plugin manager + 'options' => [ /* ... */ ], // Optional. Provide options specific to the required filter + 'priority' => 500, // Optional. Set the execution priority of the filter (Default 1000) + ], + ], + 'callbacks' => [ + [ + 'callback' => static fn (string $in): string => strrev($in), // Required. Any type of PHP callable + 'priority' => 500, // Optional priority, default 1000 + ], + [ + 'callback' => new Laminas\Filter\StringToLower(), // Any object implementing FilterInterface + ], + ], +]; ``` + +NOTE: **Callbacks are Registered First** +It's important to note that internally, `callbacks` are registered _first_. +This means that if you do not specify priorities when using the array configuration format, the filter execution order may not be what you want. + +## Using the Plugin Manager + +As with other plugin managers in the laminas ecosystem, you can retrieve filters either by fully qualified class name, or by any configured alias of that class. +For example, the `Laminas\Filter\StringToLower` filter is aliased to `stringToLower`, therefore, calling `$pluginManager->get('stringToLower')` will yield an instance of this filter. + +When using filter chain, you must remember to [register custom filters](writing-filters.md#registering-custom-filters-with-the-plugin-manager) with the plugin manager correctly if you wish to reference your filters by FQCN or alias. diff --git a/docs/book/v3/writing-filters.md b/docs/book/v3/writing-filters.md index 48552743..72a2aa76 100644 --- a/docs/book/v3/writing-filters.md +++ b/docs/book/v3/writing-filters.md @@ -1,9 +1,8 @@ # Writing Filters `Laminas\Filter` supplies a set of commonly needed filters, but developers will -often need to write custom filters for their particular use cases. You can do -so by writing classes that implement `Laminas\Filter\FilterInterface`, which -defines a single method, `filter()`. +often need to write custom filters for their particular use cases. +You can do so by writing classes that implement `Laminas\Filter\FilterInterface`, which defines two methods, `filter()` and `__invoke()`. ## Example @@ -14,28 +13,45 @@ use Laminas\Filter\FilterInterface; class MyFilter implements FilterInterface { - public function filter($value) + public function filter(mixed $value): mixed { - // perform some transformation upon $value to arrive on $valueFiltered + // perform some transformation upon $value to arrive at $valueFiltered return $valueFiltered; } + + public function __invoke(mixed $value): mixed { + return $this->filter($value); + } } ``` To attach an instance of the filter defined above to a filter chain: ```php -$filterChain = new Laminas\Filter\FilterChain(); +$filterChain = new Laminas\Filter\FilterChain($pluginManager); $filterChain->attach(new Application\Filter\MyFilter()); ``` -Alternately, add it to the `FilterPluginManager`: +## Registering Custom Filters with the Plugin Manager + +In both Laminas MVC and Mezzio applications, the top-level `filters` configuration key can be used to register filters with the plugin manager in standard Service Manager format: ```php -$filterChain = new Laminas\Filter\FilterChain(); -$filterChain - ->getPluginManager() - ->setInvokableClass('myfilter', Application\Filter\MyFilter::class) -$filterChain->attachByName('myfilter'); +use Laminas\ServiceManager\Factory\InvokableFactory; + +return [ + 'filters' => [ + 'factories' => [ + My\Filter\FilterOne::class => InvokableFactory::class, + My\Filter\FilterTwo::class => My\Filter\SomeCustomFactory::class, + ], + 'aliases' => [ + 'filterOne' => My\Filter\FilterOne::class, + 'filterTwo' => My\Filter\FilterTwo::class, + ], + ], +]; ``` + +Assuming the configuration above is merged into your application configuration, either by way of a dedicated configuration file, or via an MVC Module class or Mezzio Config Provider, you would be able to retrieve filter instances from the plugin manager by FQCN or alias.