Як створити користувацьке обмеження валідації

Дата оновлення перекладу 2024-06-08

Як створити користувацьке обмеження валідації

Ви можете створити користувацьке обмеження, розширивши базовий клас обмеження Constraint. В якості прикладу ви створите простий валідатор, який перевіряє, чи містить рядок лише буквенно-цифрові знаки.

Створення класу обмеження

Спочатку вам потрібно створити клас обмеження і розширити Constraint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
    public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';
    public string $mode = 'strict';

    // всі опції, доступні для конфігурування, мають бути передані конструктору
    public function __construct(string $mode = null, string $message = null, array $groups = null, $payload = null)
    {
        parent::__construct([], $groups, $payload);

        $this->mode = $mode ?? $this->mode;
        $this->message = $message ?? $this->message;
    }
}

Додайте #[\Attribute] до класу обмеження, якщо ви хочете використати його як атрибут в інших класах.

Ви можете використати #[HasNamedArguments], щоб зробити опції обмеження обовʼязковими:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;

use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
    public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';

    #[HasNamedArguments]
    public function __construct(
        public string $mode,
        array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct([], $groups, $payload);
    }
}

Створення самого валідатора

Як ви бачите, клас обмеження достатньо мінімальний. Сама валідація виконується іншим класом "валідатором обмеження". Клас валідатора обмеження вказується методом обмеження validatedBy(), який містить деяку просту логіку за замовчуванням:

1
2
3
4
5
// в базовому класі Symfony\Component\Validator\Constraint
public function validatedBy(): string
{
    return static::class.'Validator';
}

Іншими словами, якщо ви створите користувацьке Constraint (наприклад, MyConstraint), Symfony автоматично шукатиме інший клас, MyConstraintValidator, при проведенні самої валідації.

Клас валідатора також простий і має лише один обов'язковий метод validate():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/Validator/ContainsAlphanumericValidator.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class ContainsAlphanumericValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof ContainsAlphanumeric) {
            throw new UnexpectedTypeException($constraint, ContainsAlphanumeric::class);
        }

        // користувацькі обмеження мають ігнорувати пусті значення та null, щоб
        // дозволити іншим обмеженням (NotBlank, NotNull, та ін.) попіклуватися про це
        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            // викличте це виключення, якщо ваш валідатор не може обробити переданий тип, щоб він міг бути відмічений як невалідний
            throw new UnexpectedValueException($value, 'string');

            // розділіть багато типів, використавши вертикальні риски
            // викличте нове UnexpectedValueException($value, 'string|int');
        }

        // отримайте доступ до ваших опцій конфігурації таким чином:
        if ('strict' === $constraint->mode) {
            // ...
        }

        if (preg_match('/^[a-zA-Z0-9]+$/', $value, $matches)) {
            return;
        }

            // аргумент має бути рядком або об'єктом, що реалізує implementing __toString()
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ string }}', $value)
                ->addViolation();
        }
    }
}

Всередині validate вам не потрібно повертати значення. Замість цього ви додаєте порушення до властивості валідатора context і значення буде прийняте як валідне, якщо воно не викличе ніяких порушень. Метод buildViolation() бере повідомлення про помилку в якості свого аргументу та повертає екземпляр ConstraintViolationBuilderInterface. Метод виклику addViolation() зрештою додає порушення у контекст.

Використання нового валідатора

Використовувати користувацькі валідатори як і ті, що надаються самою Symfony:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Entity/AcmeEntity.php
namespace App\Entity;

use App\Validator as AcmeAssert;
use Symfony\Component\Validator\Constraints as Assert;

class AcmeEntity
{
    // ...

    #[Assert\NotBlank]
    #[AcmeAssert\ContainsAlphanumeric(mode: 'loose')]
    protected string $name;

    // ...
}

Якщо ваше обмеження містить опції, то вони мають бути публічними властивостями користувацького класу обмеження, який ви створили раніше. Ці опції можуть бути сконфігуровані як опції базових обмежень Symfony.

Валідатори обмежень з залежностями

Якщо ви використовуєте конфігурацію services.yml за замовчуванням , ваш валідатор вже зареєстрований в якості сервісу та тегований необхідним validator.constraint_validator. Це означає, що ви можете впроваджувати сервіси або конфігурацію , як будь-який інший сервіс.

Валідатори обмежень з користувацькими опціями

Якщо ви хочете додати деякі опції конфігурації до вашого користувацького обмеження, спочатку визначте ці опції як публічні властивості класу обмеження:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// src/Validator/Foo.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class Foo extends Constraint
{
    public $mandatoryFooOption;
    public $message = 'This value is invalid';
    public $optionalBarOption = false;

    public function __construct(
        $mandatoryFooOption,
        string $message = null,
        bool $optionalBarOption = null,
        array $groups = null,
        $payload = null,
        array $options = []
    ) {
        if (\is_array($mandatoryFooOption)) {
            $options = array_merge($mandatoryFooOption, $options);
        } elseif (null !== $mandatoryFooOption) {
            $options['value'] = $mandatoryFooOption;
        }

        parent::__construct($options, $groups, $payload);

        $this->message = $message ?? $this->message;
        $this->optionalBarOption = $optionalBarOption ?? $this->optionalBarOption;
    }

    public function getDefaultOption()
    {
        return 'mandatoryFooOption';
    }

    public function getRequiredOptions()
    {
        return ['mandatoryFooOption'];
    }
}

Потім, всередині класу валідатора ви можете отримати доступ до цих опцій безпосередньо через передачі класу обмеження в метод validate():

1
2
3
4
5
6
7
8
9
10
11
12
class FooValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint)
    {
        // отримати доступ до будь-якої опції обмеження
        if ($constraint->optionalBarOption) {
            // ...
        }

        // ...
    }
}

При використанні цього обмеження у власному додатку, ви можете передати значення користувацьких опцій так само, як ви передаєте будь-які інші опції у вбудованих обмеженнях:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/Entity/AcmeEntity.php
namespace App\Entity;

use App\Validator as AcmeAssert;
use Symfony\Component\Validator\Constraints as Assert;

class AcmeEntity
{
    // ...

    #[Assert\NotBlank]
    #[AcmeAssert\Foo(
        mandatoryFooOption: 'bar',
        optionalBarOption: true
    )]
    protected $name;

    // ...
}

Створіть повторно використовуваний набір обмежень

Якщо вам часто необхідно застосовувати загальний набір обмежень в різних місцях по всьому вашому додатку, ви можете розширити обмеження Compound.

Валідатор класу обмежень

Окрім валідації однієї властивості, обмеження може мати цілий клас в якості свого поля діяльності.

Наприклад, уявіть, що у вас також є сутність PaymentReceipt і вам треба гарантувати, що email корисного навантаження чеку співпадає з email користувача. Спочатку, створіть обмеження та перевизначіть метод getTargets():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Validator/ConfirmedPaymentReceipt.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ConfirmedPaymentReceipt extends Constraint
{
    public string $userDoesNotMatchMessage = 'User\'s e-mail address does not match that of the receipt';

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}

Тепер, валідатор обмеження отримає обʼєкт як перший аргумент validate():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/Validator/ConfirmedPaymentReceiptValidator.php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class ConfirmedPaymentReceiptValidator extends ConstraintValidator
{
    /**
     * @param PaymentReceipt $receipt
     */
    public function validate($receipt, Constraint $constraint): void
    {
        if (!$receipt instanceof PaymentReceipt) {
            throw new UnexpectedValueException($receipt, PaymentReceipt::class);
        }

        if (!$constraint instanceof ConfirmedPaymentReceipt) {
            throw new UnexpectedValueException($constraint, ConfirmedPaymentReceipt::class);
        }

        $receiptEmail = $receipt->getPayload()['email'] ?? null;
        $userEmail = $receipt->getUser()->getEmail();

        if ($userEmail !== $receiptEmail) {
            $this->context
                ->buildViolation($constraint->userDoesNotMatchMessage)
                ->atPath('user.email')
                ->addViolation();
        }
    }
}

Tip

Метод atPath() визначає властивість, з якою асоціюється помилка валідації. Використайте будь-який валідний синтаксис PropertyAccess, щоб визначити цю властивість.

Валідатор класу обмеження застосовується до самого класу, а не його властивості:

1
2
3
4
5
6
7
8
9
10
// src/Entity/AcmeEntity.php
namespace App\Entity;

use App\Validator as AcmeAssert;

#[AcmeAssert\ProtocolClass]
class AcmeEntity
{
    // ...
}

Тестування користувацьких обмежень

Використайте клас ConstraintValidatorTestCase`, щоб спросити написання модульних тестів для ваших користувацьких обмежень:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// tests/Validator/ContainsAlphanumericValidatorTest.php
namespace App\Tests\Validator;

use App\Validator\ContainsAlphanumeric;
use App\Validator\ContainsAlphanumericValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;

class ContainsAlphanumericValidatorTest extends ConstraintValidatorTestCase
{
    protected function createValidator(): ConstraintValidatorInterface
    {
        return new ContainsAlphanumericValidator();
    }

    public function testNullIsValid(): void
    {
        $this->validator->validate(null, new ContainsAlphanumeric());

        $this->assertNoViolation();
    }

    /**
     * @dataProvider provideInvalidConstraints
     */
    public function testTrueIsInvalid(ContainsAlphanumeric $constraint): void
    {
        $this->validator->validate('...', $constraint);

        $this->buildViolation('myMessage')
            ->setParameter('{{ string }}', '...')
            ->assertRaised();
    }

    public function provideInvalidConstraints(): \Generator
    {
        yield [new ContainsAlphanumeric(message: 'myMessage')];
        // ...
    }
}