Search code examples
symfonydoctrine-ormapi-platform.com

update related entity in API Platform 3.0


I'm trying to update a related entity via the main one. For example I have products and offers. I do a POST to /products

{
    "name": "sample product",
    "offers": [
        {
            "description": "sample offer"
        }
    ]
}

And I get this response

{
    "@context": "/contexts/Product",
    "@id": "/products/1",
    "@type": "Product",
    "name": "sample product",
    "offers": [
        {
            "@id": "/offers/1",
            "@type": "https://schema.org/Offer",
            "description": "sample offer"
        }
    ]
}

So far, so good. Now, I want to perform a PUT operation at /products/1

{
    "name": "product updated",
    "offers": [
        {
            "@id": "/offers/1",
            "description": "offer updated"
        }
    ]
}

And get this error

An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'product_id' cannot be null

If I remove #[ORM\JoinColumn(nullable:false)] from Offer.php it works, but product_id is set to null (the rest of the row remains) and a new row is inserted.

What I want is to get the row updated (just update description field). Is that possible?

I'm using the example given at https://api-platform.com/docs/core/getting-started/#mapping-the-entities with some minor modifications (see comments)

My code is:

Product.php

<?php
// api/src/Entity/Product.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Serializer\Annotation\Groups; //ADDED THIS
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    normalizationContext: ["groups" => ["products"]],
    denormalizationContext: ["groups" => ["products"]],
)]
class Product // The class name will be used to name exposed resources
{
    #[ORM\Id, ORM\Column, ORM\GeneratedValue]
    private ?int $id = null;

    /**
     * A name property - this description will be available in the API documentation too.
     *
     */
    #[ORM\Column]
    #[Assert\NotBlank]
    #[Groups(["products"])] //ADDED THIS
    public string $name = '';

    // Notice the "cascade" option below, this is mandatory if you want Doctrine to automatically persist the related entity
    /**
     * @var Offer[]|ArrayCollection
     *
     */
    #[ORM\OneToMany(targetEntity: Offer::class, mappedBy: 'product', cascade: ['persist'])]
    #[Groups(["products"])] //ADDED THIS
    public iterable $offers;

    public function __construct()
    {
        $this->offers = new ArrayCollection(); // Initialize $offers as a Doctrine collection
    }

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

    // Adding both an adder and a remover as well as updating the reverse relation is mandatory
    // if you want Doctrine to automatically update and persist (thanks to the "cascade" option) the related entity
    public function addOffer(Offer $offer): void
    {
        $offer->product = $this;
        $this->offers->add($offer);
    }

    public function removeOffer(Offer $offer): void
    {
        $offer->product = null;
        $this->offers->removeElement($offer);
    }

    // ...

    //ADDED THIS SET AND GET
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

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

Offer.php

<?php
// api/src/Entity/Offer.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups; //ADDED THIS
use Symfony\Component\Validator\Constraints as Assert;

/**
 * An offer from my shop - this description will be automatically extracted from the PHPDoc to document the API.
 *
 */
#[ORM\Entity]
#[ApiResource(types: ['https://schema.org/Offer'])]
class Offer
{
    #[ORM\Id, ORM\Column, ORM\GeneratedValue]
    private ?int $id = null;

    #[ORM\Column(type: 'text')]
    #[Groups(["products"])] //ADDED THIS
    public string $description = '';

    #[ORM\Column]
    #[Assert\Range(minMessage: 'The price must be superior to 0.', min: 0)]
    public float $price = 1.0; //MODIFIED THIS

    #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'offers')]
    #[ORM\JoinColumn(nullable:false)] //ADDED THIS
    public ?Product $product = null;

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

    //ADDED THIS SET AND GET
    public function setDescription($description)
    {
        $this->description = $description;

        return $this;
    }

    public function getDescription()
    {
        return $this->description;
    }
}

Solution

  • Well, apparently it was as simple as doing

    {
        "name": "product updated",
        "offers": [
            {
                "id": "/offers/1",
                "description": "offer updated"
            }
        ]
    }
    

    notice the id without @!!!

    UPDATE 2024-01-10: In version 3.2.10 this doesn't work

    UPDATE 2024-01-19: This is the new way to do it

    {
        "@id": "/products/1",
        "name": "product updated",
        "offers": [
            {
                "@id": "/offers/1",
                "description": "offer updated"
            }
        ]
    }
    

    Notice the IRIs for both product and offer. But that would delete other offers related to the product if any. If you want to keep them do this

    {
        "@id": "/products/1",
        "name": "product updated",
        "offers": [
            {
                "@id": "/offers/1",
                "description": "offer updated"
            },
            "/offers/2",
            "/offers/3"
        ]
    }