Как использовать преобразователи данных

Дата обновления перевода 2024-07-19

Как использовать преобразователи данных

Преобразователи данных используются для перевода данных для поля в формат, который может быть отображён в форме (и обратно при отправке). Они уже используются внутренне для многих типов полей. Например, поле DateType может быть отображено в виде текстового ввода формата yyyy-MM-dd. Внутренне, преобразователь данных конвертирует начальное значение поля DateTime в строку yyyy-MM-dd, чтобы отобразить форму, а потом обратно в объект DateTime при отправке.

Caution

Если в поле формы установлена опция inherit_data, преобразователи данных не будут применены к такому полю.

See also

Если, вместо преобразования репрезентации значения, вам нужно вывести значения в поле формы и обратно, вам нужно использовать преобразователь data mapper. Смотрите Как и когда использовать отображатели данных.

Пример #1: Преобразование строковых тегов данных из ввода пользователя в массив

Представьте, что у вас есть форма задачи с типом тегов text:

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
// src/Form/Type/TaskType.php
namespace App\Form\Type;

use App\Entity\Task;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

// ...
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('tags', TextType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Task::class,
        ]);
    }

    // ...
}

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

Это идеальное время, чтобы присоединить пользовательский преобразователь данных к полю tags. Легче всего это сделать с помощью класса CallbackTransformer:

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
// src/Form/Type/TaskType.php
namespace App\Form\Type;

use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
// ...

class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('tags', TextType::class);

        $builder->get('tags')
            ->addModelTransformer(new CallbackTransformer(
                function ($tagsAsArray): string {
                    // преобразовать массив в строку
                    return implode(', ', $tagsAsArray);
                },
                function ($tagsAsString): array {
                    // преобразовать строку обратно в массив
                    return explode(', ', $tagsAsString);
                }
            ))
        ;
    }

    // ...
}

CallbackTransformer берёт две функции обратного вызова в качестве аргументов. Первый преобразует первоначальное значение в формат, который будет использован для отображения поля. Второй делает обратное - он преобразует отправленное значение обратно в формат, который вы увидите в своём коде.

Tip

Метод addModelTransformer() принимает любой объект, который реализует DataTransformerInterface - так что вы можете создать ваши собственные классы, вместо того, чтобы вставлять всю логику в форму (смотрите следующий раздел).

Вы также можете добавить преобразователь во время добавления поля, слегка изменив формат:

1
2
3
4
5
6
7
use Symfony\Component\Form\Extension\Core\Type\TextType;

$builder->add(
    $builder
        ->create('tags', TextType::class)
        ->addModelTransformer(...)
);

Пример #2: Преобразование номера проблемы в сущность проблемы

Скажем, у вас есть отношение многие-к-одному между сущностью Задачи и сущностью Проблемы (т.е. каждая задача имеет необязательный внешний ключ к относящейся к ней проблеме). Добавление окна списка со всеми возможными проблемами может быть очень долгим и долго загружаться. Вместо этого, вы рашаете, что вы хотите добавить текстовое окно, где пользователь может просто вводить номер проблемы.

Начните с установки текстового поля, как обычно:

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
// src/Form/Type/TaskType.php
namespace App\Form\Type;

use App\Entity\Task;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

// ...
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('description', TextareaType::class)
            ->add('issue', TextType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Task::class,
        ]);
    }

    // ...
}

Хорошее начало! Но если бы вы остановились здесь и отправили форму, то свойство задачи issue было бы строкой (например, "55"). Как вы можете преобразовать это в сущность Issue при отправке?

Создание преобразователя

Вы можете использовать CallbackTransformer, как ранее. Но так как это немного сложнее, создание нового класса преобразователя будет упрощать класс формы TaskType.

Создайте класс IssueToNumberTransformer: он будет отвечать за конвертирование из номера проблемы в объект Issue и наоборот:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/Form/DataTransformer/IssueToNumberTransformer.php
namespace App\Form\DataTransformer;

use App\Entity\Issue;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;

class IssueToNumberTransformer implements DataTransformerInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {
    }

    /**
     * Преобразует объект (проблему) в строку (цифру).
     *
     * @param  Issue|null $issue
     */
    public function transform($issue): string
    {
        if (null === $issue) {
            return '';
        }

        return $issue->getId();
    }

    /**
     * Преобразует строку (число) в объект (проблему).
     *
     * @param  string $issueNumber
     * @throws TransformationFailedException if object (issue) is not found.
     */
    public function reverseTransform($issueNumber): ?Issue
    {
        // нет номера проблемы? Это необязательно, так что всё хорошо
        if (!$issueNumber) {
            return null;
        }

        $issue = $this->entityManager
            ->getRepository(Issue::class)
            // запросить проблему по этому id
            ->find($issueNumber)
        ;

        if (null === $issue) {
            // вызывает ошибку валидации
            // это сообщение не отображается пользователю
            // смотрите опцию invalid_message
            throw new TransformationFailedException(sprintf(
                'An issue with number "%s" does not exist!',
                $issueNumber
            ));
        }

        return $issue;
    }
}

Так же, как и в первом примере, преобразователь имеет два направления. Метод transform() отвечает за конвертирование данных, используемых в вашем коде, в формат, который может быть отображён в вашей форме (например, объект Issue в строку id). Метод reverseTransform() делает обратное: он конвертирует отправленные данные обратно в формат, который вы хотите (например, конвертирует id обратно в объект Issue).

Чтобы вызвать ошибку валидации, используйте TransformationFailedException. Но сообщение, которое вы передаёте этому исключению, не будет отображено пользователю. Вы установите это сообщение с помощью опции invalid_message (см. ниже).

Note

Когда методу transform() передаётся null, ваш преобразователь должен вернуть эквивалентное значение типа, в который он трансформирует (например, пустую строку, 0 для целых чисел или 0.0 для плавающих).

Использование преобразователя

Далее вам нужно использовать объект IssueToNumberTransformer внутри TaskType, и добаивть его в поле issue. Не проблема! Просто добавьте метод __construct() и типизируйте новый класс:

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
// src/Form/Type/TaskType.php
namespace App\Form\Type;

use App\Form\DataTransformer\IssueToNumberTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

// ...
class TaskType extends AbstractType
{
    public function __construct(
        private IssueToNumberTransformer $transformer,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('description', TextareaType::class)
            ->add('issue', TextType::class, [
                // сообщение валидации при ошибке преобразователя данных
                'invalid_message' => 'That is not a valid issue number',
            ]);

        // ...

        $builder->get('issue')
            ->addModelTransformer($this->transformer);
    }

    // ...
}

Каждый раз, когда преобразователь вызывает исключение, пользователю отображается invalid_message. Вместо отображения одного и того же сообщения каждый раз, вы можете установить сообщение об ошибке для конечного пользователя в преобразователе данных, используя метод setInvalidMessage(). Это также позволит вам добавить значения пользователей:

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
// src/Form/DataTransformer/IssueToNumberTransformer.php
namespace App\Form\DataTransformer;

use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;

class IssueToNumberTransformer implements DataTransformerInterface
{
    // ...

    public function reverseTransform($issueNumber): ?Issue
    {
        // ...

        if (null === $issue) {
            $privateErrorMessage = sprintf('An issue with number "%s" does not exist!', $issueNumber);
            $publicErrorMessage = 'The given "{{ value }}" value is not a valid issue number.';

            $failure = new TransformationFailedException($privateErrorMessage);
            $failure->setInvalidMessage($publicErrorMessage, [
                '{{ value }}' => $issueNumber,
            ]);

            throw $failure;
        }

        return $issue;
    }
}

Вот и всё! Если вы используете конфигурацию services.yaml по умолчанию , Symfony автоматически будет знать, что надо передать вашему TaskType экземпляр IssueToNumberTransformer благодаря автомантированию и автоконфигурации . В других случаях, зарегистрируйте класс формы в качестве сервиса и тегируйте его тегом form.type.

Теперь вы можете использовать ваш TaskType:

1
2
3
4
// например, где-то в контроллере
$form = $this->createForm(TaskType::class, $task);

// ...

Супер, вы закончили! Ваш пользователь будет иметь возможность ввести номер проблемы в текстовое поле и он будет преобразован в объект проблемы (issue). Это означает, что после успешной отправки, компонент Form передаст Task::setIssue() настоящий объект Issue вместо номера проблемы.

Если проблема не будет найдена, для этого поля будет создана ошибка формы; сообщение об ошибке можно контролировать с помощью опции поля invalid_message.

Caution

Будьте осторожны при добавлении собственных преобразователей. Например, следующее неправильно, так как преобразователь будет применён ко всей форме, вместо одного поля:

1
2
3
4
// ЭТО НЕПРАВИЛЬНО - ПРЕОБРАЗОВАТЕЛЬ БУДЕТ ПРИМЕНЁН КО ВСЕЙ ФОРМЕ
// см. пример выше для правильного кода
$builder->add('issue', TextType::class)
    ->addModelTransformer($transformer);

Создание повторно используемого поля issue_selector

В примере выше, вы применяли преобразователь к нормальному полю text. Но если вы часто делаете это преобразование, то может быть лучше создать пользовательский тип поля. который делает это автоматически.

Для начала, создайте пользовательский класс типа поля:

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/Form/IssueSelectorType.php
namespace App\Form;

use App\Form\DataTransformer\IssueToNumberTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class IssueSelectorType extends AbstractType
{
    public function __construct(
        private IssueToNumberTransformer $transformer,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->addModelTransformer($this->transformer);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'invalid_message' => 'The selected issue does not exist',
        ]);
    }

    public function getParent(): string
    {
        return TextType::class;
    }
}

Отлично! Он будет действовать и отображаться как текстовое поле (getParent()), но будет автоматически иметь преобразователь данных и хорошее значение по умолчанию для опции invalid_message.

Если вы используете автомонтирование и автоконфигурацию , вы можете незамедлительно начать использование формы:

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

use App\Form\DataTransformer\IssueToNumberTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
// ...

class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('description', TextareaType::class)
            ->add('issue', IssueSelectorType::class)
        ;
    }

    // ...
}

Tip

Если вы не используете autowire и autoconfigure, смотрите Как создать пользовательский тип поля формы, чтобы узнать, как сконфигурировать ваш новый IssueSelectorType.

О преобразователях модели и просмотра

В примере выше, преобразователь был использован как "модель". На самом деле, существует два разных вида преобразователей и три разных типа лежащих в основе данных.

  • Данные модели - Это данные в формате, используемом в вашем приложении (например, объект Issue). Если вы вызовете Form::getData() или Form::setData(), вы имеете дело с данными "модели".
  • Данные нормы - Это нормализированная версия ваших данных и она зачастую совпадает с данными "модели" (но не в нашем примере). Обычно они не используются напрямую.
  • Данные просмотра - Это формат, который используется для заполнения самих полей формы. Это также формат, в котором пользователь будет отправлять данные. Когда вы вызываете Form::submit($data), $data имеет формат данных "просмотра".
  • Два разных типа преобразователей помогают конвертировать из и в каждый из этих типов данных:

    Преобразователи модели:
    • transform(): "данные модели" => "данные нормы"
    • reverseTransform(): "данные нормы" => "данные модели"
    Преобразователи просмотра:
    • transform(): "данные нормы" => "данные просмотра"
    • reverseTransform(): "данные просмотра" => "данные нормы"

    То, какой преобразователь вам нужен, зависит от ситуации.

    Чтобы использовать преобразователь просмотра, вызовите addViewTransformer().

    Caution

    Будьте осторожны с преобразователями моделей и типами полей Коллекция. Дочери Коллекции создаются на ранних этапах в PRE_SET_DATA его ResizeFormListener, и их данные заполняются позже, из нормализованных данных. Поэтому ваш преобразователь модели не может уменить количество объектов в Коллекции (т.е. отфильтровать некоторые объекты), и в таком случае коллекция получается с некоторыми пустыми дочерями.

    Возможным обходным путем этого ограничения может быть непрямое использование низлежащего объекта, а использование DTO (Объект переноса данных), который реализует преобразование таких несовместимых структур данных.

    Так зачем использовать преобразователь модели?

    В этом примере, поле - это поле text, а текстовое поле всегда должно быть простым скалярным форматом в форматах "нормы" и "просмотра". По этой причине, наиболее подходящим преобразователем был преобразователь "модели" (который конвертирует из/в формата нормы - строки номера проблемы - в формат модели - объект проблемы).

    Разница между преобразователями тонкая, и вы всегда должны думать о том, какими на самом деле должны быть данные "нормы" для поля. Например, данные "нормы" для поля text - это строка, но для поля date - это объект DateTime.

    Tip

    В качестве общего правила, нормализированные данные должны содержать максимально возможное количество информации.