How to configure the Symfony serializer to link relational fields when deserialising?

Viewed 35

Objective:

I'm importing a bunch of JSON files data into the database. Keeping the id fields the same as in the json files and link the relational id's to existing rows.

Problem:

When deserialising relational fields, the serialiser is inserting new empty records rather than linking them to existing rows.

Context:

I'm deserialising the files into respective entity objects. Let's focus on one called Region.json which has an entity called Region and has a ManyToOne relation to Country.

Here is a snippet from Region.json the fields are the same as the entity properties.

[
 {

   "id": 1,
   "name": "Aera",
   "code": AR",
   "country": 1, // relational field 
   "isActive": true,
 },
 {

   "id": 2,
   "name": "Mauw",
   "code": "MW",
   "country": 8, // relational field 
   "isActive": true,
 }
]

The deserialisation process is as follows:


public function getDeserializeData(): mixed
{
    $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
    $normalizers = [new ObjectNormalizer( classMetadataFactory: $classMetadataFactory,propertyTypeExtractor: new ReflectionExtractor()), new GetSetMethodNormalizer(), new ArrayDenormalizer()];
    $encoders = [new JsonEncoder(), new XmlEncoder(), new CsvEncoder()];

    $serializer = new Serializer(
        normalizers: $normalizers,
        encoders: $encoders
    );

    return $serializer->deserialize(
        $this->staticDataFile->getContents(),
        $this->getEntityNamespace() . '[]',
        $this->staticDataFile->getExtension()
    );
}

I'm using the ReflectionExtractor because are you can see the json data files have pre-defined ids and this can not be changed.

If I try to change the generated value strategy from 'IDENTITY' to 'NONE' I get the following error:

Entity of type App\Entity\Country is missing an assigned ID for field  'id'. The identifier generation strategy for this  
   entity requires the ID field to be populated before EntityManager#persist() is called. If you want automatically genera  
  ted identifiers instead you need to adjust the metadata mapping accordingly.  
1 Answers

You will likely need a custom (De-)Normalizer for this, designed for each specific entity, e.g. for Region. Then you know, which fields contain associated data like country and how to search for that data. Your normalizer will take the id from the input, get the country from the database and add it in place of the number. It could look roughly like this:

class RegionDenormalizer implements DenormalizerInterface
{
    public function __construct(
        private CountryRepository $countryRepository,
    ) {}

    public functionsupportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
    {
        return $type === Region::class;
    }

    public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
    {
        $country = $this->countryRepository->find($data[’country’];

        if (!$country instanceof Country)
        {
             // throw an Exception probably
        }

        $region = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
        $region->setCountry($country);
        // Probably also set the other fields
    }
}

You can also use $context to prevent your Denormalizer from being called twice, replace the id with the country in data and then use the original ObjectNormalizer. This is a bit more complicated, but I prefer this:

class RegionDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;
    public function __construct(
        private CountryRepository $countryRepository,
    ) {}

    public functionsupportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
    {
        return $type === Region::class
            && !in_array($data[‘id’], $context[‘visited_regions’] ?? []);
    }

    public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
    {
        $innerContext = $context;
        $innerContext[‘visited_regions’][] = $data[‘id’];
        $country = $this->countryRepository->find($data[’country’];

        if (!$country instanceof Country)
        {
             // throw an Exception probably
        }

        $innerContext = $context;
        // By setting this inner context, we prevent this listener from being called again for this region
        $innerContext[‘visited_regions’][] = $data[‘id’];
        // By replacing the country in data, we now have the expected country instead of the id or a new entity
        $data[‘country’] = $country;

        return $this->denormalizer->denormalize($data, $type, $innerContext);
    }
}

I prefer this, because I don’t have to care about how to deserialize the region itself, only about replacing the country-id with the actual instance, but handling the context is more difficult.

Note: the single quotes in the code samples are wrong, because I am typing this on an iPad. You will have to replace them.

Related