Собственные типы данных для Doctrine и Value Object

30.11.-0001 в 00:00
12917
+103

Типы данных - это такие множества данных, для которых характерны определенные свойства и для которых применимы определенные операции. У СУБД тоже есть типы данных, у которых есть свои ограничения. Скажем, у VARCHAR, INT, TEXT есть максимальная длина. Существуют также еще так называемые пользовательские типы данных, которые на самом деле используют примитивные, но при этом предоставляют совершенно другой управляющий интерфейс. Сегодня мы поговорим, как хранить в базе несовместимые с ней типы данных.

Собственные типы данных

Представим, у вас есть массив. В обычном случае перед сохранением в базу вы превратите его в строку через implode, а на выходе опять примените explode, чтобы получить тот же массив. Это неудобно, а еще об этом можно забыть, если данные достаются из разных мест. К счастью, Doctrine избавляет нас от этой рутины. Вспомните аннотации, которые мы писали над свойствами сущности:

 /**
  * @var string|null
  * @ORM\Column(type="string", nullable=true)
  */
private $field;

Доктрина из коробки предоставляет нам все допустимые типы для популярных СУБД. А что если мы захотим сохранить массив? Не проблема, давайте напишем свой тип:

<?php

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

final class SimpleArrayType extends Type
{
    public const NAME = 'simple_array';

   /**
     * {@inheritdoc}
     */
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return $platform->getClobTypeDeclarationSQL($fieldDeclaration);
    }

    /**
     * {@inheritdoc}
     */
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if (!$value) {
            return null;
        }

        return implode(',', $value);
    }

    /**
     * {@inheritdoc}
     */
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        if ($value === null) {
            return [];
        }

        return explode(',', $value);
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return self::NAME;
    }

    /**
     * {@inheritdoc}
     */
    public function requiresSQLCommentHint(AbstractPlatform $platform)
    {
        return true;
    }
}

Мы дали имя нашему типу, присвоив его константе NAME, который позже зарегистрируем. Метод getSQLDeclaration определяет длину нашего поля (чтобы понять, как он работает, перейдите в метод AbstractPlatform::getClobTypeDeclarationSQL. Самое интересное происходит в методах convertToDatabaseValue и convertToPHPValue. Как понятно из названия, первый метод конвертирует ваши данные в понятный базе тип, а второй - из типа базы данных в тип PHP. Таким образом, при вставке в базу данных доктрина сама позаботится за нас о том, чтобы вызвать для каждого типа метод convertToDatabaseValue, а при запросе из базы данных - convertToPHPValue. Метод requiresSQLCommentHint возвращает true, что говорит доктрине использовать комментарии к полю в базе данных для хранения названия типа: это подскажет доктрине, какой конкретно класс использовать, чтобы модифицировать наши данные.

Чтобы наш тип начал работать, его необходимо зарегистрировать. Сделать это можно в файле config/packages/doctrine.yml:

doctrine:
    dbal:
        types:
              simple_array: 'path/to/ClassType'

Теперь вы можете его использовать в аннотации @ORM\Column. Таким образом вы можете хранить не только данные, которые необходимо преобразовать, но и обычные объекты. Например, вы хотите хранить объект Slug, который, во-первых, будет возвращать строку слага, а во-вторых, проверять, что какой-то другой слаг не равен этому. Давайте напишем наш объект и тип к нему:

class Slug
{
    /**
     * @var string
     */
    private string $value;

    public function __construct(string $value)
    {
        $this->value = $value;
    }

    /**
     * @param Slug $slug
     *
     * @return bool
     */
    public function isEqualTo(Slug $slug): bool
    {
        return $this->value === $slug->getValue();
    }

    /**
     * @return string
     */
    public function getValue(): string
    {
        return $this->value;
    }
}
class SlugType extends StringType
{
    public const NAME = 'advanced_slug_type';

    /**
     * {@inheritDoc}
     */
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        return $value instanceof Slug ? $value->getValue() : $value;
    }

    /**
     * {@inheritDoc}
     */
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        return !empty($value) ? new Slug($value) : null;
    }

    /**
     * {@inheritDoc}
     */
    public function getName(): string
    {
        return self::NAME;
    }

    /**
     * {@inheritDoc}
     */
    public function requiresSQLCommentHint(AbstractPlatform $platform) : bool
    {
        return true;
    }
}

Не забудем его зарегистрировать:

doctrine:
    dbal:
        types:
              simple_array: 'SimpleArrayType'
              advanced_slug_type: 'SlugType'

Использовать типы можно так же, как простые объекты:

$post = new Post($title, new Slug($title));
$em->persist($post);
$em->flush();

Embeddable

Наверняка вы знакомы с понятием объект-значение, или Value Object. Объекты-значения - это такие объекты, которые сравниваются по значению, а не идентификатору. Вы можете помещать туда бизнес-логику или логику валидации. К примеру, Email или Phone - это объекты-значения. Получив в виде строки почту или телефон, объект-значение первым делом проверит его валидность. Как вы уже догадались, доктрина позволяет использовать и их. Для этого нам необходимо над нашим объектом-значением определить аннотацию Embeddable. Давайте реализуем популярный пример объекта-значения - VerifyCode. Он будет хранить сам код верификации и время его истечения:

/**
 * @ORM\Embeddable()
 */
class VerifyCode
{
    /**
     * @var string|null
     * @ORM\Column(type="string", nullable=true)
     */
    private string $code;

    /**
     * @var \DateTimeImmutable|null
     * @ORM\Column(type="datetime_immutable", nullable=true)
     */
    private \DateTimeImmutable $expiresAt;

    public function __construct(string $code, \DateTimeImmutable $expiresAt)
    {
        $this->code = $code;
        $this->expiresAt = $expiresAt;
    }

    /**
     * @param DateTimeImmutable $now
     *
     * @return bool
     */
    public function isValid(\DateTimeImmutable $now): bool
    {
        return $this->expiresAt >= $now;
    }

    /**
     * @return string|null
     */
    public function getCode(): ?string
    {
        return $this->code;
    }

    /**
     * @return DateTimeImmutable|null
     */
    public function getExpiresAt(): ?DateTimeImmutable
    {
        return $this->expiresAt;
    }
}

Объекты-значения не являются полноценными сущностями, у них нет идентификатора, поэтому хранить их отдельно нельзя, они принадлежат другим сущностям. Наш VerifyCode будет принадлежать сущности Phone:

/**
 * @ORM\Entity()
 */
class Phone
{
    /**
     * @var VerifyCode
     * @ORM\Embedded(class=VerifyCode::class, columnPrefix=false)
     */
    private VerifyCode $verifyCode;
}

В аннотации Embedded мы указываем имя встраиваемого класса и указываем, что префикс нам не нужен. Дополнительной конфигурации не нужно, мы уже можем использовать наш VO:

$phone->changeVerificationCode(new VerifyCode(4234234, new \DateTimeImmutable('+1 day')));
$em->flush();

Чтобы достать из базы по коду верификации, нам понадобится составить запрос следующим образом:

$phone = $this->repository->findOneBy(['verifyCode.code' => $code]);

Через точку указываем свойство нашего объекта-значения.

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

loader
30.11.-0001 в 00:00
12917
+103
Логические задачи с собеседований