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

Стандартно 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 подобный функционал.

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

Комментарии (1)


  1. Chrome
    29.09.2023 11:56
    +2

    В теории можно сделать всё проще, написав в репо метод с fetch join, в котором перечислить дополнительные нужные вам сущности и вы сможете получить сразу всё, что нужно, через один запрос и доктрина вам заботливо это разложит в удобном виде.