Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~20 мин +50 XP

Жадная загрузка With*

Рёбра объявлены в пятом и шестом уроках, фильтры по ним — в десятом, но после каждого запроса поля Edges оставались пустыми: предикаты отбирают строки, а соседей не привозят. Сегодня — ent preload, жадная загрузка: методы With* на query-билдере — WithGroups, WithPosts, WithOwner — просят ent привезти соседей вместе с родителями и разложить их по Edges. Разберём, что именно генерирует ent в query.go, сколько запросов уезжает в базу и почему один JOIN невозможен, — и честно сравним механику с Preload из курса GORM: проблема N+1 решается в обеих ORM одинаково, одной выборкой на уровень. Затем прикладная часть: условная With-форма — функциональные аргументы, которые фильтруют, сортируют и ограничивают соседей; вложенная загрузка через gq.WithUsers(); безопасные доступоры GroupsOrErr и PostsOrErr с ошибкой NotLoadedError, тизер которой шёл с девятого урока. Если вы гуглили with ent и eager loading — этот урок закрывает тему целиком на нашем каноне User, Group и Post.

Содержание урока · 12 разделов

План урока повторяет путь одного 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 — ровно при вызванном WithAuthor

GroupsOrErr и 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_usersSELECT ... 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.

// 1. пользователи с группами; друзья — только взрослые: users, err := client.User.Query(). (). WithFriends(func(fq *ent.UserQuery) { fq.Where(user.AgeGTE(18)) // условная загрузка соседей }). All(ctx) // 2. вложенная загрузка: у каждой группы её участники: users2, err := client.User.Query(). WithGroups(func(gq *ent.GroupQuery) { gq.() // участники групп третьим уровнем }). All(ctx) // 3. безопасное чтение незапрошенного ребра: _, err = users[0].Edges.PostsOrErr() if ent.(err) { // ребро posts не запрашивали — это не сбой базы }

Путь одного With-запроса

Соберите жизненный цикл жадной загрузки: что происходит и в каком порядке от вызова WithGroups до готовой страницы.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Что дальше#

Вы умеете привозить граф целиком: рёбра любого типа, условные фильтры на соседей, вложенные уровни и честная диагностика через OrErr. Ограничивать саму страницу пользователей — Limit, Offset и курсорную пагинацию — разберёт двенадцатый урок 12-paginaciya: жадная загрузка сочетается с пагинацией без сюрпризов, но механика страниц — его территория, там же и сортировка. Домашнее задание превратит тариф «запрос на ребро» в пальцевую память: возьмите базу из домашек восьмого урока, перепишите ручной цикл с QueryGroups на WithGroups и посчитайте разницу в логе драйвера. Если понадобится вспомнить, как отбираются сами строки, — фильтры и HasEdge-предикаты остались в десятом уроке, а устройство M2M-таблиц — в шестом. Полный план из 25 уроков — на странице курса.

Карточки: жадная загрузка

Термин

Жадная загрузка в ent

нажмите, чтобы перевернуть
Определение

Eager loading: методы With* на query-билдере (WithGroups, WithPosts, WithAuthor) заполняют поле Edges возвращённых сущностей. Ленивой загрузки в ent нет: без With* поля рёбер — nil, и никакого дочитывания при обращении не происходит. Документация называет это ent preload; поддерживают только SQL-диалекты.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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-рёбер с пагинацией соседей?

Личные заметкиПусто
Хранятся только в вашем браузере (ent · Жадная загрузка With*)

Вопросы и ответы по уроку

Чем 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-kit25 уроков~14 чпродвинутый25 уроков уже доступны

go-kit: микросервисный тулкит

Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.

  • Понимать архитектуру endpoint → transport → service
  • Оборачивать сервисы middleware: логи, метрики, трейсинг
  • Подключать HTTP и gRPC транспорты
  • Ставить rate limiting и circuit breaker