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