5

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?

Gergő
  • 588
  • 8
  • 20

2 Answers2

6

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.

Erdal G.
  • 2,694
  • 2
  • 27
  • 37
garethlawson
  • 61
  • 1
  • 5
4

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)

DonCallisto
  • 29,419
  • 9
  • 72
  • 100
  • Tags are persisted before the `prePersist` event of the `Article` entity, so I cannot just iterate through the `$tags` to check if they already exist (with a custom `TagRepository`). How should I connect the custom repository method to a lifecycle callback then? Isn't it necessary to use event listeners? – Gergő Feb 20 '14 at 23:57
  • @Gergő: I suppose that `prePersist` could be classificated like an event listener but I'm not sure... However if tags are persisted before articles you should follow the first solution I proposed – DonCallisto Feb 21 '14 at 07:53
  • I created a data transformer that successfully returns the existing tag objects, but Doctrine still tries to do an `INSERT` instead of `UPDATE`, so it just throws an exception. How can I fix that? – Gergő Mar 26 '14 at 13:44
  • 1
    @Gergő: you're doing something wrong for sure but I don't know what. If you try to persist a prefetched db object it will result in an UPDATE, is doctrine logic. – DonCallisto Mar 26 '14 at 14:21
  • I've asked a [new question](http://stackoverflow.com/q/22671294/868052) about the exception with more details. – Gergő Mar 26 '14 at 19:42
  • @DonCallisto - For the second solution, how do you access to repo within the entity? – BentCoder Jun 28 '14 at 16:02
  • Hi @Gergő, Did you solve this problem? I have the same problem and I don't know how to fix it, please if you can look [here](http://stackoverflow.com/questions/34285413/symfony2-prevent-duplicate-in-database-with-form-many-to-one) – Joseph Dec 15 '15 at 22:34
  • Hi @DonCallisto, I have the same problem and I don't know how to fix it, please if you can look [here](http://stackoverflow.com/questions/34285413/symfony2-prevent-duplicate-in-database-with-form-many-to-one) – Joseph Dec 17 '15 at 09:28
  • For me, "If you try to persist a prefetched db object it will result in an UPDATE, is doctrine logic" is the golden rule. My logic is very different than the above question and answer, but the important thing is to ensure you have first attempted to fetch the db object before persisting -- then those Doctrine annoyances will be no more. – MusikAnimal Nov 26 '17 at 22:24