Skip to content

Commit b615a83

Browse files
committed
Serialize associative arrays as objects instead of arrays
1 parent 484ad0f commit b615a83

14 files changed

+445
-19
lines changed

src/ConcreteType.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
*/
1515
final class ConcreteType
1616
{
17+
public bool $associative = false;
18+
1719
public function __construct(public string $name, public bool $isBuiltIn)
1820
{
1921
}

src/DefinitionProvider.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ private function stringifyConstructor(ReflectionMethod $constructor): string
146146
public function provideSerializationDefinition(string $className): ClassSerializationDefinition
147147
{
148148
$reflection = new ReflectionClass($className);
149+
$constructor = $this->constructorResolver->resolveConstructor($reflection);
149150
$objectSettings = $this->resolveObjectSettings($reflection);
150151
$classAttributes = $reflection->getAttributes();
151152
$properties = [];
@@ -173,7 +174,7 @@ public function provideSerializationDefinition(string $className): ClassSerializ
173174
PropertySerializationDefinition::TYPE_METHOD,
174175
$methodName,
175176
$this->resolveSerializers($returnType, $attributes),
176-
PropertyType::fromReflectionType($returnType),
177+
$this->propertyTypeResolver->typeFromMethod($method),
177178
$returnType->allowsNull(),
178179
$this->resolveKeys($key, $attributes),
179180
$typeSpecifier?->key,
@@ -204,7 +205,7 @@ public function provideSerializationDefinition(string $className): ClassSerializ
204205
PropertySerializationDefinition::TYPE_PROPERTY,
205206
$property->getName(),
206207
$serializers,
207-
PropertyType::fromReflectionType($propertyType),
208+
$this->propertyTypeResolver->typeFromProperty($property, $constructor),
208209
$propertyType->allowsNull(),
209210
$this->resolveKeys($key, $attributes),
210211
$typeSpecifier?->key,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EventSauce\ObjectHydrator\Fixtures;
6+
7+
use EventSauce\ObjectHydrator\PropertyCasters\CastToArrayWithKey;
8+
use EventSauce\ObjectHydrator\PropertyCasters\CastToType;
9+
10+
final class ClassThatHasMultipleCastersOnMapProperty
11+
{
12+
/**
13+
* @param array<string, array<string, string>> $map
14+
*/
15+
public function __construct(
16+
#[CastToType('array')]
17+
#[CastToArrayWithKey('second_level')]
18+
#[CastToArrayWithKey('first_level')]
19+
public array $map,
20+
) {
21+
}
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EventSauce\ObjectHydrator\Fixtures;
6+
7+
final class ClassThatSpecifiesArrayWithIntegerKeys
8+
{
9+
/**
10+
* @param array<int, string> $arrayWithIntegerKeys
11+
*/
12+
public function __construct(
13+
public array $arrayWithIntegerKeys,
14+
) {
15+
}
16+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EventSauce\ObjectHydrator\Fixtures;
6+
7+
use EventSauce\ObjectHydrator\Fixtures\ClassWithCamelCaseProperty as CamelClass;
8+
9+
final class ClassThatSpecifiesArraysWithDocComments
10+
{
11+
/**
12+
* @param array<string, CamelClass> $mapWithObjects
13+
* @param array<string, int> $mapWithScalars
14+
* @param array<string, array<string, string>> $mapWithAssociativeArrays
15+
* @param array<int, string> $listWithTypeHint
16+
*/
17+
public function __construct(
18+
public array $mapWithObjects,
19+
public array $mapWithScalars,
20+
public array $mapWithAssociativeArrays,
21+
public array $listWithoutTypeHint,
22+
public array $listWithTypeHint,
23+
) {
24+
}
25+
26+
/**
27+
* @return array<string, CamelClass>
28+
*/
29+
public function methodMapWithObjects(): array
30+
{
31+
return $this->mapWithObjects;
32+
}
33+
34+
/**
35+
* @return array<string, int>
36+
*/
37+
public function methodMapWithScalars(): array
38+
{
39+
return $this->mapWithScalars;
40+
}
41+
42+
/**
43+
* @return array<string, array<string, string>>
44+
*/
45+
public function methodMapWithAssociativeArrays(): array
46+
{
47+
return $this->mapWithAssociativeArrays;
48+
}
49+
50+
public function methodListWithoutTypeHint(): array
51+
{
52+
return $this->listWithoutTypeHint;
53+
}
54+
55+
/**
56+
* @return array<int, string>
57+
*/
58+
public function methodListWithTypeHint(): array
59+
{
60+
return $this->listWithTypeHint;
61+
}
62+
}

src/FixturesFor81/ClassWithEnumArrayProperty.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace EventSauce\ObjectHydrator\FixturesFor81;
46

57
final class ClassWithEnumArrayProperty

src/IntegrationTests/HydratingSerializedObjectsTestCase.php

Lines changed: 202 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
use EventSauce\ObjectHydrator\Fixtures\ClassThatCastsListsToBasedOnDocComments;
88
use EventSauce\ObjectHydrator\Fixtures\ClassThatCastsListsToDifferentTypes;
99
use EventSauce\ObjectHydrator\Fixtures\ClassThatCastsListToScalarType;
10+
use EventSauce\ObjectHydrator\Fixtures\ClassThatHasMultipleCastersOnMapProperty;
1011
use EventSauce\ObjectHydrator\Fixtures\ClassThatHasMultipleCastersOnSingleProperty;
12+
use EventSauce\ObjectHydrator\Fixtures\ClassThatSpecifiesArraysWithDocComments;
13+
use EventSauce\ObjectHydrator\Fixtures\ClassThatSpecifiesArrayWithIntegerKeys;
1114
use EventSauce\ObjectHydrator\Fixtures\ClassWithCamelCaseProperty;
1215
use EventSauce\ObjectHydrator\Fixtures\ClassWithPropertyCasting;
1316
use EventSauce\ObjectHydrator\Fixtures\ClassWithStaticConstructor;
@@ -22,7 +25,7 @@
2225

2326
abstract class HydratingSerializedObjectsTestCase extends TestCase
2427
{
25-
abstract public function objectMapper(): ObjectMapper;
28+
abstract public function objectMapper(bool $serializeMapsAsObjects = false): ObjectMapper;
2629

2730
/**
2831
* @test
@@ -137,10 +140,204 @@ public function dataProvider(): iterable
137140
}
138141
}
139142

143+
/**
144+
* @test
145+
* @dataProvider arrayDataProvider
146+
*/
147+
public function serializes_associative_arrays_as_objects_based_on_configuration(
148+
string $class,
149+
bool $serializeMapsAsObjects,
150+
array $input,
151+
array $types,
152+
): void {
153+
$mapper = $this->objectMapper(serializeMapsAsObjects: $serializeMapsAsObjects);
154+
155+
$object = $mapper->hydrateObject($class, $input);
156+
$payload = $mapper->serializeObject($object);
157+
158+
self::assertInstanceOf($class, $object);
159+
self::assertEquals($input, $payload);
160+
self::assertExpectedTypes($types, $object);
161+
}
162+
163+
public function arrayDataProvider(): iterable
164+
{
165+
yield 'associative arrays as objects when casting enabled' => [
166+
ClassThatSpecifiesArraysWithDocComments::class,
167+
true,
168+
[
169+
'map_with_objects' => (object) [
170+
'frank' => ['snake_case' => 'Frank'],
171+
'renske' => ['snake_case' => 'Renske'],
172+
],
173+
'map_with_scalars' => (object) ['one' => 1, 'two' => 2],
174+
'map_with_associative_arrays' => (object) [
175+
'one' => ['key' => 'value'],
176+
'two' => ['another_key' => 'another_value'],
177+
],
178+
'list_without_type_hint' => ['Frank', 'Renske'],
179+
'list_with_type_hint' => ['Frank', 'Renske'],
180+
'method_map_with_objects' => (object) [
181+
'frank' => ['snake_case' => 'Frank'],
182+
'renske' => ['snake_case' => 'Renske'],
183+
],
184+
'method_map_with_scalars' => (object) ['one' => 1, 'two' => 2 ],
185+
'method_map_with_associative_arrays' => (object) [
186+
'one' => ['key' => 'value'],
187+
'two' => ['another_key' => 'another_value'],
188+
],
189+
'method_list_without_type_hint' => ['Frank', 'Renske'],
190+
'method_list_with_type_hint' => ['Frank', 'Renske'],
191+
],
192+
[
193+
'mapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class],
194+
'mapWithScalars' => ['type' => 'map', 'values' => 'integer'],
195+
'mapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'],
196+
'listWithoutTypeHint' => ['type' => 'list', 'values' => 'string'],
197+
'listWithTypeHint' => ['type' => 'list', 'values' => 'string'],
198+
'methodMapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class],
199+
'methodMapWithScalars' => ['type' => 'map', 'values' => 'integer'],
200+
'methodMapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'],
201+
'methodListWithoutTypeHint' => ['type' => 'list', 'values' => 'string'],
202+
'methodListWithTypeHint' => ['type' => 'list', 'values' => 'string'],
203+
]
204+
];
205+
206+
yield 'associative arrays as arrays when casting disabled' => [
207+
ClassThatSpecifiesArraysWithDocComments::class,
208+
false,
209+
[
210+
'map_with_objects' => [
211+
'frank' => ['snake_case' => 'Frank'],
212+
'renske' => ['snake_case' => 'Renske'],
213+
],
214+
'map_with_scalars' => ['one' => 1, 'two' => 2],
215+
'map_with_associative_arrays' => [
216+
'one' => ['key' => 'value'],
217+
'two' => ['another_key' => 'another_value'],
218+
],
219+
'list_without_type_hint' => ['Frank', 'Renske'],
220+
'list_with_type_hint' => ['Frank', 'Renske'],
221+
'method_map_with_objects' => [
222+
'frank' => ['snake_case' => 'Frank'],
223+
'renske' => ['snake_case' => 'Renske'],
224+
],
225+
'method_map_with_scalars' => ['one' => 1, 'two' => 2 ],
226+
'method_map_with_associative_arrays' => [
227+
'one' => ['key' => 'value'],
228+
'two' => ['another_key' => 'another_value'],
229+
],
230+
'method_list_without_type_hint' => ['Frank', 'Renske'],
231+
'method_list_with_type_hint' => ['Frank', 'Renske'],
232+
],
233+
[
234+
'mapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class],
235+
'mapWithScalars' => ['type' => 'map', 'values' => 'integer'],
236+
'mapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'],
237+
'listWithoutTypeHint' => ['type' => 'list', 'values' => 'string'],
238+
'listWithTypeHint' => ['type' => 'list', 'values' => 'string'],
239+
'methodMapWithObjects' => ['type' => 'map', 'values' => ClassWithCamelCaseProperty::class],
240+
'methodMapWithScalars' => ['type' => 'map', 'values' => 'integer'],
241+
'methodMapWithAssociativeArrays' => ['type' => 'map', 'values' => 'array'],
242+
'methodListWithoutTypeHint' => ['type' => 'list', 'values' => 'string'],
243+
'methodListWithTypeHint' => ['type' => 'list', 'values' => 'string'],
244+
]
245+
];
246+
247+
yield 'non-sequential lists serialized as objects when casting enabled' => [
248+
ClassThatSpecifiesArrayWithIntegerKeys::class,
249+
true,
250+
[
251+
'array_with_integer_keys' => (object) [0 => 'zero', 2 => 'two'],
252+
],
253+
[
254+
'arrayWithIntegerKeys' => ['type' => 'map', 'values' => 'string'],
255+
],
256+
];
257+
258+
yield 'non-sequential lists serialized as arrays when casting disabled' => [
259+
ClassThatSpecifiesArrayWithIntegerKeys::class,
260+
false,
261+
[
262+
'array_with_integer_keys' => [0 => 'zero', 2 => 'two'],
263+
],
264+
[
265+
'arrayWithIntegerKeys' => ['type' => 'map', 'values' => 'string'],
266+
],
267+
];
268+
269+
yield 'sequential arrays serialized as arrays when casting enabled' => [
270+
ClassThatSpecifiesArrayWithIntegerKeys::class,
271+
true,
272+
[
273+
'array_with_integer_keys' => [0 => 'zero', 1 => 'one'],
274+
],
275+
[
276+
'arrayWithIntegerKeys' => ['type' => 'list', 'values' => 'string'],
277+
],
278+
];
279+
280+
yield 'sequential arrays serialized as arrays when casting disabled' => [
281+
ClassThatSpecifiesArrayWithIntegerKeys::class,
282+
false,
283+
[
284+
'array_with_integer_keys' => [0 => 'zero', 1 => 'one'],
285+
],
286+
[
287+
'arrayWithIntegerKeys' => ['type' => 'list', 'values' => 'string'],
288+
],
289+
];
290+
}
291+
292+
/**
293+
* @test
294+
* @dataProvider associativeArraysWithPropertySerializersDataProvider
295+
*/
296+
public function serializes_associative_arrays_with_property_serializers_as_objects(
297+
bool $serializeMapsAsObjects,
298+
ClassThatHasMultipleCastersOnMapProperty $expectedObject,
299+
array $input,
300+
): void {
301+
$mapper = $this->objectMapper($serializeMapsAsObjects);
302+
303+
$object = $mapper->hydrateObject(ClassThatHasMultipleCastersOnMapProperty::class, $input);
304+
self::assertEquals($expectedObject, $object);
305+
306+
$payload = $mapper->serializeObject($object);
307+
self::assertEquals($input, $payload);
308+
}
309+
310+
private function associativeArraysWithPropertySerializersDataProvider(): iterable
311+
{
312+
yield 'associative arrays as objects' => [
313+
true,
314+
new ClassThatHasMultipleCastersOnMapProperty([
315+
'first_level' => [
316+
'second_level' => ['one' => 1, 'two' => 2, 'three' => 3],
317+
],
318+
]),
319+
[
320+
'map' => (object) ['one' => 1, 'two' => 2, 'three' => 3],
321+
],
322+
];
323+
324+
yield 'associative arrays as arrays' => [
325+
false,
326+
new ClassThatHasMultipleCastersOnMapProperty([
327+
'first_level' => [
328+
'second_level' => ['one' => 1, 'two' => 2, 'three' => 3],
329+
],
330+
]),
331+
[
332+
'map' => ['one' => 1, 'two' => 2, 'three' => 3],
333+
],
334+
];
335+
}
336+
140337
private static function assertExpectedTypes(array $types, object $object): void
141338
{
142339
foreach ($types as $property => $type) {
143-
$value = $object->$property;
340+
$value = property_exists($object, $property) ? $object->{$property} : $object->$property();
144341

145342
self::assertExpectedType($type['type'], $value);
146343

@@ -200,8 +397,10 @@ private static function assertArrayIsMap(mixed $value): void
200397
{
201398
self::assertIsArray($value);
202399

400+
self::assertFalse(array_is_list($value));
401+
203402
foreach (array_keys($value) as $key) {
204-
self::assertIsString($key);
403+
self::assertIsScalar($key);
205404
}
206405
}
207406
}

0 commit comments

Comments
 (0)