Содержание урока · 12 разделов
- ent preload: рёбра заполняются только по просьбе
- With-методы в query.go: что именно генерирует ent
- Сколько запросов уезжает в базу
- Условная With: функциональная форма
- Вложенная загрузка: соседи соседей
- NotLoadedError и семейство OrErr-доступоров
- Формы загрузки: сводка урока
- GraphQL, CollectFields и названные рёбра
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
План урока повторяет путь одного With-вызова: от генерации в query.go до раскладки соседей по Edges. Десятый урок закончился договорённостью: рёберные предикаты HasEdge только отбирают строки — поля рёбер в найденных сущностях пусты, и заполнять их предстоит жадной загрузке. Первый сюжет — что такое eager loading в ent и как выглядит первая загрузка WithGroups().WithPosts(). Второй — With-методы в сгенерированном query.go: сигнатура, поле-хранилище, свежий билдер соседа. Третий — SQL-след: сколько запросов уезжает в базу и почему ent не собирает все рёбра одним JOIN. Четвёртый — условная With-форма: функциональные опции, которые фильтруют и проецируют соседей, в противовес HasGroupsWith из прошлого урока. Пятый — вложенная загрузка через gq.WithUsers(), прямая наследница M2M-схемы шестого урока. Шестой — NotLoadedError и семейство OrErr-доступоров. Данные те же, что и весь курс: анна, борис и вера, группы и посты, go-ninja, таблицы group_users и user_friends.
ent preload: рёбра заполняются только по просьбе#
Жадная загрузка (eager loading) — это просьба привезти связанные сущности тем же походом в базу, что и основных. В ent она включается методами вида With* на query-билдере: WithGroups у UserQuery, WithPosts рядом, WithAuthor у запроса постов — по методу на каждое ребро схемы. Документация зовёт эту возможность ent preload и формулирует честно: ассоциированные сущности попадают в поле Edges возвращённых объектов. Важно проговорить то же, что мы говорили про GORM в его четырнадцатом уроке: ленивой загрузки в ent нет вовсе. После обычного All(ctx) поле u.Edges.Groups — nil, и никакого «дочитывания при первом обращении» не произойдёт: ни прокси, ни сессий, ни отложенных запросов — ent не сделает ни одного запроса без вашей команды. Пустое ребро — не баг и не отложенная загрузка, а запрос, которого никогда не было. Отсюда же и диагноз проблемы N+1: она возникает не сама, а из рук — когда вы читаете соседей в цикле, по одному обходу на каждую запись. Жадная загрузка — единственный способ наполнить Edges, и она же — лекарство: вместо N запросов на страницу уезжает фиксированное число выборок. Сколько именно — считаем на следующих блоках, а начнём с самого простого запроса и посмотрим, что окажется внутри.
Первая жадная загрузка
GO// пользователи + их группы + их посты — одной цепочкой:
users, err := client.User.Query().
WithGroups().
WithPosts().
All(ctx)
if err != nil {
return err // соединение, диалект, отмена — обычная проверка
}
// соседи уже внутри; читаем без единого лишнего запроса:
for _, u := range users {
for _, g := range u.Edges.Groups {
fmt.Printf("%s -> группа %s\n", u.Name, g.Name)
}
for _, p := range u.Edges.Posts {
fmt.Printf("%s -> пост «%s»\n", u.Name, p.Title)
}
}WithGroups() и WithPosts() вызываются на UserQuery и возвращают тот же UserQuery — цепочка собирается как любая другая. После All(ctx) у каждой сущности заполнены u.Edges.Groups ([]Group) и u.Edges.Posts ([]Post): декодер раскладывает соседей по владельцам сам. Без With-вызовов оба поля остались бы nil — это та самая договорённость девятого урока. Обратите внимание на типизацию: Edges.Posts — это []Post со всеми полями, а не проекция из двух колонок; что именно грузить для соседей, настраивает условная форма, до неё дойдём ниже.
With-методы в query.go: что именно генерирует ent#
Откроем сгенерированный blog/ent/user_query.go — на каждое ребро схемы здесь напечатан свой With-метод, и его устройство стоит знать дословно. Сигнатура: func (uq UserQuery) WithGroups(opts ...func(GroupQuery)) UserQuery. Комментарий генератора переводится так: метод просит билдер жадно загрузить узлы, подключённые ребром groups, а необязательные аргументы настраивают билдер запроса этого ребра. Тело короткое: создаётся свежий GroupQuery через GroupClient — (&GroupClient{config: uq.config}).Query(), затем каждая опция из opts применяется к нему, и готовый билдер сохраняется в поле-хранилище withGroups GroupQuery. Именно это поле — переключатель жадности: nil означает «ребро не просили». Три следствия. Первое: опции вариативны — можно передать хоть три функции разом, и все применятся к одному и тому же запросу соседа. Второе: состояние With участвует в Clone() — скопированный билдер уносит with-поля с собой, так что заготовку с загрузкой можно клонировать. Третье: With живут не только на корневых запросах — обход anna.QueryGroups() возвращает тот же *GroupQuery, и WithUsers() на нём работает без оговорок. И ещё одна граница из документации: жадную загрузку поддерживают только SQL-диалекты — гремлиновский бэкенд её не имеет, но нас это не касается. Если вы искали по запросу with ent примеры — сгенерированный код и есть первоисточник: сигнатуры короткие, магии нет.
Проверка: сигнатура With-метода
Что делает сгенерированный метод WithGroups и какова его сигнатура?
Сколько запросов уезжает в базу#
Самый практичный вопрос жадной загрузки — тариф. Ответ доки прямолинеен: поскольку один запрос может жадно загрузить несколько рёбер, собрать все ассоциации одним JOIN невозможно — ent исполняет дополнительный запрос на каждую связь. В сгенерированном sqlAll это видно построчно: сначала главный SELECT по users через sqlgraph.QueryNodes, затем ранний выход — если len(nodes) == 0, за соседями никто не ходит, пустой странице они не нужны; дальше по каждому ребру с непустым with-полем вызывается свой загрузчик loadGroups, loadPosts, loadFriends — по одному запросу на ребро. Попутно заполняется служебный массив Edges.loadedTypes — по булеву на каждое ребро схемы: он запоминает, что именно вы просили, и сыграет главную роль в OrErr-доступорах ближе к концу урока. Итого тариф: один запрос на родителя плюс один на каждое запрошенное ребро, независимо от числа строк. Двадцать пользователей с группами и постами — три SELECT, а не двадцать один. Здесь курс GORM снова рядом: его четырнадцатый урок разбирал Preload как лечение того же N+1 — один главный запрос плюс IN-выборка на уровень связей. Философия совпадает до забавного: и там, и там ленивой загрузки нет, пустые срезы честные, а N+1 рождается из ручных циклов. Разница в мелочах: в GORM уровень m2m стоит две выборки — сначала join-таблица, потом записи; ent в m2m-шаге объединяет join-таблицу с записями JOIN-ом в одном запросе. Экономия в один запрос на уровень — приятный бонус за графовую архитектуру.
SQL-след: один запрос на ребро
GO// анна, борис и вера на странице; запрос со всеми тремя рёбрами:
users, err := client.User.Query().
WithPosts(). // o2m: у пользователя много постов
WithGroups(). // m2m: через group_users
WithFriends(). // само-ребро m2m: через user_friends
All(ctx)
// SQL-след (упрощённо) — четыре SELECT:
// 1) SELECT * FROM users
// 2) SELECT * FROM posts WHERE author_id IN (1, 2) -- анна, борис
// 3) SELECT group_users.user_id, groups.* FROM groups
// JOIN group_users ON groups.id = group_users.group_id
// WHERE group_users.user_id IN (1, 2)
// 4) SELECT user_friends.user_id, users.* FROM users
// JOIN user_friends ON users.id = user_friends.friend_id
// WHERE user_friends.user_id IN (1, 2)Три формы загрузки — три SQL-шаблона. O2M-ребро грузится условием по колонке внешнего ключа: WHERE author_id IN (идентификаторы родителей) — колонка та самая, из пятого урока. M2M-ребро объединяет соседей с таблицей связи: JOIN group_users по ключу группы плюс WHERE по ключам владельцев — один запрос, без предварительного чтения прослойки. Само-ребро friends устроено так же, только таблица user_friends и владелец совпадает с соседом по таблице users. У веры, у которой нет ни постов, ни групп, ветки просто не найдётся — пустые срезы создаст раскладка, а не запросы.
Сколько SELECT на страницу
Прочитайте код и выберите, что он выведет:
// страница из 20 пользователей; у каждого есть и посты, и группы:
users, err := client.User.Query().
WithGroups().
WithPosts().
All(ctx)
// сколько SELECT уедет в базу?Условная With: функциональная форма#
Пустые скобки после WithGroups — не единственная форма. Каждый With-метод принимает вариативный список опций-функций, и это открывает условную With-форму: загрузить не всех соседей, а только нужных. Выглядит так: WithGroups(func(gq *ent.GroupQuery) { gq.Where(group.Name("go-ninja")) }) — внутрь приходит полноценный билдер GroupQuery, и вы можете делать с ним всё, что умеет любой запрос: Where с предикатами из десятого урока, Order для сортировки соседей, Limit для ограничения их числа, Select для проекции колонок. В документации каноничный пример добавляет q.Limit(5) и вложенное q.WithUsers() в одну опцию — жадность настраивается теми же словами, что и обычный запрос. Семантику нужно зафиксировать раз и навсегда: условие применяется к выборке соседей, а не к родителям. Строки родителя не теряются никогда: пользователь, у которого нет go-ninja, вернётся как вернулся — просто с пустым срезом Groups. Это зеркало-противоположность HasGroupsWith из прошлого урока: там фильтр по соседям отбирал строки родителя, здесь — наполняет рёбра подмножеством. Фильтр и загрузка — две разные развилки, и путать их дорого. Ещё два практических штриха. Повторный WithGroups с опциями не суммируется с предыдущим: каждый вызов создаёт свежий GroupQuery и затирает поле — выигрывает последний, опции не складываются. А проекция через q.Select(user.FieldName) не сломает раскладку: ent сам допишет к выбранным колонкам те, что нужны для привязки рёбер — первичный ключ и внешние ключи, — так что короткие проекции соседей безопасны.
Фильтруем, сортируем и проецируем соседей
GO// условная With: только группа go-ninja и только имена её участников:
users, err := client.User.Query().
WithGroups(func(gq *ent.GroupQuery) {
gq.Where(group.Name("go-ninja")) // фильтр на соседей
gq.Order(ent.Asc(group.FieldName)) // сортировка соседей
gq.WithUsers(func(uq *ent.UserQuery) {
uq.Select(user.FieldName) // проекция: только имя
})
}).
All(ctx)
// вера вернётся как вернулась — но с пустым срезом Groups:
// фильтровал gq, а не родительский запрос.Опция получает типизированный билдер соседа — gq *ent.GroupQuery, — поэтому group.Name("go-ninja") компилируется, а предикат чужой схемы не подставится никогда. Проекция uq.Select(user.FieldName) безвредна: ent добавит к выборке первичный ключ и ключи рёбер, без которых не собрать Edges, — лишние колонки в вашем Select он не спросит. Вложенное WithUsers здесь показано мельком: следующий сюжет посвящает ему целый блок.
Проверка: фильтр соседа или фильтр родителя
Запрос: Query().WithGroups(func(gq *ent.GroupQuery) { gq.Where(group.Name("go-ninja")) }).All(ctx). Анна состоит в go-ninja, вера — ни в одной группе. Что вернётся?
Вложенная загрузка: соседи соседей#
У загруженного соседа рёбра тоже пусты — пока не попросите. Вложенная загрузка делается ровно теми же With-методами, только внутри функциональной опции: WithGroups(func(gq *ent.GroupQuery) { gq.WithUsers() }) просит у каждой загруженной группы её участников. Синтаксис не новый — новый только уровень: ent рекурсивно применяет тот же тариф, по одному запросу на ребро каждого уровня. Пользователи с группами и участниками групп — три SELECT: users, группы через group_users, затем участники групп через ту же таблицу связи с другого конца. Глубина не ограничена схемой: WithUsers(func(uq){ uq.WithPosts() }) достроит четвёртый этаж — но каждый этаж стоит свой запрос, поэтому граф загрузки стоит рисовать до кода, а не после жалобы дежурного. Для нашей M2M-пары это возвращает сюжет шестого урока: таблица group_users с двумя NOT NULL ключами и каскадами обслуживает оба направления — WithGroups читает её со стороны пользователя, вложенное WithUsers — со стороны группы, и оба шага выглядят как JOIN с IN-списком. Ещё одно следствие держите в голове при отладке: незагруженное вложенное ребро — это nil, и никакой ошибки при обычном чтении не будет. Обратились к g.Edges.Users без WithUsers — получили nil-срез молча; ошибка NotLoadedError появится только в OrErr-доступорах, о которых ниже. Если страница должна отдавать граф целиком — пользователей, их группы и участников — вложенная загрузка делает это за константное число запросов: один на ребро каждого уровня, и это ровно то, о чём просит любой REST-выпускник и что соберёт вам GraphQL-интеграция автоматически, но об этом в конце урока.
Users → Groups → Users: три запроса на два уровня
GO// двухуровневая загрузка: пользователи -> группы -> участники групп:
users, err := client.User.Query().
WithGroups(func(gq *ent.GroupQuery) {
gq.WithUsers() // вложенная загрузка: соседи соседей
}).
WithFriends(). // само-ребро из шестого урока: user_friends
All(ctx)
// обход двух уровней без единого запроса в цикле:
for _, u := range users {
for _, g := range u.Edges.Groups {
for _, m := range g.Edges.Users {
fmt.Printf("%s -> %s -> %s\n", u.Name, g.Name, m.Name)
}
}
}
// SQL-след (упрощённо) — четыре SELECT: users; groups JOIN group_users
// WHERE user_id IN (...); users JOIN group_users WHERE group_id IN (...);
// users JOIN user_friends WHERE user_id IN (...)Четыре запроса на три рёберных шага: группы пользователя, участники групп (вложенный уровень) и друзья. Циклы внутри — только по уже загруженным данным: ни один обход не ходит в базу. Таблица group_users работает на обоих уровнях: на первом JOIN идёт по ключу группы, на вложенном — по ключу пользователя; это та же прослойка, что собирала anna.QueryGroups() в шестом уроке, только пачкой.
Что окажется в Edges вложенной сущности
Прочитайте код и выберите, что он выведет:
users, err := client.User.Query().
WithGroups(). // вложенной загрузки НЕТ
All(ctx)
if err != nil {
return err
}
g := users[0].Edges.Groups[0] // первая группа первого пользователя
// что окажется в g.Edges.Users?NotLoadedError и семейство OrErr-доступоров#
Тизер с девятого урока наконец закрывается. В сгенерированной структуре UserEdges рядом с полями Groups, Posts, Friends живёт служебный массив loadedTypes — по булеву на каждое ребро схемы: sqlAll расставляет их по факту «with-поле непусто». На его основе генерируются безопасные доступоры: GroupsOrErr() ([]Group, error), PostsOrErr(), FriendsOrErr() — по одному на каждое незуникальное ребро. Семантика трёхслойная. Ребро запрошено — доступор вернёт срез, пусть даже пустой: «загрузили, соседей нет» — законный ответ, и он неотличим от «загрузили, соседей много, но фильтр в условной With их отсёк». Ребро не запрошено — NotLoadedError с текстом вида «ent: posts edge was not loaded»: это не ошибка базы, а честное «вы об этом нас не просили», и проверяется она хелпером ent.IsNotLoaded(err). Отдельный случай — уникальные рёбра вроде Post.author: AuthorOrErr() вернёт nil и NotFoundError, если WithAuthor был вызван, а соседа в базе не оказалось — «загрузили, но не нашли»; без вызова — всё тот же NotLoadedError. Разница между nil-срезом и ошибкой — это разница между «пусто, потому что так в данных» и «пусто, потому что вы забыли With»: первый случай — бизнес-логика, второй — баг вызова, и доступоры их разделяют за вас. Читать u.Edges.Groups напрямую по-прежнему можно — но тогда молчаливый nil и NotLoadedError-случай неразличимы; в API-слое берите OrErr, в горячих внутренних циклах — прямое поле. Так вы закрываете последнюю дыру между «запрос вернулся» и «данные на месте».
OrErr-доступоры на практике
GO// запрошены только группы:
users, err := client.User.Query().WithGroups().All(ctx)
if err != nil {
return err
}
// запрошено — срез соседей (пустой или нет, ошибки не будет):
groups, err := users[0].Edges.GroupsOrErr()
// не запрошено — *NotLoadedError:
_, err = users[0].Edges.PostsOrErr()
if ent.IsNotLoaded(err) {
// «ent: posts edge was not loaded»: ребро никто не просил
}
// уникальное ребро: загружено, но автора нет — NotFoundError:
posts, _ := client.Post.Query().WithAuthor().All(ctx)
author, err := posts[0].Edges.AuthorOrErr()
// author == nil, ent.IsNotFound(err) == true — ровно при вызванном WithAuthorGroupsOrErr и PostsOrErr — сгенерированные методы на структуре Edges: нулевых сюрпризов нет, каждый путь описан. PostsOrErr без WithPosts — типовой источник NotLoadedError; текст ошибки прямо называет ребро, так что диагностика мгновенная. Для поста с Optional-ребром author вариант «WithAuthor вызван, автора нет» отвечает NotFoundError — единственное место в уроке, где ошибка отсутствия соседа законна; без WithAuthor та же ситуация ответила бы NotLoadedError. Разводите эти случаи по коду обработки: один — данные, другой — забытый With.
Проверка: что ответит доступор
users получены запросом БЕЗ WithGroups. Что произойдёт при вызове users[0].Edges.GroupsOrErr()?
Формы загрузки: сводка урока#
| Вызов | Тип ребра | SQL-шаг загрузки | Результат в Edges |
|---|---|---|---|
| WithPosts() | o2m (User → Post) | SELECT ... FROM posts WHERE author_id IN (ids родителей) | u.Edges.Posts — []*Post |
| WithAuthor() на Post (в доках — WithOwner) | m2o | собрать FK из строк, затем SELECT ... FROM users WHERE id IN (fk) | p.Edges.Author — *User или nil |
| WithGroups(func(gq){ ... }) | m2m через group_users | SELECT ... FROM groups JOIN group_users ... WHERE user_id IN (ids) | u.Edges.Groups — []*Group |
| WithFriends() | само-ребро m2m через user_friends | тот же JOIN-шаблон, владелец и сосед — одна таблица | u.Edges.Friends — []*User |
| gq.WithUsers() внутри опции | вложенная загрузка | дополнительный запрос на ребро каждого уровня | g.Edges.Users — []*User |
| GroupsOrErr() | SQL не порождает | чтение служебного loadedTypes | срез, NotLoadedError, для unique — NotFoundError |
GraphQL, CollectFields и названные рёбра#
К чему ведёт вся эта конструкция, лучше всего видно в GraphQL-интеграции entgql — до неё дойдём в двадцать первом уроке, здесь только прицел. Клиент GraphQL запрашивает не таблицы, а граф: пользователей, у них — группы, у групп — участников. Функция CollectFields собирает запрошенные клиентом поля, видит среди них рёберные и превращает каждый в With-вызов нужной глубины — жадная загрузка, построенная автоматически по форме запроса. Всё, что вы сегодня сделали руками — вложенные With, проекции, ограничения числа соседей, — в entgql собирается из документа GraphQL; то, зачем условной With нужна проекция Select, станет очевидно именно там: незапрошенные колонки не хочется возить из базы. Второй виток той же истории — названные рёбра: экспериментальный feature-флаг namedges включает семейство WithNamed, где одно ребро можно загрузить дважды под разными именами с разными условиями — WithNamedComments("published", ...) и WithNamedComments("draft", ...) в одном запросе. Мотивация чисто GraphQL-овская: два алиаса одного ребра с разными аргументами. Названные рёбра отключены по умолчанию и в обычном коде не нужны — обычные With накладываются друг на друга перезаписью поля, а не множатся. Забирайте отсюда главное: With — это не костыль для ORM-задач, а транспортный слой графа, на котором стоит целый стек поверх ent.
Соберите жадную загрузку
Впишите имя With-метода для групп, вложенную загрузку участников и хелпер проверки NotLoadedError.
Путь одного With-запроса
Соберите жизненный цикл жадной загрузки: что происходит и в каком порядке от вызова WithGroups до готовой страницы.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Runner-ов в курсе ent нет, и песочница не появится и здесь: жадной загрузке нужны настоящий драйвер и база — правило всего курса. Интерактив на странице — квизы, predict, fill, order и карточки, а SQL-след смотрите локально: возьмите базу из домашек восьмого урока, включите лог драйвера и исполните каждый запрос из урока. Считайте SELECT-ы, находите JOIN по group_users в m2m-шаге, IN-списки идентификаторов в o2m-шаге и ранний выход на пустой странице: запросите несуществующую роль и убедитесь, что за соседями ent не поехал вообще.
Что дальше#
Вы умеете привозить граф целиком: рёбра любого типа, условные фильтры на соседей, вложенные уровни и честная диагностика через OrErr. Ограничивать саму страницу пользователей — Limit, Offset и курсорную пагинацию — разберёт двенадцатый урок 12-paginaciya: жадная загрузка сочетается с пагинацией без сюрпризов, но механика страниц — его территория, там же и сортировка. Домашнее задание превратит тариф «запрос на ребро» в пальцевую память: возьмите базу из домашек восьмого урока, перепишите ручной цикл с QueryGroups на WithGroups и посчитайте разницу в логе драйвера. Если понадобится вспомнить, как отбираются сами строки, — фильтры и HasEdge-предикаты остались в десятом уроке, а устройство M2M-таблиц — в шестом. Полный план из 25 уроков — на странице курса.
Карточки: жадная загрузка
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Жадная загрузка — единственный способ заполнить Edges: методы With* (WithGroups, WithPosts, WithAuthor — в доках WithOwner) просят ent привезти соседей вместе с родителями. Ленивой загрузки нет: без With* поля рёбер — nil, и это договорённость, а не отложенный запрос.
- Генерация: в query.go на каждое ребро — метод вида WithGroups(opts ...func(*GroupQuery)) *UserQuery. Тело создаёт свежий GroupQuery, применяет опции и кладёт билдер в поле withGroups; Clone() переносит with-поля; With* работают и на обходах вроде anna.QueryGroups().
- Тариф жадной загрузки: один главный SELECT плюс один запрос на каждое запрошенное ребро. Один JOIN на все рёбра невозможен — ent исполняет дополнительный запрос на каждую связь; пустой результат родителя — ранний выход без соседних запросов.
- SQL-шаблоны: o2m — WHERE author_id IN (ids); m2m — JOIN group_users с WHERE по ключам владельцев, один запрос без отдельного чтения прослойки; m2o — собрать FK из строк и SELECT ... WHERE id IN; само-ребро friends — тот же m2m-шаблон через user_friends.
- Условная With-форма: функциональные опции фильтруют, сортируют, ограничивают и проецируют соседей (Where, Order, Limit, Select). Строки родителя не теряются; противовес — HasGroupsWith из десятого урока, который отбирает строки. Повторный WithGroups перезаписывает опции, а не складывает их.
- Вложенная загрузка — gq.WithUsers() внутри опции: глубина любая, тариф рекурсивный, по запросу на ребро каждого уровня. Незагруженное вложенное ребро — тихий nil; таблица group_users обслуживает оба направления M2M.
- OrErr-доступоры читают loadedTypes: запрошенное ребро — срез и nil, незапрошенное — *NotLoadedError («ent: posts edge was not loaded»), проверка ent.IsNotLoaded; уникальное ребро, загруженное без результата, — *NotFoundError. Прямое поле Edges молчит nil-ом.
- N+1 решается одинаково в ent и GORM: фиксированное число выборок вместо цикла обходов; в ent m2m-уровень стоит один запрос против двух у GORM. Дальше CollectFields из entgql строит With*-граф автоматически по GraphQL-запросу — тема двадцать первого урока.
Домашнее задание
Работайте на базе из восьмого урока с анной, борисом, верой и go-ninja; включите лог SQL драйвера. Первое: перепишите ручной цикл — запрос страницы пользователей, затем QueryGroups().All(ctx) на каждой записи — на один вызов WithGroups().WithPosts(), и посчитайте в логе разницу: должно уйти три SELECT вместо 1+N. Второе: соберите вложенную загрузку users → groups → users и убедитесь, что цикл по двум уровням не делает ни одного запроса; затем уберите gq.WithUsers() и проверьте, что g.Edges.Users — nil без ошибок. Третье: добавьте условную With — только группы, где есть взрослые участники, и только их посты — и сверьте по логу, что строки родителя не потерялись: вера должна вернуться с пустым срезом. Четвёртое: вызовите PostsOrErr() без WithPosts и прочитайте текст ошибки, затем обработайте её через ent.IsNotLoaded и догрузите ребро обходом QueryPosts у конкретного пользователя. Пятое: сравните два запроса — с HasGroupsWith(group.Name("go-ninja")) и с условной With тем же предикатом — и опишите словами, кто из них вернул веру и зачем нужен каждый. Вопрос на подумать: почему ent не собирает несколько рёбер одним LEFT JOIN, в каких случаях JOIN был бы выгоднее, и что теряется при этом для o2m-рёбер с пагинацией соседей?
Личные заметкиПусто
Вопросы и ответы по уроку
Чем WithGroups отличается от anna.QueryGroups().All(ctx)?
Обход QueryGroups — отдельный запрос, который возвращает группы одной сущности; для списка пользователей это цикл с запросом на каждую запись — классическое N+1. WithGroups грузит рёбра пачкой: один дополнительный SELECT с JOIN по group_users и IN-списком ключей всех родителей, а соседи раскладываются по Edges автоматически. Для одной сущности разницы в числах почти нет; для страницы из двадцати — двадцать один запрос против двух.
Можно ли настроить ent так, чтобы связи грузились сами, без With*?
Нет. Ленивой загрузки в ent нет — как и в GORM: ни один запрос не выполняется без вашей команды, и это осознанный дизайн, а не недоработка. Автоматизировать построение With* можно только на другом уровне: интеграция entgql собирает жадную загрузку из запрошенных GraphQL-полей через CollectFields — но это по-прежнему явные With-вызовы под капотом, а не магия при обращении к полю.
Почему после условной With у части пользователей пустые Groups, хотя группы у них есть?
Так и задумано: условная With фильтрует содержимое рёбер, а не строки родителя — Where внутри опции применялся к выборке групп. Если нужно, чтобы пользователи без go-ninja вообще не попали в выборку, это работа рёберного предиката HasGroupsWith из десятого урока. Практическое правило: Has* отвечает на вопрос «кого вернуть», условная With — на вопрос «что привезти с собой»; проверяйте пустые срезы через GroupsOrErr, чтобы отличать «пусто в данных» от «забыли With».
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker