Skip to content

Commit 9826ca2

Browse files
committed
feat: add normalizer configurator NormalizeKeysToKebabCase
Normalizes the keys of an object to `kebab-case`. This class can be used either as a configurator for global usage or as an attribute to target a specific class. Global usage as a configurator ------------------------------ ```php use CuyZ\Valinor\NormalizerBuilder; use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeysToKebabCase; use CuyZ\Valinor\Normalizer\Format; // The keys of every normalized object will be converted to kebab-case $userAsArray = (new NormalizerBuilder()) ->configureWith(new NormalizeKeysToKebabCase()) ->normalizer(Format::array()) ->normalize($user); // ['first-name' => 'John'] ``` Local usage as an attribute --------------------------- ```php use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeysToKebabCase; use CuyZ\Valinor\Normalizer\Format; use CuyZ\Valinor\NormalizerBuilder; // Only the keys of this class will be converted to `kebab-case` #[NormalizeKeysToKebabCase] final readonly class User { public function __construct( public string $firstName, ) {} } $userAsArray = (new NormalizerBuilder()) ->normalizer(Format::array()) ->normalize(new User('John')); // ['first-name' => 'John'] ```
1 parent 53bff2b commit 9826ca2

3 files changed

Lines changed: 197 additions & 0 deletions

File tree

docs/pages/serialization/use-provided-normalizer-configurators.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ naming convention than the one used in the PHP codebase.
7272
| `NormalizeKeysToSnakeCase` | `first_name` |
7373
| `NormalizeKeysToCamelCase` | `firstName` |
7474
| `NormalizeKeysToPascalCase` | `FirstName` |
75+
| `NormalizeKeysToKebabCase` | `first-name` |
7576

7677
Each of these classes can be used either as a configurator for global usage or
7778
as an attribute to target a specific class.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CuyZ\Valinor\Normalizer\Configurator;
6+
7+
use Attribute;
8+
use CuyZ\Valinor\Normalizer\AsTransformer;
9+
use CuyZ\Valinor\NormalizerBuilder;
10+
11+
use function is_array;
12+
use function lcfirst;
13+
use function preg_replace;
14+
use function str_replace;
15+
use function strtolower;
16+
17+
/**
18+
* Normalizes the keys of an object to `kebab-case`.
19+
*
20+
* This class can be used either as a configurator for global usage or as an
21+
* attribute to target a specific class.
22+
*
23+
* Global usage as a configurator
24+
* ------------------------------
25+
*
26+
* ```
27+
* use CuyZ\Valinor\NormalizerBuilder;
28+
* use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeysToKebabCase;
29+
* use CuyZ\Valinor\Normalizer\Format;
30+
*
31+
* // The keys of every normalized object will be converted to `kebab-case`
32+
* $userAsArray = (new NormalizerBuilder())
33+
* ->configureWith(new NormalizeKeysToKebabCase())
34+
* ->normalizer(Format::array())
35+
* ->normalize($user);
36+
*
37+
* // ['first-name' => 'John']
38+
* ```
39+
*
40+
* Local usage as an attribute
41+
* ---------------------------
42+
*
43+
* ```
44+
* use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeysToKebabCase;
45+
* use CuyZ\Valinor\Normalizer\Format;
46+
* use CuyZ\Valinor\NormalizerBuilder;
47+
*
48+
* // Only the keys of this class will be converted to `kebab-case`
49+
* #[NormalizeKeysToKebabCase]
50+
* final readonly class User
51+
* {
52+
* public function __construct(
53+
* public string $firstName,
54+
* ) {}
55+
* }
56+
*
57+
* $userAsArray = (new NormalizerBuilder())
58+
* ->normalizer(Format::array())
59+
* ->normalize(new User('John'));
60+
*
61+
* // ['first-name' => 'John']
62+
* ```
63+
*
64+
* @api
65+
*/
66+
#[Attribute(Attribute::TARGET_CLASS)]
67+
#[AsTransformer]
68+
final readonly class NormalizeKeysToKebabCase implements NormalizerBuilderConfigurator
69+
{
70+
public function configureNormalizerBuilder(NormalizerBuilder $builder): NormalizerBuilder
71+
{
72+
return $builder->registerTransformer($this->normalize(...));
73+
}
74+
75+
public function normalize(object $object, callable $next): mixed
76+
{
77+
$result = $next();
78+
79+
if (! is_array($result)) {
80+
return $result;
81+
}
82+
83+
$kebabCased = [];
84+
85+
foreach ($result as $key => $value) {
86+
$lcFirstKey = preg_replace('/[A-Z]/', '-$0', lcfirst($key));
87+
$newKey = str_replace('_', '-', strtolower($lcFirstKey ?? $key));
88+
89+
$kebabCased[$newKey] = $value;
90+
}
91+
92+
return $kebabCased;
93+
}
94+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CuyZ\Valinor\Tests\Integration\Normalizer\Configurator;
6+
7+
use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeysToKebabCase;
8+
use CuyZ\Valinor\Normalizer\Format;
9+
use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;
10+
use DateTimeImmutable;
11+
use PHPUnit\Framework\Attributes\DataProvider;
12+
13+
final class NormalizeKeysToKebabCaseTest extends IntegrationTestCase
14+
{
15+
/**
16+
* @param array<string, non-empty-string> $expectedValue
17+
*/
18+
#[DataProvider('to_kebab_case_data_provider')]
19+
public function test_to_kebab_case_converts_keys(array $expectedValue, object $object): void
20+
{
21+
$result = $this->normalizerBuilder()
22+
->configureWith(new NormalizeKeysToKebabCase())
23+
->normalizer(Format::array())
24+
->normalize($object);
25+
26+
self::assertSame($expectedValue, $result);
27+
}
28+
29+
public function test_to_kebab_case_attribute_converts_keys_of_annotated_class(): void
30+
{
31+
$object = new #[NormalizeKeysToKebabCase] class () {
32+
public string $someValue = 'foo';
33+
};
34+
35+
$result = $this->normalizerBuilder()
36+
->normalizer(Format::array())
37+
->normalize($object);
38+
39+
self::assertSame(['some-value' => 'foo'], $result);
40+
}
41+
42+
public function test_to_kebab_case_attribute_targets_only_annotated_class(): void
43+
{
44+
$annotated = new #[NormalizeKeysToKebabCase] class () {
45+
public string $postalCode = 'NW1 6XE';
46+
};
47+
48+
$object = new class ($annotated) {
49+
public function __construct(
50+
public object $address,
51+
public string $userName = 'John Doe',
52+
) {}
53+
};
54+
55+
$result = $this->normalizerBuilder()
56+
->normalizer(Format::array())
57+
->normalize($object);
58+
59+
// Only the annotated nested class is converted; the enclosing object
60+
// keeps its original `camelCase` keys.
61+
self::assertSame([
62+
'address' => ['postal-code' => 'NW1 6XE'],
63+
'userName' => 'John Doe',
64+
], $result);
65+
}
66+
67+
public function test_to_kebab_case_leaves_non_array_normalized_value_untouched(): void
68+
{
69+
// Some objects (e.g. a `DateTimeInterface`) normalize to a scalar; the
70+
// configurator must return that value as-is instead of mangling it.
71+
$date = new DateTimeImmutable('2000-01-01T00:00:00+00:00');
72+
73+
$result = $this->normalizerBuilder()
74+
->configureWith(new NormalizeKeysToKebabCase())
75+
->normalizer(Format::array())
76+
->normalize($date);
77+
78+
self::assertSame('2000-01-01T00:00:00.000000+00:00', $result);
79+
}
80+
81+
/**
82+
* @return iterable<string, array{array, object}>
83+
*/
84+
public static function to_kebab_case_data_provider(): iterable
85+
{
86+
yield 'from camelCase' => [['some-value' => 'foo'], new class () {
87+
public string $someValue = 'foo';
88+
}];
89+
90+
yield 'from PascalCase' => [['some-value' => 'foo'], new class () {
91+
public string $SomeValue = 'foo';
92+
}];
93+
94+
yield 'from snake_case' => [['some-value' => 'foo'], new class () {
95+
public string $some_value = 'foo';
96+
}];
97+
98+
yield 'from camelCase with multiple words' => [['number-of-items' => 'foo'], new class () {
99+
public string $numberOfItems = 'foo';
100+
}];
101+
}
102+
}

0 commit comments

Comments
 (0)