Skip to content

Commit 145e802

Browse files
authored
feat: Polymorphic adapter (#10)
This reverts commit aae982b.
1 parent 84d3d1d commit 145e802

22 files changed

+1049
-437
lines changed

README.md

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

5556
$jsonAdapter = $serializer->adapter(
5657
JsonTypeAdapter::class,
5758
NamedType::wrap(Item::class, [PrimitiveType::int()])
5859
);
59-
$jsonAdapter->deserialize('{"int": 123, ...}') // -> new Item(123, ...)
60+
// new Item(123, ...)
61+
$jsonAdapter->deserialize('{"int": 123, ...}');
6062
```
6163

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

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-
```
66+
Basic documentation is available in [docs/](docs). For examples, you can look at the
67+
test suite: [tests/Integration](tests/Integration).
42368

42469
## Why this over everything else?
42570

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

428-
- doesn't rely on inheritance, hence allows serializing third-party classes
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
42975
- parses existing PHPDoc information instead of duplicating it through attributes
43076
- supports generic types which are quite useful for wrapper types
43177
- allows simple extension through mappers and complex stuff through type adapters
43278
- produces developer-friendly error messages for invalid data
43379
- correctly handles optional (missing keys) and `null` values as separate concerns
43480
- 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)