schmittjoh / serializer

Library for (de-)serializing data of any complexity (supports JSON, and XML)
http://jmsyst.com/libs/serializer
MIT License
2.32k stars 585 forks source link

Union types complex #1549

Closed idbentley closed 3 months ago

idbentley commented 3 months ago
Q A
Bug fix? no
New feature? yes
Doc updated no
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets
License MIT

This pull request is part two, building on #1546.

I made some assumptions while making this change, and am very open to feedback. I've tested the Union functionality pretty thoroughly by running my external project's test suite against it, so I'm fairly confident that things work in general, but because of my inexperience with jms/serializer there may be many interactions that I've missed.

This adds Union support for objects in 2 ways:

  1. Discriminated Unions

This is the easy case. For objects that have a field which specifies their type, i.e.:

class DiscriminatedAuthor
{
    /**
     * @Type("string")
     * @SerializedName("full_name")
     */
    #[Type(name: 'string')]
    #[SerializedName(name: 'full_name')]
    private $name;

    /**
     * @Type("string")
     */
    #[Type(name: 'string')]
    private $type = 'JMS\Serializer\Tests\Fixtures\DiscriminatedAuthor';
    ..snip...
}

I added a new Annotation: UnionDiscriminator that allows you to use this field to determine which object to deserialize into.

    #[UnionDiscriminator(field: 'type')]
    private DiscriminatedAuthor|DiscriminatedComment $data;

For more complex cases, the UnionDiscriminator Annotation can also receive a map attribute, which will map from the values contained in the discriminator field, to the fully qualified class.

i.e.

    #[UnionDiscriminator(field: 'objectType', map: ['author' => 'JMS\Serializer\Tests\Fixtures\DiscriminatedAuthor', 'comment' => 'JMS\Serializer\Tests\Fixtures\DiscriminatedComment'])]
    private DiscriminatedAuthor|DiscriminatedComment $data;

Known issues with Discriminated Unions:

There is no "correct way" to deserialize into Union Types without discriminated fields, so I borrowed an approach I've seen in other languages (I recently added a similar implementation to the Python dataclasses_json package, and this approach is also similar to TypeScript's zod package).

Essentially, in order to deserialize a Union, I "try" to deserialize into the possible types one by one until I find a type that the data matches well, and I assume that is the correct type.

Step 1: I Sort the objects based on the number of required properties on the object (descending from most required properties to least) primitives are always sorted first.

Step 2: Attempt to deserialize into each type

Deserialization may fail for a couple reasons:

If JSON contains data of a type that doesn't match the expected type from the Object.

If the JSON does not contain a required property from the Object (This behaviour is configurable in the UnionHandler constructor - but for resolving potential conflicts, it's essentially mandatory).

Notes: If the JSON contains a field that the Object doesn't have a matching field for, then we just ignore that field - I don't treat that as a failure.

The bulk of the code supporting this change can be seen in the UnionHandler and the DerserializationGraphNavigator.

The enumeration of possible exceptions in UnionHandler seems like a point of fragility.

The introduction of requireAllRequiredProperties field on the JsonDeserializationVisitor is a bit peculiar - any feedback on a more idiomatic way to add this functionality is welcomed.

@scyzoryck @goetas - sorry that this PR is so big! I hope you'll agree it's worth the time to add.

JustSteveKing commented 3 months ago

This will be :fire:

idbentley commented 3 months ago

I moved the substantive changes into smaller PRs, and removed the non-discriminated union deserialization support: #1554 and #1553 .