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.
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 0d76c2be..d0e2aa2a 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -185,20 +185,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'));
+ }
+}