Загрузка ассоциаций по запросу в Symfony

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всех приветствую!

Стандартно Doctrine загружает сущности отложено (Lazy load). Это означает, что данные взаимосвязей фактически не загружаются до тех пор, пока не будет явный вызов свойства. Механизмы Doctrine позволяют изменить поведение и загружать связи во время запроса к родительской сущности (fetch:'EAGER'), однако это не совсем подходит для динамической загрузки ассоциаций по запросу.

В статье я бы хотел поговорить о том, как реализовать функционал загрузки ассоциаций по запросу средствами Symfony, на примере (не)выдуманной задачи.

Задача

Есть витрина с книгами:

Схема данных
Схема данных

Задача: Реализовать эндпоинт для получения всех книг с возможностью загрузки связанных сущностей по запросу. Эндпоинт будет иметь следующий формат: /books?with[]=author&with[]=author_subscribers, где with - опциональный параметр, принимающий массив названий связанных сущностей, которые необходимо загрузить и добавить в результат.

Первое решение

Не мудрствуя лукаво, пишем код:

    #[Route('/books', name: 'app_book_index')]
    public function index(Request $request): Response
    {
        // Получаем и валидируем Request
        $requestListBook = $this->serializer->deserialize(
            json_encode($request->query->all()),
            RequestListBook::class,
            'json'
        );
        $requestListBook->validate();

        // Результат
        $books = $this->bookRepository->findAll();

        $data = $this->serializer->serialize($books, 'json', [
            'groups' => $requestListBook->getWith(),
        ]);

        return new JsonResponse($data, 200, [], true);
    }

Этот код работает, предварительно атрибуты сущностей были разделены на группы и согласованы с названиями из параметра with.

Проблема

При получении всех книг со всеми связанными сущностями (with=[author,author_subscribers]), получаем запрос на каждую связанную сущность, это особенность Lazy Load:

Запросы при Lazy Load
Запросы при Lazy Load

Doctrine может жадно загружать (Eager) отношения во время запроса к родительской сущности. Для этого добавим к атрибутам сущности (fetch: 'EAGER'):

class Book
    #[ORM\ManyToOne(fetch: 'EAGER', inversedBy: 'books')]
    #[ORM\JoinColumn(nullable: false)]
    #[Groups(['author', 'author_subscribers'])]
    private ?Author $author = null;

class Author
    #[ORM\ManyToMany(targetEntity: Subscriber::class, mappedBy: 'authors', fetch: 'EAGER')]
    #[Groups(['author_subscribers])]
    private Collection $subscribers;

Результат:

Запросы при Eager Load
Запросы при Eager Load

Хотелось бы получить все данные за один запрос, к тому же, при таком подходе возникла ещё проблема: при запросе на получение books без ассоциаций (with=[]), ассоциации всё равно будут загружены:

Лишний JOIN
Лишний JOIN

Чтобы исправить эти проблемы, вернем Lazy load и обратимся к Laravel.

Второе решение

В Laravel, для загрузки связанных сущностей, есть конструкция with. Реализуем подобный функционал в приложении:

class QueryBuilderService
{
    protected ?QueryBuilder $queryBuilder = null;

    public function with($association, $alias, array $rootAliases = []): QueryBuilderService
    {
        $rootAliases = $rootAliases ?: $this->queryBuilder->getRootAliases();

        if (count($rootAliases) === 1) {
            // Если есть только один корневой алиас, используем его
            $rootAlias = reset($rootAliases);
            $this->queryBuilder->leftJoin("$rootAlias.$association", $alias)
                ->addSelect($alias);

        } else {
            // Если есть несколько корневых алиасов, создаем собственный алиас для каждого
            foreach ($rootAliases as $index => $rootAlias) {
                $this->queryBuilder->leftJoin("$rootAlias.$association", "$alias$index")
                    ->addSelect("$alias$index");
            }
        }

        return $this;
    }

    public function getQueryBuilder(): QueryBuilder
    {
        return $this->queryBuilder;
    }

    public function setQueryBuilder(QueryBuilder $queryBuilder): static
    {
        $this->queryBuilder = $queryBuilder;
        return $this;
    }

}

Динамическое добавление связанных сущностей можно реализовать множеством способов, таких как использование Symfony workflow, цепочки обязанностей и других. Но мы реализуем нечто схожее с Laravel pipeline:

abstract class AbstractPipeline
{
    protected array $pipes;

    protected array|string $passable;

    public function through(array $pipes): static
    {
        foreach ($pipes as $pipe) {
            $this->pipes[] = $pipe;
        }

        return $this;
    }

    public function send(array|string $passable): static
    {
        $this->passable = $passable;
        return $this;
    }
    
    abstract public function handle(mixed $request): void;
}

Для рассматриваемого примера, обработчики реализуют контракт:

interface BookListHandlerInterface
{
    public function handle(mixed $passable, mixed &$qb): void;
}

Это реализации для загрузки author и author_subscribers:

class WithAuthorHandler implements BookListHandlerInterface
{

    public function handle(mixed $passable, mixed &$qb): void
    {
        if (in_array("author", $passable) || in_array("author_subscribers", $passable)) {
            $qb->with('author', 'a');
        }
    }
}

class WithAuthorSubscribersHandler implements BookListHandlerInterface
{
    public function handle(mixed $passable, mixed &$qb): void
    {
        if (in_array("author_subscribers", $passable)) {
            $qb->with('subscribers', 's', ['a']);
        }
    }
}

Конечная реализация эндпоинта (для лучшего восприятия вся логика в методе):

    #[Route('/books', name: 'app_book')]
    public function index(Request $request, BookListPipeline $pipeline): Response
    {
        // Получаем и валидируем Request
        $requestListBook = $this->serializer->deserialize(
            json_encode($request->query->all()),
            RequestListBook::class,
            'json'
        );
        $requestListBook->validate();

        // Получаем данные из хранилища
        $qb = $this->bookRepository->createQueryBuilder('t');
        $qbService = $this->queryBuilderService->setQueryBuilder($qb);

        // Модифицируем данные
        $pipeline
            ->send($requestListBook->getWith())
            ->through([
                new WithAuthorHandler(),
                new WithAuthorSubscribersHandler(),
            ])
            ->handle($qbService);

        // Результат
        $books = $qbService->getQueryBuilder()->getQuery()->getResult();

        $data = $this->serializer->serialize($books, 'json', [
            'groups' => $requestListBook->getWith(),
        ]);

        return new JsonResponse($data, 200, [], true);
    }

Результат

Протестируем решение (в Response только book с id=1):

/books?with[]=author&with[]=author_subscribers

Response

    {
        "id": 1,
        "title": "book1",
        "author": {
            "id": 1,
            "name": "Author1",
            "subscribers": [
                {
                    "id": 1,
                    "name": "sub1"
                },
                {
                    "id": 3,
                    "name": "sub3"
                },
                {
                    "id": 4,
                    "name": "string"
                },
                {
                    "id": 5,
                    "name": "string1"
                },
                {
                    "id": 6,
                    "name": "string12"
                },
                {
                    "id": 7,
                    "name": "string123"
                },
                {
                    "id": 8,
                    "name": "string1243"
                }
            ]
        }
    },

Запросы:

/books?with[]=author

Response

    {
        "id": 1,
        "title": "book1",
        "author": {
            "id": 1,
            "name": "Author1"
        }
    },

Запросы:

/books

Response
   {
        "id": 1,
        "title": "book1"
    },

Запросы:

Заключение

В данной статье был рассмотрен важный аспект работы с Doctrine в Symfony - загрузка связанных сущностей по запросу. Стандартно Doctrine использует отложенную загрузку (Lazy Load), что может привести к множественным запросам к базе данных при доступе к связанным данным.

Текущее решение далеко до идеала, но демонстрирует один из подходов к решению задачи.

*На перспективу.

Что, если понадобится выводить в api связанные сущности в ключе included? В Laravel для этого есть API Resouces.

У Spatie есть QueryBuilder, было бы замечательно иметь в Symfony подобный функционал.

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

Источник: https://habr.com/ru/articles/763084/


Интересные статьи

Интересные статьи

В этом руководстве я покажу вам, как настроить полноценную локальную среду с Nginx, MySql и Symfony всего за несколько минут. Более того, файл, о котором здесь пойдет речь, я сам использую для каждого...
Среди всего прочего в Leaseweb мы предлагаем нашим пользователям сервис Private Network, который позволяет им создать свою собственную частную сеть между другими продуктами Leaseweb.Для решения задачи...
Удалять персональные данные пользователя по его запросу, чтобы продукт соответствовал законам CCPA или GDPR, можно по-разному. Хоть вручную каждую заявку на почте разбирать. Главное — сделать процесс ...
В статье о возможности загружать Linux с VHD был предложен способ загружать Linux на машине с Windows без необходимости разбивать диск на разделы. Но было одно существенное ограничение: р...
В данной статье мы коротко пройдемся по теории и на практике разберемся как перевести любое Legacy приложение на гексагональную архитектуру. Повествование будет в контексте фреймворка Sym...