phpsymfonydoctrine-ormapi-platform.com

How to fix infinite loop in cascade POST request in an owner-owned combo for api-platform?


I am trying to do a category relationship table, basically having categories that own other categories like children and parents when i try to POST a new category (e.g. “Child-category-1”) and assign it an owner it goes into an infinite loop with an error of :

The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the \"api_platform.eager_loading.max_joins\" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the \"enable_max_depth\" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).

A parent category looks like this:

    {
    "@context": "/contexts/Category",
    "@id": "/categories/1",
    "@type": "Category",
    "name": "Parent1",
    "ownedUsers": [
        "/category_owners/1",
        "/category_owners/2",
        "/category_owners/3"
    ],
    "owners": []
    }

My payload would looks like this:

{
    "name" : "child-category-4",
    "owners":[
        "/categories/1"
    ]
}

I have a ManyToOne with cascade:["persist"] from category entity to categoryOwner entity. however i can not pass this infinite loop.

What am i missing?

Here’s how Category and CategoryOwner entities look like:

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use App\Repository\CategoryRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
#[ApiResource(
    normalizationContext: [
        'groups' => ['category:read'],
    ],
    denormalizationContext: [
        'groups' => ['category:write'],
    ],
)]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['category:write', 'category:read'])]
    private ?string $name = null;

    #[ORM\OneToMany(mappedBy: 'owner', targetEntity: CategoryOwner::class,cascade: ["persist"])]
    #[Groups(['category:write', 'category:read'])]
    private Collection $ownedUsers;

    #[ORM\OneToMany(mappedBy: 'owned', targetEntity: CategoryOwner::class,cascade: ["persist"])]
    #[Groups(['category:write', 'category:read'])]
    private Collection $owners;

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

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

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

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Collection<int, CategoryOwner>
     */
    public function getOwnedUsers(): Collection
    {
        return $this->ownedUsers;
    }

    public function addOwnedUser(CategoryOwner $ownedUser): static
    {
        if (!$this->ownedUsers->contains($ownedUser)) {
            $this->ownedUsers->add($ownedUser);
            $ownedUser->setOwner($this);
        }

        return $this;
    }

    public function removeOwnedUser(CategoryOwner $ownedUser): static
    {
        if ($this->ownedUsers->removeElement($ownedUser)) {
            // set the owning side to null (unless already changed)
            if ($ownedUser->getOwner() === $this) {
                $ownedUser->setOwner(null);
            }
        }

        return $this;
    }

    /**
     * @return Collection<int, CategoryOwner>
     */
    public function getOwners(): Collection
    {
        return $this->owners;
    }

    public function addOwner(CategoryOwner $owner): static
    {
        if (!$this->owners->contains($owner)) {
            $this->owners->add($owner);
            $owner->setOwned($this);
        }

        return $this;
    }

    public function removeOwner(CategoryOwner $owner): static
    {
        if ($this->owners->removeElement($owner)) {
            // set the owning side to null (unless already changed)
            if ($owner->getOwned() === $this) {
                $owner->setOwned(null);
            }
        }

        return $this;
    }
}

===

namespace App\Entity;
    
    use ApiPlatform\Metadata\ApiResource;
    use App\Repository\CategoryOwnerRepository;
    use Doctrine\ORM\Mapping as ORM;
    use Symfony\Component\Serializer\Annotation\Groups;
    
    #[ORM\Entity(repositoryClass: CategoryOwnerRepository::class)]
    #[ApiResource(
        normalizationContext: [
            'groups' => ['category:owner:read'],
        ],
        denormalizationContext: [
            'groups' => ['category:owner:write'],
        ],
    )]
    
    class CategoryOwner
    {
        #[ORM\Id]
        #[ORM\GeneratedValue]
        #[ORM\Column]
        private ?int $id = null;
    
        #[ORM\ManyToOne(inversedBy: 'ownedUsers')]
        #[ORM\JoinColumn(nullable: false)]
        #[Groups(['category:owner:read', 'category:owner:write', 'category:write'])]
        private ?Category $owner = null;
    
        #[ORM\ManyToOne(inversedBy: 'owners')]
        #[ORM\JoinColumn(nullable: false)]
        #[Groups(['category:owner:read', 'category:owner:write', 'category:write'])]
        private ?Category $owned = null;
    
        public function getId(): ?int
        {
            return $this->id;
        }
    
        public function getOwner(): ?Category
        {
            return $this->owner;
        }
    
        public function setOwner(?Category $owner): static
        {
            $this->owner = $owner;
    
            return $this;
        }
    
        public function getOwned(): ?Category
        {
            return $this->owned;
        }
    
        public function setOwned(?Category $owned): static
        {
            $this->owned = $owned;
    
            return $this;
        }
    }

Solution

  • I have used a transparent ManyToMany relation based on the doctrine docs thus i removed the unnecessary entity CategoryOwner.

    Category entity would look like:

    namespace App\Entity;
    
    use ApiPlatform\Metadata\ApiResource;
    use App\Repository\CategoryRepository;
    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\Common\Collections\Collection;
    use Doctrine\ORM\Mapping as ORM;
    use Symfony\Component\Serializer\Annotation\Groups;
    use Symfony\Component\Validator\Constraints as Assert;
    
    #[ORM\Entity(repositoryClass: CategoryRepository::class)]
    #[ApiResource(
        normalizationContext: [
            'groups' => ['category:read'],
        ],
        denormalizationContext: [
            'groups' => ['category:write'],
        ],
    )]
    class Category
    {
        #[ORM\Id]
        #[ORM\GeneratedValue]
        #[ORM\Column]
        private ?int $id = null;
    
        #[ORM\Column(length: 255)]
        #[Groups(['category:write', 'category:read'])]
        private ?string $name = null;
        
        #[ORM\ManyToMany(targetEntity: Category::class, inversedBy: "ownedCategories")]
        #[Groups(['category:write', 'category:read'])]
        #[ORM\JoinTable(name: 'category_owner')]
        #[ORM\JoinColumn(name: 'owned_id', referencedColumnName: 'id', nullable: false)]
        #[ORM\InverseJoinColumn(name: 'owner_id', referencedColumnName: 'id', nullable: false)]
        #[Assert\NotEqualTo(value:"owner.id", message: 'A category cannot be its own owner.')]
        private Collection $owners;
    
        #[ORM\ManyToMany(targetEntity: Category::class, mappedBy: "owners")]
        #[Groups(['category:write', 'category:read'])]
        private Collection $ownedCategories;
    
        public function __construct()
        {
            $this->ownedCategories = new ArrayCollection();
            $this->owners = new ArrayCollection();
        }
        
        public function getId(): ?int
        {
            return $this->id;
        }
        
        public function getName(): ?string
        {
            return $this->name;
        }
        
        public function setName(string $name): self
        {
            $this->name = $name;
            return $this;
        }
        
        public function getOwners(): Collection
        {
            return $this->owners;
        }
        
        public function addOwner(Category $owner): self
        {
            if ($owner === $this) {
                throw new \InvalidArgumentException("A category cannot be its own owner.");
            }
            
            if (!$this->owners->contains($owner)) {
                $this->owners[] = $owner;
            }
            return $this;
        }
        
        public function removeOwner(Category $owner): self
        {
            $this->owners->removeElement($owner);
            return $this;
        }
        
        public function getOwnedCategories(): Collection
        {
            return $this->ownedCategories;
        }
        
        public function addOwnedCategory(Category $ownedCategory): self
        {
            if ($ownedCategory === $this) {
                throw new \InvalidArgumentException("A category cannot be owned by itself.");
            }
            
            if (!$this->ownedCategories->contains($ownedCategory)) {
                $this->ownedCategories[] = $ownedCategory;
            }
            return $this;
        }
        
        public function removeOwnedCategory(Category $ownedCategory): self
        {
            $this->ownedCategories->removeElement($ownedCategory);
            return $this;
        }
    }