diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e23a8698..1fe630e8 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -218,99 +218,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - rules[$spec][]]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - getPluginManager()->get($rule)]]> - getPluginManager()->get($rule)]]> - rules[$spec]]]> - rules[$spec][$index]]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -393,55 +300,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/FilterPluginManager.php b/src/FilterPluginManager.php index 402c6bb0..0d808445 100644 --- a/src/FilterPluginManager.php +++ b/src/FilterPluginManager.php @@ -51,7 +51,7 @@ final class FilterPluginManager extends AbstractPluginManager ForceUriScheme::class => InvokableFactory::class, HtmlEntities::class => InvokableFactory::class, ImmutableFilterChain::class => ImmutableFilterChainFactory::class, - Inflector::class => InvokableFactory::class, + Inflector::class => InflectorFactory::class, ToFloat::class => InvokableFactory::class, MonthSelect::class => InvokableFactory::class, UpperCaseWords::class => InvokableFactory::class, diff --git a/src/Inflector.php b/src/Inflector.php index 4516fc09..a41abc1c 100644 --- a/src/Inflector.php +++ b/src/Inflector.php @@ -4,18 +4,15 @@ namespace Laminas\Filter; -use Laminas\Filter\FilterInterface; -use Laminas\ServiceManager\ServiceManager; -use Laminas\Stdlib\ArrayUtils; -use Traversable; +use Laminas\Filter\Exception\InvalidArgumentException; -use function array_key_exists; use function array_keys; -use function array_shift; +use function array_map; use function array_values; -use function class_exists; -use function func_get_args; +use function assert; +use function get_object_vars; use function is_array; +use function is_object; use function is_scalar; use function is_string; use function ltrim; @@ -23,426 +20,132 @@ use function preg_quote; use function preg_replace; use function str_replace; +use function str_starts_with; /** * Filter chain for string inflection * + * @psalm-import-type InstanceType from FilterPluginManager + * @psalm-type RulesArray = array> * @psalm-type Options = array{ - * target?: string, - * rules?: array, + * target: string, + * rules?: RulesArray, * throwTargetExceptionsOn?: bool, - * targetReplacementIdentifier?: string, - * pluginManager?: FilterPluginManager, + * targetReplacementIdentifier?: non-empty-string, * } - * @extends AbstractFilter + * @implements FilterInterface */ -final class Inflector extends AbstractFilter +final class Inflector implements FilterInterface { - /** @var FilterPluginManager */ - protected $pluginManager; - - /** @var string */ - protected $target; - - /** @var bool */ - protected $throwTargetExceptionsOn = true; - - /** @var string */ - protected $targetReplacementIdentifier = ':'; - - /** @var array */ - protected $rules = []; - - /** - * @param string|array|Traversable $options Options to set - */ - public function __construct($options = null) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } - if (! is_array($options)) { - $options = func_get_args(); - $temp = []; - - if (! empty($options)) { - $temp['target'] = array_shift($options); - } - - if (! empty($options)) { - $temp['rules'] = array_shift($options); - } - - if (! empty($options)) { - $temp['throwTargetExceptionsOn'] = array_shift($options); - } - - if (! empty($options)) { - $temp['targetReplacementIdentifier'] = array_shift($options); - } - - $options = $temp; - } - - $this->setOptions($options); - } - - /** - * Retrieve plugin manager - * - * @return FilterPluginManager - */ - public function getPluginManager() - { - if (! $this->pluginManager instanceof FilterPluginManager) { - $this->setPluginManager(new FilterPluginManager(new ServiceManager())); - } - - return $this->pluginManager; - } - - /** - * Set plugin manager - * - * @return self - */ - public function setPluginManager(FilterPluginManager $manager) - { - $this->pluginManager = $manager; - return $this; - } - - /** - * Set options - * - * @param array|Options|iterable $options - * @return self - */ - public function setOptions($options) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } - - // Set plugin manager - if (array_key_exists('pluginManager', $options)) { - if (is_scalar($options['pluginManager']) && class_exists($options['pluginManager'])) { - $options['pluginManager'] = new $options['pluginManager'](); - } - $this->setPluginManager($options['pluginManager']); - } - - if (array_key_exists('throwTargetExceptionsOn', $options)) { - $this->setThrowTargetExceptionsOn($options['throwTargetExceptionsOn']); - } - - if (array_key_exists('targetReplacementIdentifier', $options)) { - $this->setTargetReplacementIdentifier($options['targetReplacementIdentifier']); - } - - if (array_key_exists('target', $options)) { - $this->setTarget($options['target']); - } - - if (array_key_exists('rules', $options)) { - $this->addRules($options['rules']); - } - - return $this; - } - - /** - * Set Whether or not the inflector should throw an exception when a replacement - * identifier is still found within an inflected target. - * - * @param bool $throwTargetExceptionsOn - * @return self - */ - public function setThrowTargetExceptionsOn($throwTargetExceptionsOn) - { - $this->throwTargetExceptionsOn = (bool) $throwTargetExceptionsOn; - return $this; - } - - /** - * Will exceptions be thrown? - * - * @return bool - */ - public function isThrowTargetExceptionsOn() - { - return $this->throwTargetExceptionsOn; - } - - /** - * Set the Target Replacement Identifier, by default ':' - * - * @param string $targetReplacementIdentifier - * @return self - */ - public function setTargetReplacementIdentifier($targetReplacementIdentifier) - { - if ($targetReplacementIdentifier) { - $this->targetReplacementIdentifier = (string) $targetReplacementIdentifier; - } - - return $this; - } - - /** - * Get Target Replacement Identifier - * - * @return string - */ - public function getTargetReplacementIdentifier() - { - return $this->targetReplacementIdentifier; - } - - /** - * Set a Target - * ex: 'scripts/:controller/:action.:suffix' - * - * @param string $target - * @return self - */ - public function setTarget($target) - { - $this->target = (string) $target; - return $this; - } - - /** - * Retrieve target - * - * @return string - */ - public function getTarget() - { - return $this->target; - } - - /** - * Set Target Reference - * - * @param string $target - * @return self - */ - public function setTargetReference(&$target) - { - $this->target = &$target; - return $this; - } - - /** - * Is the same as calling addRules() with the exception that it - * clears the rules before adding them. - * - * @return self - */ - public function setRules(array $rules) - { - $this->clearRules(); - $this->addRules($rules); - return $this; - } - - /** - * Multi-call to setting filter rules. - * - * If prefixed with a ":" (colon), a filter rule will be added. If not - * prefixed, a static replacement will be added. - * - * ex: - * array( - * ':controller' => array('CamelCaseToUnderscore', 'StringToLower'), - * ':action' => array('CamelCaseToUnderscore', 'StringToLower'), - * 'suffix' => 'phtml' - * ); - * - * @return self - */ - public function addRules(array $rules) - { - $keys = array_keys($rules); - foreach ($keys as $spec) { - if ($spec[0] === ':') { - $this->addFilterRule($spec, $rules[$spec]); + /** @var non-empty-string */ + private readonly string $target; + private readonly bool $throwTargetExceptionsOn; + /** @var non-empty-string */ + private readonly string $targetReplacementIdentifier; + /** @var array> */ + private readonly array $rules; + + /** @param Options $options */ + public function __construct( + private readonly FilterPluginManager $pluginManager, + array $options, + ) { + $target = $options['target'] ?? null; + if (! is_string($target) || $target === '') { + throw new InvalidArgumentException('Inflector requires the target option to be a non-empty string'); + } + + $this->target = $target; + $this->throwTargetExceptionsOn = $options['throwTargetExceptionsOn'] ?? true; + $this->targetReplacementIdentifier = $options['targetReplacementIdentifier'] ?? ':'; + $this->rules = $this->resolveRules($options['rules'] ?? []); + } + + /** + * Resolve rules argument + * + * If prefixed with a ":" (colon), a filter rule will be added. + * If not prefixed, a static string replacement will be added. + * + * example: + * [ + * ':controller' => [CamelCaseToUnderscore::class, StringToLower::class], + * ':action' => [CamelCaseToUnderscore::class, StringToLower::class], + * 'suffix' => 'phtml', + * ] + * + * @param RulesArray $rules + * @return array> + */ + private function resolveRules(array $rules): array + { + $resolved = []; + foreach ($rules as $spec => $ruleSet) { + $name = ltrim($spec, ':'); + if (str_starts_with($spec, ':')) { + $resolved[$name] = array_map( + function (string|FilterInterface|callable $filter): FilterInterface|callable { + return $this->loadFilter($filter); + }, + is_string($ruleSet) ? [$ruleSet] : $ruleSet, + ); } else { - $this->setStaticRule($spec, $rules[$spec]); - } - } - - return $this; - } - - /** - * Get rules - * - * By default, returns all rules. If a $spec is provided, will return those - * rules if found, false otherwise. - * - * @param string $spec - * @return array|false - */ - public function getRules($spec = null) - { - if (null !== $spec) { - $spec = $this->_normalizeSpec($spec); - if (isset($this->rules[$spec])) { - return $this->rules[$spec]; + assert(is_string($ruleSet)); + $resolved[$name] = $ruleSet; } - return false; } - return $this->rules; + return $resolved; } - /** - * Returns a rule set by setFilterRule(), a numeric index must be provided - * - * @param string $spec - * @param int $index - * @return FilterInterface|false - */ - public function getRule($spec, $index) - { - $spec = $this->_normalizeSpec($spec); - if (isset($this->rules[$spec]) && is_array($this->rules[$spec])) { - if (isset($this->rules[$spec][$index])) { - return $this->rules[$spec][$index]; - } - } - return false; - } - - /** - * Clears the rules currently in the inflector - * - * @return self - */ - public function clearRules() - { - $this->rules = []; - return $this; - } - - /** - * Set a filtering rule for a spec. $ruleSet can be a string, Filter object - * or an array of strings or filter objects. - * - * @param string $spec - * @param array|string|FilterInterface $ruleSet - * @return self - */ - public function setFilterRule($spec, $ruleSet) - { - $spec = $this->_normalizeSpec($spec); - $this->rules[$spec] = []; - return $this->addFilterRule($spec, $ruleSet); - } - - /** - * Add a filter rule for a spec - * - * @return self - */ - public function addFilterRule(mixed $spec, mixed $ruleSet) + public function filter(mixed $value): mixed { - $spec = $this->_normalizeSpec($spec); - if (! isset($this->rules[$spec])) { - $this->rules[$spec] = []; + if (is_object($value)) { + $value = get_object_vars($value); } - if (! is_array($ruleSet)) { - $ruleSet = [$ruleSet]; + if (! is_array($value)) { + return $value; } - if (is_string($this->rules[$spec])) { - $temp = $this->rules[$spec]; - $this->rules[$spec] = []; - $this->rules[$spec][] = $temp; - } - - foreach ($ruleSet as $rule) { - $this->rules[$spec][] = $this->_getRule($rule); - } - - return $this; - } - - /** - * Set a static rule for a spec. This is a single string value - * - * @param string $name - * @param string $value - * @return self - */ - public function setStaticRule($name, $value) - { - $name = $this->_normalizeSpec($name); - $this->rules[$name] = (string) $value; - return $this; - } - - /** - * Set Static Rule Reference. - * - * This allows a consuming class to pass a property or variable - * in to be referenced when its time to build the output string from the - * target. - * - * @param string $name - * @return self - */ - public function setStaticRuleReference($name, mixed &$reference) - { - $name = $this->_normalizeSpec($name); - $this->rules[$name] = &$reference; - return $this; - } - - /** - * Inflect - * - * @param string|array $value - * @throws Exception\RuntimeException - */ - public function filter(mixed $value): mixed - { // clean source - foreach ((array) $value as $sourceName => $sourceValue) { - $value[ltrim($sourceName, ':')] = $sourceValue; + $subject = []; + foreach ($value as $sourceName => $sourceValue) { + if (! is_string($sourceName) || ! is_scalar($sourceValue)) { + continue; + } + + $sourceName = ltrim($sourceName, ':'); + $subject[$sourceName] = (string) $sourceValue; } $pregQuotedTargetReplacementIdentifier = preg_quote($this->targetReplacementIdentifier, '#'); $processedParts = []; foreach ($this->rules as $ruleName => $ruleValue) { - if (isset($value[$ruleName])) { + if (isset($subject[$ruleName])) { if (is_string($ruleValue)) { - // overriding the set rule $processedParts['#' . $pregQuotedTargetReplacementIdentifier . $ruleName . '#'] = str_replace( '\\', '\\\\', - $value[$ruleName] + $subject[$ruleName], ); - } elseif (is_array($ruleValue)) { - $processedPart = $value[$ruleName]; + } else { + $processedPart = $subject[$ruleName]; foreach ($ruleValue as $ruleFilter) { - $processedPart = $ruleFilter($processedPart); + $processedPart = (string) $ruleFilter($processedPart); } $processedParts['#' . $pregQuotedTargetReplacementIdentifier . $ruleName . '#'] = str_replace( '\\', '\\\\', - $processedPart + $processedPart, ); } } elseif (is_string($ruleValue)) { $processedParts['#' . $pregQuotedTargetReplacementIdentifier . $ruleName . '#'] = str_replace( '\\', '\\\\', - $ruleValue + $ruleValue, ); } } @@ -458,41 +161,29 @@ public function filter(mixed $value): mixed throw new Exception\RuntimeException( 'A replacement identifier ' . $this->targetReplacementIdentifier . ' was found inside the inflected target, perhaps a rule was not satisfied with a target source? ' - . 'Unsatisfied inflected target: ' . $inflectedTarget + . 'Unsatisfied inflected target: ' . $inflectedTarget, ); } return $inflectedTarget; } - /** - * Normalize spec string - * - * @param string $spec - * @return string - */ - // @codingStandardsIgnoreStart - protected function _normalizeSpec($spec) + public function __invoke(mixed $value): mixed { - // @codingStandardsIgnoreEnd - return ltrim((string) $spec, ':&'); + return $this->filter($value); } /** * Resolve named filters and convert them to filter objects. * - * @param string $rule - * @return FilterInterface|callable(mixed): mixed + * @return InstanceType */ - // @codingStandardsIgnoreStart - protected function _getRule($rule) + private function loadFilter(string|FilterInterface|callable $rule): FilterInterface|callable { - // @codingStandardsIgnoreEnd - if ($rule instanceof FilterInterface) { + if (! is_string($rule)) { return $rule; } - $rule = (string) $rule; - return $this->getPluginManager()->get($rule); + return $this->pluginManager->get($rule); } } diff --git a/src/InflectorFactory.php b/src/InflectorFactory.php new file mode 100644 index 00000000..b5b598c9 --- /dev/null +++ b/src/InflectorFactory.php @@ -0,0 +1,28 @@ + $options */ + public function __invoke( + ContainerInterface $container, + string $requestedName, + ?array $options = null, + ): Inflector { + /** @psalm-var Options $options - Forcing this type to avoid unnecessary runtime validation */ + $options = $options ?? []; + $pluginManager = $container->get(FilterPluginManager::class); + assert($pluginManager instanceof FilterPluginManager); + + return new Inflector($pluginManager, $options); + } +} diff --git a/test/FilterPluginManagerCompatibilityTest.php b/test/FilterPluginManagerCompatibilityTest.php index e4a1eabf..1eec67a2 100644 --- a/test/FilterPluginManagerCompatibilityTest.php +++ b/test/FilterPluginManagerCompatibilityTest.php @@ -6,9 +6,12 @@ use Generator; use Laminas\Filter\Callback; +use Laminas\Filter\DataUnitFormatter; use Laminas\Filter\FilterPluginManager; +use Laminas\Filter\Inflector; use Laminas\Filter\PregReplace; use Laminas\ServiceManager\Exception\InvalidServiceException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; use stdClass; @@ -17,12 +20,13 @@ use function assert; use function class_exists; use function in_array; -use function str_contains; class FilterPluginManagerCompatibilityTest extends TestCase { private const FILTERS_WITH_REQUIRED_OPTIONS = [ Callback::class, + DataUnitFormatter::class, + Inflector::class, PregReplace::class, ]; @@ -44,11 +48,6 @@ public static function aliasProvider(): Generator self::assertIsString($alias); self::assertIsString($target); - // Skipping as it has required options - if (str_contains($target, 'DataUnitFormatter')) { - continue; - } - if (in_array($target, self::FILTERS_WITH_REQUIRED_OPTIONS, true)) { continue; } @@ -61,8 +60,8 @@ public static function aliasProvider(): Generator /** * @param class-string $expected - * @dataProvider aliasProvider */ + #[DataProvider('aliasProvider')] public function testPluginAliasesResolve(string $alias, string $expected): void { self::assertInstanceOf( diff --git a/test/InflectorFactoryTest.php b/test/InflectorFactoryTest.php new file mode 100644 index 00000000..80ac825b --- /dev/null +++ b/test/InflectorFactoryTest.php @@ -0,0 +1,77 @@ +set(FilterPluginManager::class, $plugins); + + $filter = (new InflectorFactory())->__invoke( + $container, + 'whatever', + [ + 'target' => '/:controller/:action.:suffix', + 'rules' => [ + ':controller' => [CamelCaseToDash::class, StringToLower::class], + ':action' => [CamelCaseToDash::class, StringToLower::class], + 'suffix' => 'phtml', + ], + ], + ); + + self::assertSame( + '/my-controller/some-action.php', + $filter->filter([ + 'controller' => 'MyController', + 'action' => 'SomeAction', + 'suffix' => 'php', + ]), + ); + } + + public function testFilterProductionViaPluginManager(): void + { + $container = new ServiceManager([ + 'factories' => [ + FilterPluginManager::class => FilterPluginManagerFactory::class, + ], + ]); + $plugins = $container->get(FilterPluginManager::class); + $filter = $plugins->build( + Inflector::class, + [ + 'target' => '/:controller/:action.:suffix', + 'rules' => [ + ':controller' => [CamelCaseToDash::class, StringToLower::class], + ':action' => [CamelCaseToDash::class, StringToLower::class], + 'suffix' => 'phtml', + ], + ], + ); + + self::assertSame( + '/my-controller/some-action.php', + $filter->filter([ + 'controller' => 'MyController', + 'action' => 'SomeAction', + 'suffix' => 'php', + ]), + ); + } +} diff --git a/test/InflectorTest.php b/test/InflectorTest.php index 9feacccc..e9ba52e9 100644 --- a/test/InflectorTest.php +++ b/test/InflectorTest.php @@ -4,210 +4,152 @@ namespace LaminasTest\Filter; -use ArrayObject; use Laminas\Filter\Exception; +use Laminas\Filter\Exception\InvalidArgumentException; use Laminas\Filter\FilterInterface; use Laminas\Filter\FilterPluginManager; -use Laminas\Filter\Inflector as InflectorFilter; +use Laminas\Filter\Inflector; use Laminas\Filter\StringToLower; use Laminas\Filter\StringToUpper; use Laminas\Filter\Word\CamelCaseToDash; -use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\ServiceManager\ServiceManager; -use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use function array_values; -use function count; +use function strtoupper; use const DIRECTORY_SEPARATOR; +/** @psalm-import-type Options from Inflector */ class InflectorTest extends TestCase { - private InflectorFilter $inflector; - - public function setUp(): void - { - $this->inflector = new InflectorFilter(); - } - - public function testGetPluginManagerReturnsFilterManagerByDefault(): void - { - $broker = $this->inflector->getPluginManager(); - self::assertInstanceOf(FilterPluginManager::class, $broker); - } - - public function testSetPluginManagerAllowsSettingAlternatePluginManager(): void - { - $defaultManager = $this->inflector->getPluginManager(); - $manager = new FilterPluginManager(new ServiceManager()); - $this->inflector->setPluginManager($manager); - $receivedManager = $this->inflector->getPluginManager(); - self::assertNotSame($defaultManager, $receivedManager); - self::assertSame($manager, $receivedManager); - } - - public function testTargetAccessorsWork(): void - { - $this->inflector->setTarget('foo/:bar/:baz'); - self::assertSame('foo/:bar/:baz', $this->inflector->getTarget()); - } - - public function testTargetInitiallyNull(): void - { - self::assertNull($this->inflector->getTarget()); - } - - public function testPassingTargetToConstructorSetsTarget(): void + /** @param Options $options */ + private static function withOptions(array $options): Inflector { - $inflector = new InflectorFilter('foo/:bar/:baz'); - self::assertSame('foo/:bar/:baz', $inflector->getTarget()); + return new Inflector(new FilterPluginManager(new ServiceManager()), $options); } - public function testSetTargetByReferenceWorks(): void + /** @return array */ + public static function invalidTargets(): array { - $target = 'foo/:bar/:baz'; - $this->inflector->setTargetReference($target); - self::assertSame('foo/:bar/:baz', $this->inflector->getTarget()); - /* this variable is used by-ref through `setTargetReference` above */ - $target .= '/:bat'; - self::assertSame('foo/:bar/:baz/:bat', $this->inflector->getTarget()); + return [ + 'Empty String' => [''], + 'Null' => [null], + 'Array' => [['foo' => 'bar']], + ]; } - public function testSetFilterRuleWithStringRuleCreatesRuleEntryAndFilterObject(): void + #[DataProvider('invalidTargets')] + public function testTargetOptionMustBeValid(mixed $option): void { - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $this->inflector->setFilterRule('controller', StringToLower::class); - $rules = $this->inflector->getRules('controller'); - self::assertIsArray($rules); - self::assertSame(1, count($rules)); - $filter = $rules[0]; - self::assertInstanceOf(FilterInterface::class, $filter); + $this->expectException(InvalidArgumentException::class); + /** @psalm-suppress MixedArgumentTypeCoercion - Intentionally invalid argument */ + self::withOptions([ + 'target' => $option, + ]); } - public function testSetFilterRuleWithFilterObjectCreatesRuleEntryWithFilterObject(): void + public function testExpectedResultWithValidTargetOption(): void { - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $filter = new StringToLower(); - $this->inflector->setFilterRule('controller', $filter); - $rules = $this->inflector->getRules('controller'); - self::assertIsArray($rules); - self::assertSame(1, count($rules)); - $received = $rules[0]; - self::assertInstanceOf(FilterInterface::class, $received); - self::assertSame($filter, $received); - } + $filter = self::withOptions([ + 'target' => 'foo/:bar/:baz.:bat', + 'rules' => [ + ':bar' => [StringToUpper::class], + ':baz' => [StringToUpper::class], + 'bat' => 'z', + ], + ]); - public function testAddFilterRuleAppendsRuleEntries(): void - { - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $this->inflector->setFilterRule('controller', [StringToLower::class, TestAsset\Alpha::class]); - $rules = $this->inflector->getRules('controller'); - self::assertIsArray($rules); - self::assertSame(2, count($rules)); - self::assertInstanceOf(FilterInterface::class, $rules[0]); - self::assertInstanceOf(FilterInterface::class, $rules[1]); + self::assertSame('foo/A/B.z', $filter->__invoke([ + 'bar' => 'a', + 'baz' => 'b', + ])); } - public function testSetStaticRuleCreatesScalarRuleEntry(): void + /** @return array */ + public static function filterTypesProvider(): array { - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $this->inflector->setStaticRule('controller', 'foobar'); - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress DocblockTypeContradiction */ - self::assertSame('foobar', $rules); + return [ + 'Closure' => [ + static fn (string $input): string => strtoupper($input), + 'foo', + 'FOO', + ], + 'Filter FQCN' => [ + StringToUpper::class, + 'foo', + 'FOO', + ], + 'Filter Instance' => [ + new StringToUpper(), + 'foo', + 'FOO', + ], + 'Filter Alias' => [ + 'stringtoupper', + 'foo', + 'FOO', + ], + ]; } - public function testSetStaticRuleMultipleTimesOverwritesEntry(): void + /** @param string|FilterInterface|callable(mixed):mixed $ruleFilter */ + #[DataProvider('filterTypesProvider')] + public function testFilterRuleExecutesExpectedFilter(mixed $ruleFilter, string $input, string $expect): void { - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $this->inflector->setStaticRule('controller', 'foobar'); - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress DocblockTypeContradiction */ - self::assertSame('foobar', $rules); - $this->inflector->setStaticRule('controller', 'bazbat'); - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress DocblockTypeContradiction */ - self::assertSame('bazbat', $rules); - } + $filter = self::withOptions([ + 'target' => ':target', + 'rules' => [ + ':target' => [$ruleFilter], + ], + ]); - public function testSetStaticRuleReferenceAllowsUpdatingRuleByReference(): void - { - $rule = 'foobar'; - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $this->inflector->setStaticRuleReference('controller', $rule); - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress DocblockTypeContradiction */ - self::assertSame('foobar', $rules); - $rule .= '/baz'; - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress DocblockTypeContradiction */ - self::assertSame('foobar/baz', $rules); + self::assertSame($expect, $filter->filter(['target' => $input])); } - public function testAddRulesCreatesAppropriateRuleEntries(): void + public function testStaticRulesBehaveLikeStringReplace(): void { - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(0, count($rules)); - $this->inflector->addRules([ - ':controller' => [StringToLower::class, TestAsset\Alpha::class], - 'suffix' => 'phtml', + $filter = self::withOptions([ + 'target' => '/:c/:b/:a', + 'rules' => [ + 'a' => 'A', + 'b' => 'B', + 'c' => 'C', + ], ]); - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(2, count($rules)); - self::assertSame(2, count($rules['controller'])); - self::assertSame('phtml', $rules['suffix']); + + self::assertSame('/C/B/A', $filter->filter(['foo' => 'bar'])); } - public function testSetRulesCreatesAppropriateRuleEntries(): void + public function testStaticRuleReplacementsCanBeOverriddenInFilterValue(): void { - $this->inflector->setStaticRule('some-rules', 'some-value'); - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(1, count($rules)); - $this->inflector->setRules([ - ':controller' => [StringToLower::class, TestAsset\Alpha::class], - 'suffix' => 'phtml', + $filter = self::withOptions([ + 'target' => '/:c/:b/:a', + 'rules' => [ + 'a' => 'A', + 'b' => 'B', + 'c' => 'C', + ], ]); - $rules = $this->inflector->getRules(); - self::assertIsArray($rules); - self::assertSame(2, count($rules)); - self::assertSame(2, count($rules['controller'])); - self::assertSame('phtml', $rules['suffix']); - } - public function testGetRule(): void - { - $this->inflector->setFilterRule(':controller', [TestAsset\Alpha::class, StringToLower::class]); - self::assertInstanceOf(StringToLower::class, $this->inflector->getRule('controller', 1)); - self::assertFalse($this->inflector->getRule('controller', 2)); + self::assertSame('/z/y/x', $filter->filter([ + 'a' => 'x', + 'b' => 'y', + 'c' => 'z', + ])); } public function testFilterTransformsStringAccordingToRules(): void { - $this->inflector - ->setTarget(':controller/:action.:suffix') - ->addRules([ + $filter = self::withOptions([ + 'target' => ':controller/:action.:suffix', + 'rules' => [ ':controller' => [CamelCaseToDash::class], ':action' => [CamelCaseToDash::class], 'suffix' => 'phtml', - ]); + ], + ]); - $filter = $this->inflector; $filtered = $filter([ 'controller' => 'FooBar', 'action' => 'bazBat', @@ -215,27 +157,38 @@ public function testFilterTransformsStringAccordingToRules(): void self::assertSame('Foo-Bar/baz-Bat.phtml', $filtered); } - public function testTargetReplacementIdentifierAccessorsWork(): void + public function testInputWithNonStringKeysIsIgnored(): void { - self::assertSame(':', $this->inflector->getTargetReplacementIdentifier()); - $this->inflector->setTargetReplacementIdentifier('?='); - self::assertSame('?=', $this->inflector->getTargetReplacementIdentifier()); + $filter = self::withOptions([ + 'target' => ':controller/:action.:suffix', + 'rules' => [ + ':controller' => [CamelCaseToDash::class], + ':action' => [CamelCaseToDash::class], + 'suffix' => 'phtml', + ], + ]); + + $filtered = $filter([ + 'controller' => 'FooBar', + 0 => 'bing', + 'action' => 99, + ]); + self::assertSame('Foo-Bar/99.phtml', $filtered); } public function testTargetReplacementIdentifierWorksWhenInflected(): void { - $inflector = new InflectorFilter( - '?=##controller/?=##action.?=##suffix', - [ + $filter = self::withOptions([ + 'target' => '?=##controller/?=##action.?=##suffix', + 'rules' => [ ':controller' => [CamelCaseToDash::class], ':action' => [CamelCaseToDash::class], 'suffix' => 'phtml', ], - null, - '?=##' - ); + 'targetReplacementIdentifier' => '?=##', + ]); - $filtered = $inflector([ + $filtered = $filter->__invoke([ 'controller' => 'FooBar', 'action' => 'bazBat', ]); @@ -243,123 +196,57 @@ public function testTargetReplacementIdentifierWorksWhenInflected(): void self::assertSame('Foo-Bar/baz-Bat.phtml', $filtered); } - public function testThrowTargetExceptionsAccessorsWork(): void - { - self::assertSame(':', $this->inflector->getTargetReplacementIdentifier()); - $this->inflector->setTargetReplacementIdentifier('?='); - self::assertSame('?=', $this->inflector->getTargetReplacementIdentifier()); - } - - public function testThrowTargetExceptionsOnAccessorsWork(): void - { - self::assertTrue($this->inflector->isThrowTargetExceptionsOn()); - $this->inflector->setThrowTargetExceptionsOn(false); - self::assertFalse($this->inflector->isThrowTargetExceptionsOn()); - } - public function testTargetExceptionThrownWhenTargetSourceNotSatisfied(): void { - $inflector = new InflectorFilter( - '?=##controller/?=##action.?=##suffix', - [ + $filter = self::withOptions([ + 'target' => '?=##controller/?=##action.?=##suffix', + 'rules' => [ ':controller' => [CamelCaseToDash::class], ':action' => [CamelCaseToDash::class], 'suffix' => 'phtml', ], - true, - '?=##' - ); + 'targetReplacementIdentifier' => '?=##', + ]); $this->expectException(Exception\RuntimeException::class); $this->expectExceptionMessage('perhaps a rule was not satisfied'); - $filtered = $inflector(['controller' => 'FooBar']); + $filter->filter(['controller' => 'FooBar']); } - public function testTargetExceptionNotThrownOnIdentifierNotFollowedByCharacter(): void + public function testTargetExceptionsCanBeDisabled(): void { - $inflector = new InflectorFilter( - 'e:\path\to\:controller\:action.:suffix', - [ - ':controller' => [CamelCaseToDash::class, StringToLower::class], + $filter = self::withOptions([ + 'target' => ':controller/:action.:suffix', + 'rules' => [ + ':controller' => [CamelCaseToDash::class], ':action' => [CamelCaseToDash::class], 'suffix' => 'phtml', ], - true, - ':' - ); + 'throwTargetExceptionsOn' => false, + ]); - $filtered = $inflector(['controller' => 'FooBar', 'action' => 'MooToo']); - self::assertSame($filtered, 'e:\path\to\foo-bar\Moo-Too.phtml'); + self::assertSame( + 'Foo-Bar/:action.phtml', + $filter->filter(['controller' => 'FooBar']), + ); } - /** - * @return array - */ - public function getOptions(): array + public function testTargetExceptionNotThrownOnIdentifierNotFollowedByCharacter(): void { - return [ - 'target' => '$controller/$action.$suffix', - 'throwTargetExceptionsOn' => true, - 'targetReplacementIdentifier' => '$', - 'rules' => [ - ':controller' => [ - 'rule1' => CamelCaseToUnderscore::class, - 'rule2' => StringToLower::class, - ], - ':action' => [ - 'rule1' => CamelCaseToDash::class, - 'rule2' => StringToUpper::class, - ], - 'suffix' => 'php', + $filter = self::withOptions([ + 'target' => 'e:\path\to\:controller\:action.:suffix', + 'rules' => [ + ':controller' => [CamelCaseToDash::class, StringToLower::class], + ':action' => [CamelCaseToDash::class], + 'suffix' => 'phtml', ], - ]; - } - - /** - * This method returns an ArrayObject instance in place of a - * Laminas\Config\Config instance; the two are interchangeable, as inflectors - * consume the more general array or Traversable types. - */ - public function getConfig(): ArrayObject - { - $options = $this->getOptions(); - - return new ArrayObject($options); - } - - // @codingStandardsIgnoreStart - protected function _testOptions($inflector) - { - // @codingStandardsIgnoreEnd - $options = $this->getOptions(); - $broker = $inflector->getPluginManager(); - self::assertSame($options['target'], $inflector->getTarget()); - - self::assertInstanceOf(FilterPluginManager::class, $broker); - self::assertTrue($inflector->isThrowTargetExceptionsOn()); - self::assertSame($options['targetReplacementIdentifier'], $inflector->getTargetReplacementIdentifier()); - - $rules = $inflector->getRules(); - /** @psalm-suppress MixedArrayAccess */ - foreach (array_values($options['rules'][':controller']) as $key => $rule) { - $class = $rules['controller'][$key]::class; - self::assertStringContainsString($rule, $class); - } - /** @psalm-suppress MixedArrayAccess */ - foreach (array_values($options['rules'][':action']) as $key => $rule) { - $class = $rules['action'][$key]::class; - self::assertStringContainsString($rule, $class); - } - /** @psalm-suppress MixedArrayAccess */ - self::assertSame($options['rules']['suffix'], $rules['suffix']); - } + 'throwTargetExceptionsOn' => true, + ]); - public function testSetConfigSetsStateAndRules(): void - { - $config = $this->getConfig(); - $inflector = new InflectorFilter(); - $inflector->setOptions($config); - $this->_testOptions($inflector); + self::assertSame( + 'e:\path\to\foo-bar\Moo-Too.phtml', + $filter->filter(['controller' => 'FooBar', 'action' => 'MooToo']), + ); } /** @@ -369,108 +256,83 @@ public function testSetConfigSetsStateAndRules(): void */ public function testCheckInflectorWithPregBackreferenceLikeParts(): void { - $inflector = new InflectorFilter( - ':moduleDir' . DIRECTORY_SEPARATOR . ':controller' . DIRECTORY_SEPARATOR . ':action.:suffix', - [ + $filter = self::withOptions([ + 'target' => ':moduleDir' . DIRECTORY_SEPARATOR . ':controller' . DIRECTORY_SEPARATOR . ':action.:suffix', + 'rules' => [ + 'moduleDir' => 'C:\htdocs\public\cache\00\01\42\app\modules', ':controller' => [CamelCaseToDash::class, StringToLower::class], ':action' => [CamelCaseToDash::class], 'suffix' => 'phtml', ], - true, - ':' - ); - - $inflector->setStaticRule('moduleDir', 'C:\htdocs\public\cache\00\01\42\app\modules'); - - $filtered = $inflector([ - 'controller' => 'FooBar', - 'action' => 'MooToo', ]); + self::assertSame( - $filtered, 'C:\htdocs\public\cache\00\01\42\app\modules' . DIRECTORY_SEPARATOR . 'foo-bar' . DIRECTORY_SEPARATOR - . 'Moo-Too.phtml' + . 'Moo-Too.phtml', + $filter->filter([ + 'controller' => 'FooBar', + 'action' => 'MooToo', + ]), ); } - /** - * @issue Laminas-2522 - */ - public function testTestForFalseInConstructorParams(): void - { - $inflector = new InflectorFilter('something', [], false, false); - self::assertFalse($inflector->isThrowTargetExceptionsOn()); - self::assertSame($inflector->getTargetReplacementIdentifier(), ':'); - - new InflectorFilter('something', [], false, '#'); - } - /** * @issue Laminas-2964 */ public function testNoInflectableTarget(): void { - $inflector = new InflectorFilter('abc'); - $inflector->addRules([':foo' => []]); - self::assertSame($inflector(['fo' => 'bar']), 'abc'); - } + $inflector = self::withOptions([ + 'target' => 'abc', + 'rules' => [':foo' => []], + ]); - /** - * @issue Laminas-7544 - */ - public function testAddFilterRuleMultipleTimes(): void - { - $rules = $this->inflector->getRules(); - self::assertSame(0, count($rules)); - $this->inflector->setFilterRule('controller', StringToLower::class); - $rules = $this->inflector->getRules('controller'); - self::assertSame(1, count($rules)); - $this->inflector->addFilterRule('controller', [TestAsset\Alpha::class, StringToLower::class]); - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress PossiblyFalseArgument */ - self::assertSame(3, count($rules)); - $context = StringToLower::class; - $this->inflector->setStaticRuleReference('context', $context); - $this->inflector->addFilterRule('controller', [TestAsset\Alpha::class, StringToLower::class]); - $rules = $this->inflector->getRules('controller'); - /** @psalm-suppress PossiblyFalseArgument */ - self::assertSame(5, count($rules)); + self::assertSame($inflector(['any' => 'thing']), 'abc'); } - #[Group('Laminas-8997')] - public function testPassingArrayToConstructorSetsStateAndRules(): void + public static function unFilterableInput(): array { - $options = $this->getOptions(); - $inflector = new InflectorFilter($options); - $this->_testOptions($inflector); + return [ + ['Foo'], + [1], + [1.23], + [true], + [null], + ]; } - #[Group('Laminas-8997')] - public function testPassingArrayToSetConfigSetsStateAndRules(): void + #[DataProvider('unFilterableInput')] + public function testOnlyArraysCanBeFiltered(mixed $input): void { - $options = $this->getOptions(); - $inflector = new InflectorFilter(); - $inflector->setOptions($options); - $this->_testOptions($inflector); - } + $filter = self::withOptions([ + 'target' => 'abc', + ]); - #[Group('Laminas-8997')] - public function testPassingConfigObjectToConstructorSetsStateAndRules(): void - { - $config = $this->getConfig(); - $inflector = new InflectorFilter($config); - $this->_testOptions($inflector); + self::assertSame($input, $filter->filter($input)); } - #[Group('Laminas-8997')] - public function testPassingConfigObjectToSetConfigSetsStateAndRules(): void + public function testObjectPropertiesAreExtractedAsFilterSubject(): void { - $config = $this->getConfig(); - $inflector = new InflectorFilter(); - $inflector->setOptions($config); - $this->_testOptions($inflector); + $filter = self::withOptions([ + 'target' => '/:controller/:action.:suffix', + 'rules' => [ + ':controller' => [CamelCaseToDash::class, StringToLower::class], + ':action' => [CamelCaseToDash::class, StringToLower::class], + 'suffix' => 'phtml', + ], + ]); + + $value = new class () { + public string $controller = 'MyController'; + public string $action = 'SomeAction'; + public string $suffix = 'php'; + }; + + self::assertSame( + '/my-controller/some-action.php', + $filter->filter($value), + ); } } diff --git a/test/TestAsset/Alpha.php b/test/TestAsset/Alpha.php deleted file mode 100644 index 21f51cac..00000000 --- a/test/TestAsset/Alpha.php +++ /dev/null @@ -1,30 +0,0 @@ - */ -class Alpha implements FilterInterface -{ - /** @inheritDoc */ - public function filter(mixed $value): mixed - { - if (! is_string($value)) { - return $value; - } - - return preg_replace('/[^a-zA-Z\s]/', '', $value); - } - - /** @inheritDoc */ - public function __invoke(mixed $value): mixed - { - return $this->filter($value); - } -} diff --git a/test/TestAsset/InMemoryContainer.php b/test/TestAsset/InMemoryContainer.php new file mode 100644 index 00000000..ec69bc7e --- /dev/null +++ b/test/TestAsset/InMemoryContainer.php @@ -0,0 +1,39 @@ + */ + private array $services = []; + + /** @inheritDoc */ + public function get(string $id): mixed + { + if (! array_key_exists($id, $this->services)) { + throw new class ($id . ' was not found') extends RuntimeException implements NotFoundExceptionInterface { + }; + } + + return $this->services[$id]; + } + + /** @inheritDoc */ + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } + + public function set(string $id, mixed $item): void + { + $this->services[$id] = $item; + } +}