diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 45b763fa..3f96db62 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -30,6 +30,6 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.3'] + ['8.5'] secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f9a495..2bcfd215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 1.6.4 under development +- Chg #131: Deprecate the `..` range syntax in the `characters` parameter of `Trim`, `LeftTrim` and `RightTrim` + attributes (@vjik) +- Enh #131: Add `multibyte` and `encoding` parameters to `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` + attributes and their resolvers (@vjik) - Enh #117: Explicitly import functions and constants in "use" section (@mspirkov) - Enh #132: Exclude development files from the distribution archive (@vjik) diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 0707abc5..5e6f0ea5 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -5,10 +5,22 @@ use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; -return (new Configuration()) +$config = (new Configuration()) ->disableComposerAutoloadPathScan() ->setFileExtensions(['php']) ->addPathToScan(__DIR__ . '/config', isDev: false) ->addPathToScan(__DIR__ . '/src', isDev: false) ->addPathToScan(__DIR__ . '/tests', isDev: true) ->ignoreErrorsOnExtension('ext-intl', [ErrorType::SHADOW_DEPENDENCY]); + +// Multibyte trim support (`Trim`, `LeftTrim`, `RightTrim`, `ToArrayOfStrings` attributes and `TrimCharacters` +// helper) uses `mb_str_split()`, `mb_ord()`, `mb_chr()` and, since PHP 8.4, `mb_trim()`/`mb_ltrim()`/`mb_rtrim()`. +// All of them come either from the "mbstring" extension, or, for the trim functions on PHP older than 8.4, from +// the "symfony/polyfill-mbstring" package. The extension is only suggested to users, and the package is required +// for dev only, to run tests on PHP older than 8.4, so neither of them is intentionally required in production. +$config->ignoreErrorsOnExtension('ext-mbstring', [ErrorType::SHADOW_DEPENDENCY]); +if (PHP_VERSION_ID < 80400) { + $config->ignoreErrorsOnPackage('symfony/polyfill-mbstring', [ErrorType::DEV_DEPENDENCY_IN_PROD]); +} + +return $config; diff --git a/composer.json b/composer.json index afb35361..db0cb8a4 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "rector/rector": "^2.6.7", "shipmonk/composer-dependency-analyser": "^1.8", "spatie/phpunit-watcher": "^1.24", + "symfony/polyfill-mbstring": "^1.31", "vimeo/psalm": "^6.19.0", "yiisoft/code-style": "^1.1", "yiisoft/di": "^1.4", @@ -45,7 +46,9 @@ "yiisoft/test-support": "^3.0.2" }, "suggest": { - "ext-intl": "Allows using `ToDateTime` parameter attribute" + "ext-intl": "Allows using `ToDateTime` parameter attribute", + "ext-mbstring": "Allows using multibyte mode of `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` attributes on PHP 8.4 or later", + "symfony/polyfill-mbstring": "Allows using multibyte mode of `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` attributes on PHP versions earlier than 8.4" }, "autoload": { "psr-4": { diff --git a/docs/guide/en/typecasting.md b/docs/guide/en/typecasting.md index 2b8fac7f..522931aa 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -163,6 +163,33 @@ class Person $person = $hydrator->create(Person::class, ['name' => ' John ']); ``` +By default, these attributes are not multibyte-aware, so Unicode whitespace characters, such as `U+00A0` (no-break +space) or `U+2003` (em space), are kept. To strip them as well, enable the `multibyte` parameter: + +```php +use Yiisoft\Hydrator\Attribute\Parameter\Trim; + +class Person +{ + public function __construct( + #[Trim(multibyte: true)] // "\u{A0}John\u{2003}" → 'John' + private ?string $name = null, + ) {} +} + +$person = $hydrator->create(Person::class, ['name' => "\u{A0}John\u{2003}"]); +``` + +Multibyte mode uses `mb_trim()`, `mb_ltrim()` and `mb_rtrim()` functions that are provided by `mbstring` PHP extension +since PHP 8.4. To use it with an earlier PHP version, install +[symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) package. The `encoding` parameter selects +the encoding used in multibyte mode; `null` (default) means using `mb_internal_encoding()`. + +With `..` you can specify a range of characters in the `characters` parameter, for example, `a..z`. It works both in +the default and in the multibyte mode, but in the default mode ranges are byte-based, so a range of non-ASCII +characters, such as `а..я`, works correctly only in the multibyte mode. This syntax is deprecated and will be removed +in the next major version, so avoid it in new code. + ### `ToDatetime` To cast a value to `DateTimeImmutable` or `DateTime` object explicitly, you can use `ToDateTime` attribute: @@ -239,4 +266,10 @@ Attribute parameters: - `removeEmpty` — remove empty strings from array (boolean, default `false`); - `splitResolvedValue` — split resolved value by separator (boolean, default `true`); - `separator` — the boundary string (default, `\R`), it's a part of regular expression so should be taken into account - or properly escaped with `preg_quote()`. + or properly escaped with `preg_quote()`; +- `multibyte` — whether to use multibyte-aware trimming when `trim` is enabled (nullable boolean, default `null` + meaning the resolver default is used); requires the `mb_trim()` function provided by `mbstring` PHP extension + since PHP 8.4, or by [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) package on earlier + versions; +- `encoding` — the encoding to use in multibyte mode (nullable string, default `null` meaning the resolver default + is used). diff --git a/infection.json.dist b/infection.json.dist index 61e758c0..544dcec8 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,4 +1,5 @@ { + "bootstrap": "./vendor/autoload.php", "source": { "directories": [ "src" diff --git a/src/Attribute/Parameter/LeftTrim.php b/src/Attribute/Parameter/LeftTrim.php index 0cb0e8e8..423543c5 100644 --- a/src/Attribute/Parameter/LeftTrim.php +++ b/src/Attribute/Parameter/LeftTrim.php @@ -9,18 +9,30 @@ /** * Strip whitespace (or other characters) from the beginning of a resolved string value. * + * In multibyte mode, Unicode whitespace characters, such as `U+00A0` (no-break space), are stripped as well. + * It requires the `mb_ltrim()` function provided by the `mbstring` PHP extension since PHP 8.4, or by the + * `symfony/polyfill-mbstring` package on earlier versions. + * * @see https://www.php.net/manual/function.ltrim.php + * @see https://www.php.net/manual/function.mb-ltrim.php */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] final class LeftTrim implements ParameterAttributeInterface { /** - * @param string|null $characters The list all characters that you want to be stripped. With `..` you can specify - * a range of characters. + * @param string|null $characters The list of all characters that you want to be stripped. With `..` you can + * specify a range of characters, both in the default and in the multibyte mode. This syntax is deprecated and + * will be removed in the next major version. + * @param bool|null $multibyte Whether to use multibyte-aware trimming. `null` means using the resolver default. + * @param string|null $encoding The encoding to use in multibyte mode. `null` means using the resolver default. */ public function __construct( public readonly ?string $characters = null, - ) {} + public readonly ?bool $multibyte = null, + public readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/LeftTrimResolver.php b/src/Attribute/Parameter/LeftTrimResolver.php index f9899d3e..a5a4fb05 100644 --- a/src/Attribute/Parameter/LeftTrimResolver.php +++ b/src/Attribute/Parameter/LeftTrimResolver.php @@ -10,11 +10,25 @@ use function is_string; +/** + * Resolver for {@see LeftTrim} attribute. + */ final class LeftTrimResolver implements ParameterAttributeResolverInterface { + /** + * @param string|null $characters The list of characters to strip when it is not specified in the attribute. + * With `..` you can specify a range of characters, both in the default and in the multibyte mode. This syntax + * is deprecated and will be removed in the next major version. + * @param bool $multibyte Whether to use multibyte-aware trimming when it is not specified in the attribute. + * @param string|null $encoding The encoding to use in multibyte mode when it is not specified in the attribute. + */ public function __construct( private readonly ?string $characters = null, - ) {} + private readonly bool $multibyte = false, + private readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -34,9 +48,23 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + TrimCharacters::checkDeprecatedRanges($characters, $multibyte, $encoding); + + if (!$multibyte) { + return Result::success( + $characters === null ? ltrim($resolvedValue) : ltrim($resolvedValue, $characters), + ); + } return Result::success( - $characters === null ? ltrim($resolvedValue) : ltrim($resolvedValue, $characters), + mb_ltrim( + $resolvedValue, + $characters === null ? null : TrimCharacters::expandRanges($characters, $encoding), + $encoding, + ), ); } } diff --git a/src/Attribute/Parameter/RightTrim.php b/src/Attribute/Parameter/RightTrim.php index d1f1dd1d..c6596eba 100644 --- a/src/Attribute/Parameter/RightTrim.php +++ b/src/Attribute/Parameter/RightTrim.php @@ -9,18 +9,30 @@ /** * Strip whitespace (or other characters) from the end of a resolved string value. * + * In multibyte mode, Unicode whitespace characters, such as `U+00A0` (no-break space), are stripped as well. + * It requires the `mb_rtrim()` function provided by the `mbstring` PHP extension since PHP 8.4, or by the + * `symfony/polyfill-mbstring` package on earlier versions. + * * @see https://www.php.net/manual/function.rtrim.php + * @see https://www.php.net/manual/function.mb-rtrim.php */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] final class RightTrim implements ParameterAttributeInterface { /** - * @param string|null $characters The list all characters that you want to be stripped. With `..` you can specify - * a range of characters. + * @param string|null $characters The list of all characters that you want to be stripped. With `..` you can + * specify a range of characters, both in the default and in the multibyte mode. This syntax is deprecated and + * will be removed in the next major version. + * @param bool|null $multibyte Whether to use multibyte-aware trimming. `null` means using the resolver default. + * @param string|null $encoding The encoding to use in multibyte mode. `null` means using the resolver default. */ public function __construct( public readonly ?string $characters = null, - ) {} + public readonly ?bool $multibyte = null, + public readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/RightTrimResolver.php b/src/Attribute/Parameter/RightTrimResolver.php index 7cec0060..95504990 100644 --- a/src/Attribute/Parameter/RightTrimResolver.php +++ b/src/Attribute/Parameter/RightTrimResolver.php @@ -10,11 +10,25 @@ use function is_string; +/** + * Resolver for {@see RightTrim} attribute. + */ final class RightTrimResolver implements ParameterAttributeResolverInterface { + /** + * @param string|null $characters The list of characters to strip when it is not specified in the attribute. + * With `..` you can specify a range of characters, both in the default and in the multibyte mode. This syntax + * is deprecated and will be removed in the next major version. + * @param bool $multibyte Whether to use multibyte-aware trimming when it is not specified in the attribute. + * @param string|null $encoding The encoding to use in multibyte mode when it is not specified in the attribute. + */ public function __construct( private readonly ?string $characters = null, - ) {} + private readonly bool $multibyte = false, + private readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -34,9 +48,23 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + TrimCharacters::checkDeprecatedRanges($characters, $multibyte, $encoding); + + if (!$multibyte) { + return Result::success( + $characters === null ? rtrim($resolvedValue) : rtrim($resolvedValue, $characters), + ); + } return Result::success( - $characters === null ? rtrim($resolvedValue) : rtrim($resolvedValue, $characters), + mb_rtrim( + $resolvedValue, + $characters === null ? null : TrimCharacters::expandRanges($characters, $encoding), + $encoding, + ), ); } } diff --git a/src/Attribute/Parameter/ToArrayOfStrings.php b/src/Attribute/Parameter/ToArrayOfStrings.php index ce306b77..72ca32ce 100644 --- a/src/Attribute/Parameter/ToArrayOfStrings.php +++ b/src/Attribute/Parameter/ToArrayOfStrings.php @@ -8,6 +8,12 @@ /** * Casts the resolved value to array of strings. + * + * In multibyte mode, trimming strips Unicode whitespace characters, such as `U+00A0` (no-break space), as well. + * It requires the `mb_trim()` function provided by the `mbstring` PHP extension since PHP 8.4, or by the + * `symfony/polyfill-mbstring` package on earlier versions. + * + * @see https://www.php.net/manual/function.mb-trim.php */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] final class ToArrayOfStrings implements ParameterAttributeInterface @@ -18,13 +24,20 @@ final class ToArrayOfStrings implements ParameterAttributeInterface * @param bool $splitResolvedValue Split non-array resolved value to array of strings by {@see $separator}. * @param string $separator The boundary string. It is a part of regular expression * so should be taken into account or properly escaped with {@see preg_quote()}. + * @param bool|null $multibyte Whether to use multibyte-aware trimming when {@see $trim} is enabled. `null` + * means using the resolver default. + * @param string|null $encoding The encoding to use in multibyte mode. `null` means using the resolver default. */ public function __construct( public readonly bool $trim = false, public readonly bool $removeEmpty = false, public readonly bool $splitResolvedValue = true, public readonly string $separator = '\R', - ) {} + public readonly ?bool $multibyte = null, + public readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index 8b925234..550eb958 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -12,8 +12,24 @@ use function is_scalar; +/** + * Resolver for {@see ToArrayOfStrings} attribute. + */ final class ToArrayOfStringsResolver implements ParameterAttributeResolverInterface { + /** + * @param bool $multibyte Whether to use multibyte-aware trimming that strips Unicode whitespace characters + * such as `U+00A0` (no-break space) as well, when it is not specified in the attribute. Requires PHP 8.4 or + * later with `mbstring` extension, or `symfony/polyfill-mbstring` package. + * @param string|null $encoding The encoding to use in multibyte mode when it is not specified in the attribute. + */ + public function __construct( + private readonly bool $multibyte = false, + private readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } + public function getParameterValue( ParameterAttributeInterface $attribute, ParameterAttributeResolveContext $context, @@ -44,7 +60,13 @@ public function getParameterValue( } if ($attribute->trim) { - $array = array_map(trim(...), $array); + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + $array = array_map( + $multibyte ? static fn(string $value): string => mb_trim($value, null, $encoding) : trim(...), + $array, + ); } if ($attribute->removeEmpty) { diff --git a/src/Attribute/Parameter/Trim.php b/src/Attribute/Parameter/Trim.php index 35687fc9..3a785e78 100644 --- a/src/Attribute/Parameter/Trim.php +++ b/src/Attribute/Parameter/Trim.php @@ -9,18 +9,30 @@ /** * Strip whitespace (or other characters) from the beginning and end of a resolved string value. * + * In multibyte mode, Unicode whitespace characters, such as `U+00A0` (no-break space), are stripped as well. + * It requires the `mb_trim()` function provided by the `mbstring` PHP extension since PHP 8.4, or by the + * `symfony/polyfill-mbstring` package on earlier versions. + * * @see https://www.php.net/manual/function.trim.php + * @see https://www.php.net/manual/function.mb-trim.php */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)] final class Trim implements ParameterAttributeInterface { /** - * @param string|null $characters The list all characters that you want to be stripped. With `..` you can specify - * a range of characters. + * @param string|null $characters The list of all characters that you want to be stripped. With `..` you can + * specify a range of characters, both in the default and in the multibyte mode. This syntax is deprecated and + * will be removed in the next major version. + * @param bool|null $multibyte Whether to use multibyte-aware trimming. `null` means using the resolver default. + * @param string|null $encoding The encoding to use in multibyte mode. `null` means using the resolver default. */ public function __construct( public readonly ?string $characters = null, - ) {} + public readonly ?bool $multibyte = null, + public readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/TrimCharacters.php b/src/Attribute/Parameter/TrimCharacters.php new file mode 100644 index 00000000..6f8cb36d --- /dev/null +++ b/src/Attribute/Parameter/TrimCharacters.php @@ -0,0 +1,218 @@ + 0 + : str_contains($characters, '..'); + + if ($hasRanges) { + trigger_error( + 'The ".." range syntax of the "characters" parameter is deprecated and will be removed in the next major version.', + E_USER_DEPRECATED, + ); + } + } + + /** + * Checks that all `mb_*` functions used by multibyte mode are available, when {@see $multibyte} is `true`. + */ + public static function checkMultibyteFunctionsExist(?bool $multibyte): void + { + if ($multibyte !== true) { + return; + } + + self::$multibyteFunctionsExist ??= function_exists('mb_trim') + && function_exists('mb_ltrim') + && function_exists('mb_rtrim') + && function_exists('mb_str_split') + && function_exists('mb_substr_count') + && function_exists('mb_ord') + && function_exists('mb_chr') + && function_exists('mb_convert_encoding') + && function_exists('mb_internal_encoding'); + + if (self::$multibyteFunctionsExist) { + return; + } + + // @codeCoverageIgnoreStart + throw new LogicException( + 'The "multibyte" parameter requires "mbstring" extension since PHP 8.4 or "symfony/polyfill-mbstring"' + . ' package on earlier versions.', + ); + // @codeCoverageIgnoreEnd + } + + /** + * Expands `..` ranges in {@see $characters} into an explicit, range-free character list, mirroring the + * range parsing of native {@see trim()} (see `php_charmask()` in `ext/standard/string.c`), including its + * `E_WARNING` messages and literal fallback for malformed ranges. + */ + public static function expandRanges(string $characters, ?string $encoding): string + { + $encoding ??= mb_internal_encoding(); + + /** @var list $chars */ + $chars = mb_str_split($characters, 1, $encoding); + $count = count($chars); + $dot = self::dot($encoding); + $native = self::supportsCodePointFunctions($encoding); + $result = ''; + + for ($i = 0; $i < $count; $i++) { + $char = $chars[$i]; + + if ($i + 3 < $count && $chars[$i + 1] === $dot && $chars[$i + 2] === $dot) { + $startOrd = self::ord($char, $encoding, $native); + $endOrd = self::ord($chars[$i + 3], $encoding, $native); + + if ($startOrd !== false && $endOrd !== false && $endOrd >= $startOrd) { + for ($ord = $startOrd; $ord <= $endOrd; $ord++) { + $rangeChar = self::chr($ord, $encoding, $native); + if ($rangeChar !== false) { + $result .= $rangeChar; + } + } + $i += 3; + continue; + } + } + + if ($i + 1 < $count && $char === $dot && $chars[$i + 1] === $dot) { + if ($i === 0) { + trigger_error("Invalid '..'-range, no character to the left of '..'", E_USER_WARNING); + continue; + } + + if ($i + 2 >= $count) { + trigger_error("Invalid '..'-range, no character to the right of '..'", E_USER_WARNING); + continue; + } + + $leftOrd = self::ord($chars[$i - 1], $encoding, $native); + $rightOrd = self::ord($chars[$i + 2], $encoding, $native); + + if ($leftOrd !== false && $rightOrd !== false && $leftOrd > $rightOrd) { + trigger_error("Invalid '..'-range, '..'-range needs to be incrementing", E_USER_WARNING); + continue; + } + + trigger_error("Invalid '..'-range", E_USER_WARNING); + continue; + } + + $result .= $char; + } + + return $result; + } + + /** + * Returns the `.` character in the {@see $encoding}. It is not a single `.` byte in encodings that are not + * ASCII-compatible, such as `UTF-16LE` or `UTF-32`. + */ + private static function dot(?string $encoding): string + { + $encoding ??= mb_internal_encoding(); + + $dot = mb_convert_encoding('.', $encoding, 'UTF-8'); + if ($dot === false) { + // @codeCoverageIgnoreStart + throw new LogicException( + sprintf('Failed to convert the "." character to the "%s" encoding.', $encoding), + ); + // @codeCoverageIgnoreEnd + } + + return $dot; + } + + /** + * Checks whether {@see mb_ord()} and {@see mb_chr()} support the {@see $encoding}. They don't support stateful + * encodings, such as `UTF-7` or `ISO-2022-JP`. + */ + private static function supportsCodePointFunctions(string $encoding): bool + { + try { + mb_chr(0x2E, $encoding); + } catch (ValueError) { + return false; + } + + return true; + } + + /** + * Returns the Unicode code point of the {@see $char} in the {@see $encoding}, or `false` if the character is + * invalid. When {@see $native} is `false`, the character is converted to `UTF-8` first. In this case, it is always + * valid, since {@see mb_str_split()} replaces invalid characters with a substitute character. + */ + private static function ord(string $char, string $encoding, bool $native): int|false + { + if ($native) { + return mb_ord($char, $encoding); + } + + return mb_ord((string) mb_convert_encoding($char, 'UTF-8', $encoding), 'UTF-8'); + } + + /** + * Returns the character with the Unicode {@see $codePoint} in the {@see $encoding}, or `false` if it is not + * representable in the encoding. When {@see $native} is `false`, the character is converted from `UTF-8`. + */ + private static function chr(int $codePoint, string $encoding, bool $native): string|false + { + if ($native) { + return mb_chr($codePoint, $encoding); + } + + $utf8Char = mb_chr($codePoint, 'UTF-8'); + if ($utf8Char === false) { + return false; + } + + $char = (string) mb_convert_encoding($utf8Char, $encoding, 'UTF-8'); + return mb_convert_encoding($char, 'UTF-8', $encoding) === $utf8Char ? $char : false; + } +} diff --git a/src/Attribute/Parameter/TrimResolver.php b/src/Attribute/Parameter/TrimResolver.php index 8284ab76..5dbb0f43 100644 --- a/src/Attribute/Parameter/TrimResolver.php +++ b/src/Attribute/Parameter/TrimResolver.php @@ -10,11 +10,25 @@ use function is_string; +/** + * Resolver for {@see Trim} attribute. + */ final class TrimResolver implements ParameterAttributeResolverInterface { + /** + * @param string|null $characters The list of characters to strip when it is not specified in the attribute. + * With `..` you can specify a range of characters, both in the default and in the multibyte mode. This syntax + * is deprecated and will be removed in the next major version. + * @param bool $multibyte Whether to use multibyte-aware trimming when it is not specified in the attribute. + * @param string|null $encoding The encoding to use in multibyte mode when it is not specified in the attribute. + */ public function __construct( private readonly ?string $characters = null, - ) {} + private readonly bool $multibyte = false, + private readonly ?string $encoding = null, + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -34,9 +48,23 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + TrimCharacters::checkDeprecatedRanges($characters, $multibyte, $encoding); + + if (!$multibyte) { + return Result::success( + $characters === null ? trim($resolvedValue) : trim($resolvedValue, $characters), + ); + } return Result::success( - $characters === null ? trim($resolvedValue) : trim($resolvedValue, $characters), + mb_trim( + $resolvedValue, + $characters === null ? null : TrimCharacters::expandRanges($characters, $encoding), + $encoding, + ), ); } } diff --git a/tests/Attribute/Parameter/LeftTrimTest.php b/tests/Attribute/Parameter/LeftTrimTest.php index 9a479c6f..55e30762 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; use Yiisoft\Hydrator\ArrayData; @@ -21,6 +22,9 @@ use Yiisoft\Hydrator\Tests\Support\TestHelper; use Yiisoft\Test\Support\Container\SimpleContainer; +use const E_USER_DEPRECATED; +use const E_USER_WARNING; + final class LeftTrimTest extends TestCase { public static function dataBase(): iterable @@ -28,6 +32,16 @@ public static function dataBase(): iterable yield ['test ', new LeftTrim(), ' test ']; yield [' test ', new LeftTrim('t'), ' test ']; yield ['est', new LeftTrim('t'), 'test']; + yield ["\u{A0}test\u{2003} ", new LeftTrim(), " \u{A0}test\u{2003} "]; + + yield ["test\u{2003} ", new LeftTrim(multibyte: true), "\u{A0}\u{2002}test\u{2003} "]; + yield [' test ', new LeftTrim('t', multibyte: true), ' test ']; + yield ["b\u{44F}", new LeftTrim("\u{430}\u{44F}", multibyte: true), "\u{430}b\u{44F}"]; + + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('теста', 'Windows-1251', 'UTF-8'); + yield [$expected, new LeftTrim($characters, multibyte: true, encoding: 'Windows-1251'), $value]; } #[DataProvider('dataBase')] @@ -122,4 +136,215 @@ public function testOverrideDefaultCharacters(): void $this->assertSame('test*', $object->a); } + + public function testDefaultMultibyteFromResolver(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + LeftTrimResolver::class => new LeftTrimResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[LeftTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}test\u{2003}"]); + + $this->assertSame("test\u{2003}", $object->a); + } + + public function testOverrideMultibyteFalse(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + LeftTrimResolver::class => new LeftTrimResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[LeftTrim(multibyte: false)] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}test\u{2003}"]); + + $this->assertSame("\u{A0}test\u{2003}", $object->a); + } + + public function testDefaultEncodingFromResolver(): void + { + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('теста', 'Windows-1251', 'UTF-8'); + + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + LeftTrimResolver::class => new LeftTrimResolver(characters: $characters, multibyte: true, encoding: 'Windows-1251'), + ]), + ), + ); + $object = new class { + #[LeftTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => $value]); + + $this->assertSame($expected, $object->a); + } + + public function testOverrideEncoding(): void + { + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('теста', 'Windows-1251', 'UTF-8'); + + $resolver = new LeftTrimResolver(multibyte: true, encoding: 'UTF-8'); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $result = $resolver->getParameterValue(new LeftTrim($characters, encoding: 'Windows-1251'), $context); + + $this->assertSame($expected, $result->getValue()); + } + + public static function dataDeprecationNoticeForRangeCharacters(): iterable + { + yield 'attribute' => [new LeftTrim('a..z'), new LeftTrimResolver()]; + yield 'resolver' => [new LeftTrim(), new LeftTrimResolver(characters: 'a..z')]; + yield 'multibyte' => [new LeftTrim('a..z', multibyte: true), new LeftTrimResolver()]; + yield 'multibyte-utf-16' => [ + new LeftTrim(mb_convert_encoding('a..z', 'UTF-16LE', 'UTF-8'), multibyte: true, encoding: 'UTF-16LE'), + new LeftTrimResolver(), + ]; + yield 'multibyte-utf-16-from-resolver' => [ + new LeftTrim(mb_convert_encoding('a..z', 'UTF-16BE', 'UTF-8')), + new LeftTrimResolver(multibyte: true, encoding: 'UTF-16BE'), + ]; + } + + #[DataProvider('dataDeprecationNoticeForRangeCharacters')] + public function testDeprecationNoticeForRangeCharacters(LeftTrim $attribute, LeftTrimResolver $resolver): void + { + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success(''), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $attribute, $context): void { + $resolver->getParameterValue($attribute, $context); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); + $this->assertSame( + 'The ".." range syntax of the "characters" parameter is deprecated and will be removed in the next major version.', + $errors[0][1], + ); + } + + public static function dataNoDeprecationNotice(): iterable + { + yield 'single-dot' => [new LeftTrim('a.b')]; + yield 'dot' => [new LeftTrim('.')]; + yield 'null' => [new LeftTrim(null)]; + yield 'multibyte-single-dot' => [new LeftTrim('a.b', multibyte: true)]; + yield 'multibyte-utf-16-dot-bytes' => [ + new LeftTrim( + mb_convert_encoding("\u{2E41}\u{412E}", 'UTF-16LE', 'UTF-8'), + multibyte: true, + encoding: 'UTF-16LE', + ), + ]; + } + + #[DataProvider('dataNoDeprecationNotice')] + public function testNoDeprecationNotice(LeftTrim $attribute): void + { + $resolver = new LeftTrimResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('test'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $attribute, $context): void { + $resolver->getParameterValue($attribute, $context); + }); + + $this->assertSame([], $errors); + } + + public function testNoDeprecationNoticeOnCreate(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new LeftTrim('a..z'); + new LeftTrimResolver(characters: 'a..z'); + }); + + $this->assertSame([], $errors); + } + + public function testRangeInNonMultibyteModeRegression(): void + { + $resolver = new LeftTrimResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('xyztest123'), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim('a..z'), $context)->getValue(); + }); + + $this->assertSame('123', $value); + } + + #[TestWith(["\u{430}..\u{44F}", "\u{436}Hello\u{44E}", "Hello\u{44E}", null])] + #[TestWith(["\u{4E00}..\u{5341}", "\u{4E00}test\u{5341}", "test\u{5341}", null])] + #[TestWith(['..z', '.zXz.', 'Xz.', "Invalid '..'-range, no character to the left of '..'"])] + #[TestWith(['a..', 'aXa..', 'Xa..', "Invalid '..'-range, no character to the right of '..'"])] + #[TestWith(['c..a', 'caXcac', 'Xcac', "Invalid '..'-range, '..'-range needs to be incrementing"])] + #[TestWith(['a..b..c', 'abXcba..b..c', 'Xcba..b..c', "Invalid '..'-range"])] + public function testRangeInMultibyteMode( + string $characters, + string $value, + string $expectedResult, + ?string $expectedWarning, + ): void { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors( + static function () use ($resolver, $context, $characters, &$result): void { + $result = $resolver->getParameterValue(new LeftTrim($characters), $context)->getValue(); + }, + ); + + if ($expectedWarning !== null) { + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame($expectedWarning, reset($warnings)[1]); + } + $this->assertSame($expectedResult, $result); + } } diff --git a/tests/Attribute/Parameter/RightTrimTest.php b/tests/Attribute/Parameter/RightTrimTest.php index 58cb0b19..7459c360 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; use Yiisoft\Hydrator\ArrayData; @@ -21,6 +22,9 @@ use Yiisoft\Hydrator\Tests\Support\TestHelper; use Yiisoft\Test\Support\Container\SimpleContainer; +use const E_USER_DEPRECATED; +use const E_USER_WARNING; + final class RightTrimTest extends TestCase { public static function dataBase(): iterable @@ -28,6 +32,16 @@ public static function dataBase(): iterable yield [' test', new RightTrim(), ' test ']; yield [' test ', new RightTrim('t'), ' test ']; yield ['tes', new RightTrim('t'), 'test']; + yield [" \u{A0}test\u{2003}", new RightTrim(), " \u{A0}test\u{2003} "]; + + yield ["\u{A0}\u{2002}test", new RightTrim(multibyte: true), "\u{A0}\u{2002}test\u{2003} "]; + yield [' test ', new RightTrim('t', multibyte: true), ' test ']; + yield ["\u{430}b", new RightTrim("\u{430}\u{44F}", multibyte: true), "\u{430}b\u{44F}"]; + + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('атест', 'Windows-1251', 'UTF-8'); + yield [$expected, new RightTrim($characters, multibyte: true, encoding: 'Windows-1251'), $value]; } #[DataProvider('dataBase')] @@ -122,4 +136,215 @@ public function testOverrideDefaultCharacters(): void $this->assertSame('*test', $object->a); } + + public function testDefaultMultibyteFromResolver(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + RightTrimResolver::class => new RightTrimResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[RightTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}test\u{2003}"]); + + $this->assertSame("\u{A0}test", $object->a); + } + + public function testOverrideMultibyteFalse(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + RightTrimResolver::class => new RightTrimResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[RightTrim(multibyte: false)] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}test\u{2003}"]); + + $this->assertSame("\u{A0}test\u{2003}", $object->a); + } + + public function testDefaultEncodingFromResolver(): void + { + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('атест', 'Windows-1251', 'UTF-8'); + + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + RightTrimResolver::class => new RightTrimResolver(characters: $characters, multibyte: true, encoding: 'Windows-1251'), + ]), + ), + ); + $object = new class { + #[RightTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => $value]); + + $this->assertSame($expected, $object->a); + } + + public function testOverrideEncoding(): void + { + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('атест', 'Windows-1251', 'UTF-8'); + + $resolver = new RightTrimResolver(multibyte: true, encoding: 'UTF-8'); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $result = $resolver->getParameterValue(new RightTrim($characters, encoding: 'Windows-1251'), $context); + + $this->assertSame($expected, $result->getValue()); + } + + public static function dataDeprecationNoticeForRangeCharacters(): iterable + { + yield 'attribute' => [new RightTrim('a..z'), new RightTrimResolver()]; + yield 'resolver' => [new RightTrim(), new RightTrimResolver(characters: 'a..z')]; + yield 'multibyte' => [new RightTrim('a..z', multibyte: true), new RightTrimResolver()]; + yield 'multibyte-utf-16' => [ + new RightTrim(mb_convert_encoding('a..z', 'UTF-16LE', 'UTF-8'), multibyte: true, encoding: 'UTF-16LE'), + new RightTrimResolver(), + ]; + yield 'multibyte-utf-16-from-resolver' => [ + new RightTrim(mb_convert_encoding('a..z', 'UTF-16BE', 'UTF-8')), + new RightTrimResolver(multibyte: true, encoding: 'UTF-16BE'), + ]; + } + + #[DataProvider('dataDeprecationNoticeForRangeCharacters')] + public function testDeprecationNoticeForRangeCharacters(RightTrim $attribute, RightTrimResolver $resolver): void + { + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success(''), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $attribute, $context): void { + $resolver->getParameterValue($attribute, $context); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); + $this->assertSame( + 'The ".." range syntax of the "characters" parameter is deprecated and will be removed in the next major version.', + $errors[0][1], + ); + } + + public static function dataNoDeprecationNotice(): iterable + { + yield 'single-dot' => [new RightTrim('a.b')]; + yield 'dot' => [new RightTrim('.')]; + yield 'null' => [new RightTrim(null)]; + yield 'multibyte-single-dot' => [new RightTrim('a.b', multibyte: true)]; + yield 'multibyte-utf-16-dot-bytes' => [ + new RightTrim( + mb_convert_encoding("\u{2E41}\u{412E}", 'UTF-16LE', 'UTF-8'), + multibyte: true, + encoding: 'UTF-16LE', + ), + ]; + } + + #[DataProvider('dataNoDeprecationNotice')] + public function testNoDeprecationNotice(RightTrim $attribute): void + { + $resolver = new RightTrimResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('test'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $attribute, $context): void { + $resolver->getParameterValue($attribute, $context); + }); + + $this->assertSame([], $errors); + } + + public function testNoDeprecationNoticeOnCreate(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new RightTrim('a..z'); + new RightTrimResolver(characters: 'a..z'); + }); + + $this->assertSame([], $errors); + } + + public function testRangeInNonMultibyteModeRegression(): void + { + $resolver = new RightTrimResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('xyztest123'), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim('a..z'), $context)->getValue(); + }); + + $this->assertSame('xyztest123', $value); + } + + #[TestWith(["\u{430}..\u{44F}", "\u{436}Hello\u{44E}", "\u{436}Hello", null])] + #[TestWith(["\u{4E00}..\u{5341}", "\u{4E00}test\u{5341}", "\u{4E00}test", null])] + #[TestWith(['..z', '.zXz.', '.zX', "Invalid '..'-range, no character to the left of '..'"])] + #[TestWith(['a..', 'aXa..', 'aX', "Invalid '..'-range, no character to the right of '..'"])] + #[TestWith(['c..a', 'caXcac', 'caX', "Invalid '..'-range, '..'-range needs to be incrementing"])] + #[TestWith(['a..b..c', 'abXcba..b..c', 'abX', "Invalid '..'-range"])] + public function testRangeInMultibyteMode( + string $characters, + string $value, + string $expectedResult, + ?string $expectedWarning, + ): void { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors( + static function () use ($resolver, $context, $characters, &$result): void { + $result = $resolver->getParameterValue(new RightTrim($characters), $context)->getValue(); + }, + ); + + if ($expectedWarning !== null) { + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame($expectedWarning, reset($warnings)[1]); + } + $this->assertSame($expectedResult, $result); + } } diff --git a/tests/Attribute/Parameter/ToArrayOfStringsTest.php b/tests/Attribute/Parameter/ToArrayOfStringsTest.php index 82ed8662..20c66684 100644 --- a/tests/Attribute/Parameter/ToArrayOfStringsTest.php +++ b/tests/Attribute/Parameter/ToArrayOfStringsTest.php @@ -78,6 +78,14 @@ public static function dataBase(): iterable public ?array $value = null; }, ]; + yield [ + ["hello\u{2003}"], + " hello\u{2003} ", + new class { + #[ToArrayOfStrings(trim: true)] + public ?array $value = null; + }, + ]; yield [ ['hello', 'world'], "hello\nworld", @@ -197,4 +205,77 @@ public function testUnexpectedAttributeException(): void ); $hydrator->hydrate($object); } + + public function testMultibyteTrim(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToArrayOfStringsResolver::class => new ToArrayOfStringsResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[ToArrayOfStrings(trim: true, separator: ',')] + public ?array $value = null; + }; + + $hydrator->hydrate($object, ['value' => "\u{A0}hello\u{2003},\u{2002}world "]); + + $this->assertSame(['hello', 'world'], $object->value); + } + + public function testAttributeMultibyteTrim(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[ToArrayOfStrings(trim: true, separator: ',', multibyte: true)] + public ?array $value = null; + }; + + $hydrator->hydrate($object, ['value' => "\u{A0}hello\u{2003},\u{2002}world "]); + + $this->assertSame(['hello', 'world'], $object->value); + } + + public function testAttributeMultibyteOverridesResolverDefault(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToArrayOfStringsResolver::class => new ToArrayOfStringsResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[ToArrayOfStrings(trim: true, separator: ',', multibyte: false)] + public ?array $value = null; + }; + + $hydrator->hydrate($object, ['value' => "\u{A0}hello\u{2003},\u{2002}world "]); + + $this->assertSame(["\u{A0}hello\u{2003}", "\u{2002}world"], $object->value); + } + + public function testAttributeEncodingOverridesResolverDefault(): void + { + $value = mb_convert_encoding(" привет ", 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('привет', 'Windows-1251', 'UTF-8'); + + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToArrayOfStringsResolver::class => new ToArrayOfStringsResolver(multibyte: true, encoding: 'UTF-8'), + ]), + ), + ); + $object = new class { + #[ToArrayOfStrings(trim: true, splitResolvedValue: false, encoding: 'Windows-1251')] + public ?array $value = null; + }; + + $hydrator->hydrate($object, ['value' => $value]); + + $this->assertSame([$expected], $object->value); + } } diff --git a/tests/Attribute/Parameter/TrimCharactersTest.php b/tests/Attribute/Parameter/TrimCharactersTest.php new file mode 100644 index 00000000..e2806a7e --- /dev/null +++ b/tests/Attribute/Parameter/TrimCharactersTest.php @@ -0,0 +1,161 @@ +assertCount(1, $errors); + $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); + $this->assertSame( + 'The ".." range syntax of the "characters" parameter is deprecated and will be removed in the next major version.', + $errors[0][1], + ); + } + + #[TestWith([null, false])] + #[TestWith(['abc', false])] + #[TestWith(['.', false])] + #[TestWith(['a.b', false])] + #[TestWith([null, true])] + #[TestWith(['a.b', true])] + #[TestWith(["a\0.\0b\0", true, 'UTF-16LE'])] + #[TestWith(["\x41\x2E\x2E\x41", true, 'UTF-16LE'])] + public function testCheckDeprecatedRangesIsSilent( + ?string $characters, + bool $multibyte, + ?string $encoding = null, + ): void { + $errors = TestHelper::captureErrors(static function () use ($characters, $multibyte, $encoding): void { + TrimCharacters::checkDeprecatedRanges($characters, $multibyte, $encoding); + }); + + $this->assertSame([], $errors); + } + + #[TestWith(['a..z', 'abcdefghijklmnopqrstuvwxyz'])] + #[TestWith(['a..a', 'a'])] + #[TestWith(["\u{430}..\u{433}", "\u{430}\u{431}\u{432}\u{433}"])] + public function testExpandRangesExpandsRange(string $characters, string $expected): void + { + $this->assertSame($expected, TrimCharacters::expandRanges($characters, null)); + } + + #[TestWith(['UTF-16LE'])] + #[TestWith(['UTF-16BE'])] + #[TestWith(['UTF-32LE'])] + #[TestWith(['UTF-32BE'])] + public function testExpandRangesWithEncoding(string $encoding): void + { + $characters = mb_convert_encoding('a..z', $encoding, 'UTF-8'); + $expected = mb_convert_encoding('abcdefghijklmnopqrstuvwxyz', $encoding, 'UTF-8'); + + $this->assertSame($expected, TrimCharacters::expandRanges($characters, $encoding)); + } + + #[TestWith(['UTF-7'])] + #[TestWith(['UTF7-IMAP'])] + #[TestWith(['JIS'])] + #[TestWith(['ISO-2022-JP'])] + #[TestWith(['ISO-2022-JP-MS'])] + #[TestWith(['CP50220'])] + #[TestWith(['CP50221'])] + #[TestWith(['CP50222'])] + public function testExpandRangesWithStatefulEncoding(string $encoding): void + { + $characters = mb_convert_encoding("x\u{430}..\u{433}y", $encoding, 'UTF-8'); + + $result = TrimCharacters::expandRanges($characters, $encoding); + + $this->assertSame("x\u{430}\u{431}\u{432}\u{433}y", mb_convert_encoding($result, 'UTF-8', $encoding)); + } + + public function testExpandRangesSkipsCodePointsNotRepresentableInStatefulEncoding(): void + { + $characters = mb_convert_encoding("\u{410}..\u{451}", 'ISO-2022-JP', 'UTF-8'); + + $result = TrimCharacters::expandRanges($characters, 'ISO-2022-JP'); + + $this->assertSame(65, mb_strlen($result, 'ISO-2022-JP')); + $this->assertStringNotContainsString('?', mb_convert_encoding($result, 'UTF-8', 'ISO-2022-JP')); + } + + public function testExpandRangesSkipsSurrogatesInStatefulEncoding(): void + { + $characters = mb_convert_encoding("\u{D7FF}..\u{E000}", 'UTF-7', 'UTF-8'); + + $result = TrimCharacters::expandRanges($characters, 'UTF-7'); + + $this->assertSame("\u{D7FF}\u{E000}", mb_convert_encoding($result, 'UTF-8', 'UTF-7')); + } + + public function testExpandRangesTreatsSingleDotAsOrdinaryCharacter(): void + { + $errors = TestHelper::captureErrors(static function () use (&$dot, &$twoChars): void { + $dot = TrimCharacters::expandRanges('.', null); + $twoChars = TrimCharacters::expandRanges('a.b', null); + }); + + $this->assertSame([], $errors); + $this->assertSame('.', $dot); + $this->assertSame('a.b', $twoChars); + } + + public function testExpandRangesSkipsCodePointsNotRepresentableInTargetEncoding(): void + { + $start = mb_chr(0x40C, 'Windows-1251'); + $end = mb_chr(0x44F, 'Windows-1251'); + + $result = TrimCharacters::expandRanges($start . '..' . $end, 'Windows-1251'); + + $this->assertSame(67, mb_strlen($result, 'Windows-1251')); + } + + #[TestWith(["\xFF..z", 'UTF-8', "Invalid '..'-range", "\xFF.z"])] + #[TestWith(['..z', null, "Invalid '..'-range, no character to the left of '..'", '.z'])] + #[TestWith(['a..', null, "Invalid '..'-range, no character to the right of '..'", 'a.'])] + #[TestWith(['c..a', null, "Invalid '..'-range, '..'-range needs to be incrementing", 'c.a'])] + #[TestWith(['a..b..c', null, "Invalid '..'-range", 'ab.c'])] + #[TestWith(["a\0.\0.\0", 'UTF-16LE', "Invalid '..'-range, no character to the right of '..'", "a\0.\0"])] + #[TestWith(['+BDI-..+BDA-', 'UTF-7', "Invalid '..'-range, '..'-range needs to be incrementing", '+BDI-.+BDA-'])] + public function testExpandRangesInvalidRange( + string $characters, + ?string $encoding, + string $expectedWarning, + string $expectedResult, + ): void { + $errors = TestHelper::captureErrors(static function () use ($characters, $encoding, &$result): void { + $result = TrimCharacters::expandRanges($characters, $encoding); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_WARNING, $errors[0][0]); + $this->assertSame($expectedWarning, $errors[0][1]); + $this->assertSame($expectedResult, $result); + } +} diff --git a/tests/Attribute/Parameter/TrimTest.php b/tests/Attribute/Parameter/TrimTest.php index 91602477..f45d8d0c 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; use Yiisoft\Hydrator\ArrayData; @@ -21,6 +22,9 @@ use Yiisoft\Hydrator\Tests\Support\TestHelper; use Yiisoft\Test\Support\Container\SimpleContainer; +use const E_USER_DEPRECATED; +use const E_USER_WARNING; + final class TrimTest extends TestCase { public static function dataBase(): iterable @@ -28,6 +32,16 @@ public static function dataBase(): iterable yield ['test', new Trim(), ' test ']; yield [' test ', new Trim('t'), ' test ']; yield ['es', new Trim('t'), 'test']; + yield ["\u{A0}test\u{2003}", new Trim(), " \u{A0}test\u{2003} "]; + + yield ['test', new Trim(multibyte: true), "\u{A0}\u{2002}test\u{2003} "]; + yield [' test ', new Trim('t', multibyte: true), ' test ']; + yield ['b', new Trim("\u{430}\u{44F}", multibyte: true), "\u{430}b\u{44F}"]; + + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('тест', 'Windows-1251', 'UTF-8'); + yield [$expected, new Trim($characters, multibyte: true, encoding: 'Windows-1251'), $value]; } #[DataProvider('dataBase')] @@ -122,4 +136,215 @@ public function testOverrideDefaultCharacters(): void $this->assertSame('test', $object->a); } + + public function testDefaultMultibyteFromResolver(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + TrimResolver::class => new TrimResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[Trim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}test\u{2003}"]); + + $this->assertSame('test', $object->a); + } + + public function testOverrideMultibyteFalse(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + TrimResolver::class => new TrimResolver(multibyte: true), + ]), + ), + ); + $object = new class { + #[Trim(multibyte: false)] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}test\u{2003}"]); + + $this->assertSame("\u{A0}test\u{2003}", $object->a); + } + + public function testDefaultEncodingFromResolver(): void + { + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('тест', 'Windows-1251', 'UTF-8'); + + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + TrimResolver::class => new TrimResolver(characters: $characters, multibyte: true, encoding: 'Windows-1251'), + ]), + ), + ); + $object = new class { + #[Trim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => $value]); + + $this->assertSame($expected, $object->a); + } + + public function testOverrideEncoding(): void + { + $characters = mb_convert_encoding('а', 'Windows-1251', 'UTF-8'); + $value = mb_convert_encoding('атеста', 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('тест', 'Windows-1251', 'UTF-8'); + + $resolver = new TrimResolver(multibyte: true, encoding: 'UTF-8'); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $result = $resolver->getParameterValue(new Trim($characters, encoding: 'Windows-1251'), $context); + + $this->assertSame($expected, $result->getValue()); + } + + public static function dataDeprecationNoticeForRangeCharacters(): iterable + { + yield 'attribute' => [new Trim('a..z'), new TrimResolver()]; + yield 'resolver' => [new Trim(), new TrimResolver(characters: 'a..z')]; + yield 'multibyte' => [new Trim('a..z', multibyte: true), new TrimResolver()]; + yield 'multibyte-utf-16' => [ + new Trim(mb_convert_encoding('a..z', 'UTF-16LE', 'UTF-8'), multibyte: true, encoding: 'UTF-16LE'), + new TrimResolver(), + ]; + yield 'multibyte-utf-16-from-resolver' => [ + new Trim(mb_convert_encoding('a..z', 'UTF-16BE', 'UTF-8')), + new TrimResolver(multibyte: true, encoding: 'UTF-16BE'), + ]; + } + + #[DataProvider('dataDeprecationNoticeForRangeCharacters')] + public function testDeprecationNoticeForRangeCharacters(Trim $attribute, TrimResolver $resolver): void + { + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success(''), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $attribute, $context): void { + $resolver->getParameterValue($attribute, $context); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); + $this->assertSame( + 'The ".." range syntax of the "characters" parameter is deprecated and will be removed in the next major version.', + $errors[0][1], + ); + } + + public static function dataNoDeprecationNotice(): iterable + { + yield 'single-dot' => [new Trim('a.b')]; + yield 'dot' => [new Trim('.')]; + yield 'null' => [new Trim(null)]; + yield 'multibyte-single-dot' => [new Trim('a.b', multibyte: true)]; + yield 'multibyte-utf-16-dot-bytes' => [ + new Trim( + mb_convert_encoding("\u{2E41}\u{412E}", 'UTF-16LE', 'UTF-8'), + multibyte: true, + encoding: 'UTF-16LE', + ), + ]; + } + + #[DataProvider('dataNoDeprecationNotice')] + public function testNoDeprecationNotice(Trim $attribute): void + { + $resolver = new TrimResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('test'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $attribute, $context): void { + $resolver->getParameterValue($attribute, $context); + }); + + $this->assertSame([], $errors); + } + + public function testNoDeprecationNoticeOnCreate(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new Trim('a..z'); + new TrimResolver(characters: 'a..z'); + }); + + $this->assertSame([], $errors); + } + + public function testRangeInNonMultibyteModeRegression(): void + { + $resolver = new TrimResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('xyztest123'), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim('a..z'), $context)->getValue(); + }); + + $this->assertSame('123', $value); + } + + #[TestWith(["\u{430}..\u{44F}", "\u{436}Hello\u{44E}", 'Hello', null])] + #[TestWith(["\u{4E00}..\u{5341}", "\u{4E00}test\u{5341}", 'test', null])] + #[TestWith(['..z', '.zXz.', 'X', "Invalid '..'-range, no character to the left of '..'"])] + #[TestWith(['a..', 'aXa..', 'X', "Invalid '..'-range, no character to the right of '..'"])] + #[TestWith(['c..a', 'caXcac', 'X', "Invalid '..'-range, '..'-range needs to be incrementing"])] + #[TestWith(['a..b..c', 'abXcba..b..c', 'X', "Invalid '..'-range"])] + public function testRangeInMultibyteMode( + string $characters, + string $value, + string $expectedResult, + ?string $expectedWarning, + ): void { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors( + static function () use ($resolver, $context, $characters, &$result): void { + $result = $resolver->getParameterValue(new Trim($characters), $context)->getValue(); + }, + ); + + if ($expectedWarning !== null) { + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame($expectedWarning, reset($warnings)[1]); + } + $this->assertSame($expectedResult, $result); + } } diff --git a/tests/Support/TestHelper.php b/tests/Support/TestHelper.php index 99b03706..8f9a0602 100644 --- a/tests/Support/TestHelper.php +++ b/tests/Support/TestHelper.php @@ -19,6 +19,30 @@ public static function getFirstParameter(Closure $closure): ReflectionParameter return reset($parameters); } + /** + * Runs {@see $callback} with a temporary error handler installed, capturing every triggered error instead of + * letting it propagate (which would otherwise fail the test suite via `failOnWarning`). + * + * @return list Captured `[$errno, $errstr]` pairs, in the order they were triggered. + */ + public static function captureErrors(Closure $callback): array + { + $errors = []; + + set_error_handler(static function (int $errno, string $errstr) use (&$errors): bool { + $errors[] = [$errno, $errstr]; + return true; + }); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $errors; + } + public static function createTypeCastContext(Closure $closure): TypeCastContext { return new TypeCastContext(