diff --git a/docs/book/v3/inflector.md b/docs/book/v3/inflector.md index 22e2c92f..063c25a9 100644 --- a/docs/book/v3/inflector.md +++ b/docs/book/v3/inflector.md @@ -1,312 +1,233 @@ # String Inflection -`Laminas\Filter\Inflector` is a general purpose tool for rules-based inflection of -strings to a given target. +`Laminas\Filter\Inflector` is a general purpose tool for rule-based inflection of strings to a given target. -As an example, you may find you need to transform MixedCase or camelCasedWords -into a path; for readability, OS policies, or other reasons, you also need to -lower case this; and finally, you want to separate the words using a dash -(`-`). An inflector can do this for you. +As an example, you may find you need to transform MixedCase or camelCasedWords into a path; +for readability, OS policies, or other reasons, you also need to lower case this; +and finally, you want to separate the words using a dash (`-`). +An inflector can do this for you. `Laminas\Filter\Inflector` implements `Laminas\Filter\FilterInterface`; you perform inflection by calling `filter()` on the object instance. -## Transforming MixedCase and CamelCaseText to another Format +## Options Reference + +- `target` **(required)** The target string containing the placeholders to replace +- `rules` **(required)** An array of inflection rules +- `targetReplacementIdentifier` Can be used to override the default placeholder delimiter of `':'` +- `throwTargetExceptionsOn` A boolean to indicate whether an exception should be thrown for un-processed placeholders *(`true` by default)* + +## Example: Transform Mixed Case and Camel Cased Text to Another Format ```php -$inflector = new Laminas\Filter\Inflector('pages/:page.:suffix'); -$inflector->setRules([ - ':page' => ['Word\CamelCaseToDash', 'StringToLower'], - 'suffix' => 'html', +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'target' => 'pages/:page.:suffix', + 'rules' => [ + ':page' => [ + Laminas\Filter\Word\CamelCaseToDash::class, + Laminas\Filter\StringToLower::class, + ], + 'suffix' => 'html', + ], ]); -$string = 'camelCasedWords'; -$filtered = $inflector->filter(['page' => $string]); +$filtered = $inflector->filter(['page' => 'camelCasedWords']); // pages/camel-cased-words.html -$string = 'this_is_not_camel_cased'; -$filtered = $inflector->filter(['page' => $string]); +$filtered = $inflector->filter(['page' => 'this_is_not_camel_cased']); // pages/this_is_not_camel_cased.html ``` ## Operation -An inflector requires a **target** and one or more **rules**. A target is -basically a string that defines placeholders for variables you wish to -substitute. These are specified by prefixing with a `:`: `:script`. +An inflector requires a **target** and one or more **rules**. -When calling `filter()`, you then pass in an array of key and value pairs -corresponding to the variables in the target. +A `target` is basically a string that defines placeholders for variables you wish to substitute. +These are specified by prefixing the placeholder name with a `:`, for example `:script`. -Each variable in the target can have zero or more rules associated with them. -Rules may be either **static** or refer to a laminas-filter class. Static rules -will replace with the text provided. Otherwise, a class matching the rule -provided will be used to inflect the text. Classes are typically specified -using a short name indicating the filter name stripped of any common prefix. +When calling `filter()`, you then pass in an array of key and value pairs corresponding to the placeholder names in the target. -As an example, you can use any laminas-filter concrete implementations; however, -instead of referring to them as `Laminas\Filter\Boolean` or -`Laminas\Filter\StringToLower`, you'd specify only `Boolean` or `StringToLower`. +Each variable in the target can have zero or more rules associated with them. +Rules may be either **static** or refer to a filter type, or callable. +**Static Rules** define straight-forward string replacement. +**Filter Rules** define one or more filters that operate on the value. -### Using Custom Filters +Filters can be specified using: -`Laminas\Filter\Inflector` uses `Laminas\Filter\FilterPluginManager` to manage -loading filters to use with inflection. By default, filters registered with -`Laminas\Filter\FilterPluginManager` are available. To access filters with that -prefix but which occur deeper in the hierarchy, such as the various `Word` -filters, simply strip off the `Laminas\Filter` prefix: +- The FQCN of a filter such as `Laminas\Filter\StringToLower::class` +- An alias of a known filter such as `stringtolower` +- A concrete filter instance, i.e. `new Laminas\Filter\StringToLower()` +- Any callable, for example a closure such as `static fn (string $input): string => strtolower($input)` -```php -// use Laminas\Filter\Word\CamelCaseToDash as a rule -$inflector->addRules(['script' => 'Word\CamelCaseToDash']); -``` +### Using Custom Filters -To use custom filters, you have two choices: reference them by fully qualified -class name (e.g., `My\Custom\Filter\Mungify`), or manipulate the composed -`FilterPluginManager` instance. +`Laminas\Filter\Inflector` uses `Laminas\Filter\FilterPluginManager` to manage loading filters to use with inflection. +By default, all [standard filters](standard-filters.md) are available by referencing any FQCN, or known alias of the filter type. +If you have configured your application with [custom filters](writing-filters.md), these will also be available in any rules you define. -```php -$filters = $inflector->getPluginManager(); -$filters->addInvokableClass('mungify', 'My\Custom\Filter\Mungify'); -``` +TIP: Try to prefer fully qualified class names *(FQCNs)* rather than aliases or 'short names'. +It will be easier for your IDE or text editor to identify specific filter usage when you use FQCNs. ### Setting the Inflector Target -The inflector target is a string with some placeholders for variables. -Placeholders take the form of an identifier, a colon (`:`) by default, followed -by a variable name: `:script`, `:path`, etc. The `filter()` method looks for -the identifier followed by the variable name being replaced. - -You can change the identifier using the `setTargetReplacementIdentifier()` -method, or passing it as the fourth argument to the constructor: +As previously mentioned, the *required* option `target` is a string with some placeholders for variables. +Placeholders take the form of an identifier, a colon (`:`) by default, followed by a variable name: `:script`, `:path`, etc. +The `filter()` method looks for the identifier followed by the variable name being replaced. ```php -// Via constructor: -$inflector = new Laminas\Filter\Inflector('#foo/#bar.#sfx', array(), null, '#'); - -// Via accessor: -$inflector->setTargetReplacementIdentifier('#'); +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'target' => 'pages/:page.:suffix', + // ... Other Options +]); ``` -Typically, you will set the target via the constructor. However, you may want -to re-set the target later. `setTarget()` can be used for this purpose: +### Changing the Target Placeholder Delimiter -```php -$inflector->setTarget('layouts/:script.phtml'); -``` - -Additionally, you may wish to have a class member for your class that you can -use to keep the inflector target updated — without needing to directly update -the target each time (thus saving on method calls). `setTargetReference()` -allows you to do this: +By setting the `targetReplacementIdentifier` option to the delimiter of your choice, you can prevent issues where the delimiter might need to be part of the `target` option: ```php -class Foo -{ - /** - * @var string Inflector target - */ - protected $target = 'foo/:bar/:baz.:suffix'; - - /** - * Constructor - * @return void - */ - public function __construct() - { - $this->inflector = new Laminas\Filter\Inflector(); - $this->inflector->setTargetReference($this->target); - } - - /** - * Set target; updates target in inflector - * - * @param string $target - * @return Foo - */ - public function setTarget($target) - { - $this->target = $target; - return $this; - } -} +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'target' => '?host:?port/?page.?suffix', + 'targetReplacementIdentifier' => '?', + 'rules' => [ + 'host' => 'example.com', + 'port' => '80', + 'page' => 'anything', + 'suffix' => 'html', + ], +]); +$inflector->filter(['page' => 'index']); // example.com:80/index.html ``` ## Inflection Rules -As mentioned in the introduction, there are two types of rules: static and filter-based. +As mentioned in the introduction, there are two types of rules: **static** and **filter-based**. -NOTE: **Order is important** -It is important to note that regardless of the method in which you add rules to the inflector, either one-by-one, or all-at-once; the order is very important. -More specific names, or names that might contain other rule names, must be added before least specific names. -For example, assuming the two rule names `moduleDir` and `module`, the `moduleDir` rule should appear before module since `module` is contained within `moduleDir`. -If `module` were added before `moduleDir`, `module` will match part of `moduleDir` and process it leaving `Dir` inside of the target uninflected. +### Specifying Static Rules -### Static Rules +Static rules are key-value items, where `['placeholder' => 'replacement']`. +Static rule names lack a leading `':'`. -Static rules do simple string substitution; use them when you have a segment in -the target that will typically be static, but which you want to allow the -developer to modify. Use the `setStaticRule()` method to set or modify the -rule: +Filter input can be used to override the replacement value for a placeholder: ```php -$inflector = new Laminas\Filter\Inflector(':script.:suffix'); -$inflector->setStaticRule('suffix', 'phtml'); +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'target' => ':a,:b,:c', + 'rules' => [ + 'a' => 'one', + 'b' => 'two', + 'c' => 'three', + ], +]); -// change it later: -$inflector->setStaticRule('suffix', 'php'); +$inflector->filter(['c' => 'foo']); // 'one,two,foo' ``` -Much like the target itself, you can also bind a static rule to a reference, -allowing you to update a single variable instead of require a method call; this -is often useful when your class uses an inflector internally, and you don't -want your users to need to fetch the inflector in order to update it. The -`setStaticRuleReference()` method is used to accomplish this: - -```php -class Foo -{ - /** - * @var string Suffix - */ - private $suffix = 'phtml'; - - /** - * Constructor - * @return void - */ - public function construct() - { - $this->inflector = new Laminas\Filter\Inflector(':script.:suffix'); - $this->inflector->setStaticRuleReference('suffix', $this->suffix); - } - - /** - * Set suffix; updates suffix static rule in inflector - * - * @param string $suffix - * @return Foo - */ - public function setSuffix($suffix) - { - $this->suffix = $suffix; - return $this; - } -} -``` +NOTE: **Order is important** +It is important to note that regardless of the rule type, either static of filter based; the order is very important. +More specific names, or names that might contain other rule names, must be added before the least specific names. +For example, assuming two rule names `moduleDir` and `module`, the `moduleDir` rule should appear before module since the word `module` is contained within `moduleDir`. +If `module` were added before `moduleDir`, `module` will match part of `moduleDir` and process it leaving `Dir` inside of the target un-inflected. ### Filter-Based Inflector Rules -`Laminas\Filter` filters may be used as inflector rules as well. Just like static -rules, these are bound to a target variable; unlike static rules, you may -define multiple filters to use when inflecting. These filters are processed in -order, so be careful to register them in an order that makes sense for the data -you receive. +Filters may be used as inflector rules as well. Just like static rules, these are bound to a target variable; unlike static rules, you may define multiple filters to use when inflecting. +These filters are processed in order, so be careful to register them in an order that makes sense for the data you receive. -Rules may be added using `setFilterRule()` (which overwrites any previous rules -for that variable) or `addFilterRule()` (which appends the new rule to any -existing rule for that variable). Filters are specified in one of the following -ways: +```php +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'target' => ':script.:suffix', + 'rules' => [ + ':script' => [ + Laminas\Filter\Word\CamelCaseToDash::class, + Laminas\Filter\StringToLower::class, + ], + 'suffix' => 'php', + ], +]); -- **String**. The string may be a filter class name, or a class name segment - minus any prefix set in the inflector's plugin loader (by default, minus the - '`Laminas\Filter`' prefix). -- **Filter object**. Any object instance implementing - `Laminas\Filter\FilterInterface` may be passed as a filter. -- **Array**. An array of one or more strings or filter objects as defined above. +$inflector->filter(['script' => 'MyScript']); // "my-script.php" +``` -```php -$inflector = new Laminas\Filter\Inflector(':script.:suffix'); +### Avoiding Exceptions -// Set rule to use Laminas\Filter\Word\CamelCaseToDash filter -$inflector->setFilterRule('script', 'Word\CamelCaseToDash'); +Finally, the option `throwTargetExceptionsOn` defines whether an exception is thrown for un-processed placeholders. -// Add rule to lowercase string -$inflector->addFilterRule('script', new Laminas\Filter\StringToLower()); +By default, the following example will cause an exception: -// Set rules en-masse -$inflector->setFilterRule('script', [ - 'Word\CamelCaseToDash', - new Laminas\Filter\StringToLower() +```php +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'target' => ':script.:suffix', + 'rules' => [ + ':script' => [ + Laminas\Filter\Word\CamelCaseToDash::class, + Laminas\Filter\StringToLower::class, + ], + 'suffix' => 'php', + ], ]); + +$inflector->filter(['wrong-key' => 'SomeValue']); // Laminas\Filter\Exception\RuntimeException ``` -## Setting many Rules at once +By setting `throwTargetExceptionsOn` to `false`, no exception will be thrown, but the filter output will contain un-processed placeholder values: -Typically, it's easier to set many rules at once than to configure a single -variable and its inflection rules one at a time. `Laminas\Filter\Inflector`'s -`addRules()` and `setRules()` methods allow this. +```php +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = new Laminas\Filter\Inflector($pluginManager, [ + 'throwTargetExceptionsOn' => false, + 'target' => ':script.:suffix', + 'rules' => [ + ':script' => [ + Laminas\Filter\Word\CamelCaseToDash::class, + Laminas\Filter\StringToLower::class, + ], + 'suffix' => 'php', + ], +]); -Each method takes an array of variable and rule pairs, where the rule may be -whatever the type of rule accepts (string, filter object, or array). Variable -names accept a special notation to allow setting static rules and filter rules, -according to the following notation: +$inflector->filter(['wrong-key' => 'SomeValue']); // ':script.php' +``` -- **`:` prefix**: filter rules. -- **No prefix**: static rule. +## Filterable Values -As an example: +The inflector can only process arrays or objects. Other types will be returned as-is, for example: ```php -// Could also use setRules() with this notation: -$inflector->addRules([ - // Filter rules: - ':controller' => ['Word\CamelCaseToUnderscore','StringToLower'], - ':action' => ['Word\CamelCaseToUnderscore','StringToLower'], - - // Static rule: - 'suffix' => 'phtml', -]); +$inflector->filter('string'); // 'string ``` -## Utility Methods - -`Laminas\Filter\Inflector` has a number of utility methods for retrieving and -setting the plugin loader, manipulating and retrieving rules, and controlling -if and when exceptions are thrown. - -- `setPluginManager()` can be used when you have configured your own - `Laminas\Filter\FilterPluginManager` instance and wish to use it with - `Laminas\Filter\Inflector`; `getPluginManager()` retrieves the currently set - one. -- `setThrowTargetExceptionsOn()` can be used to control whether or not - `filter()` throws an exception when a given replacement identifier passed to - it is not found in the target. By default, no exceptions are thrown. - `isThrowTargetExceptionsOn()` will tell you what the current value is. -- `getRules($spec = null)` can be used to retrieve all registered rules for all - variables, or just the rules for a single variable. -- `getRule($spec, $index)` fetches a single rule for a given variable; this can - be useful for fetching a specific filter rule for a variable that has a - filter chain. `$index` must be passed. -- `clearRules()` will clear all currently registered rules. - -## Using a Traversable or an Array - -You can use a `Traversable` or an array to set rules and other object state in -your inflectors, by passing either type to either the constructor or the -`setOptions()` method. The following settings may be specified: - -- `target` specifies the inflection target. -- `pluginManager` specifies the `Laminas\Filter\FilterPluginManager` instance or - extension to use for obtaining plugins; alternately, you may specify a class - name of a class that extends the `FilterPluginManager`. -- `throwTargetExceptionsOn` should be a boolean indicating whether or not to - throw exceptions when a replacement identifier is still present after - inflection. -- `targetReplacementIdentifier` specifies the character to use when identifying - replacement variables in the target string. -- `rules` specifies an array of inflection rules; it should consist of keys - that specify either values or arrays of values, consistent with `addRules()`. - -As examples: +When an object is passed, it's public properties are extracted into an array using [`get_object_vars`](https://www.php.net/get_object_vars). -```php -// $options implements Traversable: +This can be useful when you wish to use a readonly value object to define a specific, predictable set of data to be used for inflection with consistent type guarantees. -// With the constructor: -$inflector = new Laminas\Filter\Inflector($options); +## Filter Plugin Manager Requirement -// Or with setOptions(): -$inflector = new Laminas\Filter\Inflector(); -$inflector->setOptions($options); +In all the examples so far, you will notice that the first constructor argument is an instance of `Laminas\Filter\FilterPluginManager`. The plugin manager is used to create filter instances for use with inflection and cannot be omitted. + +During general usage of filters, you will typically be configuring filters as part of an [input filter specification with laminas-inputfilter](https://docs.laminas.dev/laminas-inputfilter/) or `laminas-form`. In these cases, the constructor dependencies are resolved automatically and you only need to concern yourself with setting the correct options. + +If you find the need to use the inflector stand-alone, you can use `Laminas\Filter\FilterPluginManager::build()` to create an instance by only specifying options: + +```php +$pluginManager = $container->get(Laminas\Filter\FilterPluginManager::class); +$inflector = $pluginManager->build( + Laminas\Filter\Inflector::class, + [ + 'target' => ':script.:suffix', + 'rules' => [ + ':script' => [ + Laminas\Filter\Word\CamelCaseToDash::class, + Laminas\Filter\StringToLower::class, + ], + 'suffix' => 'php', + ], + ], +); ``` diff --git a/docs/book/v3/migration/v2-to-v3.md b/docs/book/v3/migration/v2-to-v3.md index 2e91465d..3a1a5cfa 100644 --- a/docs/book/v3/migration/v2-to-v3.md +++ b/docs/book/v3/migration/v2-to-v3.md @@ -152,6 +152,36 @@ The following methods have been removed: The constructor now only accepts an associative array of [documented options](../standard-filters.md#denylist). +#### `Inflector` + +The following methods have been removed: + +- `getPluginManager` +- `setPluginManager` +- `setOptions` +- `setThrowTargetExceptionsOn` +- `isThrowTargetExceptionsOn` +- `setTargetReplacementIdentifier` +- `getTargetReplacementIdentifier` +- `setTarget` +- `getTarget` +- `setTargetReference` +- `setRules` +- `addRules` +- `getRules` +- `getRule` +- `clearRules` +- `setFilterRule` +- `addFilterRule` +- `setStaticRule` +- `setStaticRuleReference` + +The constructor now only accepts an associative array of [documented options](../inflector.md) and requires a `FilterPluginManager` instance as its first argument. + +The ability to pass in references to be used as the inflection target, or as a static rule is no longer possible. + +It is now possible to use an object as the filter input. + #### `MonthSelect` The following methods have been removed: 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..1a5b609f 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\Exception\RuntimeException; 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->expectException(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,112 @@ 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' => []], + ]); + + self::assertSame($inflector(['any' => 'thing']), 'abc'); } - /** - * @issue Laminas-7544 - */ - public function testAddFilterRuleMultipleTimes(): void + public static function unFilterableInput(): array { - $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)); + return [ + ['Foo'], + [1], + [1.23], + [true], + [null], + ]; } - #[Group('Laminas-8997')] - public function testPassingArrayToConstructorSetsStateAndRules(): void + #[DataProvider('unFilterableInput')] + public function testOnlyArraysCanBeFiltered(mixed $input): void { - $options = $this->getOptions(); - $inflector = new InflectorFilter($options); - $this->_testOptions($inflector); + $filter = self::withOptions([ + 'target' => 'abc', + ]); + + self::assertSame($input, $filter->filter($input)); } - #[Group('Laminas-8997')] - public function testPassingArrayToSetConfigSetsStateAndRules(): void + public function testObjectPropertiesAreExtractedAsFilterSubject(): void { - $options = $this->getOptions(); - $inflector = new InflectorFilter(); - $inflector->setOptions($options); - $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), + ); } - #[Group('Laminas-8997')] - public function testPassingConfigObjectToConstructorSetsStateAndRules(): void + public function testThatYouCannotUseAColonInTheTargetByDefault(): void { - $config = $this->getConfig(); - $inflector = new InflectorFilter($config); - $this->_testOptions($inflector); + $filter = self::withOptions([ + 'target' => '::something', + 'rules' => [ + 'something' => 'foo', + ], + ]); + + $this->expectException(RuntimeException::class); + $filter->filter([]); } - #[Group('Laminas-8997')] - public function testPassingConfigObjectToSetConfigSetsStateAndRules(): void + public function testThatYouCanUseAColonInTheTargetWhenTheDelimiterIsSet(): void { - $config = $this->getConfig(); - $inflector = new InflectorFilter(); - $inflector->setOptions($config); - $this->_testOptions($inflector); + $filter = self::withOptions([ + 'target' => ':?something', + 'targetReplacementIdentifier' => '?', + 'rules' => [ + 'something' => 'foo', + ], + ]); + + self::assertSame( + ':foo', + $filter->filter([]), + ); } } 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; + } +}