From 29f496b5be67d7404c13c9611298d2c9abf20703 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 15 Sep 2026 22:51:51 +0300 Subject: [PATCH 01/12] Multibyte trim support --- CHANGELOG.md | 2 + composer-dependency-analyser.php | 6 +- composer.json | 5 +- docs/guide/en/typecasting.md | 30 +++- src/Attribute/Parameter/LeftTrim.php | 5 +- src/Attribute/Parameter/MultibyteLeftTrim.php | 32 ++++ .../Parameter/MultibyteLeftTrimResolver.php | 56 +++++++ .../Parameter/MultibyteRightTrim.php | 32 ++++ .../Parameter/MultibyteRightTrimResolver.php | 56 +++++++ src/Attribute/Parameter/MultibyteTrim.php | 32 ++++ .../Parameter/MultibyteTrimResolver.php | 56 +++++++ src/Attribute/Parameter/RightTrim.php | 5 +- src/Attribute/Parameter/ToArrayOfStrings.php | 3 +- .../Parameter/ToArrayOfStringsResolver.php | 19 ++- src/Attribute/Parameter/Trim.php | 5 +- tests/Attribute/Parameter/LeftTrimTest.php | 1 + .../Parameter/MultibyteLeftTrimTest.php | 149 ++++++++++++++++++ .../Parameter/MultibyteRightTrimTest.php | 149 ++++++++++++++++++ .../Attribute/Parameter/MultibyteTrimTest.php | 149 ++++++++++++++++++ tests/Attribute/Parameter/RightTrimTest.php | 1 + .../Parameter/ToArrayOfStringsTest.php | 27 ++++ tests/Attribute/Parameter/TrimTest.php | 1 + 22 files changed, 813 insertions(+), 8 deletions(-) create mode 100644 src/Attribute/Parameter/MultibyteLeftTrim.php create mode 100644 src/Attribute/Parameter/MultibyteLeftTrimResolver.php create mode 100644 src/Attribute/Parameter/MultibyteRightTrim.php create mode 100644 src/Attribute/Parameter/MultibyteRightTrimResolver.php create mode 100644 src/Attribute/Parameter/MultibyteTrim.php create mode 100644 src/Attribute/Parameter/MultibyteTrimResolver.php create mode 100644 tests/Attribute/Parameter/MultibyteLeftTrimTest.php create mode 100644 tests/Attribute/Parameter/MultibyteRightTrimTest.php create mode 100644 tests/Attribute/Parameter/MultibyteTrimTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 77503feb..38391770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 1.6.4 under development +- New #131: Add `MultibyteTrim`, `MultibyteLeftTrim` and `MultibyteRightTrim` parameter attributes (@vjik) +- New #131: Add multibyte mode to `ToArrayOfStrings` attribute resolver (@vjik) - Enh #117: Explicitly import functions and constants in "use" section (@mspirkov) ## 1.6.3 December 16, 2025 diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 0707abc5..745db0e9 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -11,4 +11,8 @@ ->addPathToScan(__DIR__ . '/config', isDev: false) ->addPathToScan(__DIR__ . '/src', isDev: false) ->addPathToScan(__DIR__ . '/tests', isDev: true) - ->ignoreErrorsOnExtension('ext-intl', [ErrorType::SHADOW_DEPENDENCY]); + ->ignoreErrorsOnExtension('ext-intl', [ErrorType::SHADOW_DEPENDENCY]) + // Multibyte trim functions come either from "mbstring" extension since PHP 8.4, or from + // "symfony/polyfill-mbstring" package. The package is required for dev only, to run tests on PHP older than + // 8.4, and is suggested to users, but intentionally not required in production. + ->ignoreErrorsOnPackage('symfony/polyfill-mbstring', [ErrorType::DEV_DEPENDENCY_IN_PROD]); diff --git a/composer.json b/composer.json index 275a97fb..49655492 100644 --- a/composer.json +++ b/composer.json @@ -38,13 +38,16 @@ "roave/infection-static-analysis-plugin": "^1.35", "shipmonk/composer-dependency-analyser": "^1.8", "spatie/phpunit-watcher": "^1.24", + "symfony/polyfill-mbstring": "^1.31", "vimeo/psalm": "^5.26.1 || ^6.13.1", "yiisoft/di": "^1.4", "yiisoft/dummy-provider": "^1.1.0", "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 `MultibyteTrim`, `MultibyteLeftTrim`, `MultibyteRightTrim` attributes and multibyte mode of `ToArrayOfStrings` attribute on PHP 8.4 or later", + "symfony/polyfill-mbstring": "Allows using `MultibyteTrim`, `MultibyteLeftTrim`, `MultibyteRightTrim` attributes and multibyte mode of `ToArrayOfStrings` attribute 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 e68187c6..c67d8f8e 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 ']); ``` +### `MultibyteTrim` / `MultibyteLeftTrim` / `MultibyteRightTrim` + +`Trim`, `LeftTrim` and `RightTrim` 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, use `MultibyteTrim`, `MultibyteLeftTrim` +or `MultibyteRightTrim` attributes: + +```php +use Yiisoft\Hydrator\Attribute\Parameter\MultibyteTrim; + +class Person +{ + public function __construct( + #[MultibyteTrim] // "\u{A0}John\u{2003}" → 'John' + private ?string $name = null, + ) {} +} + +$person = $hydrator->create(Person::class, ['name' => "\u{A0}John\u{2003}"]); +``` + +These attributes use `mb_trim()`, `mb_ltrim()` and `mb_rtrim()` functions that are provided by `mbstring` PHP +extension since PHP 8.4. To use them with an earlier PHP version, install +[symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) package. + +Note that, unlike `Trim`, `LeftTrim` and `RightTrim`, the `characters` parameter doesn't support the `..` range syntax: +every character is treated literally. + ### `ToDatetime` To cast a value to `DateTimeImmutable` or `DateTime` object explicitly, you can use `ToDateTime` attribute: @@ -235,7 +262,8 @@ converted to array `['news', 'city', 'hot']`. Attribute parameters: -- `trim` — trim each string of array (boolean, default `false`); +- `trim` — trim each string of array (boolean, default `false`), multibyte-aware trimming can be enabled via + `multibyte` parameter of `ToArrayOfStringsResolver`; - `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 diff --git a/src/Attribute/Parameter/LeftTrim.php b/src/Attribute/Parameter/LeftTrim.php index 0cb0e8e8..c65c420a 100644 --- a/src/Attribute/Parameter/LeftTrim.php +++ b/src/Attribute/Parameter/LeftTrim.php @@ -9,13 +9,16 @@ /** * Strip whitespace (or other characters) from the beginning of a resolved string value. * + * For multibyte-aware trimming that takes Unicode whitespace characters, such as `U+00A0` (no-break + * space), into account, use {@see MultibyteLeftTrim}. + * * @see https://www.php.net/manual/function.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 + * @param string|null $characters The list of all characters that you want to be stripped. With `..` you can specify * a range of characters. */ public function __construct( diff --git a/src/Attribute/Parameter/MultibyteLeftTrim.php b/src/Attribute/Parameter/MultibyteLeftTrim.php new file mode 100644 index 00000000..968bcc6a --- /dev/null +++ b/src/Attribute/Parameter/MultibyteLeftTrim.php @@ -0,0 +1,32 @@ +isResolved()) { + return Result::fail(); + } + + $resolvedValue = $context->getResolvedValue(); + if (!is_string($resolvedValue)) { + return Result::fail(); + } + + return Result::success( + mb_ltrim($resolvedValue, $attribute->characters ?? $this->characters), + ); + } +} diff --git a/src/Attribute/Parameter/MultibyteRightTrim.php b/src/Attribute/Parameter/MultibyteRightTrim.php new file mode 100644 index 00000000..449ae094 --- /dev/null +++ b/src/Attribute/Parameter/MultibyteRightTrim.php @@ -0,0 +1,32 @@ +isResolved()) { + return Result::fail(); + } + + $resolvedValue = $context->getResolvedValue(); + if (!is_string($resolvedValue)) { + return Result::fail(); + } + + return Result::success( + mb_rtrim($resolvedValue, $attribute->characters ?? $this->characters), + ); + } +} diff --git a/src/Attribute/Parameter/MultibyteTrim.php b/src/Attribute/Parameter/MultibyteTrim.php new file mode 100644 index 00000000..3507a106 --- /dev/null +++ b/src/Attribute/Parameter/MultibyteTrim.php @@ -0,0 +1,32 @@ +isResolved()) { + return Result::fail(); + } + + $resolvedValue = $context->getResolvedValue(); + if (!is_string($resolvedValue)) { + return Result::fail(); + } + + return Result::success( + mb_trim($resolvedValue, $attribute->characters ?? $this->characters), + ); + } +} diff --git a/src/Attribute/Parameter/RightTrim.php b/src/Attribute/Parameter/RightTrim.php index d1f1dd1d..fea003b8 100644 --- a/src/Attribute/Parameter/RightTrim.php +++ b/src/Attribute/Parameter/RightTrim.php @@ -9,13 +9,16 @@ /** * Strip whitespace (or other characters) from the end of a resolved string value. * + * For multibyte-aware trimming that takes Unicode whitespace characters, such as `U+00A0` (no-break + * space), into account, use {@see MultibyteRightTrim}. + * * @see https://www.php.net/manual/function.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 + * @param string|null $characters The list of all characters that you want to be stripped. With `..` you can specify * a range of characters. */ public function __construct( diff --git a/src/Attribute/Parameter/ToArrayOfStrings.php b/src/Attribute/Parameter/ToArrayOfStrings.php index ce306b77..24e248a1 100644 --- a/src/Attribute/Parameter/ToArrayOfStrings.php +++ b/src/Attribute/Parameter/ToArrayOfStrings.php @@ -13,7 +13,8 @@ final class ToArrayOfStrings implements ParameterAttributeInterface { /** - * @param bool $trim Trim each string of array. + * @param bool $trim Trim each string of array. Multibyte-aware trimming can be enabled via `$multibyte` + * parameter of {@see ToArrayOfStringsResolver}. * @param bool $removeEmpty Remove empty strings from array. * @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 diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index 8b925234..6a371a49 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -4,16 +4,33 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; +use LogicException; use Stringable; use Traversable; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; +use function function_exists; use function is_scalar; 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. Requires PHP 8.4 or later with `mbstring` extension, or + * `symfony/polyfill-mbstring` package. + */ + public function __construct( + private readonly bool $multibyte = false, + ) { + if ($multibyte && !function_exists('mb_trim')) { + throw new LogicException( + 'Multibyte mode requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', + ); + } + } + public function getParameterValue( ParameterAttributeInterface $attribute, ParameterAttributeResolveContext $context, @@ -44,7 +61,7 @@ public function getParameterValue( } if ($attribute->trim) { - $array = array_map(trim(...), $array); + $array = array_map($this->multibyte ? mb_trim(...) : trim(...), $array); } if ($attribute->removeEmpty) { diff --git a/src/Attribute/Parameter/Trim.php b/src/Attribute/Parameter/Trim.php index 35687fc9..afad4deb 100644 --- a/src/Attribute/Parameter/Trim.php +++ b/src/Attribute/Parameter/Trim.php @@ -9,13 +9,16 @@ /** * Strip whitespace (or other characters) from the beginning and end of a resolved string value. * + * For multibyte-aware trimming that takes Unicode whitespace characters, such as `U+00A0` (no-break + * space), into account, use {@see MultibyteTrim}. + * * @see https://www.php.net/manual/function.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 + * @param string|null $characters The list of all characters that you want to be stripped. With `..` you can specify * a range of characters. */ public function __construct( diff --git a/tests/Attribute/Parameter/LeftTrimTest.php b/tests/Attribute/Parameter/LeftTrimTest.php index 9a479c6f..5453eaa8 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -28,6 +28,7 @@ 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} "]; } #[DataProvider('dataBase')] diff --git a/tests/Attribute/Parameter/MultibyteLeftTrimTest.php b/tests/Attribute/Parameter/MultibyteLeftTrimTest.php new file mode 100644 index 00000000..c1031977 --- /dev/null +++ b/tests/Attribute/Parameter/MultibyteLeftTrimTest.php @@ -0,0 +1,149 @@ + null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $result = $resolver->getParameterValue($attribute, $context); + + $this->assertTrue($result->isResolved()); + $this->assertSame($expected, $result->getValue()); + } + + public function testWithHydrator(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteLeftTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}hello\u{2003}"]); + + $this->assertSame("hello\u{2003}", $object->a); + } + + public function testNotResolve(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteLeftTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => new stdClass()]); + + $this->assertNull($object->a); + } + + public function testNotResolvedValue(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteLeftTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['b' => "\u{A0}test\u{2003}"]); + + $this->assertNull($object->a); + } + + public function testUnexpectedAttributeException(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + CounterResolver::class => new MultibyteLeftTrimResolver(), + ]), + ), + ); + $object = new CounterClass(); + + $this->expectException(UnexpectedAttributeException::class); + $this->expectExceptionMessage( + 'Expected "' . MultibyteLeftTrim::class . '", but "' . Counter::class . '" given.', + ); + $hydrator->hydrate($object); + } + + public function testDefaultCharacters(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + MultibyteLeftTrimResolver::class => new MultibyteLeftTrimResolver(characters: "\u{2022}"), + ]), + ), + ); + $object = new class { + #[MultibyteLeftTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); + + $this->assertSame("test\u{2022}", $object->a); + } + + public function testOverrideDefaultCharacters(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + MultibyteLeftTrimResolver::class => new MultibyteLeftTrimResolver(characters: '_-'), + ]), + ), + ); + $object = new class { + #[MultibyteLeftTrim(characters: "\u{2022}")] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); + + $this->assertSame("test\u{2022}", $object->a); + } +} diff --git a/tests/Attribute/Parameter/MultibyteRightTrimTest.php b/tests/Attribute/Parameter/MultibyteRightTrimTest.php new file mode 100644 index 00000000..1c2b9c72 --- /dev/null +++ b/tests/Attribute/Parameter/MultibyteRightTrimTest.php @@ -0,0 +1,149 @@ + null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $result = $resolver->getParameterValue($attribute, $context); + + $this->assertTrue($result->isResolved()); + $this->assertSame($expected, $result->getValue()); + } + + public function testWithHydrator(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteRightTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}hello\u{2003}"]); + + $this->assertSame("\u{A0}hello", $object->a); + } + + public function testNotResolve(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteRightTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => new stdClass()]); + + $this->assertNull($object->a); + } + + public function testNotResolvedValue(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteRightTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['b' => "\u{A0}test\u{2003}"]); + + $this->assertNull($object->a); + } + + public function testUnexpectedAttributeException(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + CounterResolver::class => new MultibyteRightTrimResolver(), + ]), + ), + ); + $object = new CounterClass(); + + $this->expectException(UnexpectedAttributeException::class); + $this->expectExceptionMessage( + 'Expected "' . MultibyteRightTrim::class . '", but "' . Counter::class . '" given.', + ); + $hydrator->hydrate($object); + } + + public function testDefaultCharacters(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + MultibyteRightTrimResolver::class => new MultibyteRightTrimResolver(characters: "\u{2022}"), + ]), + ), + ); + $object = new class { + #[MultibyteRightTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); + + $this->assertSame("\u{2022}test", $object->a); + } + + public function testOverrideDefaultCharacters(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + MultibyteRightTrimResolver::class => new MultibyteRightTrimResolver(characters: '_-'), + ]), + ), + ); + $object = new class { + #[MultibyteRightTrim(characters: "\u{2022}")] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); + + $this->assertSame("\u{2022}test", $object->a); + } +} diff --git a/tests/Attribute/Parameter/MultibyteTrimTest.php b/tests/Attribute/Parameter/MultibyteTrimTest.php new file mode 100644 index 00000000..168aa4a5 --- /dev/null +++ b/tests/Attribute/Parameter/MultibyteTrimTest.php @@ -0,0 +1,149 @@ + null), + Result::success($value), + new ArrayData(), + new Hydrator(), + ); + + $result = $resolver->getParameterValue($attribute, $context); + + $this->assertTrue($result->isResolved()); + $this->assertSame($expected, $result->getValue()); + } + + public function testWithHydrator(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{A0}hello\u{2003}"]); + + $this->assertSame('hello', $object->a); + } + + public function testNotResolve(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => new stdClass()]); + + $this->assertNull($object->a); + } + + public function testNotResolvedValue(): void + { + $hydrator = new Hydrator(); + $object = new class { + #[MultibyteTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['b' => "\u{A0}test\u{2003}"]); + + $this->assertNull($object->a); + } + + public function testUnexpectedAttributeException(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + CounterResolver::class => new MultibyteTrimResolver(), + ]), + ), + ); + $object = new CounterClass(); + + $this->expectException(UnexpectedAttributeException::class); + $this->expectExceptionMessage( + 'Expected "' . MultibyteTrim::class . '", but "' . Counter::class . '" given.', + ); + $hydrator->hydrate($object); + } + + public function testDefaultCharacters(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + MultibyteTrimResolver::class => new MultibyteTrimResolver(characters: "\u{2022}"), + ]), + ), + ); + $object = new class { + #[MultibyteTrim] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); + + $this->assertSame('test', $object->a); + } + + public function testOverrideDefaultCharacters(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + MultibyteTrimResolver::class => new MultibyteTrimResolver(characters: '_-'), + ]), + ), + ); + $object = new class { + #[MultibyteTrim(characters: "\u{2022}")] + public ?string $a = null; + }; + + $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); + + $this->assertSame('test', $object->a); + } +} diff --git a/tests/Attribute/Parameter/RightTrimTest.php b/tests/Attribute/Parameter/RightTrimTest.php index 58cb0b19..67d77879 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -28,6 +28,7 @@ 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} "]; } #[DataProvider('dataBase')] diff --git a/tests/Attribute/Parameter/ToArrayOfStringsTest.php b/tests/Attribute/Parameter/ToArrayOfStringsTest.php index 82ed8662..a67ea996 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,23 @@ 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); + } } diff --git a/tests/Attribute/Parameter/TrimTest.php b/tests/Attribute/Parameter/TrimTest.php index 91602477..bcb3a65f 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -28,6 +28,7 @@ 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} "]; } #[DataProvider('dataBase')] From b69ae6fc9c83da5f42c2771f48d58dda2158c0d3 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 15 Sep 2026 23:28:59 +0300 Subject: [PATCH 02/12] fix dependency analyser --- composer-dependency-analyser.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 745db0e9..8ea4e327 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -5,14 +5,21 @@ 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 functions come either from "mbstring" extension since PHP 8.4, or from - // "symfony/polyfill-mbstring" package. The package is required for dev only, to run tests on PHP older than - // 8.4, and is suggested to users, but intentionally not required in production. - ->ignoreErrorsOnPackage('symfony/polyfill-mbstring', [ErrorType::DEV_DEPENDENCY_IN_PROD]); + ->ignoreErrorsOnExtension('ext-intl', [ErrorType::SHADOW_DEPENDENCY]); + +// Multibyte trim functions come either from "mbstring" extension since PHP 8.4, or from "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. +if (PHP_VERSION_ID < 80400) { + $config->ignoreErrorsOnPackage('symfony/polyfill-mbstring', [ErrorType::DEV_DEPENDENCY_IN_PROD]); +} else { + $config->ignoreErrorsOnExtension('ext-mbstring', [ErrorType::SHADOW_DEPENDENCY]); +} + +return $config; From c18fdbbe63efd5f2145eb83db9dfc971e35f0f06 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 15 Sep 2026 23:31:32 +0300 Subject: [PATCH 03/12] fix code coverage --- src/Attribute/Parameter/MultibyteLeftTrimResolver.php | 2 ++ src/Attribute/Parameter/MultibyteRightTrimResolver.php | 2 ++ src/Attribute/Parameter/MultibyteTrimResolver.php | 2 ++ src/Attribute/Parameter/ToArrayOfStringsResolver.php | 2 ++ 4 files changed, 8 insertions(+) diff --git a/src/Attribute/Parameter/MultibyteLeftTrimResolver.php b/src/Attribute/Parameter/MultibyteLeftTrimResolver.php index 53e8f69d..849a82ed 100644 --- a/src/Attribute/Parameter/MultibyteLeftTrimResolver.php +++ b/src/Attribute/Parameter/MultibyteLeftTrimResolver.php @@ -26,9 +26,11 @@ public function __construct( private readonly ?string $characters = null, ) { if (!function_exists('mb_ltrim')) { + // @codeCoverageIgnoreStart throw new LogicException( 'MultibyteLeftTrim attribute requires "mb_ltrim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', ); + // @codeCoverageIgnoreEnd } } diff --git a/src/Attribute/Parameter/MultibyteRightTrimResolver.php b/src/Attribute/Parameter/MultibyteRightTrimResolver.php index 7d475311..902c1b29 100644 --- a/src/Attribute/Parameter/MultibyteRightTrimResolver.php +++ b/src/Attribute/Parameter/MultibyteRightTrimResolver.php @@ -26,9 +26,11 @@ public function __construct( private readonly ?string $characters = null, ) { if (!function_exists('mb_rtrim')) { + // @codeCoverageIgnoreStart throw new LogicException( 'MultibyteRightTrim attribute requires "mb_rtrim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', ); + // @codeCoverageIgnoreEnd } } diff --git a/src/Attribute/Parameter/MultibyteTrimResolver.php b/src/Attribute/Parameter/MultibyteTrimResolver.php index 4e84fdf8..4bb2abdb 100644 --- a/src/Attribute/Parameter/MultibyteTrimResolver.php +++ b/src/Attribute/Parameter/MultibyteTrimResolver.php @@ -26,9 +26,11 @@ public function __construct( private readonly ?string $characters = null, ) { if (!function_exists('mb_trim')) { + // @codeCoverageIgnoreStart throw new LogicException( 'MultibyteTrim attribute requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', ); + // @codeCoverageIgnoreEnd } } diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index 6a371a49..72a765e7 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -25,9 +25,11 @@ public function __construct( private readonly bool $multibyte = false, ) { if ($multibyte && !function_exists('mb_trim')) { + // @codeCoverageIgnoreStart throw new LogicException( 'Multibyte mode requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', ); + // @codeCoverageIgnoreEnd } } From aa521b1c1798759246593e8073416f18226dcf4a Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 20 Sep 2026 13:49:59 +0300 Subject: [PATCH 04/12] improve --- CHANGELOG.md | 6 +- composer.json | 5 +- docs/guide/en/typecasting.md | 34 ++- docs/guide/pt-BR/typecasting.md | 44 ++- docs/guide/ru/typecasting.md | 47 +++- docs/po/pt-BR/typecasting.md.po | 59 +++- docs/po/ru/typecasting.md.po | 59 +++- docs/pot/typecasting.md.pot | 66 ++++- src/Attribute/Parameter/LeftTrim.php | 19 +- src/Attribute/Parameter/LeftTrimResolver.php | 40 ++- src/Attribute/Parameter/MultibyteLeftTrim.php | 32 --- .../Parameter/MultibyteLeftTrimResolver.php | 58 ---- .../Parameter/MultibyteRightTrim.php | 32 --- .../Parameter/MultibyteRightTrimResolver.php | 58 ---- src/Attribute/Parameter/MultibyteTrim.php | 32 --- .../Parameter/MultibyteTrimResolver.php | 58 ---- src/Attribute/Parameter/RightTrim.php | 19 +- src/Attribute/Parameter/RightTrimResolver.php | 40 ++- src/Attribute/Parameter/ToArrayOfStrings.php | 8 +- .../Parameter/ToArrayOfStringsResolver.php | 32 ++- src/Attribute/Parameter/Trim.php | 19 +- src/Attribute/Parameter/TrimCharacters.php | 92 ++++++ src/Attribute/Parameter/TrimResolver.php | 40 ++- tests/Attribute/Parameter/LeftTrimTest.php | 265 ++++++++++++++++++ .../Parameter/MultibyteLeftTrimTest.php | 149 ---------- .../Parameter/MultibyteRightTrimTest.php | 149 ---------- .../Attribute/Parameter/MultibyteTrimTest.php | 149 ---------- tests/Attribute/Parameter/RightTrimTest.php | 265 ++++++++++++++++++ .../Parameter/ToArrayOfStringsTest.php | 54 ++++ .../Parameter/TrimCharactersTest.php | 151 ++++++++++ tests/Attribute/Parameter/TrimTest.php | 265 ++++++++++++++++++ tests/Support/TestHelper.php | 24 ++ 32 files changed, 1586 insertions(+), 784 deletions(-) delete mode 100644 src/Attribute/Parameter/MultibyteLeftTrim.php delete mode 100644 src/Attribute/Parameter/MultibyteLeftTrimResolver.php delete mode 100644 src/Attribute/Parameter/MultibyteRightTrim.php delete mode 100644 src/Attribute/Parameter/MultibyteRightTrimResolver.php delete mode 100644 src/Attribute/Parameter/MultibyteTrim.php delete mode 100644 src/Attribute/Parameter/MultibyteTrimResolver.php create mode 100644 src/Attribute/Parameter/TrimCharacters.php delete mode 100644 tests/Attribute/Parameter/MultibyteLeftTrimTest.php delete mode 100644 tests/Attribute/Parameter/MultibyteRightTrimTest.php delete mode 100644 tests/Attribute/Parameter/MultibyteTrimTest.php create mode 100644 tests/Attribute/Parameter/TrimCharactersTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad68e7d..6726b093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ## 1.6.4 under development -- New #131: Add `MultibyteTrim`, `MultibyteLeftTrim` and `MultibyteRightTrim` parameter attributes (@vjik) -- New #131: Add multibyte mode to `ToArrayOfStrings` attribute resolver (@vjik) +`- New #131: Add `multibyte` and `encoding` parameters to `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` + attributes and their resolvers (@vjik) +- Chg #131: Deprecate the `..` range syntax in the `characters` parameter of `Trim`, `LeftTrim` and `RightTrim` + attributes (@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.json b/composer.json index 4870a1f9..d1e2ab7e 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "yiisoft/strings": "^2.3" }, "require-dev": { + "ext-iconv": "*", "friendsofphp/php-cs-fixer": "^3.92", "phpunit/phpunit": "^10.5.48", "rector/rector": "^2.6.7", @@ -47,8 +48,8 @@ }, "suggest": { "ext-intl": "Allows using `ToDateTime` parameter attribute", - "ext-mbstring": "Allows using `MultibyteTrim`, `MultibyteLeftTrim`, `MultibyteRightTrim` attributes and multibyte mode of `ToArrayOfStrings` attribute on PHP 8.4 or later", - "symfony/polyfill-mbstring": "Allows using `MultibyteTrim`, `MultibyteLeftTrim`, `MultibyteRightTrim` attributes and multibyte mode of `ToArrayOfStrings` attribute on PHP versions earlier than 8.4" + "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 a2e6efec..7cf5d23d 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -163,19 +163,16 @@ class Person $person = $hydrator->create(Person::class, ['name' => ' John ']); ``` -### `MultibyteTrim` / `MultibyteLeftTrim` / `MultibyteRightTrim` - -`Trim`, `LeftTrim` and `RightTrim` 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, use `MultibyteTrim`, `MultibyteLeftTrim` -or `MultibyteRightTrim` attributes: +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\MultibyteTrim; +use Yiisoft\Hydrator\Attribute\Parameter\Trim; class Person { public function __construct( - #[MultibyteTrim] // "\u{A0}John\u{2003}" → 'John' + #[Trim(multibyte: true)] // "\u{A0}John\u{2003}" → 'John' private ?string $name = null, ) {} } @@ -183,12 +180,14 @@ class Person $person = $hydrator->create(Person::class, ['name' => "\u{A0}John\u{2003}"]); ``` -These attributes use `mb_trim()`, `mb_ltrim()` and `mb_rtrim()` functions that are provided by `mbstring` PHP -extension since PHP 8.4. To use them with an earlier PHP version, install -[symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) package. +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()`. -Note that, unlike `Trim`, `LeftTrim` and `RightTrim`, the `characters` parameter doesn't support the `..` range syntax: -every character is treated literally. +With `..` you can specify a range of characters in the `characters` parameter, for example, `а..я`. It works both in +the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so +avoid it in new code. ### `ToDatetime` @@ -262,9 +261,14 @@ converted to array `['news', 'city', 'hot']`. Attribute parameters: -- `trim` — trim each string of array (boolean, default `false`), multibyte-aware trimming can be enabled via - `multibyte` parameter of `ToArrayOfStringsResolver`; +- `trim` — trim each string of array (boolean, default `false`); - `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/docs/guide/pt-BR/typecasting.md b/docs/guide/pt-BR/typecasting.md index 73c56036..89eed982 100644 --- a/docs/guide/pt-BR/typecasting.md +++ b/docs/guide/pt-BR/typecasting.md @@ -168,6 +168,36 @@ 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, `а..я`. It works both in the default and in the +multibyte mode. This syntax is deprecated and will be removed in the next +major version, so avoid it in new code. + ### `ToDatetime` Para converter um valor para o objeto `DateTimeImmutable` ou `DateTime` @@ -250,6 +280,14 @@ Parâmetros do atributo: `false`); - `splitResolvedValue` — divide o valor resolvido pelo separador (booleano, padrão `true`); -- `separator` — a string delimitadora (padrão, `\R`), faz parte de uma - expressão regular, portanto deve ser levada em conta ou devidamente - escapada com `preg_quote()`. +- `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()`; +- `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/docs/guide/ru/typecasting.md b/docs/guide/ru/typecasting.md index 40cd5a46..8c431367 100644 --- a/docs/guide/ru/typecasting.md +++ b/docs/guide/ru/typecasting.md @@ -170,6 +170,37 @@ class Person $person = $hydrator->create(Person::class, ['name' => ' John ']); ``` +По умолчанию эти атрибуты не поддерживают многобайтовые кодировки, поэтому +символы юникод-пробелов, такие как `U+00A0` (неразрывный пробел) или +`U+2003` (широкий пробел), не удаляются. Чтобы удалять и их, включите +параметр `multibyte`: + +```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}"]); +``` + +Режим многобайтовой обработки использует функции `mb_trim()`, `mb_ltrim()` и +`mb_rtrim()`, которые предоставляются расширением PHP `mbstring`, начиная с +PHP 8.4. Чтобы использовать его на более ранних версиях PHP, установите +пакет [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring). +Параметр `encoding` задаёт кодировку, используемую в многобайтовом режиме; +значение `null` (по умолчанию) означает использование `mb_internal_encoding()`. + +С помощью `..` можно указать диапазон символов в параметре `characters`, +например, `а..я`. Это работает как в обычном, так и в многобайтовом режиме. +Этот синтаксис устарел и будет удалён в следующей мажорной версии, поэтому +не используйте его в новом коде. + ### `ToDatetime` Чтобы явно привести значение к объекту `DateTimeImmutable` или `DateTime`, @@ -251,6 +282,16 @@ final class Post умолчанию `false`); - `splitResolvedValue` — разделить значения по разделителю (логическое значение, по умолчанию `true`); -- `separator` — символ перевода строки (по умолчанию, `\R`). Это часть - регулярного выражения, поэтому ее следует учитывать или правильно - экранировать с помощью `preg_quote()`. +- `separator` — символ перевода строки (по умолчанию, `\R`), это часть + регулярного выражения, поэтому его следует учитывать или правильно + экранировать с помощью `preg_quote()`; +- `multibyte` — использовать ли многобайтовую обработку при включённом + `trim` (необязательное логическое значение, по умолчанию `null`, что + означает использование значения по умолчанию из резолвера); требует + функцию `mb_trim()`, предоставляемую расширением PHP `mbstring`, начиная + с PHP 8.4, либо пакет + [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) + на более ранних версиях; +- `encoding` — кодировка, используемая в многобайтовом режиме (необязательная + строка, по умолчанию `null`, что означает использование значения по + умолчанию из резолвера). diff --git a/docs/po/pt-BR/typecasting.md.po b/docs/po/pt-BR/typecasting.md.po index 66add88c..04f4a728 100644 --- a/docs/po/pt-BR/typecasting.md.po +++ b/docs/po/pt-BR/typecasting.md.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2026-09-15 20:44+0000\n" +"POT-Creation-Date: 2026-09-20 10:41+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -378,6 +378,50 @@ msgstr "" "\n" "$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" +#. type: Plain text +#: guide/en/typecasting.md +msgid "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:" +msgstr "" + +#. type: Fenced code block (php) +#: guide/en/typecasting.md +#, fuzzy, no-wrap +msgid "" +"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" +"\n" +"class Person\n" +"{\n" +" public function __construct(\n" +" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" +" private ?string $name = null,\n" +" ) {}\n" +"}\n" +"\n" +"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" +msgstr "" +"use DateTimeImmutable;\n" +"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" +"\n" +"class Person\n" +"{\n" +" public function __construct(\n" +" #[Trim] // ' John ' → 'John'\n" +" private ?string $name = null, \n" +" ) {}\n" +"}\n" +"\n" +"$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" + +#. type: Plain text +#: guide/en/typecasting.md +msgid "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()`." +msgstr "" + +#. type: Plain text +#: guide/en/typecasting.md +msgid "With `..` you can specify a range of characters in the `characters` parameter, for example, `а..я`. It works both in the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so avoid it in new code." +msgstr "" + #. type: Title ### #: guide/en/typecasting.md #, no-wrap @@ -548,5 +592,16 @@ msgstr "`splitResolvedValue` — divide o valor resolvido pelo separador (boolea #. type: Bullet: '- ' #: guide/en/typecasting.md -msgid "`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()`." +#, fuzzy +msgid "`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()`;" msgstr "`separator` — a string delimitadora (padrão, `\\R`), faz parte de uma expressão regular, portanto deve ser levada em conta ou devidamente escapada com `preg_quote()`." + +#. type: Bullet: '- ' +#: guide/en/typecasting.md +msgid "`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;" +msgstr "" + +#. type: Bullet: '- ' +#: guide/en/typecasting.md +msgid "`encoding` — the encoding to use in multibyte mode (nullable string, default `null` meaning the resolver default is used)." +msgstr "" diff --git a/docs/po/ru/typecasting.md.po b/docs/po/ru/typecasting.md.po index c69c86cb..dc9fa36b 100644 --- a/docs/po/ru/typecasting.md.po +++ b/docs/po/ru/typecasting.md.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2026-09-15 20:44+0000\n" +"POT-Creation-Date: 2026-09-20 10:41+0000\n" "PO-Revision-Date: 2025-03-01 15:15+0500\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -380,6 +380,49 @@ msgstr "" "\n" "$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" +#. type: Plain text +#: guide/en/typecasting.md +msgid "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:" +msgstr "По умолчанию эти атрибуты не поддерживают многобайтовые кодировки, поэтому символы юникод-пробелов, такие как `U+00A0` (неразрывный пробел) или `U+2003` (широкий пробел), не удаляются. Чтобы удалять и их, включите параметр `multibyte`:" + +#. type: Fenced code block (php) +#: guide/en/typecasting.md +#, no-wrap +msgid "" +"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" +"\n" +"class Person\n" +"{\n" +" public function __construct(\n" +" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" +" private ?string $name = null,\n" +" ) {}\n" +"}\n" +"\n" +"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" +msgstr "" +"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" +"\n" +"class Person\n" +"{\n" +" public function __construct(\n" +" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" +" private ?string $name = null,\n" +" ) {}\n" +"}\n" +"\n" +"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" + +#. type: Plain text +#: guide/en/typecasting.md +msgid "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()`." +msgstr "Режим многобайтовой обработки использует функции `mb_trim()`, `mb_ltrim()` и `mb_rtrim()`, которые предоставляются расширением PHP `mbstring`, начиная с PHP 8.4. Чтобы использовать его на более ранних версиях PHP, установите пакет [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring). Параметр `encoding` задаёт кодировку, используемую в многобайтовом режиме; значение `null` (по умолчанию) означает использование `mb_internal_encoding()`." + +#. type: Plain text +#: guide/en/typecasting.md +msgid "With `..` you can specify a range of characters in the `characters` parameter, for example, `а..я`. It works both in the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so avoid it in new code." +msgstr "С помощью `..` можно указать диапазон символов в параметре `characters`, например, `а..я`. Это работает как в обычном, так и в многобайтовом режиме. Этот синтаксис устарел и будет удалён в следующей мажорной версии, поэтому не используйте его в новом коде." + #. type: Title ### #: guide/en/typecasting.md #, no-wrap @@ -550,5 +593,15 @@ msgstr "`splitResolvedValue` — разделить значения по раз #. type: Bullet: '- ' #: guide/en/typecasting.md -msgid "`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()`." -msgstr "`separator` — символ перевода строки (по умолчанию, `\\R`). Это часть регулярного выражения, поэтому ее следует учитывать или правильно экранировать с помощью `preg_quote()`." +msgid "`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()`;" +msgstr "`separator` — символ перевода строки (по умолчанию, `\\R`), это часть регулярного выражения, поэтому его следует учитывать или правильно экранировать с помощью `preg_quote()`;" + +#. type: Bullet: '- ' +#: guide/en/typecasting.md +msgid "`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;" +msgstr "`multibyte` — использовать ли многобайтовую обработку при включённом `trim` (необязательное логическое значение, по умолчанию `null`, что означает использование значения по умолчанию из резолвера); требует функцию `mb_trim()`, предоставляемую расширением PHP `mbstring`, начиная с PHP 8.4, либо пакет [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) на более ранних версиях;" + +#. type: Bullet: '- ' +#: guide/en/typecasting.md +msgid "`encoding` — the encoding to use in multibyte mode (nullable string, default `null` meaning the resolver default is used)." +msgstr "`encoding` — кодировка, используемая в многобайтовом режиме (необязательная строка, по умолчанию `null`, что означает использование значения по умолчанию из резолвера)." diff --git a/docs/pot/typecasting.md.pot b/docs/pot/typecasting.md.pot index b78a01f9..a85bd1d0 100644 --- a/docs/pot/typecasting.md.pot +++ b/docs/pot/typecasting.md.pot @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2026-09-15 20:44+0000\n" +"POT-Creation-Date: 2026-09-20 10:41+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -270,6 +270,51 @@ msgid "" "$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" msgstr "" +#. type: Plain text +#: guide/en/typecasting.md +msgid "" +"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:" +msgstr "" + +#. type: Fenced code block (php) +#: guide/en/typecasting.md +#, no-wrap +msgid "" +"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" +"\n" +"class Person\n" +"{\n" +" public function __construct(\n" +" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" +" private ?string $name = null,\n" +" ) {}\n" +"}\n" +"\n" +"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" +msgstr "" + +#. type: Plain text +#: guide/en/typecasting.md +msgid "" +"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()`." +msgstr "" + +#. type: Plain text +#: guide/en/typecasting.md +msgid "" +"With `..` you can specify a range of characters in the `characters` " +"parameter, for example, `а..я`. It works both in the default and in the " +"multibyte mode. This syntax is deprecated and will be removed in the next " +"major version, so avoid it in new code." +msgstr "" + #. type: Title ### #: guide/en/typecasting.md #, no-wrap @@ -408,5 +453,22 @@ msgstr "" msgid "" "`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()`." +"`preg_quote()`;" +msgstr "" + +#. type: Bullet: '- ' +#: guide/en/typecasting.md +msgid "" +"`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;" +msgstr "" + +#. type: Bullet: '- ' +#: guide/en/typecasting.md +msgid "" +"`encoding` — the encoding to use in multibyte mode (nullable string, " +"default `null` meaning the resolver default is used)." msgstr "" diff --git a/src/Attribute/Parameter/LeftTrim.php b/src/Attribute/Parameter/LeftTrim.php index c65c420a..76ef9cab 100644 --- a/src/Attribute/Parameter/LeftTrim.php +++ b/src/Attribute/Parameter/LeftTrim.php @@ -9,21 +9,30 @@ /** * Strip whitespace (or other characters) from the beginning of a resolved string value. * - * For multibyte-aware trimming that takes Unicode whitespace characters, such as `U+00A0` (no-break - * space), into account, use {@see MultibyteLeftTrim}. + * 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 of 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::checkDeprecatedRanges($characters); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/LeftTrimResolver.php b/src/Attribute/Parameter/LeftTrimResolver.php index f9899d3e..4bfec03b 100644 --- a/src/Attribute/Parameter/LeftTrimResolver.php +++ b/src/Attribute/Parameter/LeftTrimResolver.php @@ -4,17 +4,33 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; +use LogicException; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; +use function function_exists; 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::checkDeprecatedRanges($characters); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -34,9 +50,29 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + if (!$multibyte) { + return Result::success( + $characters === null ? ltrim($resolvedValue) : ltrim($resolvedValue, $characters), + ); + } + + if (!function_exists('mb_ltrim')) { + // @codeCoverageIgnoreStart + throw new LogicException( + 'The "multibyte" parameter requires "mb_ltrim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', + ); + // @codeCoverageIgnoreEnd + } 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/MultibyteLeftTrim.php b/src/Attribute/Parameter/MultibyteLeftTrim.php deleted file mode 100644 index 968bcc6a..00000000 --- a/src/Attribute/Parameter/MultibyteLeftTrim.php +++ /dev/null @@ -1,32 +0,0 @@ -isResolved()) { - return Result::fail(); - } - - $resolvedValue = $context->getResolvedValue(); - if (!is_string($resolvedValue)) { - return Result::fail(); - } - - return Result::success( - mb_ltrim($resolvedValue, $attribute->characters ?? $this->characters), - ); - } -} diff --git a/src/Attribute/Parameter/MultibyteRightTrim.php b/src/Attribute/Parameter/MultibyteRightTrim.php deleted file mode 100644 index 449ae094..00000000 --- a/src/Attribute/Parameter/MultibyteRightTrim.php +++ /dev/null @@ -1,32 +0,0 @@ -isResolved()) { - return Result::fail(); - } - - $resolvedValue = $context->getResolvedValue(); - if (!is_string($resolvedValue)) { - return Result::fail(); - } - - return Result::success( - mb_rtrim($resolvedValue, $attribute->characters ?? $this->characters), - ); - } -} diff --git a/src/Attribute/Parameter/MultibyteTrim.php b/src/Attribute/Parameter/MultibyteTrim.php deleted file mode 100644 index 3507a106..00000000 --- a/src/Attribute/Parameter/MultibyteTrim.php +++ /dev/null @@ -1,32 +0,0 @@ -isResolved()) { - return Result::fail(); - } - - $resolvedValue = $context->getResolvedValue(); - if (!is_string($resolvedValue)) { - return Result::fail(); - } - - return Result::success( - mb_trim($resolvedValue, $attribute->characters ?? $this->characters), - ); - } -} diff --git a/src/Attribute/Parameter/RightTrim.php b/src/Attribute/Parameter/RightTrim.php index fea003b8..6a65ba24 100644 --- a/src/Attribute/Parameter/RightTrim.php +++ b/src/Attribute/Parameter/RightTrim.php @@ -9,21 +9,30 @@ /** * Strip whitespace (or other characters) from the end of a resolved string value. * - * For multibyte-aware trimming that takes Unicode whitespace characters, such as `U+00A0` (no-break - * space), into account, use {@see MultibyteRightTrim}. + * 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 of 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::checkDeprecatedRanges($characters); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/RightTrimResolver.php b/src/Attribute/Parameter/RightTrimResolver.php index 7cec0060..323af07c 100644 --- a/src/Attribute/Parameter/RightTrimResolver.php +++ b/src/Attribute/Parameter/RightTrimResolver.php @@ -4,17 +4,33 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; +use LogicException; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; +use function function_exists; 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::checkDeprecatedRanges($characters); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -34,9 +50,29 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + if (!$multibyte) { + return Result::success( + $characters === null ? rtrim($resolvedValue) : rtrim($resolvedValue, $characters), + ); + } + + if (!function_exists('mb_rtrim')) { + // @codeCoverageIgnoreStart + throw new LogicException( + 'The "multibyte" parameter requires "mb_rtrim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', + ); + // @codeCoverageIgnoreEnd + } 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 24e248a1..90b80f82 100644 --- a/src/Attribute/Parameter/ToArrayOfStrings.php +++ b/src/Attribute/Parameter/ToArrayOfStrings.php @@ -13,18 +13,22 @@ final class ToArrayOfStrings implements ParameterAttributeInterface { /** - * @param bool $trim Trim each string of array. Multibyte-aware trimming can be enabled via `$multibyte` - * parameter of {@see ToArrayOfStringsResolver}. + * @param bool $trim Trim each string of array. * @param bool $removeEmpty Remove empty strings from array. * @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, ) {} public function getResolver(): string diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index 72a765e7..e19dbe2b 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -18,20 +18,14 @@ final class ToArrayOfStringsResolver implements ParameterAttributeResolverInterf { /** * @param bool $multibyte Whether to use multibyte-aware trimming that strips Unicode whitespace characters - * such as `U+00A0` (no-break space) as well. Requires PHP 8.4 or later with `mbstring` extension, or - * `symfony/polyfill-mbstring` package. + * 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, - ) { - if ($multibyte && !function_exists('mb_trim')) { - // @codeCoverageIgnoreStart - throw new LogicException( - 'Multibyte mode requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', - ); - // @codeCoverageIgnoreEnd - } - } + private readonly ?string $encoding = null, + ) {} public function getParameterValue( ParameterAttributeInterface $attribute, @@ -63,7 +57,21 @@ public function getParameterValue( } if ($attribute->trim) { - $array = array_map($this->multibyte ? mb_trim(...) : trim(...), $array); + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + if ($multibyte && !function_exists('mb_trim')) { + // @codeCoverageIgnoreStart + throw new LogicException( + 'The "multibyte" parameter requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', + ); + // @codeCoverageIgnoreEnd + } + + $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 afad4deb..09e0b326 100644 --- a/src/Attribute/Parameter/Trim.php +++ b/src/Attribute/Parameter/Trim.php @@ -9,21 +9,30 @@ /** * Strip whitespace (or other characters) from the beginning and end of a resolved string value. * - * For multibyte-aware trimming that takes Unicode whitespace characters, such as `U+00A0` (no-break - * space), into account, use {@see MultibyteTrim}. + * 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 of 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::checkDeprecatedRanges($characters); + } public function getResolver(): string { diff --git a/src/Attribute/Parameter/TrimCharacters.php b/src/Attribute/Parameter/TrimCharacters.php new file mode 100644 index 00000000..5abfa92f --- /dev/null +++ b/src/Attribute/Parameter/TrimCharacters.php @@ -0,0 +1,92 @@ + $chars */ + $chars = mb_str_split($characters, 1, $encoding); + $count = count($chars); + $result = ''; + + for ($i = 0; $i < $count; $i++) { + $char = $chars[$i]; + + if ($i + 3 < $count && $chars[$i + 1] === '.' && $chars[$i + 2] === '.') { + $startOrd = mb_ord($char, $encoding); + $endOrd = mb_ord($chars[$i + 3], $encoding); + + if ($startOrd !== false && $endOrd !== false && $endOrd >= $startOrd) { + for ($ord = $startOrd; $ord <= $endOrd; $ord++) { + $rangeChar = mb_chr($ord, $encoding); + if ($rangeChar !== false) { + $result .= $rangeChar; + } + } + $i += 3; + continue; + } + } + + if ($i + 1 < $count && $char === '.' && $chars[$i + 1] === '.') { + 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 = mb_ord($chars[$i - 1], $encoding); + $rightOrd = mb_ord($chars[$i + 2], $encoding); + + 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; + } +} diff --git a/src/Attribute/Parameter/TrimResolver.php b/src/Attribute/Parameter/TrimResolver.php index 8284ab76..ce9aa7b3 100644 --- a/src/Attribute/Parameter/TrimResolver.php +++ b/src/Attribute/Parameter/TrimResolver.php @@ -4,17 +4,33 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; +use LogicException; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; +use function function_exists; 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::checkDeprecatedRanges($characters); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -34,9 +50,29 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; + $multibyte = $attribute->multibyte ?? $this->multibyte; + $encoding = $attribute->encoding ?? $this->encoding; + + if (!$multibyte) { + return Result::success( + $characters === null ? trim($resolvedValue) : trim($resolvedValue, $characters), + ); + } + + if (!function_exists('mb_trim')) { + // @codeCoverageIgnoreStart + throw new LogicException( + 'The "multibyte" parameter requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', + ); + // @codeCoverageIgnoreEnd + } 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 5453eaa8..fb0551a4 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -21,6 +21,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 @@ -29,6 +32,15 @@ public static function dataBase(): iterable 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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'теста'); + yield [$expected, new LeftTrim($characters, multibyte: true, encoding: 'Windows-1251'), $value]; } #[DataProvider('dataBase')] @@ -123,4 +135,257 @@ 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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'теста'); + + $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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'теста'); + + $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 function testDeprecationNoticeFromAttributeForRangeCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new LeftTrim('a..z'); + }); + + $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 function testDeprecationNoticeFromResolverForRangeCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new LeftTrimResolver(characters: 'a..z'); + }); + + $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 function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new LeftTrim('a.b'); + new LeftTrim('.'); + new LeftTrim(null); + new LeftTrimResolver(characters: 'a.b'); + new LeftTrimResolver(characters: '.'); + new LeftTrimResolver(characters: null); + }); + + $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); + } + + public function testRangeInMultibyteMode(): void + { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success("\u{436}Hello\u{44E}"), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim("\u{430}..\u{44F}"), $context)->getValue(); + }); + + $this->assertSame("Hello\u{44E}", $value); + } + + public function testCjkRangeInMultibyteMode(): void + { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success("\u{4E00}test\u{5341}"), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim("\u{4E00}..\u{5341}"), $context)->getValue(); + }); + + $this->assertSame("test\u{5341}", $value); + } + + public function testInvalidRangeNoLeftCharacterInMultibyteMode(): void + { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('.zXz.'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim('..z'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, no character to the left of '..'", reset($warnings)[1]); + $this->assertSame('Xz.', $value); + } + + public function testInvalidRangeNoRightCharacterInMultibyteMode(): void + { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('aXa..'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim('a..'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, no character to the right of '..'", reset($warnings)[1]); + $this->assertSame('Xa..', $value); + } + + public function testInvalidRangeNotIncrementingInMultibyteMode(): void + { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('caXcac'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim('c..a'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", reset($warnings)[1]); + $this->assertSame('Xcac', $value); + } + + public function testInvalidRangeAmbiguousInMultibyteMode(): void + { + $resolver = new LeftTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('abXcba..b..c'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new LeftTrim('a..b..c'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range", reset($warnings)[1]); + $this->assertSame('Xcba..b..c', $value); + } } diff --git a/tests/Attribute/Parameter/MultibyteLeftTrimTest.php b/tests/Attribute/Parameter/MultibyteLeftTrimTest.php deleted file mode 100644 index c1031977..00000000 --- a/tests/Attribute/Parameter/MultibyteLeftTrimTest.php +++ /dev/null @@ -1,149 +0,0 @@ - null), - Result::success($value), - new ArrayData(), - new Hydrator(), - ); - - $result = $resolver->getParameterValue($attribute, $context); - - $this->assertTrue($result->isResolved()); - $this->assertSame($expected, $result->getValue()); - } - - public function testWithHydrator(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteLeftTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{A0}hello\u{2003}"]); - - $this->assertSame("hello\u{2003}", $object->a); - } - - public function testNotResolve(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteLeftTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => new stdClass()]); - - $this->assertNull($object->a); - } - - public function testNotResolvedValue(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteLeftTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['b' => "\u{A0}test\u{2003}"]); - - $this->assertNull($object->a); - } - - public function testUnexpectedAttributeException(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - CounterResolver::class => new MultibyteLeftTrimResolver(), - ]), - ), - ); - $object = new CounterClass(); - - $this->expectException(UnexpectedAttributeException::class); - $this->expectExceptionMessage( - 'Expected "' . MultibyteLeftTrim::class . '", but "' . Counter::class . '" given.', - ); - $hydrator->hydrate($object); - } - - public function testDefaultCharacters(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - MultibyteLeftTrimResolver::class => new MultibyteLeftTrimResolver(characters: "\u{2022}"), - ]), - ), - ); - $object = new class { - #[MultibyteLeftTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); - - $this->assertSame("test\u{2022}", $object->a); - } - - public function testOverrideDefaultCharacters(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - MultibyteLeftTrimResolver::class => new MultibyteLeftTrimResolver(characters: '_-'), - ]), - ), - ); - $object = new class { - #[MultibyteLeftTrim(characters: "\u{2022}")] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); - - $this->assertSame("test\u{2022}", $object->a); - } -} diff --git a/tests/Attribute/Parameter/MultibyteRightTrimTest.php b/tests/Attribute/Parameter/MultibyteRightTrimTest.php deleted file mode 100644 index 1c2b9c72..00000000 --- a/tests/Attribute/Parameter/MultibyteRightTrimTest.php +++ /dev/null @@ -1,149 +0,0 @@ - null), - Result::success($value), - new ArrayData(), - new Hydrator(), - ); - - $result = $resolver->getParameterValue($attribute, $context); - - $this->assertTrue($result->isResolved()); - $this->assertSame($expected, $result->getValue()); - } - - public function testWithHydrator(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteRightTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{A0}hello\u{2003}"]); - - $this->assertSame("\u{A0}hello", $object->a); - } - - public function testNotResolve(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteRightTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => new stdClass()]); - - $this->assertNull($object->a); - } - - public function testNotResolvedValue(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteRightTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['b' => "\u{A0}test\u{2003}"]); - - $this->assertNull($object->a); - } - - public function testUnexpectedAttributeException(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - CounterResolver::class => new MultibyteRightTrimResolver(), - ]), - ), - ); - $object = new CounterClass(); - - $this->expectException(UnexpectedAttributeException::class); - $this->expectExceptionMessage( - 'Expected "' . MultibyteRightTrim::class . '", but "' . Counter::class . '" given.', - ); - $hydrator->hydrate($object); - } - - public function testDefaultCharacters(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - MultibyteRightTrimResolver::class => new MultibyteRightTrimResolver(characters: "\u{2022}"), - ]), - ), - ); - $object = new class { - #[MultibyteRightTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); - - $this->assertSame("\u{2022}test", $object->a); - } - - public function testOverrideDefaultCharacters(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - MultibyteRightTrimResolver::class => new MultibyteRightTrimResolver(characters: '_-'), - ]), - ), - ); - $object = new class { - #[MultibyteRightTrim(characters: "\u{2022}")] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); - - $this->assertSame("\u{2022}test", $object->a); - } -} diff --git a/tests/Attribute/Parameter/MultibyteTrimTest.php b/tests/Attribute/Parameter/MultibyteTrimTest.php deleted file mode 100644 index 168aa4a5..00000000 --- a/tests/Attribute/Parameter/MultibyteTrimTest.php +++ /dev/null @@ -1,149 +0,0 @@ - null), - Result::success($value), - new ArrayData(), - new Hydrator(), - ); - - $result = $resolver->getParameterValue($attribute, $context); - - $this->assertTrue($result->isResolved()); - $this->assertSame($expected, $result->getValue()); - } - - public function testWithHydrator(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{A0}hello\u{2003}"]); - - $this->assertSame('hello', $object->a); - } - - public function testNotResolve(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => new stdClass()]); - - $this->assertNull($object->a); - } - - public function testNotResolvedValue(): void - { - $hydrator = new Hydrator(); - $object = new class { - #[MultibyteTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['b' => "\u{A0}test\u{2003}"]); - - $this->assertNull($object->a); - } - - public function testUnexpectedAttributeException(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - CounterResolver::class => new MultibyteTrimResolver(), - ]), - ), - ); - $object = new CounterClass(); - - $this->expectException(UnexpectedAttributeException::class); - $this->expectExceptionMessage( - 'Expected "' . MultibyteTrim::class . '", but "' . Counter::class . '" given.', - ); - $hydrator->hydrate($object); - } - - public function testDefaultCharacters(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - MultibyteTrimResolver::class => new MultibyteTrimResolver(characters: "\u{2022}"), - ]), - ), - ); - $object = new class { - #[MultibyteTrim] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); - - $this->assertSame('test', $object->a); - } - - public function testOverrideDefaultCharacters(): void - { - $hydrator = new Hydrator( - attributeResolverFactory: new ContainerAttributeResolverFactory( - new SimpleContainer([ - MultibyteTrimResolver::class => new MultibyteTrimResolver(characters: '_-'), - ]), - ), - ); - $object = new class { - #[MultibyteTrim(characters: "\u{2022}")] - public ?string $a = null; - }; - - $hydrator->hydrate($object, ['a' => "\u{2022}test\u{2022}"]); - - $this->assertSame('test', $object->a); - } -} diff --git a/tests/Attribute/Parameter/RightTrimTest.php b/tests/Attribute/Parameter/RightTrimTest.php index 67d77879..b9297e50 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -21,6 +21,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 @@ -29,6 +32,15 @@ public static function dataBase(): iterable 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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'атест'); + yield [$expected, new RightTrim($characters, multibyte: true, encoding: 'Windows-1251'), $value]; } #[DataProvider('dataBase')] @@ -123,4 +135,257 @@ 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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'атест'); + + $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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'атест'); + + $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 function testDeprecationNoticeFromAttributeForRangeCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new RightTrim('a..z'); + }); + + $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 function testDeprecationNoticeFromResolverForRangeCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new RightTrimResolver(characters: 'a..z'); + }); + + $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 function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new RightTrim('a.b'); + new RightTrim('.'); + new RightTrim(null); + new RightTrimResolver(characters: 'a.b'); + new RightTrimResolver(characters: '.'); + new RightTrimResolver(characters: null); + }); + + $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); + } + + public function testRangeInMultibyteMode(): void + { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success("\u{436}Hello\u{44E}"), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim("\u{430}..\u{44F}"), $context)->getValue(); + }); + + $this->assertSame("\u{436}Hello", $value); + } + + public function testCjkRangeInMultibyteMode(): void + { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success("\u{4E00}test\u{5341}"), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim("\u{4E00}..\u{5341}"), $context)->getValue(); + }); + + $this->assertSame("\u{4E00}test", $value); + } + + public function testInvalidRangeNoLeftCharacterInMultibyteMode(): void + { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('.zXz.'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim('..z'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, no character to the left of '..'", reset($warnings)[1]); + $this->assertSame('.zX', $value); + } + + public function testInvalidRangeNoRightCharacterInMultibyteMode(): void + { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('aXa..'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim('a..'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, no character to the right of '..'", reset($warnings)[1]); + $this->assertSame('aX', $value); + } + + public function testInvalidRangeNotIncrementingInMultibyteMode(): void + { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('caXcac'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim('c..a'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", reset($warnings)[1]); + $this->assertSame('caX', $value); + } + + public function testInvalidRangeAmbiguousInMultibyteMode(): void + { + $resolver = new RightTrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('abXcba..b..c'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new RightTrim('a..b..c'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range", reset($warnings)[1]); + $this->assertSame('abX', $value); + } } diff --git a/tests/Attribute/Parameter/ToArrayOfStringsTest.php b/tests/Attribute/Parameter/ToArrayOfStringsTest.php index a67ea996..b477a70c 100644 --- a/tests/Attribute/Parameter/ToArrayOfStringsTest.php +++ b/tests/Attribute/Parameter/ToArrayOfStringsTest.php @@ -224,4 +224,58 @@ public function testMultibyteTrim(): void $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 = iconv('UTF-8', 'Windows-1251', " привет "); + $expected = iconv('UTF-8', 'Windows-1251', 'привет'); + + $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..8a07dbf2 --- /dev/null +++ b/tests/Attribute/Parameter/TrimCharactersTest.php @@ -0,0 +1,151 @@ +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 function testCheckDeprecatedRangesTriggersNoticeForMalformedRange(): void + { + $errors = TestHelper::captureErrors(static function (): void { + TrimCharacters::checkDeprecatedRanges('a..'); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); + } + + public function testCheckDeprecatedRangesIsSilentForNullOrDotFreeOrSingleDotCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + TrimCharacters::checkDeprecatedRanges(null); + TrimCharacters::checkDeprecatedRanges('abc'); + TrimCharacters::checkDeprecatedRanges('.'); + TrimCharacters::checkDeprecatedRanges('a.b'); + }); + + $this->assertSame([], $errors); + } + + public function testExpandRangesExpandsBasicRange(): void + { + $this->assertSame('abcdefghijklmnopqrstuvwxyz', TrimCharacters::expandRanges('a..z', null)); + } + + public function testExpandRangesExpandsSingleCharacterRange(): void + { + $this->assertSame('a', TrimCharacters::expandRanges('a..a', null)); + } + + 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 testExpandRangesExpandsMultibyteRange(): void + { + $this->assertSame( + "\u{430}\u{431}\u{432}\u{433}", + TrimCharacters::expandRanges("\u{430}..\u{433}", null), + ); + } + + 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')); + } + + public function testExpandRangesTreatsCharacterWithInvalidByteSequenceLiterally(): void + { + $errors = TestHelper::captureErrors(static function () use (&$result): void { + $result = TrimCharacters::expandRanges("\xFF..z", 'UTF-8'); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_WARNING, $errors[0][0]); + $this->assertSame("Invalid '..'-range", $errors[0][1]); + $this->assertSame("\xFF.z", $result); + } + + public function testExpandRangesInvalidRangeNoLeftCharacter(): void + { + $errors = TestHelper::captureErrors(static function () use (&$result): void { + $result = TrimCharacters::expandRanges('..z', null); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_WARNING, $errors[0][0]); + $this->assertSame("Invalid '..'-range, no character to the left of '..'", $errors[0][1]); + $this->assertSame('.z', $result); + } + + public function testExpandRangesInvalidRangeNoRightCharacter(): void + { + $errors = TestHelper::captureErrors(static function () use (&$result): void { + $result = TrimCharacters::expandRanges('a..', null); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_WARNING, $errors[0][0]); + $this->assertSame("Invalid '..'-range, no character to the right of '..'", $errors[0][1]); + $this->assertSame('a.', $result); + } + + public function testExpandRangesInvalidRangeNotIncrementing(): void + { + $errors = TestHelper::captureErrors(static function () use (&$result): void { + $result = TrimCharacters::expandRanges('c..a', null); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_WARNING, $errors[0][0]); + $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", $errors[0][1]); + $this->assertSame('c.a', $result); + } + + public function testExpandRangesInvalidRangeAmbiguous(): void + { + $errors = TestHelper::captureErrors(static function () use (&$result): void { + $result = TrimCharacters::expandRanges('a..b..c', null); + }); + + $this->assertCount(1, $errors); + $this->assertSame(E_USER_WARNING, $errors[0][0]); + $this->assertSame("Invalid '..'-range", $errors[0][1]); + $this->assertSame('ab.c', $result); + } +} diff --git a/tests/Attribute/Parameter/TrimTest.php b/tests/Attribute/Parameter/TrimTest.php index bcb3a65f..4e556895 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -21,6 +21,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 @@ -29,6 +32,15 @@ public static function dataBase(): iterable 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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'тест'); + yield [$expected, new Trim($characters, multibyte: true, encoding: 'Windows-1251'), $value]; } #[DataProvider('dataBase')] @@ -123,4 +135,257 @@ 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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'тест'); + + $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 = iconv('UTF-8', 'Windows-1251', 'а'); + $value = iconv('UTF-8', 'Windows-1251', 'атеста'); + $expected = iconv('UTF-8', 'Windows-1251', 'тест'); + + $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 function testDeprecationNoticeFromAttributeForRangeCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new Trim('a..z'); + }); + + $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 function testDeprecationNoticeFromResolverForRangeCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new TrimResolver(characters: 'a..z'); + }); + + $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 function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void + { + $errors = TestHelper::captureErrors(static function (): void { + new Trim('a.b'); + new Trim('.'); + new Trim(null); + new TrimResolver(characters: 'a.b'); + new TrimResolver(characters: '.'); + new TrimResolver(characters: null); + }); + + $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); + } + + public function testRangeInMultibyteMode(): void + { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success("\u{436}Hello\u{44E}"), + new ArrayData(), + new Hydrator(), + ); + + $result = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim("\u{430}..\u{44F}"), $context)->getValue(); + }); + + $this->assertSame('Hello', $value); + } + + public function testCjkRangeInMultibyteMode(): void + { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success("\u{4E00}test\u{5341}"), + new ArrayData(), + new Hydrator(), + ); + + TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim("\u{4E00}..\u{5341}"), $context)->getValue(); + }); + + $this->assertSame('test', $value); + } + + public function testInvalidRangeNoLeftCharacterInMultibyteMode(): void + { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('.zXz.'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim('..z'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, no character to the left of '..'", reset($warnings)[1]); + $this->assertSame('X', $value); + } + + public function testInvalidRangeNoRightCharacterInMultibyteMode(): void + { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('aXa..'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim('a..'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, no character to the right of '..'", reset($warnings)[1]); + $this->assertSame('X', $value); + } + + public function testInvalidRangeNotIncrementingInMultibyteMode(): void + { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('caXcac'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim('c..a'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", reset($warnings)[1]); + $this->assertSame('X', $value); + } + + public function testInvalidRangeAmbiguousInMultibyteMode(): void + { + $resolver = new TrimResolver(multibyte: true); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?string $a) => null), + Result::success('abXcba..b..c'), + new ArrayData(), + new Hydrator(), + ); + + $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { + $value = $resolver->getParameterValue(new Trim('a..b..c'), $context)->getValue(); + }); + + $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); + $this->assertCount(1, $warnings); + $this->assertSame("Invalid '..'-range", reset($warnings)[1]); + $this->assertSame('X', $value); + } } 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( From 2aa4e6c3eec2297b20ca1a24f296bb417c5e419c Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 20 Sep 2026 13:54:37 +0300 Subject: [PATCH 05/12] fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6726b093..2a9b3140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.6.4 under development -`- New #131: Add `multibyte` and `encoding` parameters to `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` +- New #131: Add `multibyte` and `encoding` parameters to `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` attributes and their resolvers (@vjik) - Chg #131: Deprecate the `..` range syntax in the `characters` parameter of `Trim`, `LeftTrim` and `RightTrim` attributes (@vjik) From f127f6226400565e1013c4f2dbb05e50094a38a7 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 20 Sep 2026 13:54:45 +0300 Subject: [PATCH 06/12] fix --- composer-dependency-analyser.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 8ea4e327..5e6f0ea5 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -13,13 +13,14 @@ ->addPathToScan(__DIR__ . '/tests', isDev: true) ->ignoreErrorsOnExtension('ext-intl', [ErrorType::SHADOW_DEPENDENCY]); -// Multibyte trim functions come either from "mbstring" extension since PHP 8.4, or from "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. +// 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]); -} else { - $config->ignoreErrorsOnExtension('ext-mbstring', [ErrorType::SHADOW_DEPENDENCY]); } return $config; From 31a84645fc38c8114373c60594bb0e5da1cd895a Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 20 Sep 2026 18:20:41 +0300 Subject: [PATCH 07/12] improve --- src/Attribute/Parameter/LeftTrim.php | 1 + src/Attribute/Parameter/LeftTrimResolver.php | 11 +----- src/Attribute/Parameter/RightTrim.php | 1 + src/Attribute/Parameter/RightTrimResolver.php | 11 +----- src/Attribute/Parameter/ToArrayOfStrings.php | 4 +- .../Parameter/ToArrayOfStringsResolver.php | 14 ++----- src/Attribute/Parameter/Trim.php | 1 + src/Attribute/Parameter/TrimCharacters.php | 39 ++++++++++++++++++- src/Attribute/Parameter/TrimResolver.php | 11 +----- 9 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/Attribute/Parameter/LeftTrim.php b/src/Attribute/Parameter/LeftTrim.php index 76ef9cab..cdb849e7 100644 --- a/src/Attribute/Parameter/LeftTrim.php +++ b/src/Attribute/Parameter/LeftTrim.php @@ -32,6 +32,7 @@ public function __construct( public readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); + TrimCharacters::checkMultibyteFunctionsExist($multibyte); } public function getResolver(): string diff --git a/src/Attribute/Parameter/LeftTrimResolver.php b/src/Attribute/Parameter/LeftTrimResolver.php index 4bfec03b..edcbadaf 100644 --- a/src/Attribute/Parameter/LeftTrimResolver.php +++ b/src/Attribute/Parameter/LeftTrimResolver.php @@ -4,12 +4,10 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; -use LogicException; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; -use function function_exists; use function is_string; /** @@ -30,6 +28,7 @@ public function __construct( private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); + TrimCharacters::checkMultibyteFunctionsExist($multibyte); } public function getParameterValue( @@ -59,14 +58,6 @@ public function getParameterValue( ); } - if (!function_exists('mb_ltrim')) { - // @codeCoverageIgnoreStart - throw new LogicException( - 'The "multibyte" parameter requires "mb_ltrim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', - ); - // @codeCoverageIgnoreEnd - } - return Result::success( mb_ltrim( $resolvedValue, diff --git a/src/Attribute/Parameter/RightTrim.php b/src/Attribute/Parameter/RightTrim.php index 6a65ba24..5643465b 100644 --- a/src/Attribute/Parameter/RightTrim.php +++ b/src/Attribute/Parameter/RightTrim.php @@ -32,6 +32,7 @@ public function __construct( public readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); + TrimCharacters::checkMultibyteFunctionsExist($multibyte); } public function getResolver(): string diff --git a/src/Attribute/Parameter/RightTrimResolver.php b/src/Attribute/Parameter/RightTrimResolver.php index 323af07c..baa2669f 100644 --- a/src/Attribute/Parameter/RightTrimResolver.php +++ b/src/Attribute/Parameter/RightTrimResolver.php @@ -4,12 +4,10 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; -use LogicException; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; -use function function_exists; use function is_string; /** @@ -30,6 +28,7 @@ public function __construct( private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); + TrimCharacters::checkMultibyteFunctionsExist($multibyte); } public function getParameterValue( @@ -59,14 +58,6 @@ public function getParameterValue( ); } - if (!function_exists('mb_rtrim')) { - // @codeCoverageIgnoreStart - throw new LogicException( - 'The "multibyte" parameter requires "mb_rtrim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', - ); - // @codeCoverageIgnoreEnd - } - return Result::success( mb_rtrim( $resolvedValue, diff --git a/src/Attribute/Parameter/ToArrayOfStrings.php b/src/Attribute/Parameter/ToArrayOfStrings.php index 90b80f82..26730484 100644 --- a/src/Attribute/Parameter/ToArrayOfStrings.php +++ b/src/Attribute/Parameter/ToArrayOfStrings.php @@ -29,7 +29,9 @@ public function __construct( 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 e19dbe2b..92bd0893 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -4,14 +4,12 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; -use LogicException; use Stringable; use Traversable; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; -use function function_exists; use function is_scalar; final class ToArrayOfStringsResolver implements ParameterAttributeResolverInterface @@ -25,7 +23,9 @@ final class ToArrayOfStringsResolver implements ParameterAttributeResolverInterf public function __construct( private readonly bool $multibyte = false, private readonly ?string $encoding = null, - ) {} + ) { + TrimCharacters::checkMultibyteFunctionsExist($multibyte); + } public function getParameterValue( ParameterAttributeInterface $attribute, @@ -60,14 +60,6 @@ public function getParameterValue( $multibyte = $attribute->multibyte ?? $this->multibyte; $encoding = $attribute->encoding ?? $this->encoding; - if ($multibyte && !function_exists('mb_trim')) { - // @codeCoverageIgnoreStart - throw new LogicException( - 'The "multibyte" parameter requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', - ); - // @codeCoverageIgnoreEnd - } - $array = array_map( $multibyte ? static fn(string $value): string => mb_trim($value, null, $encoding) : trim(...), $array, diff --git a/src/Attribute/Parameter/Trim.php b/src/Attribute/Parameter/Trim.php index 09e0b326..983d1eb1 100644 --- a/src/Attribute/Parameter/Trim.php +++ b/src/Attribute/Parameter/Trim.php @@ -32,6 +32,7 @@ public function __construct( public readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); + TrimCharacters::checkMultibyteFunctionsExist($multibyte); } public function getResolver(): string diff --git a/src/Attribute/Parameter/TrimCharacters.php b/src/Attribute/Parameter/TrimCharacters.php index 5abfa92f..0b7e349c 100644 --- a/src/Attribute/Parameter/TrimCharacters.php +++ b/src/Attribute/Parameter/TrimCharacters.php @@ -4,7 +4,10 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; +use LogicException; + use function count; +use function function_exists; use function mb_chr; use function mb_ord; use function mb_str_split; @@ -20,9 +23,15 @@ */ final class TrimCharacters { + private static ?bool $multibyteFunctionsExist = null; + public static function checkDeprecatedRanges(?string $characters): void { - if ($characters !== null && str_contains($characters, '..')) { + if ($characters === null) { + return; + } + + if (str_contains($characters, '..')) { trigger_error( 'The ".." range syntax of the "characters" parameter is deprecated and will be removed in the next major version.', E_USER_DEPRECATED, @@ -30,6 +39,34 @@ public static function checkDeprecatedRanges(?string $characters): void } } + /** + * 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_ord') + && function_exists('mb_chr'); + + 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 diff --git a/src/Attribute/Parameter/TrimResolver.php b/src/Attribute/Parameter/TrimResolver.php index ce9aa7b3..a45140ba 100644 --- a/src/Attribute/Parameter/TrimResolver.php +++ b/src/Attribute/Parameter/TrimResolver.php @@ -4,12 +4,10 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; -use LogicException; use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException; use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext; use Yiisoft\Hydrator\Result; -use function function_exists; use function is_string; /** @@ -30,6 +28,7 @@ public function __construct( private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); + TrimCharacters::checkMultibyteFunctionsExist($multibyte); } public function getParameterValue( @@ -59,14 +58,6 @@ public function getParameterValue( ); } - if (!function_exists('mb_trim')) { - // @codeCoverageIgnoreStart - throw new LogicException( - 'The "multibyte" parameter requires "mb_trim()" function that is provided by "mbstring" extension since PHP 8.4 or by "symfony/polyfill-mbstring" package.', - ); - // @codeCoverageIgnoreEnd - } - return Result::success( mb_trim( $resolvedValue, From 66fe5dd20ba0df6f2debb07bb734a4c6774dd287 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 20 Sep 2026 18:42:03 +0300 Subject: [PATCH 08/12] improve --- docs/guide/en/typecasting.md | 13 +- docs/guide/pt-BR/typecasting.md | 44 +---- docs/guide/ru/typecasting.md | 47 +----- docs/po/pt-BR/typecasting.md.po | 59 +------ docs/po/ru/typecasting.md.po | 59 +------ docs/pot/typecasting.md.pot | 66 +------- src/Attribute/Parameter/LeftTrimResolver.php | 7 +- src/Attribute/Parameter/RightTrimResolver.php | 7 +- .../Parameter/ToArrayOfStringsResolver.php | 10 +- src/Attribute/Parameter/TrimCharacters.php | 18 ++- src/Attribute/Parameter/TrimResolver.php | 7 +- tests/Attribute/Parameter/LeftTrimTest.php | 153 ++++-------------- tests/Attribute/Parameter/RightTrimTest.php | 153 ++++-------------- .../Parameter/ToArrayOfStringsTest.php | 2 +- .../Parameter/TrimCharactersTest.php | 121 ++++---------- tests/Attribute/Parameter/TrimTest.php | 153 ++++-------------- 16 files changed, 181 insertions(+), 738 deletions(-) diff --git a/docs/guide/en/typecasting.md b/docs/guide/en/typecasting.md index 7cf5d23d..599c83de 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -163,8 +163,10 @@ 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: +By default, `multibyte` mode is enabled automatically when the required `mb_*` functions are available (see below); +otherwise, the non-multibyte mode is used. In multibyte mode, Unicode whitespace characters, such as `U+00A0` +(no-break space) or `U+2003` (em space), are stripped as well, not just ASCII ones. You can force a specific mode +with the `multibyte` parameter: ```php use Yiisoft\Hydrator\Attribute\Parameter\Trim; @@ -172,7 +174,7 @@ use Yiisoft\Hydrator\Attribute\Parameter\Trim; class Person { public function __construct( - #[Trim(multibyte: true)] // "\u{A0}John\u{2003}" → 'John' + #[Trim(multibyte: false)] // "\u{A0}John\u{2003}" → "\u{A0}John\u{2003}" private ?string $name = null, ) {} } @@ -182,8 +184,9 @@ $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()`. +[symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) package. When these functions are not +available, `multibyte` defaults to `false`. 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, `а..я`. It works both in the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so diff --git a/docs/guide/pt-BR/typecasting.md b/docs/guide/pt-BR/typecasting.md index 89eed982..73c56036 100644 --- a/docs/guide/pt-BR/typecasting.md +++ b/docs/guide/pt-BR/typecasting.md @@ -168,36 +168,6 @@ 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, `а..я`. It works both in the default and in the -multibyte mode. This syntax is deprecated and will be removed in the next -major version, so avoid it in new code. - ### `ToDatetime` Para converter um valor para o objeto `DateTimeImmutable` ou `DateTime` @@ -280,14 +250,6 @@ Parâmetros do atributo: `false`); - `splitResolvedValue` — divide o valor resolvido pelo separador (booleano, padrão `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()`; -- `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). +- `separator` — a string delimitadora (padrão, `\R`), faz parte de uma + expressão regular, portanto deve ser levada em conta ou devidamente + escapada com `preg_quote()`. diff --git a/docs/guide/ru/typecasting.md b/docs/guide/ru/typecasting.md index 8c431367..40cd5a46 100644 --- a/docs/guide/ru/typecasting.md +++ b/docs/guide/ru/typecasting.md @@ -170,37 +170,6 @@ class Person $person = $hydrator->create(Person::class, ['name' => ' John ']); ``` -По умолчанию эти атрибуты не поддерживают многобайтовые кодировки, поэтому -символы юникод-пробелов, такие как `U+00A0` (неразрывный пробел) или -`U+2003` (широкий пробел), не удаляются. Чтобы удалять и их, включите -параметр `multibyte`: - -```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}"]); -``` - -Режим многобайтовой обработки использует функции `mb_trim()`, `mb_ltrim()` и -`mb_rtrim()`, которые предоставляются расширением PHP `mbstring`, начиная с -PHP 8.4. Чтобы использовать его на более ранних версиях PHP, установите -пакет [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring). -Параметр `encoding` задаёт кодировку, используемую в многобайтовом режиме; -значение `null` (по умолчанию) означает использование `mb_internal_encoding()`. - -С помощью `..` можно указать диапазон символов в параметре `characters`, -например, `а..я`. Это работает как в обычном, так и в многобайтовом режиме. -Этот синтаксис устарел и будет удалён в следующей мажорной версии, поэтому -не используйте его в новом коде. - ### `ToDatetime` Чтобы явно привести значение к объекту `DateTimeImmutable` или `DateTime`, @@ -282,16 +251,6 @@ final class Post умолчанию `false`); - `splitResolvedValue` — разделить значения по разделителю (логическое значение, по умолчанию `true`); -- `separator` — символ перевода строки (по умолчанию, `\R`), это часть - регулярного выражения, поэтому его следует учитывать или правильно - экранировать с помощью `preg_quote()`; -- `multibyte` — использовать ли многобайтовую обработку при включённом - `trim` (необязательное логическое значение, по умолчанию `null`, что - означает использование значения по умолчанию из резолвера); требует - функцию `mb_trim()`, предоставляемую расширением PHP `mbstring`, начиная - с PHP 8.4, либо пакет - [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) - на более ранних версиях; -- `encoding` — кодировка, используемая в многобайтовом режиме (необязательная - строка, по умолчанию `null`, что означает использование значения по - умолчанию из резолвера). +- `separator` — символ перевода строки (по умолчанию, `\R`). Это часть + регулярного выражения, поэтому ее следует учитывать или правильно + экранировать с помощью `preg_quote()`. diff --git a/docs/po/pt-BR/typecasting.md.po b/docs/po/pt-BR/typecasting.md.po index 04f4a728..66add88c 100644 --- a/docs/po/pt-BR/typecasting.md.po +++ b/docs/po/pt-BR/typecasting.md.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2026-09-20 10:41+0000\n" +"POT-Creation-Date: 2026-09-15 20:44+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -378,50 +378,6 @@ msgstr "" "\n" "$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" -#. type: Plain text -#: guide/en/typecasting.md -msgid "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:" -msgstr "" - -#. type: Fenced code block (php) -#: guide/en/typecasting.md -#, fuzzy, no-wrap -msgid "" -"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" -"\n" -"class Person\n" -"{\n" -" public function __construct(\n" -" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" -" private ?string $name = null,\n" -" ) {}\n" -"}\n" -"\n" -"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" -msgstr "" -"use DateTimeImmutable;\n" -"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" -"\n" -"class Person\n" -"{\n" -" public function __construct(\n" -" #[Trim] // ' John ' → 'John'\n" -" private ?string $name = null, \n" -" ) {}\n" -"}\n" -"\n" -"$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" - -#. type: Plain text -#: guide/en/typecasting.md -msgid "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()`." -msgstr "" - -#. type: Plain text -#: guide/en/typecasting.md -msgid "With `..` you can specify a range of characters in the `characters` parameter, for example, `а..я`. It works both in the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so avoid it in new code." -msgstr "" - #. type: Title ### #: guide/en/typecasting.md #, no-wrap @@ -592,16 +548,5 @@ msgstr "`splitResolvedValue` — divide o valor resolvido pelo separador (boolea #. type: Bullet: '- ' #: guide/en/typecasting.md -#, fuzzy -msgid "`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()`;" +msgid "`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()`." msgstr "`separator` — a string delimitadora (padrão, `\\R`), faz parte de uma expressão regular, portanto deve ser levada em conta ou devidamente escapada com `preg_quote()`." - -#. type: Bullet: '- ' -#: guide/en/typecasting.md -msgid "`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;" -msgstr "" - -#. type: Bullet: '- ' -#: guide/en/typecasting.md -msgid "`encoding` — the encoding to use in multibyte mode (nullable string, default `null` meaning the resolver default is used)." -msgstr "" diff --git a/docs/po/ru/typecasting.md.po b/docs/po/ru/typecasting.md.po index dc9fa36b..c69c86cb 100644 --- a/docs/po/ru/typecasting.md.po +++ b/docs/po/ru/typecasting.md.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2026-09-20 10:41+0000\n" +"POT-Creation-Date: 2026-09-15 20:44+0000\n" "PO-Revision-Date: 2025-03-01 15:15+0500\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -380,49 +380,6 @@ msgstr "" "\n" "$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" -#. type: Plain text -#: guide/en/typecasting.md -msgid "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:" -msgstr "По умолчанию эти атрибуты не поддерживают многобайтовые кодировки, поэтому символы юникод-пробелов, такие как `U+00A0` (неразрывный пробел) или `U+2003` (широкий пробел), не удаляются. Чтобы удалять и их, включите параметр `multibyte`:" - -#. type: Fenced code block (php) -#: guide/en/typecasting.md -#, no-wrap -msgid "" -"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" -"\n" -"class Person\n" -"{\n" -" public function __construct(\n" -" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" -" private ?string $name = null,\n" -" ) {}\n" -"}\n" -"\n" -"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" -msgstr "" -"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" -"\n" -"class Person\n" -"{\n" -" public function __construct(\n" -" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" -" private ?string $name = null,\n" -" ) {}\n" -"}\n" -"\n" -"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" - -#. type: Plain text -#: guide/en/typecasting.md -msgid "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()`." -msgstr "Режим многобайтовой обработки использует функции `mb_trim()`, `mb_ltrim()` и `mb_rtrim()`, которые предоставляются расширением PHP `mbstring`, начиная с PHP 8.4. Чтобы использовать его на более ранних версиях PHP, установите пакет [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring). Параметр `encoding` задаёт кодировку, используемую в многобайтовом режиме; значение `null` (по умолчанию) означает использование `mb_internal_encoding()`." - -#. type: Plain text -#: guide/en/typecasting.md -msgid "With `..` you can specify a range of characters in the `characters` parameter, for example, `а..я`. It works both in the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so avoid it in new code." -msgstr "С помощью `..` можно указать диапазон символов в параметре `characters`, например, `а..я`. Это работает как в обычном, так и в многобайтовом режиме. Этот синтаксис устарел и будет удалён в следующей мажорной версии, поэтому не используйте его в новом коде." - #. type: Title ### #: guide/en/typecasting.md #, no-wrap @@ -593,15 +550,5 @@ msgstr "`splitResolvedValue` — разделить значения по раз #. type: Bullet: '- ' #: guide/en/typecasting.md -msgid "`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()`;" -msgstr "`separator` — символ перевода строки (по умолчанию, `\\R`), это часть регулярного выражения, поэтому его следует учитывать или правильно экранировать с помощью `preg_quote()`;" - -#. type: Bullet: '- ' -#: guide/en/typecasting.md -msgid "`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;" -msgstr "`multibyte` — использовать ли многобайтовую обработку при включённом `trim` (необязательное логическое значение, по умолчанию `null`, что означает использование значения по умолчанию из резолвера); требует функцию `mb_trim()`, предоставляемую расширением PHP `mbstring`, начиная с PHP 8.4, либо пакет [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) на более ранних версиях;" - -#. type: Bullet: '- ' -#: guide/en/typecasting.md -msgid "`encoding` — the encoding to use in multibyte mode (nullable string, default `null` meaning the resolver default is used)." -msgstr "`encoding` — кодировка, используемая в многобайтовом режиме (необязательная строка, по умолчанию `null`, что означает использование значения по умолчанию из резолвера)." +msgid "`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()`." +msgstr "`separator` — символ перевода строки (по умолчанию, `\\R`). Это часть регулярного выражения, поэтому ее следует учитывать или правильно экранировать с помощью `preg_quote()`." diff --git a/docs/pot/typecasting.md.pot b/docs/pot/typecasting.md.pot index a85bd1d0..b78a01f9 100644 --- a/docs/pot/typecasting.md.pot +++ b/docs/pot/typecasting.md.pot @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2026-09-20 10:41+0000\n" +"POT-Creation-Date: 2026-09-15 20:44+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -270,51 +270,6 @@ msgid "" "$person = $hydrator->create(Person::class, ['name' => ' John ']);\n" msgstr "" -#. type: Plain text -#: guide/en/typecasting.md -msgid "" -"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:" -msgstr "" - -#. type: Fenced code block (php) -#: guide/en/typecasting.md -#, no-wrap -msgid "" -"use Yiisoft\\Hydrator\\Attribute\\Parameter\\Trim;\n" -"\n" -"class Person\n" -"{\n" -" public function __construct(\n" -" #[Trim(multibyte: true)] // \"\\u{A0}John\\u{2003}\" → 'John'\n" -" private ?string $name = null,\n" -" ) {}\n" -"}\n" -"\n" -"$person = $hydrator->create(Person::class, ['name' => \"\\u{A0}John\\u{2003}\"]);\n" -msgstr "" - -#. type: Plain text -#: guide/en/typecasting.md -msgid "" -"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()`." -msgstr "" - -#. type: Plain text -#: guide/en/typecasting.md -msgid "" -"With `..` you can specify a range of characters in the `characters` " -"parameter, for example, `а..я`. It works both in the default and in the " -"multibyte mode. This syntax is deprecated and will be removed in the next " -"major version, so avoid it in new code." -msgstr "" - #. type: Title ### #: guide/en/typecasting.md #, no-wrap @@ -453,22 +408,5 @@ msgstr "" msgid "" "`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()`;" -msgstr "" - -#. type: Bullet: '- ' -#: guide/en/typecasting.md -msgid "" -"`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;" -msgstr "" - -#. type: Bullet: '- ' -#: guide/en/typecasting.md -msgid "" -"`encoding` — the encoding to use in multibyte mode (nullable string, " -"default `null` meaning the resolver default is used)." +"`preg_quote()`." msgstr "" diff --git a/src/Attribute/Parameter/LeftTrimResolver.php b/src/Attribute/Parameter/LeftTrimResolver.php index edcbadaf..5ca11075 100644 --- a/src/Attribute/Parameter/LeftTrimResolver.php +++ b/src/Attribute/Parameter/LeftTrimResolver.php @@ -19,12 +19,13 @@ 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 bool|null $multibyte Whether to use multibyte-aware trimming when it is not specified in the + * attribute. `null` means auto-detect: multibyte mode is used when the required `mb_*` functions are available. * @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 ?bool $multibyte = null, private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); @@ -49,7 +50,7 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; - $multibyte = $attribute->multibyte ?? $this->multibyte; + $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); $encoding = $attribute->encoding ?? $this->encoding; if (!$multibyte) { diff --git a/src/Attribute/Parameter/RightTrimResolver.php b/src/Attribute/Parameter/RightTrimResolver.php index baa2669f..cc6e3d28 100644 --- a/src/Attribute/Parameter/RightTrimResolver.php +++ b/src/Attribute/Parameter/RightTrimResolver.php @@ -19,12 +19,13 @@ 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 bool|null $multibyte Whether to use multibyte-aware trimming when it is not specified in the + * attribute. `null` means auto-detect: multibyte mode is used when the required `mb_*` functions are available. * @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 ?bool $multibyte = null, private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); @@ -49,7 +50,7 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; - $multibyte = $attribute->multibyte ?? $this->multibyte; + $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); $encoding = $attribute->encoding ?? $this->encoding; if (!$multibyte) { diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index 92bd0893..aaf6918f 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -15,13 +15,13 @@ 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 bool|null $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. `null` means + * auto-detect: multibyte mode is used when the required `mb_*` functions are available. * @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 ?bool $multibyte = null, private readonly ?string $encoding = null, ) { TrimCharacters::checkMultibyteFunctionsExist($multibyte); @@ -57,7 +57,7 @@ public function getParameterValue( } if ($attribute->trim) { - $multibyte = $attribute->multibyte ?? $this->multibyte; + $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); $encoding = $attribute->encoding ?? $this->encoding; $array = array_map( diff --git a/src/Attribute/Parameter/TrimCharacters.php b/src/Attribute/Parameter/TrimCharacters.php index 0b7e349c..8c9a03bd 100644 --- a/src/Attribute/Parameter/TrimCharacters.php +++ b/src/Attribute/Parameter/TrimCharacters.php @@ -40,22 +40,24 @@ public static function checkDeprecatedRanges(?string $characters): void } /** - * Checks that all `mb_*` functions used by multibyte mode are available, when {@see $multibyte} is `true`. + * Checks whether all `mb_*` functions used by multibyte mode are available. */ - public static function checkMultibyteFunctionsExist(?bool $multibyte): void + public static function multibyteFunctionsExist(): bool { - if ($multibyte !== true) { - return; - } - - self::$multibyteFunctionsExist ??= function_exists('mb_trim') + return self::$multibyteFunctionsExist ??= function_exists('mb_trim') && function_exists('mb_ltrim') && function_exists('mb_rtrim') && function_exists('mb_str_split') && function_exists('mb_ord') && function_exists('mb_chr'); + } - if (self::$multibyteFunctionsExist) { + /** + * 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 || self::multibyteFunctionsExist()) { return; } diff --git a/src/Attribute/Parameter/TrimResolver.php b/src/Attribute/Parameter/TrimResolver.php index a45140ba..128ea1f9 100644 --- a/src/Attribute/Parameter/TrimResolver.php +++ b/src/Attribute/Parameter/TrimResolver.php @@ -19,12 +19,13 @@ 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 bool|null $multibyte Whether to use multibyte-aware trimming when it is not specified in the + * attribute. `null` means auto-detect: multibyte mode is used when the required `mb_*` functions are available. * @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 ?bool $multibyte = null, private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); @@ -49,7 +50,7 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; - $multibyte = $attribute->multibyte ?? $this->multibyte; + $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); $encoding = $attribute->encoding ?? $this->encoding; if (!$multibyte) { diff --git a/tests/Attribute/Parameter/LeftTrimTest.php b/tests/Attribute/Parameter/LeftTrimTest.php index fb0551a4..93cbf361 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -4,7 +4,9 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; +use Closure; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; use Yiisoft\Hydrator\ArrayData; @@ -31,7 +33,7 @@ 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(), " \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 ']; @@ -216,25 +218,16 @@ public function testOverrideEncoding(): void $this->assertSame($expected, $result->getValue()); } - public function testDeprecationNoticeFromAttributeForRangeCharacters(): void + public static function dataDeprecationNoticeForRangeCharacters(): iterable { - $errors = TestHelper::captureErrors(static function (): void { - new LeftTrim('a..z'); - }); - - $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], - ); + yield 'attribute' => [static fn(): LeftTrim => new LeftTrim('a..z')]; + yield 'resolver' => [static fn(): LeftTrimResolver => new LeftTrimResolver(characters: 'a..z')]; } - public function testDeprecationNoticeFromResolverForRangeCharacters(): void + #[DataProvider('dataDeprecationNoticeForRangeCharacters')] + public function testDeprecationNoticeForRangeCharacters(Closure $create): void { - $errors = TestHelper::captureErrors(static function (): void { - new LeftTrimResolver(characters: 'a..z'); - }); + $errors = TestHelper::captureErrors($create); $this->assertCount(1, $errors); $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); @@ -260,7 +253,7 @@ public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void public function testRangeInNonMultibyteModeRegression(): void { - $resolver = new LeftTrimResolver(); + $resolver = new LeftTrimResolver(multibyte: false); $context = new ParameterAttributeResolveContext( TestHelper::getFirstParameter(static fn(?string $a) => null), Result::success('xyztest123'), @@ -275,117 +268,37 @@ public function testRangeInNonMultibyteModeRegression(): void $this->assertSame('123', $value); } - public function testRangeInMultibyteMode(): void - { - $resolver = new LeftTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success("\u{436}Hello\u{44E}"), - new ArrayData(), - new Hydrator(), - ); - - TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new LeftTrim("\u{430}..\u{44F}"), $context)->getValue(); - }); - - $this->assertSame("Hello\u{44E}", $value); - } - - public function testCjkRangeInMultibyteMode(): void - { - $resolver = new LeftTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success("\u{4E00}test\u{5341}"), - new ArrayData(), - new Hydrator(), - ); - - TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new LeftTrim("\u{4E00}..\u{5341}"), $context)->getValue(); - }); - - $this->assertSame("test\u{5341}", $value); - } - - public function testInvalidRangeNoLeftCharacterInMultibyteMode(): void - { - $resolver = new LeftTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('.zXz.'), - new ArrayData(), - new Hydrator(), - ); - - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new LeftTrim('..z'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, no character to the left of '..'", reset($warnings)[1]); - $this->assertSame('Xz.', $value); - } - - public function testInvalidRangeNoRightCharacterInMultibyteMode(): void - { - $resolver = new LeftTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('aXa..'), - new ArrayData(), - new Hydrator(), - ); - - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new LeftTrim('a..'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, no character to the right of '..'", reset($warnings)[1]); - $this->assertSame('Xa..', $value); - } - - public function testInvalidRangeNotIncrementingInMultibyteMode(): void - { + #[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('caXcac'), + Result::success($value), new ArrayData(), new Hydrator(), ); - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new LeftTrim('c..a'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", reset($warnings)[1]); - $this->assertSame('Xcac', $value); - } - - public function testInvalidRangeAmbiguousInMultibyteMode(): void - { - $resolver = new LeftTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('abXcba..b..c'), - new ArrayData(), - new Hydrator(), + $errors = TestHelper::captureErrors( + static function () use ($resolver, $context, $characters, &$result): void { + $result = $resolver->getParameterValue(new LeftTrim($characters), $context)->getValue(); + }, ); - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new LeftTrim('a..b..c'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range", reset($warnings)[1]); - $this->assertSame('Xcba..b..c', $value); + 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 b9297e50..aacbf19e 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -4,7 +4,9 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; +use Closure; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; use Yiisoft\Hydrator\ArrayData; @@ -31,7 +33,7 @@ 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}test", 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 ']; @@ -216,25 +218,16 @@ public function testOverrideEncoding(): void $this->assertSame($expected, $result->getValue()); } - public function testDeprecationNoticeFromAttributeForRangeCharacters(): void + public static function dataDeprecationNoticeForRangeCharacters(): iterable { - $errors = TestHelper::captureErrors(static function (): void { - new RightTrim('a..z'); - }); - - $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], - ); + yield 'attribute' => [static fn(): RightTrim => new RightTrim('a..z')]; + yield 'resolver' => [static fn(): RightTrimResolver => new RightTrimResolver(characters: 'a..z')]; } - public function testDeprecationNoticeFromResolverForRangeCharacters(): void + #[DataProvider('dataDeprecationNoticeForRangeCharacters')] + public function testDeprecationNoticeForRangeCharacters(Closure $create): void { - $errors = TestHelper::captureErrors(static function (): void { - new RightTrimResolver(characters: 'a..z'); - }); + $errors = TestHelper::captureErrors($create); $this->assertCount(1, $errors); $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); @@ -260,7 +253,7 @@ public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void public function testRangeInNonMultibyteModeRegression(): void { - $resolver = new RightTrimResolver(); + $resolver = new RightTrimResolver(multibyte: false); $context = new ParameterAttributeResolveContext( TestHelper::getFirstParameter(static fn(?string $a) => null), Result::success('xyztest123'), @@ -275,117 +268,37 @@ public function testRangeInNonMultibyteModeRegression(): void $this->assertSame('xyztest123', $value); } - public function testRangeInMultibyteMode(): void - { - $resolver = new RightTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success("\u{436}Hello\u{44E}"), - new ArrayData(), - new Hydrator(), - ); - - TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new RightTrim("\u{430}..\u{44F}"), $context)->getValue(); - }); - - $this->assertSame("\u{436}Hello", $value); - } - - public function testCjkRangeInMultibyteMode(): void - { - $resolver = new RightTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success("\u{4E00}test\u{5341}"), - new ArrayData(), - new Hydrator(), - ); - - TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new RightTrim("\u{4E00}..\u{5341}"), $context)->getValue(); - }); - - $this->assertSame("\u{4E00}test", $value); - } - - public function testInvalidRangeNoLeftCharacterInMultibyteMode(): void - { - $resolver = new RightTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('.zXz.'), - new ArrayData(), - new Hydrator(), - ); - - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new RightTrim('..z'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, no character to the left of '..'", reset($warnings)[1]); - $this->assertSame('.zX', $value); - } - - public function testInvalidRangeNoRightCharacterInMultibyteMode(): void - { - $resolver = new RightTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('aXa..'), - new ArrayData(), - new Hydrator(), - ); - - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new RightTrim('a..'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, no character to the right of '..'", reset($warnings)[1]); - $this->assertSame('aX', $value); - } - - public function testInvalidRangeNotIncrementingInMultibyteMode(): void - { + #[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('caXcac'), + Result::success($value), new ArrayData(), new Hydrator(), ); - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new RightTrim('c..a'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", reset($warnings)[1]); - $this->assertSame('caX', $value); - } - - public function testInvalidRangeAmbiguousInMultibyteMode(): void - { - $resolver = new RightTrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('abXcba..b..c'), - new ArrayData(), - new Hydrator(), + $errors = TestHelper::captureErrors( + static function () use ($resolver, $context, $characters, &$result): void { + $result = $resolver->getParameterValue(new RightTrim($characters), $context)->getValue(); + }, ); - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new RightTrim('a..b..c'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range", reset($warnings)[1]); - $this->assertSame('abX', $value); + 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 b477a70c..3861243f 100644 --- a/tests/Attribute/Parameter/ToArrayOfStringsTest.php +++ b/tests/Attribute/Parameter/ToArrayOfStringsTest.php @@ -79,7 +79,7 @@ public static function dataBase(): iterable }, ]; yield [ - ["hello\u{2003}"], + ['hello'], " hello\u{2003} ", new class { #[ToArrayOfStrings(trim: true)] diff --git a/tests/Attribute/Parameter/TrimCharactersTest.php b/tests/Attribute/Parameter/TrimCharactersTest.php index 8a07dbf2..d6cd3e30 100644 --- a/tests/Attribute/Parameter/TrimCharactersTest.php +++ b/tests/Attribute/Parameter/TrimCharactersTest.php @@ -4,6 +4,7 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Yiisoft\Hydrator\Attribute\Parameter\TrimCharacters; use Yiisoft\Hydrator\Tests\Support\TestHelper; @@ -13,10 +14,12 @@ final class TrimCharactersTest extends TestCase { - public function testCheckDeprecatedRangesTriggersNoticeWhenCharactersContainRange(): void + #[TestWith(['a..z'])] + #[TestWith(['a..'])] + public function testCheckDeprecatedRangesTriggersNotice(string $characters): void { - $errors = TestHelper::captureErrors(static function (): void { - TrimCharacters::checkDeprecatedRanges('a..z'); + $errors = TestHelper::captureErrors(static function () use ($characters): void { + TrimCharacters::checkDeprecatedRanges($characters); }); $this->assertCount(1, $errors); @@ -27,36 +30,25 @@ public function testCheckDeprecatedRangesTriggersNoticeWhenCharactersContainRang ); } - public function testCheckDeprecatedRangesTriggersNoticeForMalformedRange(): void + #[TestWith([null])] + #[TestWith(['abc'])] + #[TestWith(['.'])] + #[TestWith(['a.b'])] + public function testCheckDeprecatedRangesIsSilentForNullOrDotFreeOrSingleDotCharacters(?string $characters): void { - $errors = TestHelper::captureErrors(static function (): void { - TrimCharacters::checkDeprecatedRanges('a..'); - }); - - $this->assertCount(1, $errors); - $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); - } - - public function testCheckDeprecatedRangesIsSilentForNullOrDotFreeOrSingleDotCharacters(): void - { - $errors = TestHelper::captureErrors(static function (): void { - TrimCharacters::checkDeprecatedRanges(null); - TrimCharacters::checkDeprecatedRanges('abc'); - TrimCharacters::checkDeprecatedRanges('.'); - TrimCharacters::checkDeprecatedRanges('a.b'); + $errors = TestHelper::captureErrors(static function () use ($characters): void { + TrimCharacters::checkDeprecatedRanges($characters); }); $this->assertSame([], $errors); } - public function testExpandRangesExpandsBasicRange(): void + #[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('abcdefghijklmnopqrstuvwxyz', TrimCharacters::expandRanges('a..z', null)); - } - - public function testExpandRangesExpandsSingleCharacterRange(): void - { - $this->assertSame('a', TrimCharacters::expandRanges('a..a', null)); + $this->assertSame($expected, TrimCharacters::expandRanges($characters, null)); } public function testExpandRangesTreatsSingleDotAsOrdinaryCharacter(): void @@ -71,14 +63,6 @@ public function testExpandRangesTreatsSingleDotAsOrdinaryCharacter(): void $this->assertSame('a.b', $twoChars); } - public function testExpandRangesExpandsMultibyteRange(): void - { - $this->assertSame( - "\u{430}\u{431}\u{432}\u{433}", - TrimCharacters::expandRanges("\u{430}..\u{433}", null), - ); - } - public function testExpandRangesSkipsCodePointsNotRepresentableInTargetEncoding(): void { $start = mb_chr(0x40C, 'Windows-1251'); @@ -89,63 +73,24 @@ public function testExpandRangesSkipsCodePointsNotRepresentableInTargetEncoding( $this->assertSame(67, mb_strlen($result, 'Windows-1251')); } - public function testExpandRangesTreatsCharacterWithInvalidByteSequenceLiterally(): void - { - $errors = TestHelper::captureErrors(static function () use (&$result): void { - $result = TrimCharacters::expandRanges("\xFF..z", 'UTF-8'); - }); - - $this->assertCount(1, $errors); - $this->assertSame(E_USER_WARNING, $errors[0][0]); - $this->assertSame("Invalid '..'-range", $errors[0][1]); - $this->assertSame("\xFF.z", $result); - } - - public function testExpandRangesInvalidRangeNoLeftCharacter(): void - { - $errors = TestHelper::captureErrors(static function () use (&$result): void { - $result = TrimCharacters::expandRanges('..z', null); - }); - - $this->assertCount(1, $errors); - $this->assertSame(E_USER_WARNING, $errors[0][0]); - $this->assertSame("Invalid '..'-range, no character to the left of '..'", $errors[0][1]); - $this->assertSame('.z', $result); - } - - public function testExpandRangesInvalidRangeNoRightCharacter(): void - { - $errors = TestHelper::captureErrors(static function () use (&$result): void { - $result = TrimCharacters::expandRanges('a..', null); - }); - - $this->assertCount(1, $errors); - $this->assertSame(E_USER_WARNING, $errors[0][0]); - $this->assertSame("Invalid '..'-range, no character to the right of '..'", $errors[0][1]); - $this->assertSame('a.', $result); - } - - public function testExpandRangesInvalidRangeNotIncrementing(): void - { - $errors = TestHelper::captureErrors(static function () use (&$result): void { - $result = TrimCharacters::expandRanges('c..a', null); - }); - - $this->assertCount(1, $errors); - $this->assertSame(E_USER_WARNING, $errors[0][0]); - $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", $errors[0][1]); - $this->assertSame('c.a', $result); - } - - public function testExpandRangesInvalidRangeAmbiguous(): void - { - $errors = TestHelper::captureErrors(static function () use (&$result): void { - $result = TrimCharacters::expandRanges('a..b..c', null); + #[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'])] + 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("Invalid '..'-range", $errors[0][1]); - $this->assertSame('ab.c', $result); + $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 4e556895..073b31bd 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -4,7 +4,9 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; +use Closure; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; use Yiisoft\Hydrator\ArrayData; @@ -31,7 +33,7 @@ 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(), " \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 ']; @@ -216,25 +218,16 @@ public function testOverrideEncoding(): void $this->assertSame($expected, $result->getValue()); } - public function testDeprecationNoticeFromAttributeForRangeCharacters(): void + public static function dataDeprecationNoticeForRangeCharacters(): iterable { - $errors = TestHelper::captureErrors(static function (): void { - new Trim('a..z'); - }); - - $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], - ); + yield 'attribute' => [static fn(): Trim => new Trim('a..z')]; + yield 'resolver' => [static fn(): TrimResolver => new TrimResolver(characters: 'a..z')]; } - public function testDeprecationNoticeFromResolverForRangeCharacters(): void + #[DataProvider('dataDeprecationNoticeForRangeCharacters')] + public function testDeprecationNoticeForRangeCharacters(Closure $create): void { - $errors = TestHelper::captureErrors(static function (): void { - new TrimResolver(characters: 'a..z'); - }); + $errors = TestHelper::captureErrors($create); $this->assertCount(1, $errors); $this->assertSame(E_USER_DEPRECATED, $errors[0][0]); @@ -260,7 +253,7 @@ public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void public function testRangeInNonMultibyteModeRegression(): void { - $resolver = new TrimResolver(); + $resolver = new TrimResolver(multibyte: false); $context = new ParameterAttributeResolveContext( TestHelper::getFirstParameter(static fn(?string $a) => null), Result::success('xyztest123'), @@ -275,117 +268,37 @@ public function testRangeInNonMultibyteModeRegression(): void $this->assertSame('123', $value); } - public function testRangeInMultibyteMode(): void - { - $resolver = new TrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success("\u{436}Hello\u{44E}"), - new ArrayData(), - new Hydrator(), - ); - - $result = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new Trim("\u{430}..\u{44F}"), $context)->getValue(); - }); - - $this->assertSame('Hello', $value); - } - - public function testCjkRangeInMultibyteMode(): void - { - $resolver = new TrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success("\u{4E00}test\u{5341}"), - new ArrayData(), - new Hydrator(), - ); - - TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new Trim("\u{4E00}..\u{5341}"), $context)->getValue(); - }); - - $this->assertSame('test', $value); - } - - public function testInvalidRangeNoLeftCharacterInMultibyteMode(): void - { + #[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('.zXz.'), - new ArrayData(), - new Hydrator(), - ); - - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new Trim('..z'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, no character to the left of '..'", reset($warnings)[1]); - $this->assertSame('X', $value); - } - - public function testInvalidRangeNoRightCharacterInMultibyteMode(): void - { - $resolver = new TrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('aXa..'), - new ArrayData(), - new Hydrator(), - ); - - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new Trim('a..'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, no character to the right of '..'", reset($warnings)[1]); - $this->assertSame('X', $value); - } - - public function testInvalidRangeNotIncrementingInMultibyteMode(): void - { - $resolver = new TrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('caXcac'), + Result::success($value), new ArrayData(), new Hydrator(), ); - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new Trim('c..a'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range, '..'-range needs to be incrementing", reset($warnings)[1]); - $this->assertSame('X', $value); - } - - public function testInvalidRangeAmbiguousInMultibyteMode(): void - { - $resolver = new TrimResolver(multibyte: true); - $context = new ParameterAttributeResolveContext( - TestHelper::getFirstParameter(static fn(?string $a) => null), - Result::success('abXcba..b..c'), - new ArrayData(), - new Hydrator(), + $errors = TestHelper::captureErrors( + static function () use ($resolver, $context, $characters, &$result): void { + $result = $resolver->getParameterValue(new Trim($characters), $context)->getValue(); + }, ); - $errors = TestHelper::captureErrors(static function () use ($resolver, $context, &$value): void { - $value = $resolver->getParameterValue(new Trim('a..b..c'), $context)->getValue(); - }); - - $warnings = array_filter($errors, static fn(array $error): bool => $error[0] === E_USER_WARNING); - $this->assertCount(1, $warnings); - $this->assertSame("Invalid '..'-range", reset($warnings)[1]); - $this->assertSame('X', $value); + 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); } } From 0a0fba93d9321ee1e87971e9b38d6de12c0fe061 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sun, 20 Sep 2026 18:54:24 +0300 Subject: [PATCH 09/12] fix --- docs/guide/en/typecasting.md | 13 +++++-------- src/Attribute/Parameter/LeftTrimResolver.php | 7 +++---- src/Attribute/Parameter/RightTrimResolver.php | 7 +++---- .../Parameter/ToArrayOfStringsResolver.php | 10 +++++----- src/Attribute/Parameter/TrimCharacters.php | 18 ++++++++---------- src/Attribute/Parameter/TrimResolver.php | 7 +++---- tests/Attribute/Parameter/LeftTrimTest.php | 4 ++-- tests/Attribute/Parameter/RightTrimTest.php | 4 ++-- .../Parameter/ToArrayOfStringsTest.php | 2 +- tests/Attribute/Parameter/TrimTest.php | 4 ++-- 10 files changed, 34 insertions(+), 42 deletions(-) diff --git a/docs/guide/en/typecasting.md b/docs/guide/en/typecasting.md index 599c83de..7cf5d23d 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -163,10 +163,8 @@ class Person $person = $hydrator->create(Person::class, ['name' => ' John ']); ``` -By default, `multibyte` mode is enabled automatically when the required `mb_*` functions are available (see below); -otherwise, the non-multibyte mode is used. In multibyte mode, Unicode whitespace characters, such as `U+00A0` -(no-break space) or `U+2003` (em space), are stripped as well, not just ASCII ones. You can force a specific mode -with the `multibyte` parameter: +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; @@ -174,7 +172,7 @@ use Yiisoft\Hydrator\Attribute\Parameter\Trim; class Person { public function __construct( - #[Trim(multibyte: false)] // "\u{A0}John\u{2003}" → "\u{A0}John\u{2003}" + #[Trim(multibyte: true)] // "\u{A0}John\u{2003}" → 'John' private ?string $name = null, ) {} } @@ -184,9 +182,8 @@ $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. When these functions are not -available, `multibyte` defaults to `false`. The `encoding` parameter selects the encoding used in multibyte mode; -`null` (default) means using `mb_internal_encoding()`. +[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, `а..я`. It works both in the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so diff --git a/src/Attribute/Parameter/LeftTrimResolver.php b/src/Attribute/Parameter/LeftTrimResolver.php index 5ca11075..edcbadaf 100644 --- a/src/Attribute/Parameter/LeftTrimResolver.php +++ b/src/Attribute/Parameter/LeftTrimResolver.php @@ -19,13 +19,12 @@ 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|null $multibyte Whether to use multibyte-aware trimming when it is not specified in the - * attribute. `null` means auto-detect: multibyte mode is used when the required `mb_*` functions are available. + * @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 = null, + private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); @@ -50,7 +49,7 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; - $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); + $multibyte = $attribute->multibyte ?? $this->multibyte; $encoding = $attribute->encoding ?? $this->encoding; if (!$multibyte) { diff --git a/src/Attribute/Parameter/RightTrimResolver.php b/src/Attribute/Parameter/RightTrimResolver.php index cc6e3d28..baa2669f 100644 --- a/src/Attribute/Parameter/RightTrimResolver.php +++ b/src/Attribute/Parameter/RightTrimResolver.php @@ -19,13 +19,12 @@ 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|null $multibyte Whether to use multibyte-aware trimming when it is not specified in the - * attribute. `null` means auto-detect: multibyte mode is used when the required `mb_*` functions are available. + * @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 = null, + private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); @@ -50,7 +49,7 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; - $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); + $multibyte = $attribute->multibyte ?? $this->multibyte; $encoding = $attribute->encoding ?? $this->encoding; if (!$multibyte) { diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index aaf6918f..92bd0893 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -15,13 +15,13 @@ final class ToArrayOfStringsResolver implements ParameterAttributeResolverInterface { /** - * @param bool|null $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. `null` means - * auto-detect: multibyte mode is used when the required `mb_*` functions are available. + * @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 = null, + private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { TrimCharacters::checkMultibyteFunctionsExist($multibyte); @@ -57,7 +57,7 @@ public function getParameterValue( } if ($attribute->trim) { - $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); + $multibyte = $attribute->multibyte ?? $this->multibyte; $encoding = $attribute->encoding ?? $this->encoding; $array = array_map( diff --git a/src/Attribute/Parameter/TrimCharacters.php b/src/Attribute/Parameter/TrimCharacters.php index 8c9a03bd..0b7e349c 100644 --- a/src/Attribute/Parameter/TrimCharacters.php +++ b/src/Attribute/Parameter/TrimCharacters.php @@ -40,24 +40,22 @@ public static function checkDeprecatedRanges(?string $characters): void } /** - * Checks whether all `mb_*` functions used by multibyte mode are available. + * Checks that all `mb_*` functions used by multibyte mode are available, when {@see $multibyte} is `true`. */ - public static function multibyteFunctionsExist(): bool + public static function checkMultibyteFunctionsExist(?bool $multibyte): void { - return self::$multibyteFunctionsExist ??= function_exists('mb_trim') + 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_ord') && function_exists('mb_chr'); - } - /** - * 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 || self::multibyteFunctionsExist()) { + if (self::$multibyteFunctionsExist) { return; } diff --git a/src/Attribute/Parameter/TrimResolver.php b/src/Attribute/Parameter/TrimResolver.php index 128ea1f9..a45140ba 100644 --- a/src/Attribute/Parameter/TrimResolver.php +++ b/src/Attribute/Parameter/TrimResolver.php @@ -19,13 +19,12 @@ 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|null $multibyte Whether to use multibyte-aware trimming when it is not specified in the - * attribute. `null` means auto-detect: multibyte mode is used when the required `mb_*` functions are available. + * @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 = null, + private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { TrimCharacters::checkDeprecatedRanges($characters); @@ -50,7 +49,7 @@ public function getParameterValue( } $characters = $attribute->characters ?? $this->characters; - $multibyte = $attribute->multibyte ?? $this->multibyte ?? TrimCharacters::multibyteFunctionsExist(); + $multibyte = $attribute->multibyte ?? $this->multibyte; $encoding = $attribute->encoding ?? $this->encoding; if (!$multibyte) { diff --git a/tests/Attribute/Parameter/LeftTrimTest.php b/tests/Attribute/Parameter/LeftTrimTest.php index 93cbf361..259102c5 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -33,7 +33,7 @@ public static function dataBase(): iterable yield ['test ', new LeftTrim(), ' test ']; yield [' test ', new LeftTrim('t'), ' test ']; yield ['est', new LeftTrim('t'), 'test']; - yield ["test\u{2003} ", new LeftTrim(), " \u{A0}test\u{2003} "]; + 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 ']; @@ -253,7 +253,7 @@ public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void public function testRangeInNonMultibyteModeRegression(): void { - $resolver = new LeftTrimResolver(multibyte: false); + $resolver = new LeftTrimResolver(); $context = new ParameterAttributeResolveContext( TestHelper::getFirstParameter(static fn(?string $a) => null), Result::success('xyztest123'), diff --git a/tests/Attribute/Parameter/RightTrimTest.php b/tests/Attribute/Parameter/RightTrimTest.php index aacbf19e..c4335797 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -33,7 +33,7 @@ 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", new RightTrim(), " \u{A0}test\u{2003} "]; + 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 ']; @@ -253,7 +253,7 @@ public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void public function testRangeInNonMultibyteModeRegression(): void { - $resolver = new RightTrimResolver(multibyte: false); + $resolver = new RightTrimResolver(); $context = new ParameterAttributeResolveContext( TestHelper::getFirstParameter(static fn(?string $a) => null), Result::success('xyztest123'), diff --git a/tests/Attribute/Parameter/ToArrayOfStringsTest.php b/tests/Attribute/Parameter/ToArrayOfStringsTest.php index 3861243f..b477a70c 100644 --- a/tests/Attribute/Parameter/ToArrayOfStringsTest.php +++ b/tests/Attribute/Parameter/ToArrayOfStringsTest.php @@ -79,7 +79,7 @@ public static function dataBase(): iterable }, ]; yield [ - ['hello'], + ["hello\u{2003}"], " hello\u{2003} ", new class { #[ToArrayOfStrings(trim: true)] diff --git a/tests/Attribute/Parameter/TrimTest.php b/tests/Attribute/Parameter/TrimTest.php index 073b31bd..5bcff6ca 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -33,7 +33,7 @@ public static function dataBase(): iterable yield ['test', new Trim(), ' test ']; yield [' test ', new Trim('t'), ' test ']; yield ['es', new Trim('t'), 'test']; - yield ['test', new Trim(), " \u{A0}test\u{2003} "]; + 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 ']; @@ -253,7 +253,7 @@ public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void public function testRangeInNonMultibyteModeRegression(): void { - $resolver = new TrimResolver(multibyte: false); + $resolver = new TrimResolver(); $context = new ParameterAttributeResolveContext( TestHelper::getFirstParameter(static fn(?string $a) => null), Result::success('xyztest123'), From aba4ab02ff6fe8ad99668a6436720cf8d3cbc5b7 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Wed, 30 Sep 2026 10:13:01 +0300 Subject: [PATCH 10/12] improve --- src/Attribute/Parameter/LeftTrim.php | 1 - src/Attribute/Parameter/LeftTrimResolver.php | 3 +- src/Attribute/Parameter/RightTrim.php | 1 - src/Attribute/Parameter/RightTrimResolver.php | 3 +- src/Attribute/Parameter/Trim.php | 1 - src/Attribute/Parameter/TrimCharacters.php | 109 ++++++++++++++++-- src/Attribute/Parameter/TrimResolver.php | 3 +- tests/Attribute/Parameter/LeftTrimTest.php | 70 +++++++++-- tests/Attribute/Parameter/RightTrimTest.php | 70 +++++++++-- .../Parameter/TrimCharactersTest.php | 93 ++++++++++++--- tests/Attribute/Parameter/TrimTest.php | 70 +++++++++-- 11 files changed, 358 insertions(+), 66 deletions(-) diff --git a/src/Attribute/Parameter/LeftTrim.php b/src/Attribute/Parameter/LeftTrim.php index cdb849e7..423543c5 100644 --- a/src/Attribute/Parameter/LeftTrim.php +++ b/src/Attribute/Parameter/LeftTrim.php @@ -31,7 +31,6 @@ public function __construct( public readonly ?bool $multibyte = null, public readonly ?string $encoding = null, ) { - TrimCharacters::checkDeprecatedRanges($characters); TrimCharacters::checkMultibyteFunctionsExist($multibyte); } diff --git a/src/Attribute/Parameter/LeftTrimResolver.php b/src/Attribute/Parameter/LeftTrimResolver.php index edcbadaf..a5a4fb05 100644 --- a/src/Attribute/Parameter/LeftTrimResolver.php +++ b/src/Attribute/Parameter/LeftTrimResolver.php @@ -27,7 +27,6 @@ public function __construct( private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { - TrimCharacters::checkDeprecatedRanges($characters); TrimCharacters::checkMultibyteFunctionsExist($multibyte); } @@ -52,6 +51,8 @@ public function getParameterValue( $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), diff --git a/src/Attribute/Parameter/RightTrim.php b/src/Attribute/Parameter/RightTrim.php index 5643465b..c6596eba 100644 --- a/src/Attribute/Parameter/RightTrim.php +++ b/src/Attribute/Parameter/RightTrim.php @@ -31,7 +31,6 @@ public function __construct( public readonly ?bool $multibyte = null, public readonly ?string $encoding = null, ) { - TrimCharacters::checkDeprecatedRanges($characters); TrimCharacters::checkMultibyteFunctionsExist($multibyte); } diff --git a/src/Attribute/Parameter/RightTrimResolver.php b/src/Attribute/Parameter/RightTrimResolver.php index baa2669f..95504990 100644 --- a/src/Attribute/Parameter/RightTrimResolver.php +++ b/src/Attribute/Parameter/RightTrimResolver.php @@ -27,7 +27,6 @@ public function __construct( private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { - TrimCharacters::checkDeprecatedRanges($characters); TrimCharacters::checkMultibyteFunctionsExist($multibyte); } @@ -52,6 +51,8 @@ public function getParameterValue( $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), diff --git a/src/Attribute/Parameter/Trim.php b/src/Attribute/Parameter/Trim.php index 983d1eb1..3a785e78 100644 --- a/src/Attribute/Parameter/Trim.php +++ b/src/Attribute/Parameter/Trim.php @@ -31,7 +31,6 @@ public function __construct( public readonly ?bool $multibyte = null, public readonly ?string $encoding = null, ) { - TrimCharacters::checkDeprecatedRanges($characters); TrimCharacters::checkMultibyteFunctionsExist($multibyte); } diff --git a/src/Attribute/Parameter/TrimCharacters.php b/src/Attribute/Parameter/TrimCharacters.php index 0b7e349c..6f8cb36d 100644 --- a/src/Attribute/Parameter/TrimCharacters.php +++ b/src/Attribute/Parameter/TrimCharacters.php @@ -5,13 +5,19 @@ namespace Yiisoft\Hydrator\Attribute\Parameter; use LogicException; +use ValueError; use function count; use function function_exists; use function mb_chr; +use function mb_convert_encoding; +use function mb_internal_encoding; use function mb_ord; use function mb_str_split; +use function mb_substr_count; +use function sprintf; use function str_contains; +use function str_repeat; use function trigger_error; use const E_USER_DEPRECATED; @@ -25,13 +31,21 @@ final class TrimCharacters { private static ?bool $multibyteFunctionsExist = null; - public static function checkDeprecatedRanges(?string $characters): void + /** + * Triggers a deprecation notice when {@see $characters} contain the `..` range syntax. In multibyte mode, + * {@see $characters} are treated as a string in the {@see $encoding}. + */ + public static function checkDeprecatedRanges(?string $characters, bool $multibyte, ?string $encoding): void { if ($characters === null) { return; } - if (str_contains($characters, '..')) { + $hasRanges = $multibyte + ? mb_substr_count($characters, str_repeat(self::dot($encoding), 2), $encoding) > 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, @@ -52,8 +66,11 @@ public static function checkMultibyteFunctionsExist(?bool $multibyte): void && 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_chr') + && function_exists('mb_convert_encoding') + && function_exists('mb_internal_encoding'); if (self::$multibyteFunctionsExist) { return; @@ -74,21 +91,25 @@ public static function checkMultibyteFunctionsExist(?bool $multibyte): void */ 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] === '.' && $chars[$i + 2] === '.') { - $startOrd = mb_ord($char, $encoding); - $endOrd = mb_ord($chars[$i + 3], $encoding); + 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 = mb_chr($ord, $encoding); + $rangeChar = self::chr($ord, $encoding, $native); if ($rangeChar !== false) { $result .= $rangeChar; } @@ -98,7 +119,7 @@ public static function expandRanges(string $characters, ?string $encoding): stri } } - if ($i + 1 < $count && $char === '.' && $chars[$i + 1] === '.') { + 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; @@ -109,8 +130,8 @@ public static function expandRanges(string $characters, ?string $encoding): stri continue; } - $leftOrd = mb_ord($chars[$i - 1], $encoding); - $rightOrd = mb_ord($chars[$i + 2], $encoding); + $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); @@ -126,4 +147,72 @@ public static function expandRanges(string $characters, ?string $encoding): stri 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 a45140ba..5dbb0f43 100644 --- a/src/Attribute/Parameter/TrimResolver.php +++ b/src/Attribute/Parameter/TrimResolver.php @@ -27,7 +27,6 @@ public function __construct( private readonly bool $multibyte = false, private readonly ?string $encoding = null, ) { - TrimCharacters::checkDeprecatedRanges($characters); TrimCharacters::checkMultibyteFunctionsExist($multibyte); } @@ -52,6 +51,8 @@ public function getParameterValue( $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), diff --git a/tests/Attribute/Parameter/LeftTrimTest.php b/tests/Attribute/Parameter/LeftTrimTest.php index 259102c5..884d1f55 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; -use Closure; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -220,14 +219,32 @@ public function testOverrideEncoding(): void public static function dataDeprecationNoticeForRangeCharacters(): iterable { - yield 'attribute' => [static fn(): LeftTrim => new LeftTrim('a..z')]; - yield 'resolver' => [static fn(): LeftTrimResolver => new LeftTrimResolver(characters: 'a..z')]; + 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(Closure $create): void + public function testDeprecationNoticeForRangeCharacters(LeftTrim $attribute, LeftTrimResolver $resolver): void { - $errors = TestHelper::captureErrors($create); + $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]); @@ -237,15 +254,44 @@ public function testDeprecationNoticeForRangeCharacters(Closure $create): void ); } - public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void + 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.b'); - new LeftTrim('.'); - new LeftTrim(null); - new LeftTrimResolver(characters: 'a.b'); - new LeftTrimResolver(characters: '.'); - new LeftTrimResolver(characters: null); + new LeftTrim('a..z'); + new LeftTrimResolver(characters: 'a..z'); }); $this->assertSame([], $errors); diff --git a/tests/Attribute/Parameter/RightTrimTest.php b/tests/Attribute/Parameter/RightTrimTest.php index c4335797..a284c3c2 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; -use Closure; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -220,14 +219,32 @@ public function testOverrideEncoding(): void public static function dataDeprecationNoticeForRangeCharacters(): iterable { - yield 'attribute' => [static fn(): RightTrim => new RightTrim('a..z')]; - yield 'resolver' => [static fn(): RightTrimResolver => new RightTrimResolver(characters: 'a..z')]; + 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(Closure $create): void + public function testDeprecationNoticeForRangeCharacters(RightTrim $attribute, RightTrimResolver $resolver): void { - $errors = TestHelper::captureErrors($create); + $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]); @@ -237,15 +254,44 @@ public function testDeprecationNoticeForRangeCharacters(Closure $create): void ); } - public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void + 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.b'); - new RightTrim('.'); - new RightTrim(null); - new RightTrimResolver(characters: 'a.b'); - new RightTrimResolver(characters: '.'); - new RightTrimResolver(characters: null); + new RightTrim('a..z'); + new RightTrimResolver(characters: 'a..z'); }); $this->assertSame([], $errors); diff --git a/tests/Attribute/Parameter/TrimCharactersTest.php b/tests/Attribute/Parameter/TrimCharactersTest.php index d6cd3e30..e2806a7e 100644 --- a/tests/Attribute/Parameter/TrimCharactersTest.php +++ b/tests/Attribute/Parameter/TrimCharactersTest.php @@ -14,12 +14,20 @@ final class TrimCharactersTest extends TestCase { - #[TestWith(['a..z'])] - #[TestWith(['a..'])] - public function testCheckDeprecatedRangesTriggersNotice(string $characters): void - { - $errors = TestHelper::captureErrors(static function () use ($characters): void { - TrimCharacters::checkDeprecatedRanges($characters); + #[TestWith(['a..z', false])] + #[TestWith(['a..', false])] + #[TestWith(['a..z', true])] + #[TestWith(["a\0.\0.\0z\0", true, 'UTF-16LE'])] + #[TestWith(["\0a\0.\0.\0z", true, 'UTF-16BE'])] + #[TestWith(["a\0\0\0.\0\0\0.\0\0\0z\0\0\0", true, 'UTF-32LE'])] + #[TestWith(["\0\0\0a\0\0\0.\0\0\0.\0\0\0z", true, 'UTF-32BE'])] + public function testCheckDeprecatedRangesTriggersNotice( + string $characters, + bool $multibyte, + ?string $encoding = null, + ): void { + $errors = TestHelper::captureErrors(static function () use ($characters, $multibyte, $encoding): void { + TrimCharacters::checkDeprecatedRanges($characters, $multibyte, $encoding); }); $this->assertCount(1, $errors); @@ -30,14 +38,21 @@ public function testCheckDeprecatedRangesTriggersNotice(string $characters): voi ); } - #[TestWith([null])] - #[TestWith(['abc'])] - #[TestWith(['.'])] - #[TestWith(['a.b'])] - public function testCheckDeprecatedRangesIsSilentForNullOrDotFreeOrSingleDotCharacters(?string $characters): void - { - $errors = TestHelper::captureErrors(static function () use ($characters): void { - TrimCharacters::checkDeprecatedRanges($characters); + #[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); @@ -51,6 +66,54 @@ public function testExpandRangesExpandsRange(string $characters, string $expecte $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 { @@ -78,6 +141,8 @@ public function testExpandRangesSkipsCodePointsNotRepresentableInTargetEncoding( #[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, diff --git a/tests/Attribute/Parameter/TrimTest.php b/tests/Attribute/Parameter/TrimTest.php index 5bcff6ca..378c569d 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Hydrator\Tests\Attribute\Parameter; -use Closure; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -220,14 +219,32 @@ public function testOverrideEncoding(): void public static function dataDeprecationNoticeForRangeCharacters(): iterable { - yield 'attribute' => [static fn(): Trim => new Trim('a..z')]; - yield 'resolver' => [static fn(): TrimResolver => new TrimResolver(characters: 'a..z')]; + 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(Closure $create): void + public function testDeprecationNoticeForRangeCharacters(Trim $attribute, TrimResolver $resolver): void { - $errors = TestHelper::captureErrors($create); + $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]); @@ -237,15 +254,44 @@ public function testDeprecationNoticeForRangeCharacters(Closure $create): void ); } - public function testNoDeprecationNoticeForSingleDotOrNullCharacters(): void + 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.b'); - new Trim('.'); - new Trim(null); - new TrimResolver(characters: 'a.b'); - new TrimResolver(characters: '.'); - new TrimResolver(characters: null); + new Trim('a..z'); + new TrimResolver(characters: 'a..z'); }); $this->assertSame([], $errors); From 19a69ecbb0302672b5ae56071cdb6b4a5c82415b Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Wed, 30 Sep 2026 10:20:14 +0300 Subject: [PATCH 11/12] improve --- CHANGELOG.md | 4 ++-- composer.json | 1 - docs/guide/en/typecasting.md | 7 ++++--- src/Attribute/Parameter/ToArrayOfStrings.php | 6 ++++++ .../Parameter/ToArrayOfStringsResolver.php | 3 +++ tests/Attribute/Parameter/LeftTrimTest.php | 18 +++++++++--------- tests/Attribute/Parameter/RightTrimTest.php | 18 +++++++++--------- .../Parameter/ToArrayOfStringsTest.php | 4 ++-- tests/Attribute/Parameter/TrimTest.php | 18 +++++++++--------- 9 files changed, 44 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9b3140..2bcfd215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## 1.6.4 under development -- New #131: Add `multibyte` and `encoding` parameters to `Trim`, `LeftTrim`, `RightTrim` and `ToArrayOfStrings` - attributes and their resolvers (@vjik) - 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.json b/composer.json index ca0a6ac5..db0cb8a4 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,6 @@ "yiisoft/strings": "^2.3" }, "require-dev": { - "ext-iconv": "*", "bamarni/composer-bin-plugin": "^1.9.1", "friendsofphp/php-cs-fixer": "^3.92", "phpunit/phpunit": "^10.5.48", diff --git a/docs/guide/en/typecasting.md b/docs/guide/en/typecasting.md index 7cf5d23d..522931aa 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -185,9 +185,10 @@ 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, `а..я`. It works both in -the default and in the multibyte mode. This syntax is deprecated and will be removed in the next major version, so -avoid it in new code. +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` diff --git a/src/Attribute/Parameter/ToArrayOfStrings.php b/src/Attribute/Parameter/ToArrayOfStrings.php index 26730484..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 diff --git a/src/Attribute/Parameter/ToArrayOfStringsResolver.php b/src/Attribute/Parameter/ToArrayOfStringsResolver.php index 92bd0893..550eb958 100644 --- a/src/Attribute/Parameter/ToArrayOfStringsResolver.php +++ b/src/Attribute/Parameter/ToArrayOfStringsResolver.php @@ -12,6 +12,9 @@ use function is_scalar; +/** + * Resolver for {@see ToArrayOfStrings} attribute. + */ final class ToArrayOfStringsResolver implements ParameterAttributeResolverInterface { /** diff --git a/tests/Attribute/Parameter/LeftTrimTest.php b/tests/Attribute/Parameter/LeftTrimTest.php index 884d1f55..55e30762 100644 --- a/tests/Attribute/Parameter/LeftTrimTest.php +++ b/tests/Attribute/Parameter/LeftTrimTest.php @@ -38,9 +38,9 @@ public static function dataBase(): iterable 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 = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'теста'); + $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]; } @@ -177,9 +177,9 @@ public function testOverrideMultibyteFalse(): void public function testDefaultEncodingFromResolver(): void { - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'теста'); + $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( @@ -200,9 +200,9 @@ public function testDefaultEncodingFromResolver(): void public function testOverrideEncoding(): void { - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'теста'); + $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( diff --git a/tests/Attribute/Parameter/RightTrimTest.php b/tests/Attribute/Parameter/RightTrimTest.php index a284c3c2..7459c360 100644 --- a/tests/Attribute/Parameter/RightTrimTest.php +++ b/tests/Attribute/Parameter/RightTrimTest.php @@ -38,9 +38,9 @@ public static function dataBase(): iterable 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 = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'атест'); + $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]; } @@ -177,9 +177,9 @@ public function testOverrideMultibyteFalse(): void public function testDefaultEncodingFromResolver(): void { - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'атест'); + $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( @@ -200,9 +200,9 @@ public function testDefaultEncodingFromResolver(): void public function testOverrideEncoding(): void { - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'атест'); + $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( diff --git a/tests/Attribute/Parameter/ToArrayOfStringsTest.php b/tests/Attribute/Parameter/ToArrayOfStringsTest.php index b477a70c..20c66684 100644 --- a/tests/Attribute/Parameter/ToArrayOfStringsTest.php +++ b/tests/Attribute/Parameter/ToArrayOfStringsTest.php @@ -259,8 +259,8 @@ public function testAttributeMultibyteOverridesResolverDefault(): void public function testAttributeEncodingOverridesResolverDefault(): void { - $value = iconv('UTF-8', 'Windows-1251', " привет "); - $expected = iconv('UTF-8', 'Windows-1251', 'привет'); + $value = mb_convert_encoding(" привет ", 'Windows-1251', 'UTF-8'); + $expected = mb_convert_encoding('привет', 'Windows-1251', 'UTF-8'); $hydrator = new Hydrator( attributeResolverFactory: new ContainerAttributeResolverFactory( diff --git a/tests/Attribute/Parameter/TrimTest.php b/tests/Attribute/Parameter/TrimTest.php index 378c569d..f45d8d0c 100644 --- a/tests/Attribute/Parameter/TrimTest.php +++ b/tests/Attribute/Parameter/TrimTest.php @@ -38,9 +38,9 @@ public static function dataBase(): iterable yield [' test ', new Trim('t', multibyte: true), ' test ']; yield ['b', new Trim("\u{430}\u{44F}", multibyte: true), "\u{430}b\u{44F}"]; - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'тест'); + $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]; } @@ -177,9 +177,9 @@ public function testOverrideMultibyteFalse(): void public function testDefaultEncodingFromResolver(): void { - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'тест'); + $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( @@ -200,9 +200,9 @@ public function testDefaultEncodingFromResolver(): void public function testOverrideEncoding(): void { - $characters = iconv('UTF-8', 'Windows-1251', 'а'); - $value = iconv('UTF-8', 'Windows-1251', 'атеста'); - $expected = iconv('UTF-8', 'Windows-1251', 'тест'); + $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( From 9a27ebbf6ced658f3c1cc9fb714b9c1581b94f53 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Wed, 30 Sep 2026 17:59:18 +0300 Subject: [PATCH 12/12] fix --- .github/workflows/mutation.yml | 2 +- infection.json.dist | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/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"