api-platform icon indicating copy to clipboard operation
api-platform copied to clipboard

Data Transformers break subresource collections in GraphQL

Open imaximius opened this issue 3 years ago • 2 comments

API Platform version(s) affected: 2.6.8

Description
While using any DataTransformer the subresource collections inside affected objects return all the records from related resource instead of linked ones. The bug reproduces only for GraphQL (REST API works as it's expected)

How to reproduce
Lets create 2 entities and a DataTransformer

Entity Set

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ORM\Table(name="sets")
 */
#[ApiResource(
    normalizationContext: ['groups' => ['set:read', 'asset:read']],
    denormalizationContext: ['groups' => ['set:write']],
    collectionOperations: [
        'get',
        'post'
    ],
    itemOperations: [
        'get',
        'put',
        'delete',
    ],
    attributes: [
        'pagination_type' => 'page'
    ],
    output: Set::class
)]
class Set
{
    /**
     * @var string
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true)
     * @ORM\GeneratedValue(strategy="CUSTOM")
     * @ORM\CustomIdGenerator("doctrine.uuid_generator")
     * @ApiProperty(identifier=true)
     * @Groups({"set:read"})
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=32, nullable=true)
     * @Groups({"set:read", "set:write"})
     */
    private $name;

    /**
     * @var Collection|Asset[]
     * @ORM\ManyToMany(targetEntity=Asset::class, inversedBy="sets")
     * @ORM\JoinTable(name="idp_sets_assets")
     * @Groups({"set:read", "set:write"})
     */
    private $assets;

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

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

    public function setId(string $id): self
    {
        $this->id = $id;

        return $this;
    }

    /**
     * @return Collection|Asset[]
     */
    public function getAssets(): Collection
    {
        return $this->assets;
    }

    public function addAsset(Asset $asset): self
    {
        if (!$this->assets->contains($asset)) {
            $this->assets[] = $asset;
        }

        return $this;
    }

    public function removeAsset(Asset $asset): self
    {
        $this->assets->removeElement($asset);

        return $this;
    }

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

    /**
     * @param string $name
     * @return Set
     */
    public function setName(string $name): Set
    {
        $this->name = $name;

        return $this;
    }
}

Entity Asset

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ORM\Table(name="assets")
 */
#[ApiResource(
    normalizationContext: ['groups' => ['asset:read', 'set:read']],
    denormalizationContext: ['groups' => ['asset:write']],
    collectionOperations: [
        'get',
        'post'
    ],
    itemOperations: [
        'get',
        'put',
        'delete',
    ],
    attributes: [
        'pagination_type' => 'page'
    ],
    output: Asset::class
)]
class Asset
{
    /**
     * @var string
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true)
     * @ORM\GeneratedValue(strategy="CUSTOM")
     * @ORM\CustomIdGenerator("doctrine.uuid_generator")
     * @ApiProperty(identifier=true)
     * @Groups({"asset:read"})
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=32, nullable=true)
     * @Groups({"asset:read", "asset:write"})
     */
    private $name;

    /**
     * @var Collection|Set[]
     * @ORM\ManyToMany(targetEntity=Set::class, mappedBy="assets")
     * @Groups({"asset:read", "asset:write"})
     */
    private $sets;

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

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

    public function setId(string $id): Asset
    {
        $this->id = $id;

        return $this;
    }

    /**
     * @return Collection|Set[]
     */
    public function getSets(): Collection
    {
        return $this->sets;
    }

    public function addSet(Set $set): self
    {
        if (!$this->sets->contains($set)) {
            $this->sets[] = $set;
            $set->addAsset($this);
        }

        return $this;
    }

    public function removeSet(Set $set): self
    {
        if ($this->sets->removeElement($set)) {
            $set->removeAsset($this);
        }

        return $this;
    }

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

    /**
     * @param string $name
     * @return Asset
     */
    public function setName(string $name): Asset
    {
        $this->name = $name;

        return $this;
    }
}

And finally a DataTransformer

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use ReflectionException;

class DummyDataTransformer implements DataTransformerInterface
{
    /**
     * {@inheritdoc}
     *
     * @throws ReflectionException
     */
    public function transform($data, string $to, array $context = []): object
    {
        // some code, it's even possible to keep as is 

        return $data;
    }

    /**
     * @param object $data
     *
     * @throws ReflectionException
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return true;
    }
}

Than we have to create for example 2 Assets and link one of them to newly created Set. Could be done using any interface like REST API or GraphQL

Finally request Sets with included Assets collection:

{
  sets {
    collection {
      id
      assets {
        collection {
            id
          }
        }
      }  
  }
}

In the result you cant see, that there are 2 assets in Asset collection, instead of the one that we have added

Possible Solution
¯_(ツ)_/¯

imaximius avatar Jan 31 '22 12:01 imaximius

BTW, the same issue appears if made decoration described there - https://api-platform.com/docs/core/serialization/#decorating-a-serializer-and-adding-extra-data

imaximius avatar Jan 31 '22 18:01 imaximius

Having exactly the same problem.

My workaround for now is to add a filter to the related resource to be able to filter by the parent object id. But that's only possible because I fetch the nested relations only on a single parent and can pass down the variable.

But this should be fixed.

tpinne avatar Aug 05 '22 11:08 tpinne