Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to avoid duplicate entries in a many-to-many relationship with Doctrine?

I'm using an embed Symfony form to add and remove Tag entities right from the article editor. Article is the owning side on the association:

class Article
{
    /**
     * @ManyToMany(targetEntity="Tags", inversedBy="articles", cascade={"persist"})
     */
    private $tags;

    public function addTag(Tag $tags)
    {
        if (!$this->tags->contains($tags)) // It is always true.
            $this->tags[] = $tags;
    }
}

The condition doesn't help here, as it is always true, and if it wasn't, no new tags would be persisted to the database at all. Here is the Tag entity:

class Tag
{
    /**
     * @Column(unique=true)
     */
    private $name

    /**
     * @ManyToMany(targetEntity="Articles", mappedBy="tags")
     */
    private $articles;

    public function addArticle(Article $articles)
    {
        $this->articles[] = $articles;
    }
}

I've set $name to unique, because I want to use the same tag every time I enter the same name in the form. But it doesn't work this way, and I get the exception:

Integrity constraint violation: 1062 Duplicate entry

What do I need to change to use article_tag, the default join table when submitting a tag name, that's already in the Tag table?

like image 316
Gergő Avatar asked Feb 11 '14 20:02

Gergő


2 Answers

I have been battling with a similar issue for months and finally found a solution that seems to be working very well in my application. It's a complex application with quite a few many-to-many associations and I need to handle them with maximum efficiency.

The solution is explained in part here: http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/faq.html#why-do-i-get-exceptions-about-unique-constraint-failures-during-em-flush

You were already halfway there with your code:

public function addTag(Tag $tags)
{
    if (!$this->tags->contains($tags)) // It is always true.
        $this->tags[] = $tags;
}

Basically what I have added to this is to set indexedBy="name" and fetch="EXTRA_LAZY" on the owning side of the relationship, which in your case is Article entity (you may need to scroll the code block horizontally to see the addition):

class Article
{
    /**
     * @ManyToMany(targetEntity="Tags", inversedBy="articles", cascade={"persist"}, indexedBy="name" fetch="EXTRA_LAZY")
     */
    private $tags;

You can read up about the fetch="EXTRA_LAZY" option here.

You can read up about indexBy="name" option here.

Next, I modified my versions of your addTag() method as follows:

public function addTag(Tag $tags)
{
    // Check for an existing entity in the DB based on the given
    // entity's PRIMARY KEY property value
    if ($this->tags->contains($tags)) {
        return $this; // or just return;
    }
    
    // This prevents adding duplicates of new tags that aren't in the
    // DB already.
    $tagKey = $tag->getName() ?? $tag->getHash();
    $this->tags[$tagKey] = $tags;
}

NOTE: The ?? null coalesce operator requires PHP7+.

By setting the fetch strategy for tags to EXTRA_LAZY the following statement causes Doctrine to perform a SQL query to check if a Tag with the same name exists in the DB (see the related EXTRA_LAZY link above for more):

$this->tags->contains($tags)

NOTE: This can only return true if the PRIMARY KEY field of the entity passed to it is set. Doctrine can only query for existing entities in the database/entity map based on the PRIMARY KEY of that entity, when using methods like ArrayCollection::contains(). If the name property of the Tag entity is only a UNIQUE KEY, that's probably why it's always returning false. You will need a PRIMARY KEY to use methods like contains() effectively.

The rest of the code in the addTag() method after the if block creates a key for the ArrayCollection of Tags either by the value in the PRIMARY KEY property (preferred if not null) or by the Tag entity's hash (search Google for "PHP + spl_object_hash", used by Doctrine to index entities). So, you are creating an indexed association, so that if you add the same entity twice before a flush, it will just be re-added at the same key, but not duplicated.

like image 53
garethlawson Avatar answered Oct 03 '22 10:10

garethlawson


Two main solutions

First

Use a data transformer

class TagsTransformer implements DataTransformerInterface
{
    /**
     * @var ObjectManager
     */
    private $om;

    /**
     * @param ObjectManager $om
     */
    public function __construct(ObjectManager $om)
    {
        $this->om = $om;
    }

    /**
     * used to give a "form value"
     */
    public function transform($tag)
    {
        if (null === $tag) {
            //do proper actions
        }

        return $issue->getName();
    }

    /**
     * used to give "a db value"
     */
    public function reverseTransform($name)
    {
        if (!$name) {
            //do proper actions
        }

        $issue = $this->om
            ->getRepository('YourBundleName:Tag')
            ->findOneBy(array('name' => $name))
        ;

        if (null === $name) {
            //create a new tag
        }

        return $tag;
    }
}

Second

Use lifecycle callback. In particular you can use prePersist trigger onto your article entity? In that way you can check for pre-existing tags and let your entity manager manage them for you (so he don't need to try to persist causing errors).

You can learn more about prePersist here

HINT FOR SECOND SOLUTION

Make a custom repository method for search and fetch old tags (if any)

like image 22
DonCallisto Avatar answered Oct 03 '22 09:10

DonCallisto