Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor FilterChain #208

Merged
merged 2 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading