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')); + } +}