Generyczny walidator Enuma w Symfony

ekran z laptopem i błędem

Wraz z PHP 8.1 pojawiła się opcja natywnego tworzenia typu Enum. Już wcześniej, do jego tworzenia w PHP używało się zewnętrznych paczek albo własnych implementacji. Zatem opcja ta będzie na pewno szeroko wykorzystywana. Pojawiają się więc nowe potrzeby. Jedną z nich może być walidator pozwalający się upewnić, czy dana wartość jest zgodna z konkretnym typem Enum.

Z racji tego, że ostatnio miałem potrzebę implementacji walidatora Enum w Symfony, postanowiłem się nim podzielić. Może komuś z Was się przyda. Dostosujcie go do swoich potrzeb. Uznałem, że bez sensu będzie klepać nowe walidatory dla każdego Enuma, dlatego zaopatrzyłem się w coś bardziej generycznego.

A może da się skorzystać z już istniejących?

Zanim przygotowałem własną implementacje, oczywiście spojrzałem do dokumentacji w poszukiwaniu gotowego walidatora. Jedyna reguła, która wydawałaby się pasować to Symfony\Component\Validator\Constraints\Choice.

Wystąpił mały problem z dostarczeniem możliwych wartości. O ile do samego walidatora łatwo je przekazać, o tyle wyciągnięte z Enuma już trochę trudniej. Nie jest to jednak niemożliwe. Jak to zrobić? Enum musiałby posiadać specjalną metodę statyczną, która zwróci wszystkie możliwe wartości w postaci tablicy. Rzecz jasna, da się to zrobić. Być może nawet wykorzystacie ją w innych miejscach, chociaż częściej będzie potrzeba pobrania mapy w formie klucz -> wartość, niż samych kluczy.

App\Infrastructure\Symfony\Request:
    properties:
        status:
            - Choice: { callback: [App\Domain\Enum\Status, list] }
<?php

declare(strict_types=1);

namespace App\Domain\Enum;

enum Status: string
{
    case CREATED = 'created';
    case ACCEPTED = 'accepted';
    case FINISHED = 'finished';

    public static function list(): array
    {
        $list = [];
        foreach (self::cases() as $status) {
            $list[] = $status->value;
        }

        return $list;
    }
}

Własny walidator dla Enuma

Warto pamiętać, że Symfony\Component\Validator\Constraints\Choice można wykorzystać w wielu innych przypadkach. A skoro jest on tak uniwersalny to też nie do końca musi sprawdzić się idealnie do Enumów. Pokazałem jednak powyżej, że jest to możliwe. Mi nie do końca odpowiada to rozwiązanie. Jeśli jednak nie chcecie tworzyć własnego mechanizmu to chciałem pokazać, że istnieje inna opcja.

Własny walidator w Symfony to też nic skomplikowanego. Wystarczy utworzyć atrybut lub adnotacje (szczególnie dla PHP sprzed 8) i odpowiadający mu walidator. Istnieje kilka standardowych warunków, Nie są one wymagane, ale pozwolą tworzyć walidatory bardziej odporne na błędy. Mówię tutaj o pominięciu walidacji dla wartości null, czy sprawdzeniu typów przekazanych parametrów.

Zaczynając od atrybutu – zezwoliłem na dwie opcje. Wymagane jest podanie nazwy Enuma (pełny namespace). Opcjonalnie można przekazać własną treść błędu. Warto zwrócić też uwagę, aby klasa miała zarówno publiczne pola jak i możliwość ustawienia ich przez konstruktor. To daje elastyczność używania walidacji za pomocą atrybutów lub konfiguracji w pliku .yaml. I to wszystko – implementacja prezentuje się następująco.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Symfony\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\MissingOptionsException;

#[\Attribute]
final class Enum extends Constraint
{
    public string $message = 'Value is not allowed.';

    public ?string $path;

    public function __construct($options = [], ?string $path = null, ?string $message = null)
    {
        $this->message = $message ?? $this->message;
        $this->path = $path ?? $options['path'] ?? null;
        if (null === $this->path) {
            throw new MissingOptionsException('The path is required option.', ['path']);
        }

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

Został jeszcze sam walidator. Tak jak wspomniałem, trochę uniwersalnego kodu i na końcu właściwe sprawdzenie. Obiekt próbuje utworzyć zadeklarowanego Enuma z przesłanej wartości $constraint->path::tryFrom($value). Metoda tryFrom() jest wbudowana w typ wyliczeniowy i nie trzeba jej implementować. Oprócz tego istnieje też metoda from() . Różnica między nimi polega na tym, że ta pierwsza zwróci null jeśli operacja się nie uda, a ta druga wygeneruje ValueError. W tym kontekście lepsze będzie użycie tryFrom() , która jeśli zwróci null , to znaczy że podana wartość nie jest jedną z dostępnych możliwości.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Symfony\Validator;

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

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

        if (null === $value || '' === $value) {
            return;
        }

        if (! \is_string($value)) {
            throw new UnexpectedTypeException($value, 'string');
        }

        if (null === $constraint->path::tryFrom($value)) {
            $this->context
                ->buildViolation($constraint->message)
                ->addViolation();
        }
    }
}

Pisałem wcześniej o dwóch alternatywnych metodach walidacji. Framework Symfony pozwala jeszcze na inne, ale nie brałem ich pod uwagę w tej implementacji. Jeśli chcecie z nich skorzystać to należy się upewnić, że powyższy kod zadziała i ewentualnie dostosować go do własnych potrzeb. Ja prezentuję przykład walidacji pola status na bazie App\Domain\Enum\Status. Wybierzcie jedną z metod konfiguracji, której używacie do walidacji. Polecam robić to za pomocą .yaml. Wtedy klasy PHP stają się czystsze i istnieje mniej niepotrzebnych zależności.

<?php

declare(strict_types=1);

namespace App\Domain\Enum;

enum Status: string
{
    case CREATED = 'created';
    case ACCEPTED = 'accepted';
    case FINISHED = 'finished';
}
App\Infrastructure\Symfony\Request:
    properties:
        status:
            - App\Infrastructure\Symfony\Validator\Enum:
                path: App\Domain\Enum\Status
                message: 'The status is invalid.'
<?php

declare(strict_types=1);

namespace App\Infrastructure\Symfony;

use App\Infrastructure\Symfony\Validator\Enum;
use App\Domain\Enum\Status;

class Request
{
    #[Enum(path: Status::class, message: "The status is invalid.")]
    private string $status;
}

Walidacja Enuma w PHP

Przykład powstał w oparciu Symfony, ale myślę że bez problemu można przełożyć go na dowolny inny framework. Być może w innych istnieją dedykowane walidatory i nie trzeba pisać własnych. Enum to świeża sprawa, więc może dopiero powstaną. Ewentualnie twórcy Symfony mogą uznać, że nie ma takiej potrzeby, bo przecież da się to zrobić za pomocą istniejącego już walidatora. Po części jest w tym racja. Ja jednak wolę własne rozwiązanie – bardziej dopasowane do problemu .

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.