From 20a3016852cfd1ccdda7a1c1ef3cf82814ef85b2 Mon Sep 17 00:00:00 2001 From: matapatos Date: Sun, 1 Jun 2025 18:14:59 +0100 Subject: [PATCH] feat: Alias option --- composer.json | 2 +- composer.lock | 26 ++--- src/ErrorInfo.php | 5 +- src/Exceptions/InvalidOptionException.php | 7 ++ src/Options/Alias.php | 23 +++++ src/Options/AliasGenerator.php | 78 ++++++++++++++ src/Validator.php | 55 ++++++++-- src/Validators/AttributesValidator.php | 5 +- tests/Integration/ErrorHandlingTest.php | 2 +- tests/Integration/OptionsTest.php | 120 ++++++++++++++++++++++ tests/Models/Complex/Options.php | 31 ++++++ 11 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 src/Exceptions/InvalidOptionException.php create mode 100644 src/Options/Alias.php create mode 100644 src/Options/AliasGenerator.php create mode 100644 tests/Integration/OptionsTest.php create mode 100644 tests/Models/Complex/Options.php diff --git a/composer.json b/composer.json index 7910101..558c581 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "license": "MIT", "require": { "php": "^8.1", - "respect/validation": "^2.3" + "respect/validation": "^2.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index a577a83..7359f13 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "855225ae868528b54c2e911b773f4ae7", + "content-hash": "a3679f3d4bc2ad5be5bd6ae5e7dc997c", "packages": [ { "name": "respect/stringifier", @@ -827,16 +827,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -879,9 +879,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "nunomaduro/collision", @@ -3359,16 +3359,16 @@ }, { "name": "symfony/console", - "version": "v6.4.21", + "version": "v6.4.22", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a3011c7b7adb58d89f6c0d822abb641d7a5f9719" + "reference": "7d29659bc3c9d8e9a34e2c3414ef9e9e003e6cf3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a3011c7b7adb58d89f6c0d822abb641d7a5f9719", - "reference": "a3011c7b7adb58d89f6c0d822abb641d7a5f9719", + "url": "https://api.github.com/repos/symfony/console/zipball/7d29659bc3c9d8e9a34e2c3414ef9e9e003e6cf3", + "reference": "7d29659bc3c9d8e9a34e2c3414ef9e9e003e6cf3", "shasum": "" }, "require": { @@ -3433,7 +3433,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.21" + "source": "https://github.com/symfony/console/tree/v6.4.22" }, "funding": [ { @@ -3449,7 +3449,7 @@ "type": "tidelift" } ], - "time": "2025-04-07T15:42:41+00:00" + "time": "2025-05-07T07:05:04+00:00" }, { "name": "symfony/deprecation-contracts", diff --git a/src/ErrorInfo.php b/src/ErrorInfo.php index 271b575..5c6ddaf 100644 --- a/src/ErrorInfo.php +++ b/src/ErrorInfo.php @@ -25,6 +25,7 @@ namespace Attributes\Validation; +use Attributes\Validation\Exceptions\StopValidationException; use Attributes\Validation\Exceptions\ValidationException; use Exception; use Respect\Validation\Exceptions\NestedValidationException as RespectNestedValidationException; @@ -77,9 +78,9 @@ public function addError(Exception|string $error): void if ($this->context->get('option.stopFirstError')) { if (! is_string($error)) { - throw new ValidationException('Invalid data', $this, previous: $error); + throw new StopValidationException('Invalid data', $this, previous: $error); } - throw new ValidationException('Invalid data', $this); + throw new StopValidationException('Invalid data', $this); } } } diff --git a/src/Exceptions/InvalidOptionException.php b/src/Exceptions/InvalidOptionException.php new file mode 100644 index 0000000..3c5e785 --- /dev/null +++ b/src/Exceptions/InvalidOptionException.php @@ -0,0 +1,7 @@ +alias = $alias; + } + + public function getAlias(): string + { + return $this->alias; + } +} diff --git a/src/Options/AliasGenerator.php b/src/Options/AliasGenerator.php new file mode 100644 index 0000000..4df2982 --- /dev/null +++ b/src/Options/AliasGenerator.php @@ -0,0 +1,78 @@ +aliasGenerator = $aliasGenerator; + } + + /** + * @throws InvalidOptionException + */ + public function getAliasGenerator(): callable + { + if (is_callable($this->aliasGenerator)) { + return $this->aliasGenerator; + } + + switch ($this->aliasGenerator) { + case 'camel': + return $this->toCamel(...); + case 'pascal': + return $this->toPascal(...); + case 'snake': + return $this->toSnake(...); + default: + throw new InvalidOptionException("Invalid alias generator '$this->aliasGenerator'"); + } + } + + /** + * Converts a string into camelCase + * + * @taken https://github.com/symfony/string/blob/7.3/ByteString.php#camel + */ + public function toCamel(string $propertyName): string + { + $parts = explode(' ', trim(ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $propertyName)))); + $parts[0] = \strlen($parts[0]) !== 1 && ctype_upper($parts[0]) ? $parts[0] : lcfirst($parts[0]); + + return implode('', $parts); + } + + /** + * Converts a string into PascalCase + */ + public function toPascal(string $propertyName): string + { + $propertyName = $this->toCamel($propertyName); + + return ucfirst($propertyName); + } + + /** + * Converts a string into snake_case + * + * @taken https://github.com/symfony/string/blob/7.3/ByteString.php#snake + */ + public function toSnake(string $propertyName): string + { + $propertyName = $this->toCamel($propertyName); + + return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $propertyName)); + } +} diff --git a/src/Validator.php b/src/Validator.php index 2dbb466..8dfb1d2 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -8,12 +8,14 @@ use Attributes\Validation\Exceptions\ContinueValidationException; use Attributes\Validation\Exceptions\StopValidationException; use Attributes\Validation\Exceptions\ValidationException; +use Attributes\Validation\Options as Options; use Attributes\Validation\Validators\AttributesValidator; use Attributes\Validation\Validators\ChainValidator; use Attributes\Validation\Validators\PropertyValidator; use Attributes\Validation\Validators\TypeHintValidator; use ReflectionClass; use ReflectionException; +use ReflectionProperty; use Respect\Validation\Exceptions\ValidationException as RespectValidationException; use Respect\Validation\Factory; @@ -31,6 +33,7 @@ public function __construct(?PropertyValidator $validator = null, bool $stopFirs $this->context = $context ?? new Context; $this->context->set('option.stopFirstError', $stopFirstError); $this->context->set('option.strict', $strict); + $this->context->set('option.alias.generator', fn (string $name) => $name); $this->validator = $this->context->getOptional(PropertyValidator::class, $validator) ?? $this->getDefaultPropertyValidator(); $this->context->set(PropertyValidator::class, $this->validator); @@ -69,13 +72,15 @@ public function validate(array $data, string|object $model): object $reflectionClass = new ReflectionClass($validModel); $errorInfo = $this->context->getOptional(ErrorInfo::class) ?: new ErrorInfo($this->context); $this->context->set(ErrorInfo::class, $errorInfo, override: true); + $defaultAliasGenerator = $this->getDefaultAliasGenerator($reflectionClass); foreach ($reflectionClass->getProperties() as $reflectionProperty) { $propertyName = $reflectionProperty->getName(); + $aliasName = $this->getAliasName($reflectionProperty, $defaultAliasGenerator); $this->context->push('internal.currentProperty', $propertyName); - if (! array_key_exists($propertyName, $data)) { + if (! array_key_exists($aliasName, $data)) { if (! $reflectionProperty->isInitialized($validModel)) { - $errorInfo->addError("Missing required property '$propertyName'"); + $errorInfo->addError("Missing required property '$aliasName'"); } $this->context->pop('internal.currentProperty'); @@ -83,7 +88,7 @@ public function validate(array $data, string|object $model): object continue; } - $propertyValue = $data[$propertyName]; + $propertyValue = $data[$aliasName]; $property = new Property($reflectionProperty, $propertyValue, $validModel::class); $this->context->set(Property::class, $property, override: true); @@ -91,10 +96,6 @@ public function validate(array $data, string|object $model): object $this->validator->validate($property, $this->context); $reflectionProperty->setValue($validModel, $property->getValue()); } catch (ValidationException|RespectValidationException $error) { - if ($error->getMessage() == 'Invalid data' && $this->context->get('option.stopFirstError')) { - break; - } - $errorInfo->addError($error); } catch (ContinueValidationException $error) { } catch (StopValidationException $error) { @@ -119,4 +120,44 @@ private function getDefaultPropertyValidator(): PropertyValidator return $chainRulesExtractor; } + + /** + * Retrieves the default alias generator for a given class + * + * @throws ContextPropertyException + */ + private function getDefaultAliasGenerator(ReflectionClass $reflectionClass): callable + { + $allAttributes = $reflectionClass->getAttributes(Options\AliasGenerator::class); + foreach ($allAttributes as $attribute) { + $instance = $attribute->newInstance(); + + return $instance->getAliasGenerator(); + } + + $aliasGenerator = $this->context->get('option.alias.generator'); + if (is_callable($aliasGenerator)) { + return $aliasGenerator; + } + + $aliasGenerator = new Options\AliasGenerator($aliasGenerator); + + return $aliasGenerator->getAliasGenerator(); + } + + /** + * Retrieves the alias for a given property + */ + private function getAliasName(ReflectionProperty $reflectionProperty, callable $defaultAliasGenerator): string + { + $propertyName = $reflectionProperty->getName(); + $allAttributes = $reflectionProperty->getAttributes(Options\Alias::class); + foreach ($allAttributes as $attribute) { + $instance = $attribute->newInstance(); + + return $instance->getAlias($propertyName); + } + + return $defaultAliasGenerator($propertyName); + } } diff --git a/src/Validators/AttributesValidator.php b/src/Validators/AttributesValidator.php index b82d898..e0561ae 100644 --- a/src/Validators/AttributesValidator.php +++ b/src/Validators/AttributesValidator.php @@ -8,6 +8,7 @@ use Attributes\Validation\ErrorInfo; use Attributes\Validation\Exceptions\ContextPropertyException; use Attributes\Validation\Property; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; use Respect\Validation\Exceptions\ValidationException as RespectValidationException; @@ -27,7 +28,7 @@ class AttributesValidator implements PropertyValidator */ public function validate(Property $property, Context $context): void { - $allAttributes = $property->getReflection()->getAttributes(); + $allAttributes = $property->getReflection()->getAttributes(Validatable::class, ReflectionAttribute::IS_INSTANCEOF); if (! $allAttributes) { return; } @@ -35,7 +36,7 @@ public function validate(Property $property, Context $context): void $errorInfo = $context->get(ErrorInfo::class); foreach ($allAttributes as $attribute) { $className = $attribute->getName(); - if (! is_subclass_of($className, Validatable::class) || $className == Rules\DateTime::class) { + if ($className == Rules\DateTime::class) { continue; } diff --git a/tests/Integration/ErrorHandlingTest.php b/tests/Integration/ErrorHandlingTest.php index 19b0343..1f90b40 100644 --- a/tests/Integration/ErrorHandlingTest.php +++ b/tests/Integration/ErrorHandlingTest.php @@ -241,4 +241,4 @@ } }) ->with([true, false]) - ->group('validator', 'error-handling', 'nested', 'strict'); + ->group('validator', 'error-handling', 'nested'); diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php new file mode 100644 index 0000000..8b4a521 --- /dev/null +++ b/tests/Integration/OptionsTest.php @@ -0,0 +1,120 @@ + 'Full Name', + 'userProfile' => [ + 'my_post' => [ + 'postId' => 1, + 'myTitle' => 'My Post Title', + ], + ], + ]; + $model = $validator->validate($rawData, new Models\Complex\User); + expect($model) + ->toBeInstanceOf(Models\Complex\User::class) + ->toHaveProperty('full_name', 'Full Name') + ->and($model->my_profile->my_post) + ->toHaveProperty('my_post_id', 1) + ->toHaveProperty('my_title', 'My Post Title'); +}) + ->with([true, false]) + ->group('validator', 'options', 'nested'); + +test('AliasGenerator camelCase', function (bool $isStrict) { + $validator = new Validator(strict: $isStrict); + $rawData = [ + 'snakeCaseName' => 'snake', + 'pascalCaseName' => 'pascal', + 'camelCaseName' => 'camel', + ]; + $model = $validator->validate($rawData, new #[Options\AliasGenerator('camel')] class + { + public string $snake_case_name; + + public string $PascalCaseName; + + public string $camelCaseName; + }); + expect($model) + ->toHaveProperty('snake_case_name', 'snake') + ->toHaveProperty('PascalCaseName', 'pascal') + ->toHaveProperty('camelCaseName', 'camel'); +}) + ->with([true, false]) + ->group('validator', 'options', 'basic'); + +test('AliasGenerator PascalCase', function (bool $isStrict) { + $validator = new Validator(strict: $isStrict); + $rawData = [ + 'SnakeCaseName' => 'snake', + 'PascalCaseName' => 'pascal', + 'CamelCaseName' => 'camel', + ]; + $model = $validator->validate($rawData, new #[Options\AliasGenerator('pascal')] class + { + public string $snake_case_name; + + public string $PascalCaseName; + + public string $camelCaseName; + }); + expect($model) + ->toHaveProperty('snake_case_name', 'snake') + ->toHaveProperty('PascalCaseName', 'pascal') + ->toHaveProperty('camelCaseName', 'camel'); +}) + ->with([true, false]) + ->group('validator', 'options', 'basic'); + +test('AliasGenerator SnakeCase', function (bool $isStrict) { + $validator = new Validator(strict: $isStrict); + $rawData = [ + 'snake_case_name' => 'snake', + 'pascal_case_name' => 'pascal', + 'camel_case_name' => 'camel', + ]; + $model = $validator->validate($rawData, new #[Options\AliasGenerator('snake')] class + { + public string $snake_case_name; + + public string $PascalCaseName; + + public string $camelCaseName; + }); + expect($model) + ->toHaveProperty('snake_case_name', 'snake') + ->toHaveProperty('PascalCaseName', 'pascal') + ->toHaveProperty('camelCaseName', 'camel'); +}) + ->with([true, false]) + ->group('validator', 'options', 'basic'); + +test('Invalid AliasGenerator', function (bool $isStrict) { + $validator = new Validator(strict: $isStrict); + $validator->validate(['snake_case_name' => 'snake'], new #[Options\AliasGenerator('invalid')] class + { + public string $snake_case_name; + }); +}) + ->with([true, false]) + ->throws(ValidationException::class, 'Invalid alias generator \'invalid\'') + ->group('validator', 'options', 'basic'); diff --git a/tests/Models/Complex/Options.php b/tests/Models/Complex/Options.php new file mode 100644 index 0000000..f38b8df --- /dev/null +++ b/tests/Models/Complex/Options.php @@ -0,0 +1,31 @@ +