Skip to content

Commit

Permalink
Refactor FilterChain
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
gsteel committed Dec 10, 2024
1 parent cc21318 commit c279600
Show file tree
Hide file tree
Showing 11 changed files with 525 additions and 258 deletions.
14 changes: 0 additions & 14 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,20 +199,6 @@
<code><![CDATA[(bool) $flag]]></code>
</RedundantCastGivenDocblockType>
</file>
<file src="src/FilterChain.php">
<DeprecatedClass>
<code><![CDATA[AbstractFilter]]></code>
</DeprecatedClass>
<MixedPropertyTypeCoercion>
<code><![CDATA[new PriorityQueue()]]></code>
</MixedPropertyTypeCoercion>
<MoreSpecificImplementedParamType>
<code><![CDATA[$options]]></code>
</MoreSpecificImplementedParamType>
<RedundantFunctionCall>
<code><![CDATA[strtolower]]></code>
</RedundantFunctionCall>
</file>
<file src="src/FilterProviderInterface.php">
<UnusedClass>
<code><![CDATA[FilterProviderInterface]]></code>
Expand Down
224 changes: 44 additions & 180 deletions src/FilterChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,82 +25,42 @@
* priority?: int,
* }>
* }
* @extends AbstractFilter<FilterChainConfiguration>
* @implements IteratorAggregate<array-key, InstanceType>
* @implements FilterChainInterface<mixed>
*/
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<InstanceType, int> */
private PriorityQueue $filters;

/**
* Filter chain
*
* @var PriorityQueue<InstanceType, int>
* @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<InstanceType, int> $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 */
Expand All @@ -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<T>|string $name
* @return ($name is class-string<T> ? 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<FilterInterface>|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']);
Expand All @@ -203,58 +98,27 @@ public function merge(FilterChain $filterChain)
return $this;
}

/**
* Get all the filters
*
* @return PriorityQueue<FilterInterface|callable(mixed): mixed, int>
*/
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<array-key, FilterInterface|callable(mixed): mixed> */
Expand Down
27 changes: 27 additions & 0 deletions src/FilterChainFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Laminas\Filter;

use Laminas\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerInterface;

use function assert;

/** @psalm-import-type FilterChainConfiguration from FilterChain */
final class FilterChainFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, string $requestedName, ?array $options = null): FilterChain
{
/**
* It's not worth attempting runtime validation of the specification shape
* @psalm-var FilterChainConfiguration $options
*/
$options = $options ?? [];
$pluginManager = $container->get(FilterPluginManager::class);
assert($pluginManager instanceof FilterPluginManager);

return new FilterChain($pluginManager, $options);
}
}
38 changes: 38 additions & 0 deletions src/FilterChainInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Laminas\Filter;

use Psr\Container\ContainerExceptionInterface;

/**
* @template TFilteredValue
* @extends FilterInterface<TFilteredValue>
* @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<FilterInterface>|string $name
* @param array<string, mixed> $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;
}
2 changes: 2 additions & 0 deletions src/FilterPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit c279600

Please sign in to comment.