---
title: "Индексы и аннотации"
description: "ent индексы: index.Fields и index.Edges, составные и уникальные, entsql-аннотации Desc, IndexType, IndexWhere, StorageKey и частичный индекс."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/20-indeksy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Индексы и аннотации

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

Индексы — то, что отличает схему, которая работает, от схемы, которая работает в демо. Девятнадцатый урок научил схему отвечать «можно ли», сегодня — «быстро ли»: ent индексы описываются кодом рядом с полями и рёбрами, едут в миграции и превращаются в ограничения и деревья поиска базы. Разберём builder index с Fields и Edges, уникальность одного поля и уникальность пары — index.Fields(...).Unique() с её NULL-дырой, составной индекс и правило левого префикса на примере ленты author_id + created_at, entsql-аннотации: Desc для порядка сортировки, IndexType и IndexPrefix для MySQL, StorageKey для стабильных имён и обещанный четырнадцатым уроком частичный индекс IndexWhere("deleted_at IS NULL"), который вместе с интерсептором восемнадцатого наконец закрывает мягкое удаление до конца. Отдельно — entsql.Annotation{Table} на уровне схемы и жизнь индексов в миграциях Atlas из пятнадцатого урока. По канону курса: анна, борис, вера, группа go-ninja и счётчик posts_count.

Маршрут урока — от скорости к схеме. Сначала — мотив: полнотабличный скан против поиска по дереву на примере ленты постов анны и email-поиска, и заодно честная цена: индекс не бесплатен ни на диске, ни на записи. Затем — API: метод Indexes() в схеме, builder index с Fields и Edges, Unique и StorageKey; здесь же разница между уникальностью поля из третьего урока и уникальностью пары. Третья часть — составные индексы: порядок колонок, левый префикс и индекс ленты author_id + created_at с аннотацией Desc. Четвёртая — entsql-аннотации: тип и префикс для MySQL, направление сортировки, имя индекса. Пятая — частичный индекс IndexWhere: обещание четырнадцатого урока, которое вместе с интерсептором восемнадцатого закрывает мягкое удаление целиком. Финал — аннотация схемы entsql.Annotation{Table} и жизнь индексов в миграциях Atlas из пятнадцатого урока. Если вы искали «ent индексы» или «index ent» — это тот урок.

### Зачем индексы: скан против поиска

Лента анны — запрос WHERE author_id = ? ORDER BY created_at DESC на таблице постов, которых через полгода жизни группы go-ninja будут десятки тысяч. Без индекса база читает таблицу целиком — полнотабличный скан, сортирует строки в памяти, отбрасывает чужие; в плане запроса, который покажет EXPLAIN, это будет Seq Scan плюс Sort, и время растёт линейно с числом постов. С индексом по паре author_id + created_at она идёт по упорядоченному дереву: находит начало диапазона автора и читает строки уже в нужном порядке — точечный поиск вместо скана, сортировка не нужна вовсе. Та же история с поиском по email из девятого урока: селективность колонки решает — email почти уникален, и индекс на нём превращает запрос в одно спуск по дереву, тогда как индекс на deleted_at, где почти все значения одинаковые, почти бесполезен. Но индекс не бесплатен: каждый Create из восьмого урока и каждый Update из тринадцатого обслуживает все индексы таблицы — пишет не только строку, но и дерево каждого индекса; плюс место на диске и лишний шанс планировщика выбрать неудачный план. Отсюда рабочее правило: индекс появляется от запроса, который его ищет, а не «на всякий случай». Заметьте: счётчик posts_count из тринадцатого урока — колонка, а не индекс; он экономит Count, но ленту всё равно разворачивать деревом.

### Проверка: уникальность поля

**Вопрос:** В схеме User поле email объявлено field.String("email").Unique(). Что случится при попытке создать второй аккаунт с тем же email?

- ✅ ConstraintError: уникальный индекс в базе отвергнет INSERT
- Дубль сохранится: Unique() создаёт индекс, но не ограничение
- ValidationError: ent проверит уникальность до отправки в базу
- Паника на этапе кодогенерации: два поля не могут быть Unique

Unique() на поле создаёт уникальный индекс в базе, и проверка происходит там: ent отправляет INSERT, драйвер возвращает ошибку нарушения ограничения, ent оборачивает её в ConstraintError — тот же класс, что каскадные конфликты четырнадцатого урока, ловится ent.IsConstraintError. До SQL ent уникальность не проверяет: валидаторы схемы из третьего урока смотрят формат и правила поля, а не состояние таблицы. Именно поэтому гонка двух одновременных Create с одним email безопасна — решает база, а не проверка в коде.

### Индексы в схеме: Fields, Edges и Unique

Индексы объявляются в схеме — рядом с полями, рёбрами, хуками семнадцатого урока и политикой девятнадцатого, — методом func (T) Indexes() []ent.Index. Пакет ent/schema даёт builder index. index.Fields("created_at") — индекс по колонке поля; имена внутри — имена полей схемы, как они объявлены в Fields(), не имена колонок базы. index.Edges("author") — индекс по колонке внешнего ключа, которую ребро author создало ещё в пятом уроке: в таблице постов это author_id, и лента «посты автора» ищет именно её. Цепочка index.Edges("author").Fields("created_at") — составной индекс сразу по author_id и created_at. Метод Unique() превращает индекс в ограничение уникальности: поле email с field.Unique() из третьего урока уже создало уникальный индекс на одной колонке, а index.Fields("name", "website").Unique() — новый уровень: уникальна вся пара. Метод StorageKey задаёт имя индекса в базе — posts_created_idx вместо сгенерированного по умолчанию; это подарок ревью миграций из пятнадцатого урока, о котором подробнее в финале. Всё объявленное в Indexes() генератор из седьмого урока переносит в код, а миграции — в DDL: схема остаётся единственным источником правды о базе.

### Первые индексы в схеме Post

```go
// ent/schema/post.go
func (Post) Indexes() []ent.Index {
        return []ent.Index{
                // колонка внешнего ключа ребра author -> author_id:
                index.Edges("author"),
                // составной: лента автора, упорядоченная по времени:
                index.Edges("author").Fields("created_at"),
                // индекс по полю со стабильным именем в DDL:
                index.Fields("created_at").
                        StorageKey("posts_created_idx"),
        }
}
```

index.Edges("author") — индекс на author_id, колонку, которую ребро создало в пятом уроке. Edges("author").Fields("created_at") добавляет составной порядок: внутри одного автора строки лежат по времени. StorageKey фиксирует имя в DDL: без него ent сгенерирует имя сам, а переупорядочение колонок такого индекса в будущей миграции обернётся drop и create вместо аккуратного переименования. Имена в Fields — имена полей схемы, как в Fields(), не имена колонок базы.

### Составные индексы: порядок колонок решает

Составной индекс — это книга, отсортированная по двум признакам сразу: сначала по первой колонке, внутри неё по второй. Отсюда главное правило составных индексов — левый префикс: индекс (author_id, created_at) обслуживает WHERE author_id = ?, WHERE author_id = ? AND created_at > ? и WHERE author_id = ? ORDER BY created_at DESC, но запрос WHERE created_at > ? без условия на author_id не сможет взять его как основной путь: деревья отсортированы по author_id, а время внутри — искать придётся по всем веткам. Поэтому порядок объявления — не вкус, а решение: колонка равенства — первой, диапазон и сортировка — за ней. Лента анны просит именно (author_id, created_at): зафиксировали автора — и внутри его строк время уже упорядочено, ключсет-пагинация двенадцатого урока с CreatedAtLT и IDGT скачет по дереву, не трогая чужие посты. Если бы лента была глобальной — «все посты подряд по времени» — порядок развернулся бы: (created_at, id). Составных индексов на таблицу не напасёшься на каждый запрос: три-четыре осмысленных покрывают десятки запросов через левый префикс, а каждый лишний индекс тормозит мутации из тринадцатого урока. Проверяйте планы в EXPLAIN: попал ли запрос в префикс, виден ли ваш индекс.

### Индекс ленты: составной и убывающий

```go
// ent/schema/post.go: лента автора «новые сверху».
index.Edges("author").Fields("created_at").
        Annotations(entsql.Desc()).
        StorageKey("posts_author_created_idx"),
```

entsql.Desc() объявляет колонки индекса убывающими: лента «новые сверху» совпадает с порядком дерева, и планировщику не нужен обратный проход или сортировка. Зеркальная аннотация — entsql.Asc(); для смешанных составных порядков есть точечные entsql.DescColumns("created_at") и entsql.AscColumns — направление задаётся по отдельным колонкам. StorageKey здесь уже не украшение: у составного индекса самое длинное имя по умолчанию, и без стабильного ключа ревью миграций превращается в угадайку.

### Проверка: левый префикс

**Вопрос:** Есть составной индекс (author_id, created_at). Какой запрос не сможет взять его как основной путь доступа?

- ✅ WHERE created_at > ? — условие на вторую колонку без первой
- WHERE author_id = ? AND created_at > ?
- WHERE author_id = ? ORDER BY created_at DESC
- WHERE author_id = ? AND deleted_at IS NULL

Левый префикс: дерево отсортировано сначала по author_id, и диапазон по created_at без зафиксированного автора уходит в скан по всем веткам — индекс доступен, но бесполезен как путь. Варианты с author_id первой колонкой обслуживаются полностью, включая сортировку: внутри одного автора строки уже лежат по created_at, а дополнительные фильтры вроде deleted_at IS NULL применяются к уже найденным строкам. Правило запоминайте дословно: равенство — первым, диапазон и ORDER BY — следом.

### Уникальность вместе с полями: поле против пары

Уникальность в ent живёт на двух этажах, и путать их нельзя. field.Unique() — уникальность одной колонки: email из третьего урока, slug поста, телефон; индекс на такой колонке создаётся автоматически. index.Fields(...).Unique() — уникальность комбинации: пара (name, website) уникальна целиком — два «анна» с разными сайтами проходят, повторение пары запрещено. Комбинация с рёбрами даёт прикладной паттерн «одно на владельца»: index.Edges("author").Fields("kind").Unique() запрещает автору иметь два поста одного вида — уникальность в границах владельца, а не всей таблицы. И нюанс, который ловит всех: SQL-уникальность не распространяется на NULL. Строка с NULL не равна другой строке с NULL — даже при одинаковом остальном; значит, Optional-колонка из четвёртого урока внутри уникальной пары — дыра в ограничении: сколько угодно строк (name, NULL) пройдут проверку. Лечится строго: колонки пары должны быть NOT NULL, либо контроль дубликатов уходит в хук семнадцатого урока, который увидит мутацию до SQL. Ниже — предсказание на этот кейс: прежде чем читать объяснение, попробуйте ответить сами.

### Уникальный составной индекс в схеме User

```go
// ent/schema/user.go: пара name + website уникальна.
func (User) Indexes() []ent.Index {
        return []ent.Index{
                index.Fields("name", "website").Unique(),
        }
}
```

Уникальна пара целиком: (анна, https://anna.dev) и (анна, https://anna-2.dev) — разные строки, повтор пары — ConstraintError из первого квиза. Это не то же, что Unique() на каждом поле по отдельности: там запрещались бы любые повторные «анна». Нюанс с NULL не отменяется: website — Optional из четвёртого урока, колонка допускает NULL, и две строки с одинаковым name и пустым website уникальность не нарушат — смотрите предсказание.

### Предскажите результат

```go
// User: index.Fields("name", "website").Unique()
// website — Optional, у обеих записей NULL
client.User.Create().
        SetName("гонец").SetEmail("g1@entgo.io").
        Save(ctx)
client.User.Create().
        SetName("гонец").SetEmail("g2@entgo.io").
        Save(ctx)
```

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

- ✅ Обе вставки пройдут: SQL-уникальность не сравнивает NULL с NULL
- Вторая упадёт с ConstraintError: пара (name, website) уже занята
- Вторая упадёт с ValidationError: ent заметит дубликат до SQL
- Вторая перезапишет первую: уникальный индекс работает как upsert

Email у записей разные — уникальность поля email не задета. Пара name + website: имя совпадает, website у обеих NULL, а по правилам SQL NULL не равен даже самому себе — строки не считаются дубликатами, и обе вставки проходят. ValidationError не при чём: до SQL ent дубликаты не ищет, а upsert — это отдельный механизм OnConflict из тринадцатого урока, а не поведение уникального индекса. Строгая уникальность пары требует NOT NULL на всех её колонках — или контроля в хуке.

### entsql-аннотации: тип, префикс, порядок, имя

Аннотации — способ сказать диалекту то, чего нет в общем словаре index-билдера. Все индексные аннотации живут в пакете entsql и передаются методом Annotations(...). entsql.Desc() и entsql.Asc() задают направление сортировки колонок индекса целиком, а DescColumns и AscColumns — по отдельным колонкам составного индекса; убывающий индекс ленты избавляет планировщик от обратного прохода. entsql.IndexType("hash") выбирает тип индекса для MySQL: hash мгновенен на равенстве, но слеп к диапазонам и сортировке — для лент и ключсета нужен btree, значение по умолчанию. entsql.IndexPrefix(n) — префиксный индекс: индексируются первые n символов длинной строки; для TEXT-колонок MySQL требует его обязательно, без префикса миграция откажет. entsql.IndexWhere(expr) — частичный индекс, главная герой следующего раздела. Имена и типы попадают в DDL миграций и в versioned-файлы Atlas из пятнадцатого урока — потому аннотации и называются аннотациями: они выражают намерение, а мигратор каждого диалекта решает, как его исполнить, и где-то честно разводит руками.

### Каталог: API индексов ent

| Инструмент | Что даёт | Нюанс |
| --- | --- | --- |
| index.Fields(...) | индекс по колонкам полей | имена — как в Fields() схемы |
| index.Edges(...) | индекс по колонке внешнего ключа | ребро author даёт колонку author_id |
| Fields(...).Edges(...) | составной по полям и ребру | порядок колонок задаёт левый префикс |
| .Unique() | уникальный индекс-ограничение | NULL не сравниваются: дыра в strict-уникальности |
| .StorageKey(name) | имя индекса в DDL | стабильные миграции и честное ревью |
| entsql.Desc() / entsql.Asc() | направление сортировки колонок | DescColumns/AscColumns — по отдельным |
| entsql.IndexType("hash") | тип индекса для MySQL | hash — только равенство; диапазонам нужен btree |
| entsql.IndexPrefix(n) | префикс длинной строки (MySQL) | обязателен для TEXT-колонок |
| entsql.IndexWhere(expr) | частичный индекс | Postgres и SQLite; MySQL — нет |
| entsql.Annotation{Table} | имя таблицы схемы | объявляется в Annotations() схемы |
| field.Unique() | уникальность одного поля | индекс создаётся автоматически |
| Indexes() []ent.Index | список индексов схемы | метод типа рядом с Fields и Edges |

### Частичный индекс: IndexWhere и мягкое удаление

Четырнадцатый урок закончился обещанием: мягкое удаление пишет deleted_at, но живые строки тонут в таблице, где рядом лежат мёртвые, — и никакой обычный индекс это не лечит, потому что дерево в равной мере набито обоими. Интерсептор восемнадцатого урока вшивает DeletedAtIsNil в каждую выборку: SQL каждого запроса содержит WHERE deleted_at IS NULL — но без подходящего индекса условие всё равно работает по скану. Частичный индекс entsql.IndexWhere("deleted_at IS NULL") строит дерево только из живых строк: он меньше по размеру, не деградирует от накопления удалённых записей, а предикат интерсептора совпадает с условием индекса дословно — планировщик берёт его без раздумий. Канонический вид — лента живых постов автора: составной (author_id, created_at), убывающий по времени, с условием deleted_at IS NULL. Поддержка: Postgres и SQLite умеют частичные индексы, MySQL — нет, там остаётся обычный индекс, а условие фильтрует строки на чтении: платите размером дерева, но запрос работает. Заодно ответ на вопрос «почему бы не индексировать deleted_at просто так»: колонка, где почти все значения NULL, малоселективна, и полный индекс на ней почти бесполезен — ценен именно частичный.

### Частичный индекс ленты живых постов

```go
// ent/schema/post.go: дерево только из живых строк.
index.Edges("author").Fields("created_at").
        Annotations(
                entsql.DescColumns("created_at"),
                entsql.IndexWhere("deleted_at IS NULL"),
        ),
```

IndexWhere вшивает условие в определение индекса: база строит B-tree только по постам с deleted_at IS NULL — индекс меньше, не портится от удалений, а WHERE интерсептора из восемнадцатого урока совпадает с ним дословно. DescColumns("created_at") — убывание только по времени, ребро остаётся возрастающим. Поддержка — Postgres и SQLite; в MySQL условие придётся оставить фильтру на чтении, а индекс объявить без WHERE. Именно такой индекс я обещал в четырнадцатом уроке.

### Путь индекса от схемы до плана

Соберите жизнь индекса по шагам: от объявления в схеме до ленты без скана.

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

1. `index.Edges("author").Fields("created_at") объявлен в методе Indexes() схемы Post`
2. `go generate из седьмого урока переносит объявление в сгенерированный код`
3. `automigrate из пятнадцатого урока сравнивает схему с базой и создаёт индекс`
4. `StorageKey даёт имя posts_author_created_idx в DDL и в ревью миграции`
5. `WHERE author_id = ? ORDER BY created_at DESC — планировщик берёт индекс`
6. `лента постов анны отдаётся без полнотабличного скана и сортировки`

### Соберите индекс ленты

Впишите API: ребро, поле, аннотацию убывающего порядка и стабильное имя.

```go
// лента автора: author_id + created_at, «новые сверху».
index.___("author").___("created_at").
        Annotations(entsql.___()).
        ___("posts_author_created_idx")
```

Подсказка: Первый пропуск — метод индекса по колонке внешнего ключа; второй — цепочка по полю; третий — аннотация убывающего порядка из пакета entsql; четвёртый — метод, фиксирующий имя индекса в DDL.

Правильные ответы в пропусках: `Edges`, `Fields`, `Desc`, `StorageKey`

### Аннотация схемы и жизнь в миграциях

Индексы — не единственное, что настраивают аннотации. entsql.Annotation на уровне схемы переопределяет имя таблицы: func (User) Annotations() []schema.Annotation { return []schema.Annotation{ entsql.Annotation{Table: "users"} } } — явная фиксация, которая спасает, когда база старше кода и таблицы зовутся не так, как сгенерирует ent. schema.Annotation — общий интерфейс: любая аннотация реализует Name() string, а мигратор читает только те, что знает. Дальше — миграции, пятнадцатый урок. automigrate сравнивает схему с базой и создаёт недостающие индексы; но у индексов есть свои правила эволюции: переименование колонок в составе индекса или новый порядок — это, чаще всего, drop и create, и в большом проекте без StorageKey вы узнаете об этом по логу миграции, когда уже поздно. Versioned-миграции Atlas фиксируют каждый индекс в SQL-файле, который проходит ревью: именно в этом файле видно, что DescColumns породил DESC в определении, IndexWhere — WHERE deleted_at IS NULL, а IndexPrefix — длину префикса. Разные диалекты дают разный DDL для одних и тех же аннотаций — и это нормально: аннотация выражает намерение, диалект решает, как его исполнить, а где намерение неподдерживаемо — там ревью и ловит проблему, а не продакшен.

### Проверка: диалекты и IndexWhere

**Вопрос:** IndexWhere("deleted_at IS NULL") объявлен в схеме Post. На какой паре диалектов частичный индекс создастся без изменений?

- ✅ PostgreSQL и SQLite
- MySQL и PostgreSQL
- MySQL и SQLite
- На любом: IndexWhere — переносимая аннотация

Частичные индексы умеют Postgres и SQLite — там условие попадает в определение индекса, и дерево строится только из живых строк. MySQL частичные индексы не поддерживает: тот же индекс объявляется обычным, а deleted_at IS NULL остаётся фильтром на чтении — поведение запросов то же благодаря интерсептору, но цена по размеру и скорости другая. Аннотация выражает намерение, а не гарантию: что с ней сделает конкретный диалект, видно в DDL миграции — пятнадцатый урок про это.

### Чеклист индексов ent

- Индекс — цена записи и скорость чтения: каждый Create и Update обслуживает все индексы таблицы; добавляйте индексы под конкретные WHERE и ORDER BY из реальных запросов, а не «на всякий случай».
- Левый префикс правит составными индексами: равенство — первой колонкой, диапазон и сортировка — за ней; индекс (author_id, created_at) не спасёт WHERE created_at > ? без автора.
- Уникальность одного поля — field.Unique(); уникальность комбинации — index.Fields(...).Unique(); «одно на владельца» — index.Edges(...).Fields(...).Unique().
- NULL ломает уникальные пары: Optional-колонка в составе Unique-индекса — дыра; либо NOT NULL на колонках пары, либо контроль дубликатов в хуке семнадцатого урока.
- StorageKey — на каждый индекс с шансом эволюционировать: без стабильного имени переупорядочение колонок в миграции оборачивается drop и create.
- entsql.Desc и IndexType с IndexPrefix — настройки диалекта: hash и префиксы — про MySQL, не переносите их слепо на Postgres.
- Частичный индекс IndexWhere("deleted_at IS NULL") — вторая половина мягкого удаления: первая — траверзер DeletedAtIsNil из восемнадцатого урока; поддерживают Postgres и SQLite.
- Индексы — часть миграций: ревью versioned-файлов Atlas из пятнадцатого урока — место, где видно каждый созданный, переименованный и удалённый индекс.

> **Как проверять себя в этом уроке.** Runner-ов в курсе ent нет, и здесь они не появятся: индексы живут в схеме и требуют кодогенерации с базой — правило всего курса. Рабочий круг: объявите индексы урока в схеме Post, прогоните go generate из седьмого урока и automigrate из пятнадцатого, затем снимите план запроса ленты анны до и после — EXPLAIN покажет скан и сортировку до и поиск по индексу после. Проверьте имя в базе: без StorageKey вы найдёте сгенерированное имя, с ним — posts_author_created_idx. Наконец, попробуйте вставить двух «гонцов» с одинаковым name и пустым website: обе вставки пройдут — и это лучший способ запомнить, что SQL-уникальность не распространяется на NULL.

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

Вы умеете вооружать схему: метод Indexes() с index.Fields и index.Edges, уникальные поля и уникальные пары с их NULL-дырой, составные индексы с правилом левого префикса, entsql-аннотации Desc и IndexType, IndexPrefix и IndexWhere, StorageKey для стабильных имён, entsql.Annotation{Table} на уровне схемы — и полная картина миграций из пятнадцатого урока, где каждый индекс виден в DDL. Скорость без порядка — только половина истории: ключсет двенадцатого урока опирается на составной индекс, интерсептор восемнадцатого — на частичный. Дальше — GraphQL-интеграция двадцать первого урока: entgql, автогенерация резолверов и связь графа с внешним API, где каждая связь рождает новые выборки, и держать их будет снова индекс. Домашнее задание ниже заставит индекс показать себя в плане запроса. Полный план из 25 уроков — на [странице курса](/courses/ent/), уникальные поля и валидаторы — в [третьем](/courses/ent/lessons/03-polya/) уроке, ключсет-пагинация — в [двенадцатом](/courses/ent/lessons/12-paginaciya/), мягкое удаление — в [четырнадцатом](/courses/ent/lessons/14-delete/), миграции и Atlas — в [пятнадцатом](/courses/ent/lessons/15-migracii/), интерсептор живых строк — в [восемнадцатом](/courses/ent/lessons/18-interceptors/).

### Карточки: индексы и аннотации

- **index.Fields и index.Edges** — index.Fields — индекс по колонкам полей, имена как в Fields() схемы; index.Edges — по колонке внешнего ключа ребра (author даёт author_id). Цепочка index.Edges("author").Fields("created_at") — составной индекс по author_id + created_at. Объявляются в методе func (T) Indexes() []ent.Index схемы.
- **field.Unique() против index.Fields(...).Unique()** — field.Unique() — уникальность одной колонки (email), индекс создаётся автоматически. index.Fields("name", "website").Unique() — уникальность пары целиком; с Edges — «одно на владельца». Нюанс: SQL-уникальность не сравнивает NULL — Optional-колонка в паре делает дыру; strict-уникальность требует NOT NULL.
- **Левый префикс составного индекса** — Дерево отсортировано по первой колонке, внутри — по второй. Индекс (author_id, created_at) обслуживает WHERE author_id = ? с диапазоном и сортировкой по времени, но не WHERE created_at > ? без автора. Правило: равенство — первой колонкой, диапазон и ORDER BY — следом. Проверяйте попадание в EXPLAIN.
- **entsql-аннотации индекса** — Desc()/Asc() — направление сортировки колонок, DescColumns/AscColumns — по отдельным; IndexType("hash") — тип для MySQL (hash — равенство, btree — диапазоны); IndexPrefix(n) — префикс длинных строк, обязателен для TEXT в MySQL; StorageKey — имя индекса в DDL. Всё передаётся методом Annotations(...).
- **Частичный индекс и мягкое удаление** — entsql.IndexWhere("deleted_at IS NULL") строит дерево только из живых строк: меньше, не деградирует от удалений, предикат совпадает с WHERE интерсептора из восемнадцатого урока. Поддерживают Postgres и SQLite; MySQL — нет, там обычный индекс плюс фильтр на чтении. Обещание четырнадцатого урока закрыто.
- **entsql.Annotation на уровне схемы** — func (T) Annotations() []schema.Annotation с entsql.Annotation{Table: "users"} переопределяет имя таблицы — явная фиксация для базы, которая старше кода. schema.Annotation — общий интерфейс (Name() string); мигратор читает знакомые аннотации. Индексы едут в automigrate и versioned-файлы Atlas — ревью показывает каждый drop и create.

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

- Индексы объявляются в схеме методом func (T) Indexes() []ent.Index: index.Fields — по колонкам полей, index.Edges — по колонке внешнего ключа ребра, цепочка Fields(...).Edges(...) — составной.
- Уникальность одного поля — field.Unique() из третьего урока; уникальность комбинации — index.Fields(...).Unique(); SQL-уникальность не распространяется на NULL — Optional-колонка в паре делает дыру.
- Составной индекс подчиняется левому префиксу: (author_id, created_at) обслуживает WHERE author_id = ? с диапазоном и сортировкой, но не WHERE created_at > ?; равенство — первой колонкой.
- entsql-аннотации индекса: Desc/Asc и DescColumns/AscColumns — направление сортировки, IndexType — тип (hash против btree в MySQL), IndexPrefix — префикс длинных строк, StorageKey — имя в DDL.
- Частичный индекс entsql.IndexWhere("deleted_at IS NULL") строит дерево только из живых строк — вторая половина мягкого удаления после интерсептора восемнадцатого урока; поддерживают Postgres и SQLite, MySQL — нет.
- entsql.Annotation{Table: ...} в schema.Annotations() фиксирует имя таблицы; schema.Annotation — общий интерфейс аннотаций, из которого мигратор читает намерения.
- Миграции: automigrate из пятнадцатого урока создаёт индексы по схеме; без StorageKey переупорядочение колонок = drop и create; versioned-файлы Atlas показывают каждый индекс в ревью.
- Индекс — цена записи и скорость чтения: каждый Create и Update обслуживает все индексы таблицы; добавляйте индексы под реальные WHERE и ORDER BY и проверяйте планы в EXPLAIN.

## Домашнее задание

Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja. Первое: снимите план запроса ленты анны — WHERE author_id = ? ORDER BY created_at DESC — до индексов: в EXPLAIN будет скан и сортировка; запишите время исполнения. Второе: объявите в схеме Post индексы из урока: index.Edges("author"), составной index.Edges("author").Fields("created_at") с Annotations(entsql.Desc()) и StorageKey("posts_author_created_idx"), частичный entsql.IndexWhere("deleted_at IS NULL") — прогоните go generate и automigrate и снимите план снова: скан обязан смениться поиском по индексу, а сортировка — исчезнуть. Третье: найдите созданный индекс в базе и убедитесь, что имя — posts_author_created_idx, а определение содержит DESC и WHERE deleted_at IS NULL; затем смените StorageKey и прогоните automigrate ещё раз — в логе миграции вы увидите drop и create: запишите в коммент, почему стабильные имена — часть контракта миграций из пятнадцатого урока. Четвёртое: соберите уникальный составной index.Fields("name", "website").Unique() на User и повторите эксперимент с двумя «гонцами» с пустым website — обе вставки пройдут; затем сделайте website обязательным и повторите: вторая вставка упадёт с ConstraintError. Пятое: если под рукой MySQL — объявите IndexType("hash") на name и IndexPrefix(20) на bio и сравните DDL с Postgres; если MySQL нет — просто зафиксируйте в комменте, какие аннотации урока зависят от диалекта. Вопрос на подумать: почему частичный индекс живёт в схеме, хотя его смысл задаёт предикат интерсептора, и что сломается, если держать их врозь?

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

### ent создаёт индексы автоматически?

Часть — да: уникальное поле field.Unique() уже даёт уникальный индекс, а колонки внешних ключей получают индексы по правилам конкретного мигратора и диалекта. Всё остальное — ваша забота: index.Fields и index.Edges в методе Indexes() схемы. Правило простое: любой частый запрос обязан найти индекс в плане (EXPLAIN), и если не нашёл — индекс объявляете вы, а не генератор.

### Чем index.Fields(...).Unique() отличается от Unique() на каждом поле?

Уникальность на поле — на колонку: два одинаковых email запрещены, но два одинаковых name — пожалуйста. Уникальный составной индекс — на комбинацию: (анна, сайт1) и (анна, сайт2) — разные строки, и только полный повтор пары запрещён. И помните про NULL: строка, где хоть одна колонка пары NULL, не считается дубликатом ни с кем — строгая уникальность требует NOT NULL на всех колонках пары.

### Нужен ли индекс, если данных пока мало?

Обычно нет: на сотнях строк скан быстрее дерева, а каждый лишний индекс платит на каждой записи. Но проектируйте от запросов, а не от объёма: лента, поиск по email и ключсет известны заранее — их индексы появляются вместе с запросами и StorageKey-именами. Правило: индекс добавляет запрос, который ищет его в EXPLAIN, а не гипотеза о будущем трафике; схему с индексами дешевле объявить сразу — миграции довезут.
