Skip to content

Commit aae982b

Browse files
authored
Revert "Merge pull request #8 from good-php/polymorphic-adapter" (#9)
This reverts commit fab6fa2.
1 parent fab6fa2 commit aae982b

22 files changed

+437
-1049
lines changed

README.md

Lines changed: 365 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,31 +50,386 @@ $primitiveAdapter = $serializer->adapter(
5050
PrimitiveTypeAdapter::class,
5151
NamedType::wrap(Item::class, [Carbon::class])
5252
);
53-
// -> ['int' => 123, ...]
54-
$primitiveAdapter->serialize(new Item(...));
53+
$primitiveAdapter->serialize(new Item(...)) // -> ['int' => 123, ...]
5554

5655
$jsonAdapter = $serializer->adapter(
5756
JsonTypeAdapter::class,
5857
NamedType::wrap(Item::class, [PrimitiveType::int()])
5958
);
60-
// new Item(123, ...)
61-
$jsonAdapter->deserialize('{"int": 123, ...}');
59+
$jsonAdapter->deserialize('{"int": 123, ...}') // -> new Item(123, ...)
6260
```
6361

64-
## Documentation
62+
### Custom mappers
6563

66-
Basic documentation is available in [docs/](docs). For examples, you can look at the
67-
test suite: [tests/Integration](tests/Integration).
64+
Mappers are the simplest form customizing serialization of types. All you have
65+
to do is to mark a method with either `#[MapTo()]` or `#[MapFrom]` attribute,
66+
specify the type in question as first parameter or return type and the serializer
67+
will handle the rest automatically. A single mapper may have as many map methods as you wish.
68+
69+
```php
70+
final class DateTimeMapper
71+
{
72+
#[MapTo(PrimitiveTypeAdapter::class)]
73+
public function serialize(DateTime $value): string
74+
{
75+
return $value->format(DateTimeInterface::RFC3339_EXTENDED);
76+
}
77+
78+
#[MapFrom(PrimitiveTypeAdapter::class)]
79+
public function deserialize(string $value): DateTime
80+
{
81+
return new DateTime($value);
82+
}
83+
}
84+
85+
$serializer = (new SerializerBuilder())
86+
->addMapperLast(new DateTimeMapper())
87+
->build();
88+
```
89+
90+
With mappers, you can even handle complex types - such as generics or inheritance:
91+
92+
```php
93+
final class ArrayMapper
94+
{
95+
#[MapTo(PrimitiveTypeAdapter::class)]
96+
public function to(array $value, Type $type, Serializer $serializer): array
97+
{
98+
$itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]);
99+
100+
return array_map(fn ($item) => $itemAdapter->serialize($item), $value);
101+
}
102+
103+
#[MapFrom(PrimitiveTypeAdapter::class)]
104+
public function from(array $value, Type $type, Serializer $serializer): array
105+
{
106+
$itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]);
107+
108+
return array_map(fn ($item) => $itemAdapter->deserialize($item), $value);
109+
}
110+
}
111+
112+
final class BackedEnumMapper
113+
{
114+
#[MapTo(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))]
115+
public function to(BackedEnum $value): string|int
116+
{
117+
return $value->value;
118+
}
119+
120+
#[MapFrom(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))]
121+
public function from(string|int $value, Type $type): BackedEnum
122+
{
123+
$enumClass = $type->name;
124+
125+
return $enumClass::tryFrom($value);
126+
}
127+
}
128+
```
129+
130+
## Type adapter factories
131+
132+
Besides type mappers which satisfy most of the needs, you can use type adapter factories
133+
to precisely control how each type is serialized.
134+
135+
The idea is the following: when building a serializer, you add all of the factories you want
136+
to use in order of priority:
137+
138+
```php
139+
(new SerializerBuilder())
140+
->addMapperLast(new TestMapper()) // #2 - then this one
141+
->addFactoryLast(new TestFactory()) // #3 - and this one last
142+
->addFactory(new TestFactory()) // #1 - attempted first
143+
```
144+
145+
A factory has the following signature:
146+
147+
```php
148+
public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter
149+
```
150+
151+
If you return `null`, the next factory is called. Otherwise, the returned type adapter is used.
152+
153+
The serialized is entirely built using type adapter factories. Every type that is
154+
supported out-of-the-box also has it's factory and can be overwritten just by doing
155+
`->addFactoryLast()`. Type mappers are also just fancy adapter factories under the hood.
156+
157+
This is how you can use them:
158+
159+
```php
160+
class NullableTypeAdapterFactory implements TypeAdapterFactory
161+
{
162+
public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter
163+
{
164+
if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NullableType) {
165+
return null;
166+
}
167+
168+
return new NullableTypeAdapter(
169+
$serializer->adapter($typeAdapterType, $type->innerType, $attributes),
170+
);
171+
}
172+
}
173+
174+
class NullableTypeAdapter implements PrimitiveTypeAdapter
175+
{
176+
public function __construct(
177+
private readonly PrimitiveTypeAdapter $delegate,
178+
) {
179+
}
180+
181+
public function serialize(mixed $value): mixed
182+
{
183+
if ($value === null) {
184+
return null;
185+
}
186+
187+
return $this->delegate->serialize($value);
188+
}
189+
190+
public function deserialize(mixed $value): mixed
191+
{
192+
if ($value === null) {
193+
return null;
194+
}
195+
196+
return $this->delegate->deserialize($value);
197+
}
198+
}
199+
```
200+
201+
In this example, `NullableTypeAdapterFactory` handles all nullable types. When a non-nullable
202+
type is given, it returns `null`. That means that the next in "queue" type adapter will be
203+
called. When a nullable is given, it returns a new type adapter instance which has two
204+
methods: `serialize` and `deserialize`. They do exactly what they're called.
205+
206+
## Naming of keys
207+
208+
By default, serializer preserves the naming of keys, but this is easily customizable (in order of priority):
209+
210+
- specify a custom property name using the `#[SerializedName]` attribute
211+
- specify a custom naming strategy per class using the `#[SerializedName]` attribute
212+
- specify a custom global (default) naming strategy (use one of the built-in or write your own)
213+
214+
Here's an example:
215+
216+
```php
217+
(new SerializerBuilder())->namingStrategy(BuiltInNamingStrategy::SNAKE_CASE);
218+
219+
// Uses snake_case by default
220+
class Item1 {
221+
public function __construct(
222+
public int $keyName, // appears as "key_name" in serialized data
223+
#[SerializedName('second_key')] public int $firstKey, // second_key
224+
#[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)] public int $thirdKey, // THIRD_KEY
225+
) {}
226+
}
227+
228+
// Uses PASCAL_CASE by default
229+
#[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)]
230+
class Item2 {
231+
public function __construct(
232+
public int $keyName, // KEY_NAME
233+
) {}
234+
}
235+
```
236+
237+
Out of the box, strategies for `snake_case`, `camelCase` and `PascalCase` are provided,
238+
but it's trivial to implement your own:
239+
240+
```php
241+
class PrefixedNaming implements NamingStrategy {
242+
public function __construct(
243+
private readonly string $prefix,
244+
) {}
245+
246+
public function translate(PropertyReflection $property): string
247+
{
248+
return $this->prefix . $property->name();
249+
}
250+
}
251+
252+
#[SerializedName(new PrefixedNaming('$'))]
253+
class SiftTrackData {}
254+
```
255+
256+
## Required, nullable, optional and default values
257+
258+
By default, if a property is missing in serialized payload:
259+
260+
- nullable properties are just set to null
261+
- properties with a default value - use the default value
262+
- optional properties are set to `MissingValue::INSTANCE`
263+
- any other throw an exception
264+
265+
Here's an example:
266+
267+
```php
268+
class Item {
269+
public function __construct(
270+
public ?int $first, // set to null
271+
public bool $second = true, // set to true
272+
public Item $third = new Item(...), // set to Item instance
273+
public int|MissingValue $fourth, // set to MissingValue::INSTANCE
274+
public int $fifth, // required, throws if missing
275+
) {}
276+
}
277+
278+
// all keys missing -> throws for 'fifth' property
279+
$adapter->deserialize([])
280+
281+
// only required property -> uses null, default values and optional
282+
$adapter->deserialize(['fifth' => 123]);
283+
284+
// all properties -> fills all values
285+
$adapter->deserialize(['first' => 123, 'second' => false, ...]);
286+
```
287+
288+
## Flattening
289+
290+
Sometimes the same set of keys/types is shared between multiple other models. You could
291+
use inheritance for this, but we believe in composition over inheritance and hence provide
292+
a simple way to achieve the same behaviour without using inheritance:
293+
294+
Here's an example:
295+
296+
```php
297+
class Pagination {
298+
public function __construct(
299+
public readonly int $perPage,
300+
public readonly int $total,
301+
) {}
302+
}
303+
304+
class UsersPaginatedList {
305+
public function __construct(
306+
#[Flatten]
307+
public readonly Pagination $pagination,
308+
/** @var User[] */
309+
public readonly array $users,
310+
) {}
311+
}
312+
313+
// {"perPage": 25, "total": 100, "users": []}
314+
$adapter->serialize(
315+
new UsersPaginatedList(
316+
pagination: new Pagination(25, 100),
317+
users: [],
318+
)
319+
);
320+
```
321+
322+
## Use default value for unexpected
323+
324+
There are situations where you're deserializing data from a third party that doesn't have an API documentation
325+
or one that can't keep a backwards compatibility promise. One such case is when a third party uses an enum
326+
and you expect that new enum values might get added in the future by them. For example, imagine this structure:
327+
328+
```php
329+
enum CardType: string
330+
{
331+
case CLUBS = 'clubs';
332+
case DIAMONDS = 'diamonds';
333+
case HEARTS = 'hearts';
334+
case SPADES = 'spades';
335+
}
336+
337+
readonly class Card {
338+
public function __construct(
339+
public CardType $type,
340+
public string $value,
341+
) {}
342+
}
343+
```
344+
345+
If you get an unexpected value for `type`, you'll get an exception:
346+
347+
```php
348+
// UnexpectedEnumValueException: Expected one of [clubs, diamonds, hearts, spades], but got 'joker'
349+
$adapter->deserialize('{"type": "joker"}');
350+
```
351+
352+
So if you suspect that might happen, add a default value you wish to use (anything) and
353+
a `#[UseDefaultForUnexpected]` attribute:
354+
355+
```php
356+
readonly class Card {
357+
public function __construct(
358+
#[UseDefaultForUnexpected]
359+
public CardType $type = null,
360+
// Can be any other valid default value
361+
#[UseDefaultForUnexpected]
362+
public CardType $type2 = CardType::SPADES,
363+
) {}
364+
}
365+
```
366+
367+
Whenever that happens, a default value will be used instead. Optionally, you can also log such cases:
368+
369+
```php
370+
$serializer = (new SerializerBuilder())
371+
->reportUnexpectedDefault(function (BoundClassProperty $property, UnexpectedValueException $e) {
372+
$log->warning("Serializer used a default for unexpected value: {$e->getMessage()}", [
373+
'property' => $property->serializedName(),
374+
'exception' => $e,
375+
]);
376+
})
377+
->build();
378+
```
379+
380+
## Error handling
381+
382+
This is expected to be used with client-provided data, so good error descriptions is a must.
383+
These are some of the errors you'll get:
384+
385+
- Expected value of type 'int', but got 'string'
386+
- Expected value of type 'string', but got 'NULL'
387+
- Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database
388+
- Expected value of type 'string|int', but got 'boolean'
389+
- Expected one of [one, two], but got 'five'
390+
- Could not map item at key '1': Expected value of type 'string', but got 'NULL'
391+
- Could not map item at key '0': Expected value of type 'string', but got 'NULL' (and 1 more errors)."
392+
- Could not map property at path 'nested.field': Expected value of type 'string', but got 'integer'
393+
394+
All of these are just a chain of PHP exceptions with `previous` exceptions. Besides
395+
those messages, you have all of the thrown exceptions with necessary information.
396+
397+
## More formats
398+
399+
You can add support for more formats as you wish with your own type adapters.
400+
All of the existing adapters are at your disposal:
401+
402+
```php
403+
interface XmlTypeAdapter extends TypeAdapter {}
404+
405+
final class FromPrimitiveXmlTypeAdapter implements XmlTypeAdapter
406+
{
407+
public function __construct(
408+
private readonly PrimitiveTypeAdapter $primitiveAdapter,
409+
) {
410+
}
411+
412+
public function serialize(mixed $value): mixed
413+
{
414+
return xml_encode($this->primitiveAdapter->serialize($value));
415+
}
416+
417+
public function deserialize(mixed $value): mixed
418+
{
419+
return $this->primitiveAdapter->deserialize(xml_decode($value));
420+
}
421+
}
422+
```
68423

69424
## Why this over everything else?
70425

71-
There are some alternatives to this, but they usually lack one of the following:
426+
There are some alternatives to this, but all of them will lack at least one of these:
72427

73-
- stupid simple internal structure: no node tree, no value/JSON wrappers, no in-repo custom reflection implementation, no PHP parsing
74-
- doesn't rely on inheritance of serializable classes, hence allows serializing third-party classes
428+
- doesn't rely on inheritance, hence allows serializing third-party classes
75429
- parses existing PHPDoc information instead of duplicating it through attributes
76430
- supports generic types which are quite useful for wrapper types
77431
- allows simple extension through mappers and complex stuff through type adapters
78432
- produces developer-friendly error messages for invalid data
79433
- correctly handles optional (missing keys) and `null` values as separate concerns
80434
- simple to extend with additional formats
435+
- simple internal structure: no node tree, no value/JSON wrappers, no custom reflection / PHP parsing, no inherent limitations

0 commit comments

Comments
 (0)