Skip to content

Commit 95fa678

Browse files
committed
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.
1 parent 46cff4e commit 95fa678

File tree

11 files changed

+691
-138
lines changed

11 files changed

+691
-138
lines changed

clover.xml

Lines changed: 242 additions & 138 deletions
Large diffs are not rendered by default.

src/Attributes/Class/MapName.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Attributes\Class;
4+
5+
use Attribute;
6+
use Nuxtifyts\PhpDto\Enums\LetterCase;
7+
8+
#[Attribute(Attribute::TARGET_CLASS)]
9+
class MapName
10+
{
11+
/**
12+
* @param LetterCase|list<LetterCase> $from
13+
*/
14+
public function __construct(
15+
protected(set) LetterCase|array $from,
16+
protected(set) LetterCase $to = LetterCase::CAMEL
17+
) {
18+
}
19+
}

src/Contexts/ClassContext.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Nuxtifyts\PhpDto\Contexts;
44

5+
use Nuxtifyts\PhpDto\Attributes\Class\MapName;
56
use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer;
7+
use Nuxtifyts\PhpDto\Contexts\ClassContext\NameMapperConfig;
68
use Nuxtifyts\PhpDto\Data;
79
use Nuxtifyts\PhpDto\Exceptions\DataCreationException;
810
use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException;
@@ -36,6 +38,8 @@ class ClassContext
3638
/** @var array<array-key, class-string<Normalizer>> */
3739
private(set) array $normalizers = [];
3840

41+
private(set) ?NameMapperConfig $nameMapperConfig = null;
42+
3943
/**
4044
* @param ReflectionClass<T> $reflection
4145
*
@@ -118,6 +122,16 @@ private function syncClassAttributes(): void
118122
...$withNormalizerAttribute->newInstance()->classStrings
119123
]);
120124
}
125+
126+
if ($nameMapperAttribute = $this->reflection->getAttributes(MapName::class)[0] ?? null) {
127+
/** @var ReflectionAttribute<MapName> $nameMapperAttribute */
128+
$instance = $nameMapperAttribute->newInstance();
129+
130+
$this->nameMapperConfig = new NameMapperConfig(
131+
from: $instance->from,
132+
to: $instance->to
133+
);
134+
}
121135
}
122136

123137
/**
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Contexts\ClassContext;
4+
5+
use Nuxtifyts\PhpDto\Enums\LetterCase;
6+
use Nuxtifyts\PhpDto\Support\Str;
7+
8+
readonly class NameMapperConfig
9+
{
10+
/** @var list<LetterCase> */
11+
protected array $from;
12+
13+
/**
14+
* @param LetterCase|list<LetterCase> $from
15+
*/
16+
public function __construct(
17+
LetterCase|array $from,
18+
protected LetterCase $to
19+
) {
20+
$this->from = is_array($from) ? $from : [$from];
21+
}
22+
23+
public function transform(string $value): string|false
24+
{
25+
if (Str::validateLetterCase($value, $this->to)) {
26+
return $value;
27+
}
28+
29+
foreach ($this->from as $letterCase) {
30+
if (Str::validateLetterCase($value, $letterCase)) {
31+
return Str::transformLetterCase($value, $letterCase, $this->to);
32+
}
33+
}
34+
35+
return false;
36+
}
37+
}

src/Enums/LetterCase.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Enums;
4+
5+
enum LetterCase
6+
{
7+
case CAMEL;
8+
case SNAKE;
9+
case KEBAB;
10+
case PASCAL;
11+
}

src/Pipelines/DeserializePipeline/DeserializePipeline.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Nuxtifyts\PhpDto\Pipelines\DeserializePipeline;
44

55
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\DecipherDataPipe;
6+
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\MapNamesPipe;
67
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\RefineDataPipe;
78
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\ResolveDefaultDataPipe;
89
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\ResolveValuesFromAliasesPipe;
@@ -17,6 +18,7 @@ public static function hydrateFromArray(): self
1718
{
1819
return new DeserializePipeline(DeserializePipelinePassable::class)
1920
->through(ResolveValuesFromAliasesPipe::class)
21+
->through(MapNamesPipe::class)
2022
->through(RefineDataPipe::class)
2123
->through(DecipherDataPipe::class)
2224
->through(ResolveDefaultDataPipe::class);
@@ -30,6 +32,7 @@ public static function createFromArray(): self
3032
{
3133
return new DeserializePipeline(DeserializePipelinePassable::class)
3234
->through(ResolveValuesFromAliasesPipe::class)
35+
->through(MapNamesPipe::class)
3336
->through(RefineDataPipe::class)
3437
->through(ResolveDefaultDataPipe::class);
3538
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes;
4+
5+
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable;
6+
use Nuxtifyts\PhpDto\Support\Passable;
7+
use Nuxtifyts\PhpDto\Support\Pipe;
8+
9+
/**
10+
* @extends Pipe<DeserializePipelinePassable>
11+
*/
12+
readonly class MapNamesPipe extends Pipe
13+
{
14+
public function handle(Passable $passable): DeserializePipelinePassable
15+
{
16+
if (!$passable->classContext->nameMapperConfig) {
17+
return $passable;
18+
}
19+
20+
$data = $passable->data;
21+
22+
foreach ($data as $key => $value) {
23+
$newKey = $passable->classContext->nameMapperConfig->transform($key);
24+
25+
if ($newKey === false || $newKey === $key) {
26+
continue;
27+
}
28+
29+
$data[$newKey] = $value;
30+
unset($data[$key]);
31+
}
32+
33+
return $passable->with(data: $data);
34+
}
35+
}

src/Support/Str.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Support;
4+
5+
use Nuxtifyts\PhpDto\Enums\LetterCase;
6+
7+
final readonly class Str
8+
{
9+
public static function validateLetterCase(string $value, LetterCase $letterCase): bool
10+
{
11+
return match ($letterCase) {
12+
LetterCase::CAMEL => self::isCamelCase($value),
13+
LetterCase::SNAKE => self::isSnakeCase($value),
14+
LetterCase::KEBAB => self::isKebabCase($value),
15+
LetterCase::PASCAL => self::isPascalCase($value),
16+
};
17+
}
18+
19+
public static function isCamelCase(string $value): bool
20+
{
21+
return preg_match('/^[a-z]+(?:[A-Z][a-z]+)*$/', $value) === 1;
22+
}
23+
24+
public static function isSnakeCase(string $value): bool
25+
{
26+
return preg_match('/^[a-z]+(?:_[a-z]+)*$/', $value) === 1;
27+
}
28+
29+
public static function isKebabCase(string $value): bool
30+
{
31+
return preg_match('/^[a-z]+(?:-[a-z]+)*$/', $value) === 1;
32+
}
33+
34+
public static function isPascalCase(string $value): bool
35+
{
36+
return preg_match('/^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/', $value) === 1;
37+
}
38+
39+
public static function transformLetterCase(
40+
string $value,
41+
LetterCase $from,
42+
LetterCase $to
43+
): string {
44+
if ($from === $to) {
45+
return $value;
46+
}
47+
48+
$value = match ($from) {
49+
LetterCase::CAMEL => self::camelToSnake($value),
50+
LetterCase::SNAKE => self::snakeToCamel($value),
51+
LetterCase::KEBAB => self::kebabToCamel($value),
52+
LetterCase::PASCAL => self::pascalToSnake($value),
53+
};
54+
55+
return match ($to) {
56+
LetterCase::CAMEL => self::snakeToCamel($value),
57+
LetterCase::SNAKE => $value,
58+
LetterCase::KEBAB => self::camelToKebab($value),
59+
LetterCase::PASCAL => self::snakeToPascal($value),
60+
};
61+
}
62+
63+
public static function camelToSnake(string $value): string
64+
{
65+
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $value) ?? '');
66+
}
67+
68+
public static function snakeToCamel(string $value): string
69+
{
70+
return lcfirst(str_replace('_', '', ucwords($value, '_')));
71+
}
72+
73+
public static function kebabToCamel(string $value): string
74+
{
75+
return lcfirst(str_replace('-', '', ucwords($value, '-')));
76+
}
77+
78+
public static function pascalToSnake(string $value): string
79+
{
80+
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $value) ?? '');
81+
}
82+
83+
public static function snakeToPascal(string $value): string
84+
{
85+
return str_replace('_', '', ucwords($value, '_'));
86+
}
87+
88+
public static function camelToKebab(string $value): string
89+
{
90+
return strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $value) ?? '');
91+
}
92+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Tests\Dummies;
4+
5+
use Nuxtifyts\PhpDto\Attributes\Class\MapName;
6+
use Nuxtifyts\PhpDto\Data;
7+
use Nuxtifyts\PhpDto\Enums\LetterCase;
8+
9+
#[MapName(from: [ LetterCase::SNAKE, LetterCase::KEBAB, LetterCase::PASCAL ])]
10+
final readonly class PropertyNameMapperData extends Data
11+
{
12+
public function __construct(
13+
public string $camelCase
14+
) {
15+
}
16+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace Nuxtifyts\PhpDto\Tests\Unit\Attributes;
4+
5+
use Nuxtifyts\PhpDto\Tests\Dummies\PropertyNameMapperData;
6+
use Nuxtifyts\PhpDto\Attributes\Class\MapName;
7+
use Nuxtifyts\PhpDto\Contexts\ClassContext;
8+
use Nuxtifyts\PhpDto\Contexts\ClassContext\NameMapperConfig;
9+
use Nuxtifyts\PhpDto\Data;
10+
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\MapNamesPipe;
11+
use Nuxtifyts\PhpDto\Tests\Unit\UnitCase;
12+
use PHPUnit\Framework\Attributes\CoversClass;
13+
use PHPUnit\Framework\Attributes\DataProvider;
14+
use PHPUnit\Framework\Attributes\UsesClass;
15+
use PHPUnit\Framework\Attributes\Test;
16+
use Throwable;
17+
18+
#[CoversClass(MapName::class)]
19+
#[CoversClass(NameMapperConfig::class)]
20+
#[CoversClass(ClassContext::class)]
21+
#[CoversClass(MapNamesPipe::class)]
22+
#[UsesClass(PropertyNameMapperData::class)]
23+
final class MapNameTest extends UnitCase
24+
{
25+
/**
26+
* @param class-string<Data> $dataClassString
27+
* @param array<string, mixed> $data
28+
* @param array<string, mixed> $expected
29+
*
30+
* @throws Throwable
31+
*/
32+
#[Test]
33+
#[DataProvider('property_name_mapper_data_provider')]
34+
public function will_be_able_to_map_properties(
35+
string $dataClassString,
36+
array $data,
37+
array $expected
38+
): void {
39+
$data = $dataClassString::from($data);
40+
41+
foreach ($expected as $key => $value) {
42+
self::assertObjectHasProperty($key, $data);
43+
self::assertEquals($value, $data->{$key});
44+
}
45+
}
46+
47+
/**
48+
* @return array<string, mixed>
49+
*/
50+
public static function property_name_mapper_data_provider(): array
51+
{
52+
return [
53+
'snake_case' => [
54+
'dataClassString' => PropertyNameMapperData::class,
55+
'data' => [ 'camel_case' => 'value' ],
56+
'expected' => [ 'camelCase' => 'value' ]
57+
],
58+
'kebab_case' => [
59+
'dataClassString' => PropertyNameMapperData::class,
60+
'data' => [ 'camel-case' => 'value' ],
61+
'expected' => [ 'camelCase' => 'value' ]
62+
],
63+
'pascal_case' => [
64+
'dataClassString' => PropertyNameMapperData::class,
65+
'data' => [ 'CamelCase' => 'value' ],
66+
'expected' => [ 'camelCase' => 'value' ]
67+
],
68+
'no_change' => [
69+
'dataClassString' => PropertyNameMapperData::class,
70+
'data' => [ 'camelCase' => 'value' ],
71+
'expected' => [ 'camelCase' => 'value' ]
72+
],
73+
'multiple_letter_cases_can_be_transformed' => [
74+
'dataClassString' => PropertyNameMapperData::class,
75+
'data' => [ 'CamelCase' => 'value', 'un_kNoWnCAsE' => 'anotherValue', 'another_snake_case' => 'value' ],
76+
'expected' => [ 'camelCase' => 'value' ]
77+
]
78+
];
79+
}
80+
}

0 commit comments

Comments
 (0)