---
title: "Жадная загрузка With*"
description: "ent preload: жадная загрузка WithGroups и WithOwner, условная With-форма, вложенные загрузки, SQL-след eager loading и честное решение проблемы N+1."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/11-with/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «ent: графовая ORM с кодогенерацией», урок 11 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ent/lessons/11-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.

План урока повторяет путь одного With-вызова: от генерации в query.go до раскладки соседей по Edges. [Десятый урок](/courses/ent/lessons/10-predikaty/) закончился договорённостью: рёберные предикаты 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 и какова его сигнатура?

- ✅ WithGroups(opts ...func(*GroupQuery)) *UserQuery: создаёт свежий GroupQuery, применяет опции и сохраняет его в поле withGroups
- WithGroups(edge string) *UserQuery: принимает имя ребра строкой и включает загрузку по имени
- WithGroups() ([]*Group, error): метод сразу исполняет запрос соседей и возвращает срез
- WithGroups(opts ...func(*UserQuery)) *UserQuery: опции настраивают запрос самого пользователя

Опции — функции настройки запроса соседа, типизированного его схемой: func(*GroupQuery), а не строки и не запрос родителя. Исполнения внутри With нет ни грамма: метод лишь запоминает билдер соседа, а в базу ходит по-прежнему All(ctx). Строковых имён рёбер в API нет — опечатка в имени метода ловится компиляцией, как и всё остальное в сгенерированном коде.

### Сколько запросов уезжает в базу

Самый практичный вопрос жадной загрузки — тариф. Ответ доки прямолинеен: поскольку один запрос может жадно загрузить несколько рёбер, собрать все ассоциации одним JOIN невозможно — ent исполняет дополнительный запрос на каждую связь. В сгенерированном sqlAll это видно построчно: сначала главный SELECT по users через sqlgraph.QueryNodes, затем ранний выход — если len(nodes) == 0, за соседями никто не ходит, пустой странице они не нужны; дальше по каждому ребру с непустым with-полем вызывается свой загрузчик loadGroups, loadPosts, loadFriends — по одному запросу на ребро. Попутно заполняется служебный массив Edges.loadedTypes — по булеву на каждое ребро схемы: он запоминает, что именно вы просили, и сыграет главную роль в OrErr-доступорах ближе к концу урока. Итого тариф: один запрос на родителя плюс один на каждое запрошенное ребро, независимо от числа строк. Двадцать пользователей с группами и постами — три SELECT, а не двадцать один. Здесь курс GORM снова рядом: его [четырнадцатый урок](/courses/gorm/lessons/14-preload/) разбирал 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 на страницу

```go
// страница из 20 пользователей; у каждого есть и посты, и группы:
users, err := client.User.Query().
        WithGroups().
        WithPosts().
        All(ctx)

// сколько SELECT уедет в базу?
```

**Что выведет программа?**

- 21: главный запрос плюс по одному на каждого пользователя — как в ручном цикле
- 40: по два запроса на каждое ребро — join-таблица отдельно, записи отдельно
- ✅ 3: главный SELECT по users плюс по одному запросу на каждое запрошенное ребро
- 1: ent соберёт пользователей, группы и посты одним большим JOIN

Тариф жадной загрузки фиксирован: 1 главный запрос + 1 на каждое запрошенное ребро. Два With-вызова — три SELECT, сколько бы строк ни вернула страница. Вариант 21 — это и есть N+1 из ручного цикла QueryGroups().All(ctx) на каждой записи: ровно его With* и отменяет. Вариант 40 описал бы тариф GORM на m2m-уровне — там join-таблица читается отдельно; ent в m2m-шаге JOIN-ит её сразу. А один большой JOIN докой исключён явно: несколько рёбер одним JOIN не собираются — ent всегда ходит дополнительным запросом на связь.

### Условная 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, вера — ни в одной группе. Что вернётся?

- ✅ users содержит и анну, и веру; у анны Edges.Groups — go-ninja, у веры — пустой срез
- users содержит только анну: условие отфильтровало родительские строки
- Ошибка: Where нельзя вызывать внутри With-опции
- users содержит обоих; у обеих полный список групп — фильтр игнорируется

Условная With фильтрует содержимое рёбер, а не набор строк: родители возвращаются все, кому отвечал их собственный WHERE. Отобрать только «состоящих в go-ninja» — это работа HasGroupsWith из десятого урока: он оставит в users одну анну. И да, Where внутри опции — штатный инструмент: опция получает настоящий GroupQuery.

### Вложенная загрузка: соседи соседей

У загруженного соседа рёбра тоже пусты — пока не попросите. Вложенная загрузка делается ровно теми же With-методами, только внутри функциональной опции: WithGroups(func(gq *ent.GroupQuery) { gq.WithUsers() }) просит у каждой загруженной группы её участников. Синтаксис не новый — новый только уровень: ent рекурсивно применяет тот же тариф, по одному запросу на ребро каждого уровня. Пользователи с группами и участниками групп — три SELECT: users, группы через group_users, затем участники групп через ту же таблицу связи с другого конца. Глубина не ограничена схемой: WithUsers(func(uq){ uq.WithPosts() }) достроит четвёртый этаж — но каждый этаж стоит свой запрос, поэтому граф загрузки стоит рисовать до кода, а не после жалобы дежурного. Для нашей M2M-пары это возвращает сюжет [шестого урока](/courses/ent/lessons/06-edges-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 вложенной сущности

```go
users, err := client.User.Query().
        WithGroups(). // вложенной загрузки НЕТ
        All(ctx)
if err != nil {
        return err
}
g := users[0].Edges.Groups[0] // первая группа первого пользователя

// что окажется в g.Edges.Users?
```

**Что выведет программа?**

- ✅ nil: соседи группы не запрашивались — ленивой загрузки в ent нет
- Все участники группы: ent сам дочитает ребро при первом обращении к полю
- Ошибка исполнения: читать Edges вложенной сущности нельзя
- Копия users: ent сопоставит обратные ссылки автоматически

Декодер заполнил только то, о чём просили: у групп не было WithUsers — поле осталось nil, и чтение просто вернёт nil-срез без ошибок и без запросов. Ленивого дочитывания нет ни на каком уровне — это в точности поведение GORM из его четырнадцатого урока, только у ent одинаковая семантика и для корней, и для вложенных сущностей. Хотите участников — добавьте gq.WithUsers() в опцию, и тариф вырастет на один запрос.

### 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()?

- ✅ *NotLoadedError: массив loadedTypes помнит, что ребро не запрашивали
- Пустой срез и nil-ошибка: доступоры всегда безопасны
- nil и nil-ошибка: поле просто не заполнено
- Паника: чтение незагруженного ребра запрещено на уровне рантайма

Не запрошено — значит NotLoadedError, без вариантов: массив loadedTypes заполнялся при sqlAll по непустым with-полям, и его false превращается в ошибку с текстом «ent: groups edge was not loaded». Пустой срез и nil доступны только при вызванном WithGroups — «загрузили, соседей нет». Паники в ent не будет ни при каком чтении Edges: прямое поле вернёт nil молча, доступор — ошибку.

### Формы загрузки: сводка урока

| Вызов | Тип ребра | 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.

```go
// 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-метода для ребра groups; второй — вложенная загрузка участников группы внутри опции; третий — хелпер пакета ent для проверки именно NotLoadedError.

Правильные ответы в пропусках: `WithGroups`, `WithUsers`, `IsNotLoaded`

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

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

Правильный порядок строк:

1. `WithGroups(func(gq *ent.GroupQuery) { gq.Where(...) }) — создан свежий GroupQuery, опция применена к нему, билдер сохранил соседа в поле withGroups`
2. `All(ctx) — sqlgraph.QueryNodes исполняет главный SELECT по users и декодирует строки в []*User`
3. `len(nodes) == 0 — ранний выход: пустой странице родителя не нужен ни один запрос за соседями`
4. `по массиву Edges.loadedTypes видно, какие рёбра запрошены; для каждого — свой шаг load<Name>`
5. `m2m-шаг: JOIN group_users с WHERE user_id IN (идентификаторы родителей) — один запрос на ребро`
6. `раскладка по карте byID: строки соседей приписаны владельцам, Edges заполнены — страница готова`

> **Как проверять себя в этом уроке.** Runner-ов в курсе ent нет, и песочница не появится и здесь: жадной загрузке нужны настоящий драйвер и база — правило всего курса. Интерактив на странице — квизы, predict, fill, order и карточки, а SQL-след смотрите локально: возьмите базу из домашек восьмого урока, включите лог драйвера и исполните каждый запрос из урока. Считайте SELECT-ы, находите JOIN по group_users в m2m-шаге, IN-списки идентификаторов в o2m-шаге и ранний выход на пустой странице: запросите несуществующую роль и убедитесь, что за соседями ent не поехал вообще.

### Что дальше

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

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

- **Жадная загрузка в ent** — Eager loading: методы With* на query-билдере (WithGroups, WithPosts, WithAuthor) заполняют поле Edges возвращённых сущностей. Ленивой загрузки в ent нет: без With* поля рёбер — nil, и никакого дочитывания при обращении не происходит. Документация называет это ent preload; поддерживают только SQL-диалекты.
- **Сигнатура With-метода** — func (uq *UserQuery) WithGroups(opts ...func(*GroupQuery)) *UserQuery: создаётся свежий GroupQuery через GroupClient, опции применяются к нему, билдер сохраняется в поле withGroups. Опции вариативны; состояние With* переносится Clone(); With* есть и на обходах — anna.QueryGroups().WithUsers().
- **Сколько запросов порождает With*** — Один на родителя плюс один на каждое запрошенное ребро — независимо от числа строк; один JOIN на все рёбра невозможен, doc прямо говорит: ent исполняет дополнительный запрос на каждую связь. Пустой главный результат — ранний выход, соседние запросы не исполняются. N+1 из ручных циклов заменяется фиксированным числом выборок.
- **Условная With-форма** — WithGroups(func(gq *ent.GroupQuery) { gq.Where(group.Name("go-ninja")) }): фильтрует, сортирует, ограничивает и проецирует соседей (Order, Limit, Select). Строки родителя не теряются — без соседей вернётся пустой срез; это противовес HasGroupsWith, который отбирает строки родителя. Повторный WithGroups затирает предыдущие опции: выигрывает последний вызов.
- **Вложенная загрузка** — WithGroups(func(gq *ent.GroupQuery) { gq.WithUsers() }): соседи соседей грузятся теми же With-методами внутри опции. Тариф рекурсивный — по запросу на ребро каждого уровня: users + groups + участники = три SELECT. Незагруженное вложенное ребро — тихий nil; M2M-шаги идут через ту же таблицу group_users с двух сторон.
- **GroupsOrErr и NotLoadedError** — Сгенерированные доступоры на структуре Edges читают loadedTypes. Запрошено — срез (пусть пустой) и nil; не запрошено — *NotLoadedError («ent: posts edge was not loaded»), проверка ent.IsNotLoaded. Уникальное ребро, загруженное, но не найденное, отвечает *NotFoundError. Прямое поле Edges.Groups вернёт nil молча — без различения этих случаев.

## Коротко: что запомнить

- Жадная загрузка — единственный способ заполнить 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».
