Home > front end >  How to properly populate an ArrayCollection property of a DTO using Symfony's serializer?
How to properly populate an ArrayCollection property of a DTO using Symfony's serializer?

Time:11-11

I have a DTO class looking like:

class ParamsDto
{
    #[Assert\Type(ArrayCollection::class)]
    #[Assert\All([
        new Assert\Type('digit'),
        new Assert\Positive(),
    ])]
    private ?ArrayCollection $tagIds = null;

    public function getTagIds(): ?ArrayCollection
    {
        return $this->tagIds;
    }

    public function setTagIds(?ArrayCollection $tagIds): self
    {
        $this->tagIds = $tagIds;

        return $this;
    }
}

Given a request to a url like https://domain.php?tag-ids[]=2, I'd like to parse the tag-ids request param into this DTO's tagIds property.

First step I did, I created a name converter, so I can convert between tag-ids and tagIds, so my serializer instantiation looks like:

$nameConverter = new EducationEntrySearchParamNameConverter();
$serializer = new Serializer([
    new ArrayDenormalizer(),
    new ObjectNormalizer(null, $nameConverter, null, new ReflectionExtractor()),
], [new JsonEncoder()]);

$params = $serializer->denormalize($requestParams, ParamsDto::class);

where $params shows as:


^ App\DataTransferObject\ParamsDto {#749
  -tagIds: Doctrine\Common\Collections\ArrayCollection {#753
    -elements: []
  }
}

So it is instantiated but it is empty.
Most likely because my request does not include the elements key in it. If I do a bit of preprocessing, like:

$requestParams = [];
foreach ($request->query->all() as $key => $value) {
    if (in_array($key, ['tag-ids'])) {
        $requestParams[$key] = ['elements' => $value];
    } else {
        $requestParams[$key] = $value;
    }
}
$params = $serializer->denormalize($requestParams, ParamsDto::class);

then I get the right output:

^ App\DataTransferObject\ParamsDto {#749
  -tagIds: Doctrine\Common\Collections\ArrayCollection {#757
    -elements: array:1 [
      0 => "2"
    ]
  }
}

How do I do it in a way that the serializer translate the request into the DTO in a way where I don't have to do this pre-processing?

L.E: No need for using a custom name converter, I am now using SerializedName

CodePudding user response:

Short answer: I would strongly suggest to replace the collection with just a standard php built-in array instead - at least on the setter! -, I bet that would help.

Long answer:

My guess is, since your DTO isn't an entity, that the serializer won't use something doctrine-related, which would tell it to convert arrays to collections.

IMHO, Collections are object-internal solutions that should never be exposed directly to the outside, the interface to the outside world are arrays. Very opinionated maybe.

From a non-doctrine Serializer's perspective, a collection is an object. Objects have properties. To map an array onto an object is to use the keys of the array and match them to the properties of the object, using reflection: Using the property directly, if it is public, else using adders/removers, else using setters/getters. (IIRC in that order) - That's why adding elements as a key to your input with the array of ids "works".

Using an array as the interface in the setter/getter pair will help the property accessor greatly to just set the array. You can still have the property internally as an ArrayCollection, if that's what you want/need.

This is just a guess: adding docblock type hint /** @param array<int> $tagIds */ to your setter might even trigger conversion from "2" to 2. Having addTagId removeTagId getTagIds instead with a php type hint might also work instead.

CodePudding user response:

I'll add this in order to help others struggling with same problem, but I was able to come up with it only after @Jakumi's answer, this is also why I am going to award them the bounty.

Therefore, after some research, it seems that indeed, the simplest way to go about this is to just use array as the type hint. This way, things will work out of the box, without you doing anything else, but you won't be using collections, so:

    #[Assert\Type('array')]
    #[Assert\All([
        new Assert\Type('numeric'),
        new Assert\Positive(),
    ])]
    #[SerializedName('tag-ids')]
    private ?array $tagIds = null;

    public function getTagIds(): ?array
    {
        return $this->tagIds;
    }

    public function setTagIds(?array $tagIds): self
    {
        $this->tagIds = $tagIds;

        return $this;
    }

However, if you want to keep using collections, then you need to do some extra work:

    #[Assert\Type(ArrayCollection::class)]
    #[Assert\All([
        new Assert\Type('numeric'),
        new Assert\Positive(),
    ])]
    #[SerializedName('tag-ids')]
    private ?ArrayCollection $tagIds = null;
    
    public function getTagIds(): ?ArrayCollection
    {
        return $this->tagIds;
    }

    public function setTagIds(array|ArrayCollection|null $tagIds): self
    {
        if (null !== $tagIds) {
            $tagIds = $tagIds instanceof ArrayCollection ? $tagIds : new ArrayCollection($tagIds);
        }
        $this->tagIds = $tagIds;

        return $this;
    }

    // Make sure our numeric values are treated as integers not strings
    public function addTagId(int $tagId): void
    {
        if (null === $this->getTagIds()) {
            $this->setTagIds(new ArrayCollection());
        }

        $this->getTagIds()?->add($tagId);
    }

    public function removeTagId(int $tagId): void
    {
        $this->getTagIds()?->remove($tagId);
    }

So beside the setter and getter, you'll need to add a adder and a remover as well.
This will deserialize into a ArrayCollection of integers.

Bonus, if your object property is a collection of DTO's, not just integers, and you want to denormalize those as well, then you'd do something like:

    #[Assert\Type(TagCollection::class)]
    #[Assert\All([
        new Assert\Type(TagDto::class),
    ])]
    #[Assert\Valid]
    private ?TagCollection $tags = null;

    private PropertyAccessorInterface $propertyAccessor;

    public function __construct()
    {
        $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
            ->disableExceptionOnInvalidIndex()
            ->disableExceptionOnInvalidPropertyPath()
            ->getPropertyAccessor()
        ;
    }

    public function getTags(): ?TagCollection
    {
        return $this->tags;
    }

    public function setTags(array|TagCollection|null $tags): self
    {
        if (null !== $tags) {
            $tags = $tags instanceof TagCollection ? $tags : new TagCollection($tags);
        }
        $this->tags = $tags;

        return $this;
    }

    public function addTag(array $tag): void
    {
        if (null === $this->getTags()) {
            $this->setTags(new TagCollection());
        }

        $tagDto = new TagDto();
        foreach ($tag as $key => $value) {
            $this->propertyAccessor->setValue($tagDto, $key, $value);
        }

        $this->getTags()?->add($tagDto);
    }

    public function removeTag(TagDto $tag): void
    {
        $this->getTags()?->remove($tag);
    }

This will denormalize the tags property into a ArrayCollection instance (TagCollection extends it) and each item in the collection will be a TagDto in this case.

  • Related