dacuna
dacuna

Reputation: 1096

Deserialize an entity with a relationship with Symfony Serializer Component

I'm trying to deserialize an entity with a relationship using the symfony serializer component. This is my entity:

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Document
 *
 * @ORM\Table(name="document")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\DocumentRepository")
 */
class Document
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Genre", inversedBy="documents")
     * @ORM\JoinColumn(name="id_genre", referencedColumnName="id")
     */
    private $genre;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=100)
     */
    private $name;

    //getters and setters down here
    ...
}

And the Genre entity:

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * Genre
 *
 * @ORM\Table(name="genre")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\GenreRepository")
 */
class Genre
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=50, nullable=true)
     */
    private $name;

    /**
     * @ORM\OneToMany(targetEntity="Document", mappedBy="genre")
     */
    private $documents;

    public function __construct()
    {
        $this->documents= new ArrayCollection();
    }

    //getters and setters down here
    ....
}

In my controller action right now I'm trying this:

$encoders = array(new JsonEncoder());
$normalizers = array(new ObjectNormalizer());
$serializer = new Serializer($normalizers, $encoders);

$document = $serializer->deserialize($request->getContent(), 'AppBundle\Entity\Document', 'json');

And my json data:

{"name": "My document", "genre": {"id": 1, "name": "My genre"}}

But I got the next error:

Expected argument of type "AppBundle\Entity\Genre", "array" given (500 Internal Server Error)

Is possible to deserialize a json request with an entity with relations inside?

Thanks in advace.

Upvotes: 17

Views: 27759

Answers (6)

enricog
enricog

Reputation: 4273

In case anyone else stumbles upon this issue nowadays. I created a solution based on @Gimsly answer. My solution uses Symfony 5.3 and also adds a custom Denormalizer to handle the loading of Doctrine Entities. It would additionally allow to update related existing entities by calling the ObjectNormalizer::denormalize with the OBJECT_TO_POPULATE context. If an update of the existing related object should then be allowed or not can be configured via the Serializer groups configuration.

namespace App\Serializer;

use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectRepository;
use InvalidArgumentException;
use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;

class DoctrineEntityDenormalizer implements ContextAwareDenormalizerInterface
{
    use DenormalizerAwareTrait;

    protected $doctrine;

    public function __construct(ObjectNormalizer $denormalizer, ManagerRegistry $doctrine)
    {
        $this->setDenormalizer($denormalizer);
        $this->setDoctrine($doctrine);
    }

    public function denormalize($data, string $type, string $format = null, array $context = [])
    {
        if (null === $this->denormalizer) {
            throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
        }
        $repository = $this->getRepository($type);
        if (!$repository instanceof ObjectRepository) {
            throw new InvalidArgumentException('No repository found for given type, '.$type.'.');
        }
        $entity = null;
        if (is_numeric($data) || is_string($data)) {
            $entity = $repository->find($data);
        } elseif (is_array($data) && isset($data['id'])) {
            $entity = $repository->find($data['id']);
        }
        if (is_null($entity)) {
            throw new InvalidArgumentException('No Entity found for given id of type, '.$type.'.');
        }
        // Denormalize into the found entity with given data by using the default ObjectNormalizer
        $tmpContext = array_merge($context, [
            AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
        ]);
        $entity = $this->denormalizer->denormalize($data, $type, $format, $tmpContext);

        return $entity;
    }

    public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
    {
        if (null === $this->denormalizer) {
            throw new BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" '
                    . 'to be used.', __METHOD__));
        }

        $repository = $this->getRepository($type);
        // Check that it s an Entity of our App and a Repository exist for it
        // Also only use the denormalizer if an ID is set to load from the Repository.
        return strpos($type, 'App\\Entity\\') === 0 && !is_null($repository) && (is_numeric($data) || is_string($data)
                || (is_array($data) && isset($data['id'])));
    }

    protected function getDoctrine(): ManagerRegistry
    {
        return $this->doctrine;
    }

    protected function setDoctrine(ManagerRegistry $doctrine): void
    {
        $this->doctrine = $doctrine;
    }

    protected function getRepository(string $class): ?ObjectRepository
    {
        $result = null;
        try {
            $entityManager = $this->getDoctrine()->getManagerForClass($class);
            if (!is_null($entityManager)) {
                $result = $entityManager->getRepository($class);
            }
        } catch (\Exception $ex) {
            // Manager could not be resolved
        }
        return $result;
    }
}

Example serializer definitions of the entities:

App\Entity\Group:
    attributes:
        id:
            groups: ['group:read']
        name:
            groups: ['group:read', 'group:write']

App\Entity\Account:
    attributes:
        id:
            groups: ['account:read']
        name:
            groups: ['account:read', 'account:write']
        branchGroups:
            groups: ['account:read', 'account:write']
            max_depth: 1

The entities would look like the following:

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class Account
{
    protected $id;

    protected $name;

    protected $groups;

    public function __construct()
    {
        $this->groups = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name)
    {
        $this->name = $name;
        return $this;
    }

    public function addGroup(BranchGroup $group)
    {
        $this->groups->add($group);
        return $this;
    }

    public function removeGroup(Group $Group)
    {
        $this->groups->removeElement($group);
        return $this;
    }

    public function getGroups()
    {
        return $this->groups;
    }

    public function setGroups(iterable $groups)
    {
        $this->groups->clear();
        foreach ($groups as $group) {
            $this->addGroup($group);
        }
        return $this;
    }
}

namespace App\Entity;

class Group
{
    private $id;

    protected $name = '';

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name)
    {
        $this->name = $name;
        return $this;
    }

}

Creating/Updating an Account with an existing Group would then look like this:

$entity = $this->fetchEntityToUpdate();
jsonData = json_encode([
    'name' => 'newAccountName',
    'groups' => [
        [
           'id' => 1
        ]
    ]
]);
$context = [
    'groups' => ['account:write'],
    AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
];
$entity = $serializer->deserialize($jsonData, Account::class, 'json', $context);

This would now only add/remove the Group from the Account. If I now want to additionally update the related Group object I could just add the additional serialization group group:write into the serialization context.

Hope this helps someone who stumbles upon this.

Upvotes: 2

vctls
vctls

Reputation: 735

What the Symfony documentation calls "Recursive Denormalization", starting from version 3.3, enables you to denormalize associated objects and their properties, and will specifically resolve the type error mentioned in the question.

But this does not make the objects managed by Doctrine! This means that associations that have not been normalized will be null and will not be automatically fetched from the database.
This method may be suitable for you if what you want is a snapshot of the data at the time of serialization.
If you need the objects to be managed, you have to fetch or merge them with the EntityManager.
This solution will denormalize identifiers into their corresponding managed entities by fetching them from the database. Of course, you have to keep in mind that the normalized data may not correspond to the current state of the database. IDs may have changed or been deleted, etc.

In order for Symfony to find the property types of the serialized objects, it needs to use the PropertyInfo component, which, as @slk500 stated in his answer, has to be activated in the framework configuration.

So, if you are using the full framework, all you need to do in order to deserialize nested json objects is this:

1.Enable the serializer and the property info components in config.yml:

framework:
    #...
    serializer: { enabled: true }
    property_info: { enabled: true }
  1. Then inject the serializer wherever you need it:
<?php
// src/AppBundle/Controller/DefaultController.php
namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\HttpFoundation\Request;

class DefaultController extends Controller
{
    public function indexAction(SerializerInterface $serializer, Request $request)
    {
        $document = $serializer->deserialize($request->getContent(), 'AppBundle\Entity\Document', 'json');
        // ...
    }
}

The default features of these components were enough for my needs.
Autowiring takes care of the basic service declaration, so unless you need specific normalizers, you don't even have to edit the services.yml configuration file. Depending on your use cases, you may have to enable specific features. Check the Serializer and PropertyInfo documentation for (hopefully) more specific use cases.

Upvotes: 1

Rafael Rocha
Rafael Rocha

Reputation: 1

If you are using JMS Serializer, you can use this code and the serializer will search for relation in database.

services.yml

services:
    app.jms_doctrine_object_constructor:
        class: AppBundle\Services\JMSDoctrineObjectConstructor
        arguments: ['@doctrine', '@jms_serializer.unserialize_object_constructor']

    jms_serializer.object_constructor:
        alias: app.jms_doctrine_object_constructor
        public: false

AppBundle\Services\JMSDoctrineObjectConstructor.php

<?php

namespace AppBundle\Services;

use Doctrine\Common\Persistence\ManagerRegistry;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\VisitorInterface;
use JMS\Serializer\Construction\ObjectConstructorInterface;

/**
 * Doctrine object constructor for new (or existing) objects during deserialization.
 */
class JMSDoctrineObjectConstructor implements ObjectConstructorInterface
{
    private $managerRegistry;
    private $fallbackConstructor;

    /**
     * Constructor.
     *
     * @param ManagerRegistry $managerRegistry Manager registry
     * @param ObjectConstructorInterface $fallbackConstructor Fallback object constructor
     */
    public function __construct(ManagerRegistry $managerRegistry, ObjectConstructorInterface $fallbackConstructor)
    {
        $this->managerRegistry = $managerRegistry;
        $this->fallbackConstructor = $fallbackConstructor;
    }

    /**
     * {@inheritdoc}
     */
    public function construct(VisitorInterface $visitor, ClassMetadata $metadata, $data, array $type, DeserializationContext $context)
    {
        // Locate possible ObjectManager
        $objectManager = $this->managerRegistry->getManagerForClass($metadata->name);

        if (!$objectManager) {
            // No ObjectManager found, proceed with normal deserialization
            return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
        }

        // Locate possible ClassMetadata
        $classMetadataFactory = $objectManager->getMetadataFactory();

        if ($classMetadataFactory->isTransient($metadata->name)) {
            // No ClassMetadata found, proceed with normal deserialization
            return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
        }

        // Managed entity, check for proxy load
        if (!is_array($data)) {
            // Single identifier, load proxy
            return $objectManager->getReference($metadata->name, $data);
        }

        // Fallback to default constructor if missing identifier(s)
        $classMetadata = $objectManager->getClassMetadata($metadata->name);
        $identifierList = array();

        foreach ($classMetadata->getIdentifierFieldNames() as $name) {
            if (!array_key_exists($name, $data)) {
                return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
            }

            $identifierList[$name] = $data[$name];
        }

        // Entity update, load it from database

        if (array_key_exists('id', $identifierList) && $identifierList['id']) {
            $object = $objectManager->find($metadata->name, $identifierList);
        } else {
            $object = new $metadata->name;
        }

        $objectManager->initializeObject($object);

        return $object;
    }
}

Upvotes: 0

Gimsly
Gimsly

Reputation: 306

For anyone who is working on this in '18. I've managed to get this working using two different approaches.

The associated entities I'm working with.

class Category
{
     /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", name="name", length=45, unique=true)
     */
    private $name;
}

class Item
{
     /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", name="uuid", length=36, unique=true)
     */
    private $uuid;

    /**
     * @ORM\Column(type="string", name="name", length=100)
     */
    private $name;

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\Category", fetch="EAGER")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id", nullable=false)
     */
    private $category;
}

Method 1: Using Form Classes

#ItemType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use App\Entity\Category;
use App\Entity\Item;

class ItemType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('category', EntityType::class, [
                'class' => Category::class,
                'choice_label' => 'name',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Item::class,
        ));
    }
}

#ItemController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use App\Entity\Item;
use App\Form\ItemType;

class ItemController extends BaseEntityController
{
    protected $entityClass = Item::class;

    /**
     * @Route("/items", methods="POST")
     */
    public function createAction(Request $request)
    {
        $data = $request->getContent();
        $item = new Item();
        $form = $this->createForm(ItemType::class, $item);
        $decoded = $this->get('serializer')->decode($data, 'json');
        $form->submit($decoded);

        $object = $form->getData();

        $entityManager = $this->getDoctrine()->getManager();
        $entityManager->persist($object);
        $entityManager->flush();

        return $this->generateDataResponse("response text", 201);
    }
}

Method 2: A Custom Normalizer

The PropertyInfo Component needs to be enabled.

#/config/packages/framework.yaml
framework:
    property_info:
        enabled: true

Register the custom normalizer.

#/config/services.yaml
services:
    entity_normalizer:
        class: App\SupportClasses\EntityNormalizer
        public: false
        autowire: true
        autoconfigure: true
        tags: [serializer.normalizer]

The custom normalizer.

#EntityNormalizer.php
namespace App\SupportClasses;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;


class EntityNormalizer extends ObjectNormalizer
{
    protected $entityManager;

    public function __construct(
        EntityManagerInterface $entityManager,
        ?ClassMetadataFactoryInterface $classMetadataFactory = null,
        ?NameConverterInterface $nameConverter = null,
        ?PropertyAccessorInterface $propertyAccessor = null,
        ?PropertyTypeExtractorInterface $propertyTypeExtractor = null
    ) {
        $this->entityManager = $entityManager;

        parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor);
    }

    public function supportsDenormalization($data, $type, $format = null)
    {
        return (strpos($type, 'App\\Entity\\') === 0) && 
        (is_numeric($data) || is_string($data) || (is_array($data) && isset($data['id'])));
    }

    public function denormalize($data, $class, $format = null, array $context = [])
    {
        return $this->entityManager->find($class, $data);
    }
}

Our controller's create action.

#ItemController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use App\Entity\Item;
use App\Form\ItemType;

class ItemController extends BaseEntityController
{
    protected $entityClass = Item::class;

    /**
     * @Route("/items", methods="POST")
     */
    public function createAction(Request $request)
    {
        $data = $request->getContent();
        $object = $this->get('serializer')->deserialize($data, $this->entityClass, 'json');

        $entityManager = $this->getDoctrine()->getManager();
        $entityManager->persist($object);
        $entityManager->flush();

        return $this->generateDataResponse('response text', 201);
    }
}

This has worked for me. I received inspiration from: https://medium.com/@maartendeboer/using-the-symfony-serializer-with-doctrine-relations-69ecb17e6ebd

I modified the normalizer to allow me to send the category as a child json object which is converted to a child array when the data is decoded from json. Hopefully this helps someone.

Upvotes: 8

slk500
slk500

Reputation: 781

It works now.You have to enable property_info in config.yml:

  framework:
            property_info:
                    enabled: true

Upvotes: 3

Th&#233;o
Th&#233;o

Reputation: 656

Yes and no. First, you shouldn't re-create a new instance of the serializer in your controller but use the serializer service instead.

Second, no it's not possible out of the box with Symfony serializer. We are doing it in https://api-platform.com/ but there is a bit of magic there. That said, a PR has been made to support it: https://github.com/symfony/symfony/pull/19277

Upvotes: 9

Related Questions