From 46cff4e93ddb1dda675e3b62767e15bd10fb763c Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Tue, 31 Dec 2024 20:42:27 -0500 Subject: [PATCH 1/3] Add Hidden attribute to support non-serializable properties Introduce a new `Hidden` attribute to mark properties that should be excluded from serialization. Updated `BaseData` and `PropertyContext` to handle this behavior, added a corresponding unit test, and documented the feature in `PropertyAttributes.md`. --- docs/PropertyAttributes.md | 24 +++++++++++++++++++++ src/Attributes/Property/Hidden.php | 10 +++++++++ src/Concerns/BaseData.php | 2 +- src/Contexts/PropertyContext.php | 4 ++++ tests/Unit/Attributes/HiddenTest.php | 31 ++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/Attributes/Property/Hidden.php create mode 100644 tests/Unit/Attributes/HiddenTest.php diff --git a/docs/PropertyAttributes.md b/docs/PropertyAttributes.md index 77d80fd..ab192c5 100644 --- a/docs/PropertyAttributes.md +++ b/docs/PropertyAttributes.md @@ -3,6 +3,7 @@ Property Attributes In order to provide more functionality to your DTOs, you can use the following attributes: - [Computed](#Computed) - To define a property that is computed from other properties. +- [Hidden](#Hidden) - To define a property that should not be serialized. - [Aliases](#Aliases) - To define aliases for a property. - [DefaultsTo](#DefaultsTo) - To define a default value for a property using a fallback resolver. - [CipherTarget](#CipherTarget) - To define a property that should be encrypted/decrypted. @@ -33,6 +34,29 @@ final readonly class PersonData extends Data This will make the DTO aware of the `fullName` property, and it will not be serialized or deserialized. +Hidden +- + +Sometimes, we may need to specify that a property should not be serialized. + +This can be done using the `Hidden` attribute. + +```php +use Nuxtifyts\PhpDto\Attributes\Property\Hidden; +use Nuxtifyts\PhpDto\Data; + +final readonly class PersonData extends Data +{ + public function __construct( + public string $firstName, + #[Hidden] + public string $lastName + ) {} +} +``` + +When serializing the DTO, the `lastName` property will not be included in the output. + Aliases - diff --git a/src/Attributes/Property/Hidden.php b/src/Attributes/Property/Hidden.php new file mode 100644 index 0000000..bd19b13 --- /dev/null +++ b/src/Attributes/Property/Hidden.php @@ -0,0 +1,10 @@ +properties as $propertyContext) { - if ($propertyContext->isComputed) { + if ($propertyContext->isComputed || $propertyContext->isHidden) { continue; } diff --git a/src/Contexts/PropertyContext.php b/src/Contexts/PropertyContext.php index dfc3701..a843612 100644 --- a/src/Contexts/PropertyContext.php +++ b/src/Contexts/PropertyContext.php @@ -9,6 +9,7 @@ use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget; use Nuxtifyts\PhpDto\Attributes\Property\Computed; use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo; +use Nuxtifyts\PhpDto\Attributes\Property\Hidden; use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner; use Nuxtifyts\PhpDto\Contexts\Concerns\HasTypes; use Nuxtifyts\PhpDto\Data; @@ -51,6 +52,8 @@ class PropertyContext private(set) bool $isComputed = false; + private(set) bool $isHidden = false; + private(set) ?CipherConfig $cipherConfig = null; private(set) ?FallbackConfig $fallbackConfig = null; @@ -104,6 +107,7 @@ private static function getKey(ReflectionProperty $property): string private function syncPropertyAttributes(): void { $this->isComputed = !empty($this->reflection->getAttributes(Computed::class)); + $this->isHidden = !empty($this->reflection->getAttributes(Hidden::class)); foreach ($this->reflection->getAttributes(WithRefiner::class) as $withRefinerAttribute) { /** @var ReflectionAttribute $withRefinerAttribute */ diff --git a/tests/Unit/Attributes/HiddenTest.php b/tests/Unit/Attributes/HiddenTest.php new file mode 100644 index 0000000..30d0e35 --- /dev/null +++ b/tests/Unit/Attributes/HiddenTest.php @@ -0,0 +1,31 @@ +toArray()); + } +} From 95fa6782ede4ca5625768436dc55144de87d60da Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 1 Jan 2025 09:50:29 -0500 Subject: [PATCH 2/3] Add property name mapping functionality with case transformations Introduce `MapName` attribute to define property name mappings with configurable letter case transformations. This includes a `LetterCase` enum, utilities in the `Str` class, and pipeline integration for deserialization. Comprehensive tests have been added to ensure functionality and coverage. --- clover.xml | 380 +++++++++++------- src/Attributes/Class/MapName.php | 19 + src/Contexts/ClassContext.php | 14 + .../ClassContext/NameMapperConfig.php | 37 ++ src/Enums/LetterCase.php | 11 + .../DeserializePipeline.php | 3 + .../Pipes/MapNamesPipe.php | 35 ++ src/Support/Str.php | 92 +++++ tests/Dummies/PropertyNameMapperData.php | 16 + tests/Unit/Attributes/MapNameTest.php | 80 ++++ tests/Unit/Support/StrTest.php | 142 +++++++ 11 files changed, 691 insertions(+), 138 deletions(-) create mode 100644 src/Attributes/Class/MapName.php create mode 100644 src/Contexts/ClassContext/NameMapperConfig.php create mode 100644 src/Enums/LetterCase.php create mode 100644 src/Pipelines/DeserializePipeline/Pipes/MapNamesPipe.php create mode 100644 src/Support/Str.php create mode 100644 tests/Dummies/PropertyNameMapperData.php create mode 100644 tests/Unit/Attributes/MapNameTest.php create mode 100644 tests/Unit/Support/StrTest.php diff --git a/clover.xml b/clover.xml index 0d51739..26f192e 100644 --- a/clover.xml +++ b/clover.xml @@ -1,7 +1,15 @@ - - + + + + + + + + + + @@ -49,6 +57,12 @@ + + + + + + @@ -161,7 +175,7 @@ - + @@ -208,7 +222,7 @@ - + @@ -348,154 +362,161 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - + + + + + + + - - - - - + + + + + - - - - - - + + + + + + + + + - - - - - - + + + + + - - - - + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - - + + + + @@ -584,6 +605,23 @@ + + + + + + + + + + + + + + + + + @@ -777,6 +815,9 @@ + + + @@ -1138,20 +1179,22 @@ - + - - + - - - + + + - + + + + @@ -1185,6 +1228,23 @@ + + + + + + + + + + + + + + + + + @@ -1536,7 +1596,7 @@ - + @@ -1554,7 +1614,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/src/Attributes/Class/MapName.php b/src/Attributes/Class/MapName.php new file mode 100644 index 0000000..8016665 --- /dev/null +++ b/src/Attributes/Class/MapName.php @@ -0,0 +1,19 @@ + $from + */ + public function __construct( + protected(set) LetterCase|array $from, + protected(set) LetterCase $to = LetterCase::CAMEL + ) { + } +} diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index fd1c7ae..c16419b 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -2,7 +2,9 @@ namespace Nuxtifyts\PhpDto\Contexts; +use Nuxtifyts\PhpDto\Attributes\Class\MapName; use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer; +use Nuxtifyts\PhpDto\Contexts\ClassContext\NameMapperConfig; use Nuxtifyts\PhpDto\Data; use Nuxtifyts\PhpDto\Exceptions\DataCreationException; use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException; @@ -36,6 +38,8 @@ class ClassContext /** @var array> */ private(set) array $normalizers = []; + private(set) ?NameMapperConfig $nameMapperConfig = null; + /** * @param ReflectionClass $reflection * @@ -118,6 +122,16 @@ private function syncClassAttributes(): void ...$withNormalizerAttribute->newInstance()->classStrings ]); } + + if ($nameMapperAttribute = $this->reflection->getAttributes(MapName::class)[0] ?? null) { + /** @var ReflectionAttribute $nameMapperAttribute */ + $instance = $nameMapperAttribute->newInstance(); + + $this->nameMapperConfig = new NameMapperConfig( + from: $instance->from, + to: $instance->to + ); + } } /** diff --git a/src/Contexts/ClassContext/NameMapperConfig.php b/src/Contexts/ClassContext/NameMapperConfig.php new file mode 100644 index 0000000..28b0242 --- /dev/null +++ b/src/Contexts/ClassContext/NameMapperConfig.php @@ -0,0 +1,37 @@ + */ + protected array $from; + + /** + * @param LetterCase|list $from + */ + public function __construct( + LetterCase|array $from, + protected LetterCase $to + ) { + $this->from = is_array($from) ? $from : [$from]; + } + + public function transform(string $value): string|false + { + if (Str::validateLetterCase($value, $this->to)) { + return $value; + } + + foreach ($this->from as $letterCase) { + if (Str::validateLetterCase($value, $letterCase)) { + return Str::transformLetterCase($value, $letterCase, $this->to); + } + } + + return false; + } +} diff --git a/src/Enums/LetterCase.php b/src/Enums/LetterCase.php new file mode 100644 index 0000000..7579950 --- /dev/null +++ b/src/Enums/LetterCase.php @@ -0,0 +1,11 @@ +through(ResolveValuesFromAliasesPipe::class) + ->through(MapNamesPipe::class) ->through(RefineDataPipe::class) ->through(DecipherDataPipe::class) ->through(ResolveDefaultDataPipe::class); @@ -30,6 +32,7 @@ public static function createFromArray(): self { return new DeserializePipeline(DeserializePipelinePassable::class) ->through(ResolveValuesFromAliasesPipe::class) + ->through(MapNamesPipe::class) ->through(RefineDataPipe::class) ->through(ResolveDefaultDataPipe::class); } diff --git a/src/Pipelines/DeserializePipeline/Pipes/MapNamesPipe.php b/src/Pipelines/DeserializePipeline/Pipes/MapNamesPipe.php new file mode 100644 index 0000000..17a8bf5 --- /dev/null +++ b/src/Pipelines/DeserializePipeline/Pipes/MapNamesPipe.php @@ -0,0 +1,35 @@ + + */ +readonly class MapNamesPipe extends Pipe +{ + public function handle(Passable $passable): DeserializePipelinePassable + { + if (!$passable->classContext->nameMapperConfig) { + return $passable; + } + + $data = $passable->data; + + foreach ($data as $key => $value) { + $newKey = $passable->classContext->nameMapperConfig->transform($key); + + if ($newKey === false || $newKey === $key) { + continue; + } + + $data[$newKey] = $value; + unset($data[$key]); + } + + return $passable->with(data: $data); + } +} diff --git a/src/Support/Str.php b/src/Support/Str.php new file mode 100644 index 0000000..d064f62 --- /dev/null +++ b/src/Support/Str.php @@ -0,0 +1,92 @@ + self::isCamelCase($value), + LetterCase::SNAKE => self::isSnakeCase($value), + LetterCase::KEBAB => self::isKebabCase($value), + LetterCase::PASCAL => self::isPascalCase($value), + }; + } + + public static function isCamelCase(string $value): bool + { + return preg_match('/^[a-z]+(?:[A-Z][a-z]+)*$/', $value) === 1; + } + + public static function isSnakeCase(string $value): bool + { + return preg_match('/^[a-z]+(?:_[a-z]+)*$/', $value) === 1; + } + + public static function isKebabCase(string $value): bool + { + return preg_match('/^[a-z]+(?:-[a-z]+)*$/', $value) === 1; + } + + public static function isPascalCase(string $value): bool + { + return preg_match('/^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/', $value) === 1; + } + + public static function transformLetterCase( + string $value, + LetterCase $from, + LetterCase $to + ): string { + if ($from === $to) { + return $value; + } + + $value = match ($from) { + LetterCase::CAMEL => self::camelToSnake($value), + LetterCase::SNAKE => self::snakeToCamel($value), + LetterCase::KEBAB => self::kebabToCamel($value), + LetterCase::PASCAL => self::pascalToSnake($value), + }; + + return match ($to) { + LetterCase::CAMEL => self::snakeToCamel($value), + LetterCase::SNAKE => $value, + LetterCase::KEBAB => self::camelToKebab($value), + LetterCase::PASCAL => self::snakeToPascal($value), + }; + } + + public static function camelToSnake(string $value): string + { + return strtolower(preg_replace('/(? $dataClassString + * @param array $data + * @param array $expected + * + * @throws Throwable + */ + #[Test] + #[DataProvider('property_name_mapper_data_provider')] + public function will_be_able_to_map_properties( + string $dataClassString, + array $data, + array $expected + ): void { + $data = $dataClassString::from($data); + + foreach ($expected as $key => $value) { + self::assertObjectHasProperty($key, $data); + self::assertEquals($value, $data->{$key}); + } + } + + /** + * @return array + */ + public static function property_name_mapper_data_provider(): array + { + return [ + 'snake_case' => [ + 'dataClassString' => PropertyNameMapperData::class, + 'data' => [ 'camel_case' => 'value' ], + 'expected' => [ 'camelCase' => 'value' ] + ], + 'kebab_case' => [ + 'dataClassString' => PropertyNameMapperData::class, + 'data' => [ 'camel-case' => 'value' ], + 'expected' => [ 'camelCase' => 'value' ] + ], + 'pascal_case' => [ + 'dataClassString' => PropertyNameMapperData::class, + 'data' => [ 'CamelCase' => 'value' ], + 'expected' => [ 'camelCase' => 'value' ] + ], + 'no_change' => [ + 'dataClassString' => PropertyNameMapperData::class, + 'data' => [ 'camelCase' => 'value' ], + 'expected' => [ 'camelCase' => 'value' ] + ], + 'multiple_letter_cases_can_be_transformed' => [ + 'dataClassString' => PropertyNameMapperData::class, + 'data' => [ 'CamelCase' => 'value', 'un_kNoWnCAsE' => 'anotherValue', 'another_snake_case' => 'value' ], + 'expected' => [ 'camelCase' => 'value' ] + ] + ]; + } +} diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php new file mode 100644 index 0000000..b88dc59 --- /dev/null +++ b/tests/Unit/Support/StrTest.php @@ -0,0 +1,142 @@ +assertSame($expected, Str::isCamelCase($value)); + } + + #[Test] + #[DataProvider('snake_case_provider')] + public function is_snake_case(string $value, bool $expected): void + { + $this->assertSame($expected, Str::isSnakeCase($value)); + } + + #[Test] + #[DataProvider('kebab_case_provider')] + public function is_kebab_case(string $value, bool $expected): void + { + $this->assertSame($expected, Str::isKebabCase($value)); + } + + #[Test] + #[DataProvider('pascal_case_provider')] + public function is_pascal_case(string $value, bool $expected): void + { + $this->assertSame($expected, Str::isPascalCase($value)); + } + + #[Test] + #[DataProvider('transform_provider')] + public function transform_letter_case(string $value, LetterCase $from, LetterCase $to, string $expected): void + { + $this->assertSame($expected, Str::transformLetterCase($value, $from, $to)); + } + + #[Test] + #[DataProvider('validate_letter_case_provider')] + public function validate_letter_case(string $value, LetterCase $letterCase, bool $expected): void + { + $this->assertSame($expected, Str::validateLetterCase($value, $letterCase)); + } + + /** + * @return array + */ + public static function camel_case_provider(): array + { + return [ + 'test_is_camel_case_with_camelCase' => ['camelCase', true], + 'test_is_camel_case_with_CamelCase' => ['CamelCase', false], + 'test_is_camel_case_with_camel_case' => ['camel_case', false], + 'test_is_camel_case_with_camel-case' => ['camel-case', false], + ]; + } + + /** + * @return array + */ + public static function snake_case_provider(): array + { + return [ + 'test_is_snake_case_with_snake_case' => ['snake_case', true], + 'test_is_snake_case_with_Snake_Case' => ['Snake_Case', false], + 'test_is_snake_case_with_snakeCase' => ['snakeCase', false], + 'test_is_snake_case_with_snake-case' => ['snake-case', false], + ]; + } + + /** + * @return array + */ + public static function kebab_case_provider(): array + { + return [ + 'test_is_kebab_case_with_kebab-case' => ['kebab-case', true], + 'test_is_kebab_case_with_Kebab-Case' => ['Kebab-Case', false], + 'test_is_kebab_case_with_kebab_case' => ['kebab_case', false], + 'test_is_kebab_case_with_kebabCase' => ['kebabCase', false], + ]; + } + + /** + * @return array + */ + public static function pascal_case_provider(): array + { + return [ + 'test_is_pascal_case_with_PascalCase' => ['PascalCase', true], + 'test_is_pascal_case_with_pascalCase' => ['pascalCase', false], + 'test_is_pascal_case_with_pascal_case' => ['pascal_case', false], + 'test_is_pascal_case_with_pascal-case' => ['pascal-case', false], + ]; + } + + /** + * @return array + */ + public static function transform_provider(): array + { + return [ + 'test_transform_letter_case_with_camelCase_to_camelCase' => ['camelCase', LetterCase::CAMEL, LetterCase::CAMEL, 'camelCase'], + 'test_transform_letter_case_with_camelCase_to_snake_case' => ['camelCase', LetterCase::CAMEL, LetterCase::SNAKE, 'camel_case'], + 'test_transform_letter_case_with_camel_case_to_camelCase' => ['camel_case', LetterCase::SNAKE, LetterCase::CAMEL, 'camelCase'], + 'test_transform_letter_case_with_kebab_case_to_camelCase' => ['kebab-case', LetterCase::KEBAB, LetterCase::CAMEL, 'kebabCase'], + 'test_transform_letter_case_with_PascalCase_to_snake_case' => ['PascalCase', LetterCase::PASCAL, LetterCase::SNAKE, 'pascal_case'], + 'test_transform_letter_case_with_snake_case_to_kebab_case' => ['snake_case', LetterCase::SNAKE, LetterCase::KEBAB, 'snake-case'], + 'test_transform_letter_case_with_kebab_case_to_PascalCase' => ['kebab-case', LetterCase::KEBAB, LetterCase::PASCAL, 'KebabCase'], + 'test_transform_letter_case_with_camelCase_to_CamelCase' => ['camelCase', LetterCase::CAMEL, LetterCase::PASCAL, 'CamelCase'], + ]; + } + + /** + * @return array + */ + public static function validate_letter_case_provider(): array + { + return [ + 'test_validate_letter_case_with_camelCase' => ['camelCase', LetterCase::CAMEL, true], + 'test_validate_letter_case_with_snake_case' => ['snake_case', LetterCase::SNAKE, true], + 'test_validate_letter_case_with_kebab-case' => ['kebab-case', LetterCase::KEBAB, true], + 'test_validate_letter_case_with_PascalCase' => ['PascalCase', LetterCase::PASCAL, true], + 'test_validate_letter_case_with_invalid_camelCase' => ['CamelCase', LetterCase::CAMEL, false], + 'test_validate_letter_case_with_invalid_snake_case' => ['SnakeCase', LetterCase::SNAKE, false], + 'test_validate_letter_case_with_invalid_kebab-case' => ['KebabCase', LetterCase::KEBAB, false], + 'test_validate_letter_case_with_invalid_PascalCase' => ['pascalCase', LetterCase::PASCAL, false], + ]; + } +} From 4ef75c99360e352e72233e306c1e7e267b452754 Mon Sep 17 00:00:00 2001 From: Fa-BRAIK Date: Wed, 1 Jan 2025 10:04:25 -0500 Subject: [PATCH 3/3] Add documentation for NameMapper attribute Introduce a new `NameMapper.md` file explaining the usage of the `MapName` attribute for handling property name mapping and letter case transformations. Update cross-references in `PropertyAttributes.md` and `Quickstart.md` to include this new documentation. --- docs/NameMapper.md | 36 ++++++++++++++++++++++++++++++++++++ docs/PropertyAttributes.md | 5 +++++ docs/Quickstart.md | 1 + 3 files changed, 42 insertions(+) create mode 100644 docs/NameMapper.md diff --git a/docs/NameMapper.md b/docs/NameMapper.md new file mode 100644 index 0000000..6216145 --- /dev/null +++ b/docs/NameMapper.md @@ -0,0 +1,36 @@ +Name Mapper += + +Sometimes we could be expecting payload with different letter case or different naming convention. +In such cases, we can use the `NameMapper` attribute to map the property to the correct key in the data array. + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Class\MapName; +use Nuxtifyts\PhpDto\Enums\LetterCase; + +#[MapName(from: [LetterCase::KEBAB, LetterCase::SNAKE])] +final readonly class UserData extends Data +{ + public function __construct( + public string $firstName, + public string $lastName + ) {} +} +``` + +In the above example, passed data with keys `letter_case` and `letter-case` will be mapped to `letterCase` (By default), +and all of these keys will be transformed to the selected letter case. + +```php +$user = UserData::from([ 'first-name' => 'John', 'last_name': 'Doe' ]); +``` + +> **Note:** The `MapName` attribute is applied on every key in the data array. + +`MapName` attribute accepts these params: + +| Param | Type | Description | Default | +|--------|----------------------------------|----------------------------------|-------------------| +| `from` | `LetterCase`\|`list` | List of letter cases to map from | - | +| `to` | `LetterCase` | Letter case to map to | LetterCase::CAMEL | diff --git a/docs/PropertyAttributes.md b/docs/PropertyAttributes.md index ab192c5..8452353 100644 --- a/docs/PropertyAttributes.md +++ b/docs/PropertyAttributes.md @@ -80,6 +80,11 @@ final readonly class PersonData extends Data This will make it possible to hydrate properties from multiple array keys. +> **Note:** Sometimes, we may want to apply the `Aliases` attribute to the whole class, +> in case we want to transform letter cases of all the keys in data array. +> In such cases, we can use the [MapName](https://github.com/nuxtifyts/php-dto/blob/main/docs/NameMapper.md) +> attribute. + CipherTarget - diff --git a/docs/Quickstart.md b/docs/Quickstart.md index 51d25e4..7d8ba33 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -78,6 +78,7 @@ can be found here: - [Supported Types](https://github.com/nuxtifyts/php-dto/blob/main/docs/SupportedTypes.md) - [Normalizers](https://github.com/nuxtifyts/php-dto/blob/main/docs/Normalizers.md) - [Property Attributes](https://github.com/nuxtifyts/php-dto/blob/main/docs/PropertyAttributes.md) +- [Name Mapper](https://github.com/nuxtifyts/php-dto/blob/main/docs/NameMapper.md) - [Data Refiners](https://github.com/nuxtifyts/php-dto/blob/main/docs/DataRefiners.md) - [Empty Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/EmptyData.md) - [Cloneable Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/CloneableData.md)