Всех приветствую!
Стандартно 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:
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;
Результат:
Хотелось бы получить все данные за один запрос, к тому же, при таком подходе возникла ещё проблема: при запросе на получение books без ассоциаций (with=[]), ассоциации всё равно будут загружены:
Чтобы исправить эти проблемы, вернем 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 подобный функционал.
Надеюсь, статья окажется полезной, если это так, ставьте классы. Всем добра!