Symfony2:字符串属性的唯一约束不适用于订阅者类中 prePersist() 方法中设置的值


Symfony2: Unique constraint for a string property is not working for values set in prePersist() method in a subscriber class

>我有一个表单,用户在其中输入他的电话号码。一个常见的问题是电话号码可以用许多不同的方式书写:"+49 711 XXXXXX"、"0049 (0)711 XXXXXX"或"+49 711 - XXXXXX"都是同一电话号码的表示形式。为了检测重复项,我使用"电话号码捆绑包"(https://github.com/misd-service-development/phone-number-bundle)来获取可用于比较的电话号码的"规范化"E.164表示形式。如果检测到重复,则不得存储输入的号码,并且必须向用户显示通知。

如果输入的电话号码是有效的电话号码,我想检查电话号码的 E.164 格式值是否已存储在数据库表中。

这是电话号码的 MySQL 表:

-+----+---------------------+----------------+
 | id | original            | phonenumber    |
-+----+---------------------+----------------+
 | 1  | 0711-xxxxxxx        | +49711xxxxxxx  |
-+----+---------------------+----------------+
 | 2  | +49 7034 / xxxxx-xx | +497034xxxxxxx |
-+----+---------------------+----------------+
 | 3  | +49 (0)171/xxxxxxx  | +49171xxxxxxx  |
-+----+---------------------+----------------+
 | .. | ...                 | ...            |
-+----+---------------------+----------------+

"电话号码"包含在表单中输入的值的 E.164 格式值。最初输入的第一个值作为附加信息存储在"原始"列中。

表单在"src/AppBundle/Form/PhonenumberType.php"中定义:

<?php
namespace AppBundle'Form;
use Symfony'Component'Form'AbstractType;
use libphonenumber'PhoneNumberFormat;
use Symfony'Component'Form'FormBuilderInterface;
use Symfony'Component'OptionsResolver'OptionsResolver;
class PhonenumberType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            //->add('phonenumber') // Remove comments to see that the unique constraint works when the phonenumber is submitted via form
            ->add('original')
        ;
    }
    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle'Entity'Phonenumber'
        ));
    }
}

电话号码实体"src/AppBundle/Entity/Phonenumber.php":

<?php
namespace AppBundle'Entity;
use Doctrine'ORM'Mapping as ORM;
use Symfony'Component'Validator'Constraints as Assert;
use AppBundle'Validator'Constraints as PhonenumberAssert;
use Symfony'Bridge'Doctrine'Validator'Constraints'UniqueEntity;
/**
 * Phonenumber
 *
 * @ORM'Table(name="phonenumber",
 *     uniqueConstraints={
 *          @ORM'UniqueConstraint(columns={"phonenumber"})
 *      })
 * @ORM'Entity
 * @UniqueEntity("phonenumber")
 * @ORM'HasLifecycleCallbacks()
 */
class Phonenumber
{
    /**
     * @var integer
     *
     * @ORM'Column(name="id", type="integer", precision=0, scale=0, nullable=false, unique=false)
     * @ORM'Id
     * @ORM'GeneratedValue(strategy="IDENTITY")
     */
    private $id;
    /**
     * @var string
     *
     * @ORM'Column(name="phonenumber", type="string", length=255, unique=true)
     */
    private $phonenumber;
    /**
     * @var string
     *
     * @ORM'Column(name="original", type="string", length=255, precision=0, scale=0, nullable=true, unique=false)
     * @Assert'NotBlank
     * @PhonenumberAssert'IsValidPhoneNumber
     */
    private $original;
    /**
     * Constructor
     */
    public function __construct()
    {
    }
    /**
     * Returns phonenumber.
     *
     * @return string
     */
    public function __toString()
    {
        return $this->getPhonenumber();
    }
    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }
    /**
     * Set phonenumber
     *
     * @param string $phonenumber
     *
     * @return Phonenumber
     */
    public function setPhonenumber($phonenumber)
    {
        $this->phonenumber = $phonenumber;
        return $this;
    }
    /**
     * Get phonenumber
     *
     * @return string
     */
    public function getPhonenumber()
    {
        return $this->phonenumber;
    }
    /**
     * Set original
     *
     * @param string $original
     *
     * @return Phonenumber
     */
    public function setOriginal($original)
    {
        $this->original = $original;
        return $this;
    }
    /**
     * Get original
     *
     * @return string
     */
    public function getOriginal()
    {
        return $this->original;
    }
}

在"app/config/services.yml"中定义的服务:

services:
    phonenumber_validation:
        class: AppBundle'Validator'Constraints'IsValidPhoneNumberValidator
        arguments: ["@service_container"]
        tags:
            - { name: validator.constraint_validator, alias: phonenumber_validation }
    my.subscriber:
        class: AppBundle'EventListener'PhoneNumberNormalizerSubscriber
        calls:
            - [setContainer, ["@service_container"]]
        tags:
            - { name: doctrine.event_subscriber, connection: default }

订阅者类"src/AppBundle/EventListener/PhoneNumberNormalizerSubscriber.php":

<?php
namespace AppBundle'EventListener;
use Symfony'Component'DependencyInjection'ContainerInterface;
use Doctrine'Common'EventSubscriber;
use Doctrine'ORM'Event'LifecycleEventArgs;
use AppBundle'Entity'Phonenumber;
use libphonenumber'NumberParseException;
use libphonenumber'PhoneNumber;
use libphonenumber'PhoneNumberFormat;
use libphonenumber'PhoneNumberUtil;
class PhoneNumberNormalizerSubscriber implements EventSubscriber
{
    /** @var ContainerInterface */
    protected $container;
    /**
     * @param ContainerInterface @container
     */
    public function setContainer(ContainerInterface $container)
    {
        $this->container = $container;
    }
    public function getSubscribedEvents()
    {
        return array(
            'prePersist',
            'preUpdate',
        );
    }
    // Executed when data is stored for the first time
    public function prePersist(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        // only act on "Phonenumber" entity
        if ($entity instanceof Phonenumber)
        {
            $entityManager = $args->getEntityManager();
            $phoneNumberObj = $this->container->get('libphonenumber.phone_number_util')->parse($entity->getOriginal(), 'DE');
            $normalized_phonenumber = $this->container->get('libphonenumber.phone_number_util')->format($phoneNumberObj, PhoneNumberFormat::E164);
            $entity->setPhonenumber($normalized_phonenumber);
        }
    }
    // Executed when data is already stored
    public function preUpdate(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        // only act on "Phonenumber" entity
        if ($entity instanceof Phonenumber)
        {
            $entityManager = $args->getEntityManager();
            $phoneNumberObj = $this->container->get('libphonenumber.phone_number_util')->parse($entity->getOriginal(), 'DE');
            $normalized_phonenumber = $this->container->get('libphonenumber.phone_number_util')->format($phoneNumberObj, PhoneNumberFormat::E164);
            $entity->setPhonenumber($normalized_phonenumber);
        }
    }
}

约束类"src/AppBundle/Validator/Constraint/IsValidPhoneNumber.php":

<?php
namespace AppBundle'Validator'Constraints;
use Symfony'Component'Validator'Constraint;
/**
 * @Annotation
 */
class IsValidPhoneNumber extends Constraint
{
    public $message_invalid = 'Not a valid phone number: "%string%"';
    /**
     * @return string
     */
    public function validatedBy()
    {
        return 'phonenumber_validation';
    }
}

验证器类 "src/AppBundle/Validator/Constraint/IsValidPhoneNumberValidator.php":

<?php
namespace AppBundle'Validator'Constraints;
use Symfony'Component'DependencyInjection'ContainerInterface as Container;
use Symfony'Component'Validator'Constraint;
use Symfony'Component'Validator'ConstraintValidator;
use libphonenumber'NumberParseException;
use libphonenumber'PhoneNumber;
use libphonenumber'PhoneNumberFormat;
use libphonenumber'PhoneNumberUtil;
class IsValidPhoneNumberValidator extends ConstraintValidator
{
    private $container;
    /**
     * Construct
     */
    public function __construct(Container $container)
    {
        $this->container = $container;
    }
    /**
     * Validate
     *
     * @param mixed $value
     * @param Constraint $constraint
     */
    public function validate($value, Constraint $constraint)
    {
        if ($value != '' )
        {
            $phoneNumberObj = $this->container->get('libphonenumber.phone_number_util')->parse($value, 'DE');
            if (!$this->container->get('libphonenumber.phone_number_util')->isValidNumber($phoneNumberObj))
            {
                $this->context->buildViolation($constraint->message_invalid)
                    ->setParameter('%string%', $value)
                    ->addViolation();
            }
        }
    }
}

电话号码验证器有效 - 如果号码被"电话号码捆绑包"使号码无效,则会显示一条消息"不是有效的电话号码:"3333333333333333"。E.164 格式的值也会正确保存到数据库表中。

问题:尽管我对实体类中的$phonenumber属性使用"@ORM''UniqueConstraint(columns={"phonenumber"})","@UniqueEntity("phonenumber")"和"unique=true",但表单中输入的每个有效数字都会存储在数据库中,无论表中是否已经有重复项。当电话号码字段未添加到表单类型类中时,唯一约束不起作用。

可能很有趣: 当我删除电话号码类型类中的注释时,以便

->add('phonenumber')

再次包含并在关联的表单字段"电话号码"中输入现有号码,我得到"此值已被使用"。

我做错了什么?

感谢您的帮助!

事实上,事实并非如此。您必须区分:

  • 表单验证。当您的控制器调用$form->handle($request)或类似的东西时,就会发生这种情况,这些调用在表单事件上触发
  • 原则回调,在实体管理器flush()期间调用

解决方案是不是在prePersist()上使用该捆绑包,而是在 Form 事件上使用该捆绑包。在将数据写入实体之前,将对数据进行规范化,然后有效地验证将保存到数据库的内容。

这样的代码如下所示:

<?php
namespace AppBundle'Form;
use Symfony'Component'Form'AbstractType;
use libphonenumber'PhoneNumberFormat;
use Symfony'Component'Form'FormBuilderInterface;
use Symfony'Component'OptionsResolver'OptionsResolver;
use Symfony'Component'Form'FormEvent;
use Symfony'Component'Form'FormEvents;
class PhonenumberType extends AbstractType
{
    private $phoneNumberUtil;
    public function __construct(PhoneNumberUtil $phoneNumberUtil)
    {
         $this->phoneNumberUtil = $phoneNumberUtil;
    }
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('phonenumber', 'hidden')
            ->add('original')
            ->addEventListener(FormEvents::SUBMIT, function(FormEvent $event) use ($this) {
                  $entity = $event->getData();
                  $phoneNumber = $this->phoneNumberUtil->parse($entity->getOriginal(), PhoneNumberUtil::UNKNOWN_REGION);
                  $normalized_phonenumber = $this->phoneNumberUtil->format($phoneNumberObj, PhoneNumberFormat::E164);
                  $entity->setPhoneNumber($normalized_phonenumber);
        ;
    }
    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle'Entity'Phonenumber'
        ));
    }
}

还有一件事,将您的表单类型声明为服务将使构建更简单,请看那里:http://symfony.com/doc/current/book/forms.html#defining-your-forms-as-services

编辑,这里是 YML 服务定义:

app.contact_type:
    class: AppBundle'Form'PhonenumberType
    arguments:
        - @libphonenumber.phone_number_util
    tags:
        - { name: form.type, alias: 'phone_number' }