In a Symfony 4 application that I've been asked to work on I am attempting to enforce a uniqueness constraint on the name of my program (a course of instruction, not software) within a given company. Despite the attempted constraint, the app happily lets me create a program with the same name as one that already exists in the given company.

I've found various contradictory examples of how to set up a composite constraint, and I've read through the many StackOverflow questions on this topic to no avail.

The relevant code for my entity, program.php:

<?php

namespace Domain\CoreBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Domain\AdminBundle\Service\Helper\RouteListHelper;
use Domain\CoreBundle\Repository\ProgramRepository as ProgramRepo;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Validator\Constraints as Assert;
use Doctrine\Common\Collections\ArrayCollection;
use JsonSerializable;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

 /**
  * Program
  * @ORM\Entity(repositoryClass="Domain\CoreBundle\Repository\ProgramRepository")
  * @ORM\Table(name="programs")
  * @UniqueEntity(
  *      fields={"name","company"},
  *      errorPath = "name",
  *      message="A program by that name already exists for this company."
  *      )
  * @ORM\HasLifecycleCallbacks()
  */

class Program implements JsonSerializable
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @Assert\NotBlank(message="Program Name should not be empty")
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

     /**
     * @ORM\ManyToOne(targetEntity="Company")
     * @ORM\JoinColumn(name="company_id", referencedColumnName="id", nullable=false, onDelete="CASCADE")
     */
    protected $company;

...

and my addProgramType.php:

<?php

namespace Domain\AdminBundle\Form;

use Domain\CoreBundle\Repository\UserRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;

/**
 * Class AddProgramType
 */
class AddProgramType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $params = array(
            'name' => array(
                'label' => 'Program name:',
                'attr' => array('class' => 'base-box'),
            ),
            'isEnabled' => array(
                'label' => false,
                'attr' => array(
                    'checked' => 'checked',
                ),
            ),
            'isRoiCalculating' => array(
                'label' => false,
            ),
            'duration' => array(
                'label' => 'Duration:',
                'class' => 'DomainCoreBundle:Duration',
                'query_builder' => function (EntityRepository $er) use ($options) {
                    return $er->getDurationsQb($options['company']);
                },
                'choice_label' => 'uniqueName',
                'attr' => array(
                    'class' => 'base-box',
                ),
            ),
            'sessionTypes' => array(
                'class'         => 'DomainCoreBundle:SessionType',
                'query_builder' => function (EntityRepository $er) use($options) {
                    return $er->getAllSessionTypesQb($options['company']);
                },
                'choice_label' => 'name',
                'multiple' => true,
                'label' => 'Session Types:',
                'attr' => array(
                    'class' => 'multiselect-dropdown multiselect-dropdown-session-types',
                    'required' => 'required',
                    'multiple' => 'multiple',
                ),
            ),
            'users' => array(
                'required' => false,
                'class' => 'DomainCoreBundle:User',
                'choices' => $options['userRepo']->findByRoles(
                        array(UserRepository::ROLE_ADMIN,UserRepository::ROLE_COMPANY_ADMIN),
                        $options['company'],
                        false),
                'choice_label' => 'getFullName',
                'multiple' => true,
                'label' => 'Access to admins:',
                'attr' => array(
                    'class' => 'multiselect-dropdown multiselect-dropdown-users',
                    'multiple' => 'multiple',
                ),
            ),
        );

        $builder
            ->add('name', null, $params['name'])
            ->add('isEnabled', CheckboxType::class, $params['isEnabled'])
            ->add('isRoiCalculating', CheckboxType::class, $params['isRoiCalculating'])
            ->add('duration', EntityType::class, $params['duration'])
            ->add('sessionTypes', EntityType::class, $params['sessionTypes'])
            ->add('users', EntityType::class, $params['users']);
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array('data_class' => 'Domain\CoreBundle\Entity\Program'));
        $resolver->setRequired(array('company', 'userRepo'));
    }

    /**
     * Return form name
     *
     * @return string
     */
    public function getBlockPrefix()
    {
        return 'add_program';
    }
}

While the application enforces the NotBlank constraint on the name correctly, it doesn't enforce the uniqueness of name + company.

Any suggestions?

[UPDATE] Looks like I set company after the isValid() call, thanks BoShurik for the catch. Here's the relevant controller code showing my mistake:

/**
     * Add new program
     *
     * @param Request $request
     *
     * @return Response
     */
    public function addNewAction(Request $request)
    {
        $form = $this->createForm(AddProgramType::class, null, array('company'=>$this->getCurrentCompany(),
                                'userRepo' =>$this->em->getRepository('DomainCoreBundle:User')));

        if ($request->getMethod() === 'POST') {
            $form->handleRequest($request);

            if ($form->isValid()) {
                $company = $this->getCurrentCompany();
                $program = $form->getData();
                $program->setCreatedDate(new \DateTime());
                $program->setCompany($company);
...

2 Answers

2
Jonben On

If you want to add the same check at database level you should use the @UniqueConstraint annotation in the Table() declaration and give a name to the new index. Something like:

 /**
  * Program
  * @ORM\Entity(repositoryClass="Domain\CoreBundle\Repository\ProgramRepository")
  * @ORM\Table(name="programs", uniqueConstraints={@ORM\UniqueConstraint(name="IDX_PROGRAM_COMPANY", columns={"name", "company_id"})})
  * @UniqueEntity(
  *      fields={"name","company"},
  *      errorPath = "name",
  *      message="A program by that name already exists for this company."
  *      )
  * @ORM\HasLifecycleCallbacks()
  */

class Program implements JsonSerializable

```
0
BoShurik On

As a company field is not manager by your form, you need to set its value before form validation:

public function addNewAction(Request $request)
{
    $program = new Program();
    $program->setCompany($this->getCurrentCompany());

    $form = $this->createForm(AddProgramType::class, $program, array('company' => $this->getCurrentCompany(),
        'userRepo' => $this->em->getRepository('DomainCoreBundle:User')));

    if ($request->getMethod() === 'POST') {
        $form->handleRequest($request);

        if ($form->isValid()) {
            $program = $form->getData();
            $program->setCreatedDate(new \DateTime());
        }
    }
}