Search code examples
phpmysqlsymfonydoctrinesymfony-3.2

Delete a 3-entity (one-to-many-to-one) association with Symfony 3 using Doctrine


This is my very first question!

I have two entities that I want to relate: Product and Category. A product may have multiple categories and a category may correspond to many products. I've decided to implement this relationship as a 3-class association, having an intermediate ProductCategory entity, as shown in the image below. This give me flexibility to add properties to the association in the future.

Representation of my tree-class association

I want to assign existing categories to existing products. I want to establish the relationship from within the entities themselves. I am able to do that within the Product entity, using a setter method that receives an array of Category entities, and creates a new ProductCategory entity for each category passed. The procedure is as follows:

//Product.php

/**
 * @param \Doctrine\Common\Collections\ArrayCollection $categories
 * @return \TestBundle\Entity\Product 
 */
public function setCategories($categories) {
    $productCategoryReplacement = new \Doctrine\Common\Collections\ArrayCollection();
    foreach ($categories as $category) {
        $newProductCategory = new ProductCategory();
        $newProductCategory->setProduct($this);
        $newProductCategory->setCategory($category);
        $productCategoryReplacement[] = $newProductCategory;
    }
    $this->productCategory = $productCategoryReplacement;

    return $this;
} 

Note that I clear the ProductCategory collection before adding new ones; in this way only those categories selected in the form are saved to the database.

My problem is that Doctrine doesn't delete the records from the database before inserting the new ones. This is fine when no categories were assigned to the product but I get an Integrity constraint violation: 1062 Duplicate entry '1-1' for key 'PRIMARY' when trying to update the association. I've checked the Symfony debug panel, in the Doctrine section, and no DELETE statement is ever executed prior to the INSERTs.

Is it possible to delete related entities from within an entity? If not, then why is it possible to add new ones? Thanks in advance.


My entities are as follows:

Product.php:

namespace TestBundle\Entity;

/**
 * @ORM\Table(name="product")
 * @ORM\Entity(repositoryClass="TestBundle\Repository\ProductRepository")
 */
class Product {

    /**
     * @var int
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

    /**
     * @var \Doctrine\Common\Collections\ArrayCollection
     * @ORM\OneToMany(targetEntity="ProductCategory", mappedBy="product", cascade={"persist"})
     */
    private $productCategory;

/**
 * Constructor
 */
public function __construct() {
    $this->productCategory = new \Doctrine\Common\Collections\ArrayCollection();
}

/**
 * @param \TestBundle\Entity\ProductCategory $productCategory
 * @return Product
 */
public function addProductCategory(\TestBundle\Entity\ProductCategory $productCategory) {
    $this->productCategory[] = $productCategory;
    return $this;
}

/**
 * @param \TestBundle\Entity\ProductCategory $productCategory
 */
public function removeProductCategory(\TestBundle\Entity\ProductCategory $productCategory) {
    $this->productCategory->removeElement($productCategory);
}

/**
 * @return \Doctrine\Common\Collections\Collection
 */
public function getProductCategory() {
    return $this->productCategory;
}
/**
 * @param \Doctrine\Common\Collections\ArrayCollection $categories
 * @return \TestBundle\Entity\Product 
 */
public function setCategories($categories) {
    $productCategoryReplacement = new \Doctrine\Common\Collections\ArrayCollection();
    foreach ($categories as $category) {
        $newProductCategory = new ProductCategory();
        $newProductCategory->setProduct($this);
        $newProductCategory->setCategory($category);
        $productCategoryReplacement[] = $newProductCategory;
    }
    $this->productCategory = $productCategoryReplacement;

    return $this;
}

/**
 * @return \Doctrine\Common\Collections\ArrayCollection
 */
public function getCategories() {
    $categories = new \Doctrine\Common\Collections\ArrayCollection();
    foreach ($this->getProductCategory() as $pc) {
        $categories[] = $pc->getCategory();
    }
    return $categories;
}
}

Category.php:

namespace TestBundle\Entity;

/**
 * @ORM\Table(name="category")
 * @ORM\Entity(repositoryClass="TestBundle\Repository\CategoryRepository")
 */
class Category {
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

    /**
     * @var \Doctrine\Common\Collections\ArrayCollection
     * @ORM\OneToMany(targetEntity="ProductCategory", mappedBy="category", cascade={"persist"})
     */
    private $productCategory;
}

ProductCategory.php

namespace TestBundle\Entity;

/**
 * @ORM\Table(name="product_category")
 * @ORM\Entity(repositoryClass="TestBundle\Repository\ProductCategoryRepository")
 */
class ProductCategory {

    /**
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="Product", inversedBy="productCategory")
     * @ORM\JoinColumn(name="product_id", referencedColumnName="id")
     */
    private $product;

    /**
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="productCategory")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     */
    private $category;
}

My Product form is generated as follows:

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('name')
        ->add('categories', EntityType::class, array(
           'class' => 'TestBundle:Category',
           'choice_label' => 'name',
           'expanded' => true,
           'multiple' => true,
    ));
}

Note that I use a categories field name that will be populated with categories taken from Category entity. The form returns an array of Category objects that I use to generate ProductCategory entities in the setCategories() method within Product.php.

/**
 * @param \Doctrine\Common\Collections\ArrayCollection $categories
 * @return \TestBundle\Entity\Product 
 */
public function setCategories($categories) {
    $productCategoryReplacement = new \Doctrine\Common\Collections\ArrayCollection();
    foreach ($categories as $category) {
        $newProductCategory = new ProductCategory();
        $newProductCategory->setProduct($this);
        $newProductCategory->setCategory($category);
        $productCategoryReplacement[] = $newProductCategory;
    }
    $this->productCategory = $productCategoryReplacement;

    return $this;
}

EDIT 1:

I don't have a categories field in Product, I only have a getCategories() and setCategories() methods. As shown in my form type code, I add an EntityType field of class Categories, that maps to the categories property (that doesn't actually exist). In this way I'm able to show existing categories as checkboxes an the product's categories are checked correctly.

EDIT 2: POSSIBLE SOLUTION

I ended up following Sam Jenses's suggestion. I created a service as follows:

File: src/TestBundle/Service/CategoryCleaner.php

namespace TestBundle\Service;

use Doctrine\ORM\EntityManagerInterface;
use TestBundle\Entity\Product;
use Symfony\Component\HttpFoundation\Request;

class CategoryCleaner {

    /**
     *
     * @var EntityManagerInterface
     */
    private $em;

    public function __construct(EntityManagerInterface $em) {
        $this->em = $em;
    }

    public function cleanCategories(Product $product, Request $request) {
        if ($this->em == null) {
            throw new Exception('Entity manager parameter must not be null');
        }
        if ($request == null) {
            throw new Exception('Request parameter must not be null');
        }

        if ($request->getMethod() == 'POST') {
            $categories = $this->em->getRepository('TestBundle:ProductCategory')->findByProduct($product);
            foreach ($categories as $category) {
                $this->em->remove($category);
            }
            $this->em->flush();
        }
    }
}

In the cleanCategories method, which receives the current Product and Request as parameters, all entries of ProductCategory which correspond to Product are removed, only in case of a POST request.

The service is registered as follows:

File app/config/services.yml

services:
    app.category_cleaner:
        class: TestBundle\Service\CategoryCleaner
        arguments: ['@doctrine.orm.entity_manager']

The service must be called from the controller before handleRequest($request), that is, before the new categories are added. If not, we get a duplicate entry exception.

Edit method from file TestBundle/Controller/ProductController.php

public function editAction(Request $request, Product $product) {
    $deleteForm = $this->createDeleteForm($product);
    $editForm = $this->createForm('TestBundle\Form\ProductType', $product);

    $this->container->get('app.category_cleaner')->cleanCategories($product, $request);

    $editForm->handleRequest($request);

    if ($editForm->isSubmitted() && $editForm->isValid()) {
        $this->getDoctrine()->getManager()->flush();
        return $this->redirectToRoute('product_edit', array('id' => $product->getId()));
    }

    return $this->render('product/edit.html.twig', array(
                'product' => $product,
                'edit_form' => $editForm->createView(),
                'delete_form' => $deleteForm->createView(),
    ));

Please validate my approach.


Solution

  • create an intermediate service, in which you can also use doctrine to remove the existing entities