Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to restrict adding duplicate tags? #162

Open
jimiero opened this issue Dec 5, 2019 · 7 comments
Open

How to restrict adding duplicate tags? #162

jimiero opened this issue Dec 5, 2019 · 7 comments

Comments

@jimiero
Copy link

jimiero commented Dec 5, 2019

Hello,

I'm using the add new tag option, but if we have this existing tag: Test tag and someone comes and type test tag I get an error like:

Integrity constraint violation: 1062 Duplicate entry 'test tag' for key 'UNIQ_5E3DE4775E237E06'

Any idea?

@jimiero
Copy link
Author

jimiero commented Dec 6, 2019

@tetranz any suggestions here?

@tetranz
Copy link
Owner

tetranz commented Dec 7, 2019

It's been a long time since I've done anything with tags but I had a quick play with this today.

I think duplicate tags are something you need to deal with in your own code when you persist the tag object. If you detect that the tag is new, you need to do a query or something to check if that unique value already exists and if it does, don't try to add it.

@jimiero
Copy link
Author

jimiero commented Dec 8, 2019

@tetranz thanks for your reply, seems to me the bundle itself does the add new tag, all I had done on my side was to enable the add new feature with:

'allow_add' => [ 'enabled' => true, 'new_tag_text' => ' (NEW)', 'new_tag_prefix' => '__', 'tag_separators' => '[",", " "]' ],

How can i do those verifications on my form?

@tetranz
Copy link
Owner

tetranz commented Dec 8, 2019

The bundle creates an instance of the new tag entity but it doesn't persist it to the database.
I had further play with this. I have an entity called Region and an entity called Tag. There is a many to many relationship between Region and Tag with Region being the controlling entity. My Tag entity has an auto integer id primary key and a string Name. I set Name to be unique so doctrine created a unique index.

In my controller I added this:

        foreach($region->getTags() as $tag) {
            if (empty($tag->getId())) {
                $entityManager->persist($tag);
            }
        }

That persists new tags. I can't reproduce your problem if a tag with that name already exists. The tags in region that are returned from $region->getTags() have the correct ids and nothing further needs to happen.

As I would expect, I can reproduce your problem if I open my app in another browser tab. I add a new tag in one tab but don't submit the form. Now add the same tag in the other tab and submit the form. Now go back to the other tab and submit. That reproduces the problem because both submits are trying to add the same tag. I don't think that is what you're referring to but I fixed that with this:

        foreach($region->getTags() as $tag) {
            if (empty($tag->getId())) {
                $existingTag = $this->getDoctrine()->getRepository(Tag::class)
                    ->findOneBy(['name' => $tag->getName()]);

                if (empty($existingTag)) {
                    $entityManager->persist($tag);
                }
                else {
                    $region->removeTag($tag);
                    $region->addTag($existingTag);
                }
            }
        }

That checks if the new tag already exists and substitutes the tag saved moments ago by the other browser tab / user. Even that's not perfect because it is not an atomic operation so you could still get an error with unfortunate timing between two users. You really need a try / catch around the $entityManager->persist($tag); and use the unique index on the db as the ultimate atomic operation.

But somehow you seem to be having a more general problem of simple selecting a tag that already exists. I'm not sure what happening for you.

The Tag field on my form has this:

            'allow_add' => [
                'enabled' => true,
                'new_tag_text' => '',
            ],

@jimiero
Copy link
Author

jimiero commented Dec 10, 2019

@tetranz Appreciate your assistance, in my case I use the bundle to add tags to an entity like:

            ->add('tags', Select2EntityType::class, array(
                'required' => true,
                'label' => 'Article tags',
                'multiple' => true,
                'remote_route' => 'ajax-tags',
                'class' => 'AppBundle\Entity\Tags',
                'text_property' => 'name',
                'minimum_input_length' => 2,
                'page_limit' => null,
                'placeholder' => 'Select tags',
                'attr' => array('style' => 'width: 100%;'),
                'allow_add' => [
                    'enabled' => true,
                    'new_tag_text' => ' (NEW)',
                    'new_tag_prefix' => '__',
                    'tag_separators' => '[",", ""]'
                ],
            ))

I have no check inside the form, other than this:

` $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

`

@moldwebs
Copy link

moldwebs commented Nov 26, 2021

->add('tags', Select2EntityType::class, [
'label' => 'Tags',
'placeholder' => '',
'class' => TxTag::class,
'property' => 'title',
'text_property' => 'title',
'multiple' => true,
'allow_add' => [
'enabled' => true,
'new_tag_text' => ' (+)',
'new_tag_prefix' => '__',
'tag_separators' => '[","]'
],
'transformer' => EntitiesToPropertyTransformer::class
])

namespace App\Component\DataTransformer;

class EntitiesToPropertyTransformer extends \Tetranz\Select2EntityBundle\Form\DataTransformer\EntitiesToPropertyTransformer
{

/**
 * Transform array to a collection of entities
 *
 * @param array $values
 * @return array
 */
public function reverseTransform($values)
{
    if (!is_array($values) || empty($values)) {
        return array();
    }

    // add new tag entries
    $newObjects = array();
    $tagPrefixLength = strlen($this->newTagPrefix);
    foreach ($values as $key => $value) {
        $cleanValue = strtolower(trim(substr($value, $tagPrefixLength)));
        $valuePrefix = substr($value, 0, $tagPrefixLength);
        if ($valuePrefix == $this->newTagPrefix) {

            $entity = $this->em->createQueryBuilder()
                ->select('entity')
                ->from($this->className, 'entity')
                ->where('entity.'.$this->textProperty.' = :value')
                ->setParameter('value', $cleanValue)
                ->getQuery()
                ->getOneOrNullResult();

            if ($entity) {
                $values[$key] = $entity->getId();
            } else {
                $object = new $this->className;
                $this->accessor->setValue($object, $this->textProperty, $cleanValue);
                $newObjects[] = $object;
                unset($values[$key]);
            }
        }
    }

    // get multiple entities with one query
    $entities = $this->em->createQueryBuilder()
        ->select('entity')
        ->from($this->className, 'entity')
        ->where('entity.'.$this->primaryKey.' IN (:ids)')
        ->setParameter('ids', $values)
        ->getQuery()
        ->getResult();

      // this will happen if the form submits invalid data

// if (count($entities) != count($values)) {
// throw new TransformationFailedException('One or more id values are invalid');
// }

    return array_merge($entities, $newObjects);
}

}

@zearg
Copy link

zearg commented Dec 23, 2021

Thanks for this solution @moldwebs, you should better format it ;)

It works to don't have duplicates tags in DB.

Think to add unique: true on your property (title on TxTagEntity in this example)

Add transformer to your form:

...
->add('tags', Select2EntityType::class, [
                'label' => 'Tags',
                'placeholder' => '',
                'class' => TxTag::class,
                'primary_key' => 'id',
                'text_property' => 'title',
                'multiple' => true,
                'allow_add' => [
                                'enabled' => true,
                                'new_tag_text' => ' ',
                                'new_tag_prefix' => '__',
                                'tag_separators' => '[","]'
                ],
                'transformer' => EntitiesToPropertyTransformer::class
])
...

Create your new transformer:

<?php

namespace App\Form\DataTransform;

use Doctrine\Persistence\ObjectManager;

class EntitiesToPropertyTransformer extends \Tetranz\Select2EntityBundle\Form\DataTransformer\EntitiesToPropertyTransformer
{
    /**
     * EntitiesToPropertyTransformer constructor.
     *
     * @param ObjectManager $em
     * @param               $class
     * @param null          $textProperty
     * @param string        $primaryKey
     * @param string        $newTagPrefix
     * @param string        $newTagText
     */
    public function __construct(ObjectManager $em, $class, $textProperty = null, $primaryKey = 'id', $newTagPrefix = '__', $newTagText = ''){
        // Reconstruct parent to prevent $newTagText to be null (because we set empty string in config file) and get a bad value in parent constructor
        parent::__construct($em, $class, $textProperty, $primaryKey, $newTagPrefix, $newTagText);
    }

    /**
     * Transform array to a collection of entities
     *
     * @param array $values
     * @return array
     */
    public function reverseTransform($values)
    {
        // Should always be a collection (array)
        if (!is_array($values) || empty($values)) {
            return [];
        }
        
        $newObjects = array();
        $newTagPrefixLength = strlen($this->newTagPrefix);
        
        foreach ($values as $key => $value) {
            $cleanValue = strtolower(trim(substr($value, $newTagPrefixLength)));
            $valuePrefix = substr($value, 0, $newTagPrefixLength);

            // If it's a tag that should be added to DB
            if ($valuePrefix === $this->newTagPrefix) {
                // Search for tag presence in DB
                $entity = $this->em->createQueryBuilder()
                    ->select('entity')
                    ->from($this->className, 'entity')
                    ->where('entity.'.$this->textProperty.' = :value')
                    ->setParameter('value', $cleanValue)
                    ->getQuery()
                    ->getOneOrNullResult();

                // If tag already exist in DB
                if ($entity) {
                    $values[$key] = $entity->getId();
                }
                // Transform new and non-existent tag into new Object
                else {
                    $object = new $this->className;
                    $this->accessor->setValue($object, $this->textProperty, $cleanValue);
                    $newObjects[] = $object;
                    unset($values[$key]);
                }
            }
        }

        // Get each tags already presents before
        $entities = $this->em->createQueryBuilder()
            ->select('entity')
            ->from($this->className, 'entity')
            ->where('entity.'.$this->primaryKey.' IN (:ids)')
            ->setParameter('ids', $values)
            ->getQuery()
            ->getResult();

        return array_merge($entities, $newObjects);
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants