Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[Serializer] Serialize and deserialize from abstract classes #24375

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

sroze
Copy link
Contributor

@sroze sroze commented Sep 29, 2017

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets  ø
License MIT
Doc PR Not yet

This PR adds a feature in the Serializer: allow to serialize and de-serialize abstract classes. Such feature is especially useful when dealing with domain objects.

Example

Let's take the example of the following objects:

  • CodeRepository defines a set of properties like name and url
  • GitHubCodeRepository and BitBucketCodeRepository extends from the abstract CodeRepository class and adds a few properties.
  • Project has a relation with a codeRepository, which has a type CodeRepository.

At the moment, the serializer can't serialize/deserialize correctly this Project object has it doesn't know how to deal with this CodeRepository abstract object.

This feature allows the serializer to deal with such situation. The ObjectNormalizer has now access to a ClassDiscriminatorResolver that knows, for a given abstract class:

  • Is the "type" property it needs to read/write to uniquely identify each sub-class
  • What's the name of the "type" for each sub-class mapping

Usage without Framework Bundle

$discriminatorResolver = new ClassDiscriminatorResolver();
$discriminatorResolver->addClassMapping(CodeRepository::class, new ClassDiscriminatorMapping('type', [
    'github' => GitHubCodeRepository::class,
    'bitbucket' => BitBucketCodeRepository::class,
]));

$serializer = new Serializer(array(new ObjectNormalizer(null, null, null, null, $discriminatorResolver)), array('json' => new JsonEncoder()));

$serialized = $serializer->serialize(new GitHubCodeRepository());
// {"type": "github"}

$repository = $serializer->unserialize($serialized, CodeRepository::class, 'json');
// GitHubCodeRepository

Usage with the Framework Bundle

framework:
    serializer:
        discriminator_class_mapping:
            App\CodeRepository:
                 type_property: type
                 mapping:
                    github: App\GitHubCodeRepository
                    bitbucket: App\BitBucketCodeRepository

Usage with Annotations/XML/YAML

use Symfony\Component\Serializer\Annotation\DiscriminatorMap;

/**
 * @DiscriminatorMap(typeProperty="type", mapping={
 *    "first"="Symfony\Component\Serializer\Tests\Fixtures\AbstractDummyFirstChild",
 *    "second"="Symfony\Component\Serializer\Tests\Fixtures\AbstractDummySecondChild"
 * })
 */
abstract class AbstractDummy
{
    public $foo;

    public function __construct($foo = null)
    {
        $this->foo = $foo;
    }
}

TODO

  • Working as standalone
  • Working with the framework bundle
  • Tests on mapping classes

private $typeProperty;
private $typesMapping;

public function __construct(string $typeProperty, array $typesMapping = array())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be compatible with PHP 5.4 to target 3.4.

return $this->typeProperty;
}

public function addTypeMapping(string $type, string $typeClass)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to remove this method? In the PropertyInfo component we used immutable objects.

/**
* @author Samuel Roze <samuel.roze@gmail.com>
*/
final class ClassDiscriminatorResolver
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should implement an interface as it's not a value object (it has a behavior).

/**
* {@inheritdoc}
*/
protected function instantiateObject(array &$data, $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes, string $format = null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you move this in the parent class? It will make it easier to support for this feature in other normalizers.

@dunglas
Copy link
Member

dunglas commented Sep 29, 2017

Great, I like it a lot. Would fit perfectly to denormalize JSON-LD documents (@type).

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from c472eaa to b3835d3 Compare September 29, 2017 13:32
@sroze
Copy link
Contributor Author

sroze commented Sep 29, 2017

@dunglas updated based on your requests and added the framework bundle bridge.

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from cf3ee7d to eab4a89 Compare September 29, 2017 13:59
@sroze
Copy link
Contributor Author

sroze commented Sep 29, 2017

Can't get the bottom of this Travis issue for PHP 7+ 🤔

*/
public function addClassMapping($class, ClassDiscriminatorMapping $mapping)
{
if (isset($this->mapping[$class])) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about injecting the mappings through a constructor and making this verification at container compile time, not runtime?

*/
public function getMappingForClass($class)
{
if (isset($this->mapping[$class])) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think array_key_exists can safely replace isset here

@theofidry
Copy link
Contributor

theofidry commented Sep 29, 2017

@sroze could you provide a sample of the problematic code? I do have similar relationships in my codebase and it works, because at the end of the day the class you need to serialize is not CodeRepository but GitHubRepository or BitbucketRepository which the serializer knows how to handle.

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from 95313de to 24c1e9e Compare September 29, 2017 16:20
@sroze
Copy link
Contributor Author

sroze commented Sep 29, 2017

@theofidry the tricky bit is the de-serialization, it would complain about not being able to instanciate an abstract class 😉

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from f779b20 to 8caf193 Compare September 29, 2017 20:05
@jderusse
Copy link
Member

Is this feature extensible to everything? and not only abstract classes.

Would love to use $message = $serializer->unserialize($data, MessageInterface::class, 'json');
Without need to extend an abstract class nor writing a the list of existing mapping.

Mapping could be done by using the basename of the Entity classname (serialize), and reverse mapping by browsing a list of known namespaces and chaking if the class name exists.

WDYT?

@ogizanagi
Copy link
Contributor

ogizanagi commented Sep 29, 2017

I didn't look at this deep, but:

  1. why do we expose both framework.serializer.discriminator_class_mapping and yml/xml/annotations for this?
  2. why allowing to pass a ClassDiscriminatorResolverInterface to ObjectNormalizer instead of relying on the metadata already registered?
  3. What about allowing to call a resolver instead of a property?

I agree this feature could be useful not only with abstract classes.

/**
* @author Samuel Roze <samuel.roze@gmail.com>
*/
final class ClassDiscriminatorMapping
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't make classes "final" by default, eg for data objects. Is it the case here? If there is no special reason, I'd suggest to remove them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a data object. Happy to remove the final even tho I believe closed-first is the best approach 😜

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nicolas-grekas removed the final 👍

@sroze
Copy link
Contributor Author

sroze commented Sep 30, 2017

Would love to use $message = $serializer->unserialize($data, MessageInterface::class, 'json');

@jderusse yes, good point, I'll do this.

Mapping could be done by using the basename of the Entity classname (serialize), and reverse mapping by browsing a list of known namespaces and chaking if the class name exists.

That's a very good point as well, this could potentially be automated using a class name => class name mapping. But I believe it would be better to start with an explicit configuration as I wouldn't personally advise to use class names within serialised documents. If we see people having no problem with this and see the DX can be drastically improved this way, why not :)

why do we expose both framework.serializer.discriminator_class_mapping and yml/xml/annotations for this?
why allowing to pass a ClassDiscriminatorResolverInterface to ObjectNormalizer instead of relying on the metadata already registered?

@ogizanagi The reason is that I started by exposing the discriminator_class_mapping service but @dunglas told me he would prefer to have some configuration looking like the existing one. I have kept both as I believe it gives us a greater configuration flexibility but happy to keep only one.

What about allowing to call a resolver instead of a property?

@ogizanagi I don't get the question.

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from d668d4d to 4a37a02 Compare September 30, 2017 12:17
@sroze
Copy link
Contributor Author

sroze commented Sep 30, 2017

Serializer's functional tests are the only one failing, with an exception with the newly introduced class:

1) Symfony\Bundle\FrameworkBundle\Tests\Functional\SerializerTest::testDeserializeArrayOfObject
Error: Class 'Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata' not found

Any idea why @dunglas @nicolas-grekas ?

private $classMetadataFactory;

/**
* @param ClassMetadataFactoryInterface $classMetadataFactory
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this phpdoc

@@ -24,12 +24,18 @@

<service id="serializer.property_accessor" alias="property_accessor" />

<!-- Discriminator Map -->
<service id="serializer.mapping.class_discriminator_resolver" class="Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This service must be private.
Can you alias serializer.mapping.class_discriminator_resolver to ClassDiscriminatorResolverInterface. It will allow to use autowiring.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is private, as it's in the defaults. Added the alias.

*/
public function __construct(array $data)
{
if (!isset($data['typeProperty']) || !$data['typeProperty']) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isset is faster and null should throw an exception isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, null or a falsy contents should throw an exception, hence isset.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what's empty made for

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty fails if the key do not exists.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sroze : Hmm, no it doesn't 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant:

http://php.net/manual/en/function.empty.php

empty() does not generate a warning if the variable does not exist.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from http://php.net/manual/en/function.empty.php

No warning is generated if the variable does not exist. That means empty() is essentially the concise equivalent to !isset($var) || $var == false.

see https://3v4l.org/24O2e

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used empty so we are all happy. Not sure about ?? within a if and throwing an exception...

$this->typeProperty = $data['typeProperty'] ?? throw new ...;

??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh. I was sure it was warninging, thanks ❤️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used it and added tests that proves it works 👍

throw new InvalidArgumentException(sprintf('Parameter "typeProperty" of annotation "%s" cannot be empty.', get_class($this)));
}

if (!isset($data['mapping']) || empty($data['mapping'])) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can change empty to ! like in the previous test (or just use empty and remove the call to isset).

$metadata = $this->classMetadataFactory->getMetadataFor($object);

if (null !== $metadata->getClassDiscriminatorMapping()) {
return $metadata->getClassDiscriminatorMapping();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing the result in a local cache will improve performance (especially in the case where reflection is involved).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, added local cache bellow 👍 (the class metadata factory is already cached)

*/
public function getTypeForMappedObject($object)
{
if (null === ($mapping = $this->getMappingForMappedObject($object))) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parenthesis can be removed.

{
parent::__construct($classMetadataFactory, $nameConverter);

$this->propertyTypeExtractor = $propertyTypeExtractor;
$this->classDiscriminatorResolver = $classDiscriminatorResolver ?: (null !== $classMetadataFactory ? new ClassDiscriminatorFromClassMetadata($classMetadataFactory) : null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try to avoid imbricating ternaries (for readability). Can you switch to a if construct?

@@ -98,6 +99,12 @@ protected function extractAttributes($object, $format = null, array $context = a
*/
protected function getAttributeValue($object, $attribute, $format = null, array $context = array())
{
if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForMappedObject($object)) {
if ($attribute == $mapping->getTypeProperty()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can merge both if right?

@@ -52,6 +54,22 @@ public function testLoadGroups()
$this->assertEquals(TestClassMetadataFactory::createClassMetadata(), $classMetadata);
}

public function testLoadDiscriminatorMap()
{
$classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\AbstractDummy');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use AbstractDummy::class here too (recommended for new code)

@@ -0,0 +1,15 @@
<?php
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those files should me removed now isn't it?

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from 9e12d44 to d21d2f9 Compare October 1, 2017 16:53
@sroze
Copy link
Contributor Author

sroze commented Oct 1, 2017

@jderusse this is now working with interfaces as well.
@ogizanagi I've removed the configuration via the FrameworkBundle, only kept annotations/xml/yaml.
@dunglas added the resolver local cache and changed the CS.

@sroze
Copy link
Contributor Author

sroze commented Oct 1, 2017

Test errors are normal as the functional tests are using dev-master components and therefore don't get the class introduced and used while creating the serializer. Should be ready 🤗

*/
protected $classDiscriminatorResolver;

public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to keep exposing this $classDiscriminatorResolver argument? Configuring everything through metadata looks enough to me, isn't it?

Copy link
Member

@dunglas dunglas Oct 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it. We may leverage this extension point with API Platform to remove a lot of code related to abstract classes handling. But we need to hook our own metadata system.

@fabpot
Copy link
Member

fabpot commented Oct 1, 2017

Moving this one to 4.1. There is no need to rush out such a new feature.

@fabpot fabpot added this to the 4.1 milestone Oct 1, 2017
@sroze sroze changed the base branch from 3.4 to master October 2, 2017 07:10
@sroze
Copy link
Contributor Author

sroze commented Oct 2, 2017

@fabpot changed the PR to be based on master and used PHP 7 things. The bot isn't happy about PHP7 yet tho 😉

* @var ClassMetadataFactoryInterface
*/
private $classMetadataFactory;
private $mappingForMappedObjectCache = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the short syntax for arrays has been accepted. Php-CS-fixer still has the long syntax specified in the config file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if we target PHP7+ we need to accept this :)

Copy link
Contributor

@afurculita afurculita Oct 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you. But fabbot is still complaining. I think this rule should be removed from php_cs.dist and from fabbot, to permit new developments to use the short syntax. Would you like to open an issue / PR about this? At this point, according to this PR #22862, they try to avoid merge conflicts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#24396 👍

* @param string $typeProperty
* @param array $typesMapping
*/
public function __construct($typeProperty, array $typesMapping = [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string can be added as typehint and the phpdoc removed

*
* @return ClassDiscriminatorMapping|null
*/
public function getMappingForClass(string $class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A return type can be specified for each method

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are targeting PHP 7.0, not 7.1, right? Can't add the return type when it can return null then 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symfony 4 supports PHP >= 7.1.3

@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch 2 times, most recently from 72ee16a to 5a7f984 Compare October 23, 2017 09:59
@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from 5a7f984 to a83ad3f Compare November 24, 2017 12:48
Add the `discriminator_class_mapping` in the framework extension
Discriminator map is compatible with interfaces
@sroze sroze force-pushed the serialize-and-deserialize-from-abstract-classes branch from 5271a72 to 4c70e94 Compare December 3, 2017 11:55
@sroze
Copy link
Contributor Author

sroze commented Dec 3, 2017

Rebased on master & squashed all the commits. Failure on DEPS=low is expected until we merge it.

For you @symfony/deciders 👍

@nicolas-grekas
Copy link
Member

A failure on deps=low is almost never legitimate. Should be doubly checked.

@sroze
Copy link
Contributor Author

sroze commented Dec 3, 2017

@nicolas-grekas a new class used by FrameworkBundle's configuration (in the declared services). Should we add it only when the class exists or something? 🤔

@nicolas-grekas
Copy link
Member

class_exists if possible is best, instead of bumping the minimum version

@sroze
Copy link
Contributor Author

sroze commented Dec 3, 2017

Good shout, just made the FrameworkBundle able to deal without the class 👍

And it's green now. 💚

@fabpot
Copy link
Member

fabpot commented Dec 7, 2017

Thank you @sroze.

@fabpot fabpot closed this Dec 7, 2017
fabpot added a commit that referenced this pull request Dec 7, 2017
…lasses (sroze)

This PR was squashed before being merged into the 4.1-dev branch (closes #24375).

Discussion
----------

[Serializer] Serialize and deserialize from abstract classes

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | ø
| License       | MIT
| Doc PR        | Not yet

This PR adds a feature in the Serializer: allow to serialize and de-serialize abstract classes. Such feature is especially useful when dealing with domain objects.

# Example

Let's take the example of the following objects:
- `CodeRepository` defines a set of properties like `name` and `url`
- `GitHubCodeRepository` and `BitBucketCodeRepository` extends from the abstract `CodeRepository` class and adds a few properties.
- `Project` has a relation with a `codeRepository`, which has a type `CodeRepository`.

At the moment, the serializer can't serialize/deserialize correctly this `Project` object has it doesn't know how to deal with this `CodeRepository` abstract object.

This feature allows the serializer to deal with such situation. The `ObjectNormalizer` has now access to a `ClassDiscriminatorResolver` that knows, for a given abstract class:
- Is the "type" property it needs to read/write to uniquely identify each sub-class
- What's the name of the "type" for each sub-class mapping

# Usage without Framework Bundle

```php
$discriminatorResolver = new ClassDiscriminatorResolver();
$discriminatorResolver->addClassMapping(CodeRepository::class, new ClassDiscriminatorMapping('type', [
    'github' => GitHubCodeRepository::class,
    'bitbucket' => BitBucketCodeRepository::class,
]));

$serializer = new Serializer(array(new ObjectNormalizer(null, null, null, null, $discriminatorResolver)), array('json' => new JsonEncoder()));

$serialized = $serializer->serialize(new GitHubCodeRepository());
// {"type": "github"}

$repository = $serializer->unserialize($serialized, CodeRepository::class, 'json');
// GitHubCodeRepository

```

# Usage with the Framework Bundle

```yaml
framework:
    serializer:
        discriminator_class_mapping:
            App\CodeRepository:
                 type_property: type
                 mapping:
                    github: App\GitHubCodeRepository
                    bitbucket: App\BitBucketCodeRepository
```

# Usage with Annotations/XML/YAML

```php
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;

/**
 * @DiscriminatorMap(typeProperty="type", mapping={
 *    "first"="Symfony\Component\Serializer\Tests\Fixtures\AbstractDummyFirstChild",
 *    "second"="Symfony\Component\Serializer\Tests\Fixtures\AbstractDummySecondChild"
 * })
 */
abstract class AbstractDummy
{
    public $foo;

    public function __construct($foo = null)
    {
        $this->foo = $foo;
    }
}
```

# TODO

- [x] Working as standalone
- [x] Working with the framework bundle
- [x] Tests on mapping classes

Commits
-------

4c6e05b [Serializer] Serialize and deserialize from abstract classes
@sroze
Copy link
Contributor Author

sroze commented Dec 7, 2017 via email

@sroze sroze deleted the serialize-and-deserialize-from-abstract-classes branch December 8, 2017 09:45
javiereguiluz added a commit to symfony/symfony-docs that referenced this pull request Jan 9, 2018
…n of interfaces and abstract classes (sroze, javiereguiluz)

This PR was merged into the master branch.

Discussion
----------

[Serializer] Add documentation about the (de)serialization of interfaces and abstract classes

Fixes #9011

Add documentation for symfony/symfony#24375

Commits
-------

ac831b0 Minor fixes and some tweaks
61b9c53 Add documentation about the (de)serialization of interfaces and abstract classes
@fabpot fabpot mentioned this pull request May 7, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.