---
title: "Удаление"
description: "ent delete во всех видах: DeleteOneID и Delete с предикатами, счёт удалённых строк, каскадное удаление ent через OnDelete, судьба m2m-строк и мягкое удаление."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/14-delete/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 14
---

# Удаление

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

Восьмой урок создал строки, тринадцатый научил их менять — остался последний жест жизненного цикла. Сегодня — ent delete: два билдера, DeleteOneID по ключу и Delete().Where() по предикатам из десятого урока, разные контракты возврата и счёт удалённых строк. Главный сюжет — каскадное удаление ent: что происходит с постами, членством в группах и дружбами, когда удаляется пользователь, и почему всё решают констрейнты из пятого урока, а не рантайм. Затем — мягкое удаление ent своими руками: DeletedAt-магии здесь нет, как нет её и в классических ORM, и вы соберёте её из поля, мутатора и предиката — по канону курса: анна, борис, вера, группа go-ninja и таблицы group_users, user_friends.

Маршрут урока — от одного DELETE до полной политики прощания. Первый сюжет — два билдера: точечный UserDeleteOne и массовый UserDelete, их контракты Exec и счёт удалённого. Второй — каскадное удаление ent: карта OnDelete из [пятого урока](/courses/ent/lessons/05-edges-o2m/) со стороны исполнения — что база делает с постами при удалении анны и кто чистит строки в group_users, служебной таблице из [шестого урока](/courses/ent/lessons/06-edges-m2m/). Третий — отказ: когда внешний ключ блокирует DELETE, как выглядит ConstraintError и в каком порядке удалять руками. Четвёртый — мягкое удаление: поле deleted_at, предикат DeletedAtIsNil и мост к тринадцатому уроку — «удалить» значит «обновить». Финиш — чеклист и таблица выбора между жёстким и мягким путями. Данные прежние: анна (26 лет, два поста — «про рёбра» и «черновик»), борис (19), вера (17), группа go-ninja.

### ent delete: два билдера и их контракты

Удаление живёт в сгенерированном user_delete.go, и там два строения — ровно как в обновлении из тринадцатого урока. UserDeleteOne — точечная форма: client.User.DeleteOneID(id) или DeleteOne(u) по готовой сущности; Save здесь не нужен: Exec(ctx) сразу исполняет DELETE и возвращает только ошибку. UserDelete — массовая форма: client.User.Delete() без аргументов, Where принимает предикаты, а Exec(ctx) возвращает пару (int, error) — число удалённых строк. Это главный разворот ent delete: точечная форма не знает, что ответить на «сколько», и потому докладывает иначе — удалил и молчит либо вернул ошибку. Оба билдера собирают мутацию с предикатами и исполняются на Exec; ExecX-близнецы, паникующие вместо ошибок, здесь тоже сгенерированы, но в сервисном коде держитесь ошибки. Массовая форма без Where не упадёт: DELETE без WHERE по констрейнту SQL легален и стирает таблицу целиком — та же ответственность, что у забытого Where в тринадцатом уроке, только цена выше: обновление хотя бы оставило строки, пусть и с испорченными полями. И помните каталог: любой предикат из десятого урока — AgeLT, BioIsNil, HasPosts — работает и в Delete, потому что Where массовой формы принимает те же UserSelect-комбинаторы.

### Обе формы удаления

```go
// точечно: удалить анну по ключу — только ошибка в ответе:
err := client.User.
        DeleteOneID(1).
        Exec(ctx)
if ent.IsNotFound(err) {
        // строки с id 1 не было: *NotFoundError
}

// массово: удалить всех несовершеннолетних — получим счёт:
n, err := client.User.
        Delete().
        Where(user.AgeLT(18)).
        Exec(ctx)
fmt.Println(n) // сколько строк база реально удалила
```

DeleteOneID(1).Exec(ctx) — error-only контракт: либо строка ушла, либо ошибка, включая NotFoundError для отсутствующего ключа. Delete().Where(user.AgeLT(18)) — массовая форма: тот же предикат, что served бы Query, и возврат числа. Вера с её 17 покинет базу, n станет единицей. Chain-форма идентична обновлению — Delete() возвращает билдер, Where ничего не исполняет, всё происходит на Exec.

### Проверка: контракты Exec

**Вопрос:** Что возвращают Exec у точечной и массовой форм удаления?

- ✅ DeleteOneID: только error; Delete().Where(): (int, error) — число удалённых строк
- Обе возвращают (int, error) — число удалённых
- DeleteOneID возвращает удалённую *User, массовая — число
- Обе возвращают ([]*User, error): срез удалённых сущностей

DELETE-стейтмент в SQL не возвращает строки, поэтому точечная форма докладывает успех только отсутствием ошибки, а массовая — числом от affected rows. Возвращённой сущности в ent нет ни у одной формы: если нужно прощание с данными «на память», прочитайте их до удаления — отдельным Query или с With-цепочкой из одиннадцатого урока.

### Сколько строк удалил предикат

```go
// анна 26 лет, борис 19, вера 17.
n, err := client.User.Delete().
        Where(user.AgeLT(20)).
        Exec(ctx)

// чему равно n?
```

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

- ✅ 2: борис и вера моложе двадцати — они и ушли
- 3: Delete без явного ограничителя трогает всю таблицу
- 1: предикат сравнивает возраст построчно, а удаляет первую строку
- 0 и ошибка: массовое удаление требует явного лимита

WHERE age < 20 отбирает бориса и веру; анна с её 26 не проходит, и n равен двум — число затронутых строк от affected rows. Delete без Where действительно стёр бы всех троих — но здесь Where есть, и он полный, как в любом Query. Никаких лимитов у удаления нет — это не выборка: либо предикат, либо вся таблица.

### Ноль строк, NotFoundError и отсутствие мягкого режима

Три исхода массового удаления стоит различать на автомате. n > 0 — строки ушли. n = 0 и err = nil — предикат не попал ни в одну строку: это данные, не сбой, и обрабатывать стоит как нормальный путь (в отличие от HTTP, где DELETE на несуществующий ресурс часто отвечает 404, ent массовой формой честно доложит нулём). NotFoundError — привилегия точечной формы: DeleteOneID на отсутствующем ключе вернёт *NotFoundError, которую ловит ent.IsNotFound из девятого урока. Четвёртого исхода — «удалил, но не совсем» — в ent не бывает: Exec исполняет ровно один DELETE, никаких тихих режимов и отложенных пометок в рантайме не существует. Если вам нужна отсрочка — это не delete, а мягкое удаление, и дальше мы соберём его руками. Отдельно про MaskNotFound из девятого урока: он глушит именно «не найдено», превращая ошибку в nil, — удобно в очистителях, где повторный вызов по уже удалённому ключу является нормой, а не инцидентом. В логе драйвера всё видно дословно: один DELETE ... WHERE id = ? для точечной формы, один DELETE ... WHERE <предикаты> для массовой — ent не добавляет к удалению ни подтаблиц, ни второго запроса.

### Каскадное удаление ent: кто стирает соседей

Вот главный вопрос урока: удаляем анну — что происходит с её постами, членством в go-ninja и дружбами? ent здесь не рантайм-менеджер, а генератор констрейнтов: на исполнении всё решает база по referential action, который вы объявили в схеме. Пятый урок дал полную карту: ребро Optional по умолчанию живёт с ON DELETE SET NULL, ребро Required — с NO ACTION, а аннотация entsql.OnDelete переопределяет действие на CASCADE, RESTRICT, SET NULL или SET DEFAULT. Наш канон собран по этой карте: ребро posts несёт entsql.OnDelete(entsql.Cascade) — «каскад из пятого урока», как назвал его шестой, — и удаление анны стирает оба её поста руками базы, без единой строки вашего кода. Служебные M2M-таблицы каскадят сами по себе: ent ставит CASCADE на обе колонки group_users и user_friends, не спрашивая, — строки членства и дружб исчезают вместе с пользователем, а группы остаются жить: связь умерла, данные целы. Единственный просвет — through-таблица из шестого урока: там дружба — полноценная сущность со своими рёбрами, Required по построению, и без аннотации Cascade на обоих рёбрах база ответит отказом. Так что каскадное удаление ent делает чужими руками: вашими — в схеме, базовыми — на исполнении.

### SQL-след: один DELETE и работа базы

```go
// удаляем анну — один стейтмент от ent:
err := client.User.DeleteOneID(1).Exec(ctx)

// SQL-след (упрощённо):
// DELETE FROM users WHERE id = 1
//
// дальше работает база, а не ent:
//   ON DELETE CASCADE на posts.author_id
//     -> DELETE FROM posts WHERE author_id = 1   (оба поста анны)
//   ON DELETE CASCADE на group_users.user_id
//     -> строки членства в go-ninja исчезли
//   ON DELETE CASCADE на user_friends.user_id
//     -> дружбы исчезли; группы и борис с верой — целы
```

След короток: ent исполняет ровно один DELETE — никакой ORM-магии по подтаблицам в коде ent нет. Каскады делает движок базы по констрейнтам, которые нагенерировал ent из схемы: посты анны стёр CASCADE с ребра posts, строки членства и дружб — автоматические CASCADE-колонки служебных таблиц. Группы остались: у связи не стало владельца, у данных не стало причины умирать. Если бы ребро posts было без аннотации, дефолт для Optional — SET NULL: посты остались бы сиротами с author_id = NULL, как расписано в пятом уроке.

### Проверка: судьба соседей анны

**Вопрос:** Схема канона: ребро posts — с entsql.OnDelete(entsql.Cascade), группы — служебная m2m, дружбы — through-таблица с явным CASCADE. Что произойдёт после DeleteOneID(анна)?

- ✅ Посты стёр каскад, строки членства и дружб исчезли, группы и другие пользователи целы
- Посты остались сиротами с NULL-автором, группы рассыпались без участников
- ent удалил и группы: пустая группа не имеет права на жизнь
- Ошибка базы: у анны есть посты, сперва удалите их руками

Cascade на ребре posts стирает контент вместе с автором — решение пятого урока «удалили аккаунт — удалили контент». Служебные m2m-таблицы каскадят на обеих колонках всегда: строки членства и дружб умирают с владельцем, но сами группы и пользователи не тронуты — связь без обеих сторон невозможна, а вот сторона — сколько угодно. «Пустая группа умирает» — бизнес-правило вашего кода, не базы: ent за последним участником не следит, как предупреждал шестой урок.

### Когда база отказывает: NO ACTION и ConstraintError

Каскад — выбор, а не дефолт. Ребро Required без аннотации получает NO ACTION, аннотация entsql.OnDelete(entsql.Restrict) отказывает намеренно — и в обоих случаях DELETE родителя с детьми падает ошибкой внешнего ключа. ent оборачивает её в *ConstraintError — ту же ошибку, что ловили на дубликатах уникальности в восьмом уроке, — проверяется хелпером ent.IsConstraintError. Это не сбой соединения и не баг: база исполнила контракт схемы, и логика приложения обязана понять отказ. Два пути. Первый — изменить схему: если судьба детей при смерти родителя определена, это должен быть referential action, а не код — Cascade для «контент умирает с аккаунтом», SetNull для «контент переживает аккаунт», Restrict для «случайных удалений не должно быть». Второй — ручной порядок удаления: сначала дети отдельным Delete() с предикатом, потом родитель — он же порядок в коде, и тут возникает соблазн «полуудалённого состояния», если между двумя DELETE что-то упало. Именно для таких пар операций существуют транзакции: в шестнадцатом уроке вы завернёте «детей, потом родителя» в client.Tx, где частичный успех невозможен, — а пока просто держите порядок жёстким и обрабатывайте ConstraintError как штатную ветку, а не как 500.

### Ручной порядок и ветка отказа

```go
// снести дружбы анны до неё самой (through-таблица без Cascade):
_, err := client.Friendship.Delete().
        Where(friendship.HasUserWith(user.ID(1))).
        Exec(ctx)
if err != nil {
        return err
}

// теперь родитель:
err = client.User.DeleteOneID(1).Exec(ctx)
if ent.IsConstraintError(err) {
        // FK всё ещё держит: кто-то ссылается — разберитесь в карте рёбер
        return fmt.Errorf("удаление блокировано ссылками: %w", err)
}
if err != nil {
        return err
}
```

Порядок «сначала дети, потом родитель» — прямое следствие NO ACTION: база не угадает за вас. HasUserWith — рёберный предикат из десятого урока, он отбирает строки дружбы по владельцу. ent.IsConstraintError — штатная проверка отказа внешнего ключа: после неё обычно логируют карту зависимостей, а не ретраят вслепую. Правильнее всего один раз поправить схему — аннотация Cascade на рёбрах through-таблицы убирает весь этот код, о чём честно предупреждал шестой урок.

### Чеклист удаления перед продом

- Для каждого ребра схемы known-ответ: что будет с детьми — Cascade, SetNull, Restrict или дефолт? Карта есть в пятом уроке; если ответа нет — он найдётся в проде.
- Массовые удаления идут предикатом из десятого урока и с прицелом: Count с тем же Where перед Exec — как и для обновления из тринадцатого.
- ConstraintError — штатная ветка, не 500: логируйте, какая ссылка держит строку, и решайте в коде, а не ретраями.
- Перед жёстким удалением решите, что нужно «на память»: сущность не вернётся из Exec — читайте до, с With-цепочкой из одиннадцатого урока.
- Опасные пути — за транзакциями из шестнадцатого урока: пара «дети, потом родитель» обязана быть атомарной.

### Мягкое удаление ent: руками из поля и предиката

GORM в девятом уроке своего курса показывал gorm.DeletedAt: поле объявлено — и все выборки автоматически получают IS NULL, а Delete превращается в UPDATE. В ent такой магии нет, и это осознанное решение: удаление ent удаляет, а маскировку вы собираете сами — из инструментов, которые уже есть. Рецепт из трёх деталей. Первая: в схему Optional-поле deleted_at с типом field.Time из третьего урока. Вторая: «удаление» — это обновление из тринадцатого урока: UpdateOneID(id).SetDeletedAt(time.Now()) — строка остаётся, история сохраняется, откат возможен руками. Третья: живые отбираются предикатом user.DeletedAtIsNil() — семейство IsNil из каталога десятого урока — и этот предикат обязан сопровождать каждый читающий запрос: забудете один Query — «удалённый» пользователь всплывёт в ленте. Именно здесь обычно приходят к интерсепторам восемнадцатого урока, которые вшивают фильтр во все запросы схемы, — но ручная версия должна быть написана и понята раньше автоматической. Оговорка про уникальность: email с UniqueConstraint из третьего урока не даст «удалённому» пользователю зарегистрироваться заново — уникальный индекс видит строку, даже если ваш код нет; лечится частичным индексом на живых строках, тема двадцатого урока. Мягкое удаление ent — это политика на вашей стороне, и в этом его сила и его цена.

### Soft delete: схема, удаление, живые

```go
// в схеме User (третий урок):
field.Time("deleted_at").
        Optional().
        Nillable()

// «удалить» = обновить (тринадцатый урок):
err := client.User.
        UpdateOneID(1).
        SetDeletedAt(time.Now()).
        Exec(ctx)

// живые — только с фильтром:
live, err := client.User.Query().
        Where(user.DeletedAtIsNil()).
        Order(ent.Asc(user.FieldID)).
        All(ctx)

// восстановить — снова обновление:
err = client.User.
        UpdateOneID(1).
        ClearDeletedAt().
        Exec(ctx)
```

Поле Optional + Nillable из четвёртого урока даёт честную тройку состояний: никогда не удалялся (nil), удалён и известен момент (значение), восстановлен (ClearDeletedAt вернул NULL). SetDeletedAt — обычный Set-мутатор, никакого отдельного API «мягкого удаления» в ent нет, и это упрощает лог: в следе видно ровно то, что происходит — UPDATE вместо DELETE. DeletedAtIsNil обязан стоять в каждом читающем запросе: пропущенный один Query — и вера, «удалённая» вчера, снова в каталоге.

### Проверка: механика мягкого удаления

**Вопрос:** Пользователь «удалён» через UpdateOneID(id).SetDeletedAt(time.Now()). Что вернёт client.User.Query().Where(user.DeletedAtIsNil()).Count(ctx) при трёх живых пользователях и одном помеченном?

- ✅ 3: DeletedAtIsNil отбирает строки без deleted_at, помеченный не считается
- 4: Count игнорирует deleted_at — фильтр нужен только в All
- 1: Count считает только помеченных
- 0 и ошибку: мягко удалённые строки недоступны вовсе

DeletedAtIsNil — обычный предикат IS NULL: он работает в Count так же, как в All, — никакого автоматического фильтра у ent нет, есть только то, что вы написали. Помеченная строка остаётся в таблице полностью: в выборки её не пускает ваш Where, а не магия ORM. Забытый предикат в любом Query — самая частая утечка мягко удалённых данных.

### Соберите оба пути удаления

Впишите точечный билдер удаления, хелпер ошибки внешнего ключа и предикат живых строк.

```go
// 1. жёстко удалить по id:
err := client.User.___(id).
        Exec(ctx)

// 2. поймать отказ внешнего ключа:
if ent.___(err) {
        return fmt.Errorf("строку держат ссылки: %w", err)
}

// 3. показать только живых при мягком удалении:
users, err := client.User.Query().
        Where(user.___()).
        All(ctx)
```

Подсказка: Первый пропуск — точечный delete-билдер по первичному ключу; второй — хелпер пакета ent для *ConstraintError; третий — предикат «поле deleted_at пусто» из каталога десятого урока.

Правильные ответы в пропусках: `DeleteOneID`, `IsConstraintError`, `DeletedAtIsNil`

### Путь одного DeleteOneID

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

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

1. `DeleteOneID(1) — билдер UserDeleteOne; Exec(ctx) без Save сразу исполняет операцию`
2. `sqlgraph строит ровно один стейтмент: DELETE FROM users WHERE id = 1`
3. `база проверяет внешние ключи: посты, членство и дружбы анны стираются каскадом по констрейнтам схемы`
4. `группы и остальные пользователи не тронуты: связь исчезла, данные живы`
5. `affected rows == 1 — операция успешна; для DeleteOneID ноль строк означал бы NotFoundError`
6. `Exec возвращает error-only контракт; сущности на память надо было прочитать до удаления`

### Карта удалений: форма, след, судьба соседей

| Сценарий | API | SQL-след | Что с соседями |
| --- | --- | --- | --- |
| Точечное по ключу | DeleteOneID(id).Exec(ctx) | DELETE ... WHERE id = ? | база решает по OnDelete |
| Массовое по предикату | Delete().Where(...).Exec(ctx) | DELETE ... WHERE <предикаты> | каскады по каждой строке |
| Ребро Optional, без аннотации | — | констрейнт ON DELETE SET NULL | дети-сироты с NULL-ссылкой |
| Ребро Required, без аннотации | — | констрейнт NO ACTION | DELETE блокируется ConstraintError |
| Аннотация entsql.OnDelete(entsql.Cascade) | — | констрейнт ON DELETE CASCADE | дети стираются базой (канон posts) |
| Служебная m2m-таблица | — | CASCADE на обеих колонках | строки связи исчезают, узлы целы |
| Through-таблица без аннотаций | — | NO ACTION на её рёбрах | чистить руками или аннотировать |
| Мягкое удаление | UpdateOneID(id).SetDeletedAt(...) | UPDATE ... SET deleted_at | соседи не тронуты вовсе |
| Восстановление | UpdateOneID(id).ClearDeletedAt() | UPDATE ... SET deleted_at = NULL | живость возвращает предикат IsNil |

> **Как проверять себя в этом уроке.** Runner-ов в курсе ent нет, и песочница не появится и здесь: удалениям нужны настоящий драйвер и база — правило всего курса. Возьмите базу из домашек восьмого урока, включите лог SQL драйвера и исполните каждый путь из урока. Смотрите в лог и в данные: после DeleteOneID анны в таблице posts не должно остаться ни одной её строки, в group_users — ни одной её membership, а группы должны остаться. Затем переопределите схему на дефолт без аннотации и поймайте разницу: SET NULL оставит сирот, NO ACTION уронит DELETE с ConstraintError. Мягкий путь проверьте подсчётом: живых должно быть ровно столько, сколько предикат обещает.

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

Вы умеете прощаться с данными правильно: точечная и массовая формы с разными контрактами, счёт удалённого, карта каскадов из пятого урока со стороны исполнения, ручной порядок с ConstraintError и мягкое удаление из поля, мутатора и предиката — мост между тринадцатым уроком и этим. Удаление завершает CRUD-кольцо курса: [создание](/courses/ent/lessons/08-sozdanie/), обновление, удаление — все три операции собираются из одних и тех же билдеров и отвечают компилятору за каждый вызов. Дальше — мир вокруг него: пятнадцатый урок разберёт миграции Schema.Create и atlas — как схемы из ваших файлов вообще оказываются констрейнтами в базе, — а шестнадцатый завернёт составные операции в транзакции. Домашнее задание ниже проведёт оба пути удаления по вашей базе и заставит прочитать лог как улику. Полный план из 25 уроков — на [странице курса](/courses/ent/), предикаты для Where — в [десятом уроке](/courses/ent/lessons/10-predikaty/), страницы для списков — в [двенадцатом](/courses/ent/lessons/12-paginaciya/).

### Карточки: удаление

- **Две формы удаления и их контракты** — DeleteOneID(id).Exec(ctx) — error-only: строка ушла или *NotFoundError (ent.IsNotFound). Delete().Where(...).Exec(ctx) — (int, error): число удалённых строк; n = 0 — данные, не сбой. ExecX-близнецы паникуют; сущности ни одна форма не возвращает — читайте до удаления.
- **Каскадное удаление ent** — ent ничего не каскадит в рантайме: он генерирует констрейнты, а соседей стирает база. Карта из пятого урока: Optional → SET NULL, Required → NO ACTION, entsql.OnDelete переопределяет (Cascade/Restrict/SetNull/SetDefault). В каноне ребро posts несёт Cascade — аккаунт умирает вместе с контентом.
- **Судьба m2m-строк** — Служебные таблицы (group_users, user_friends) ent строит с CASCADE на обеих колонках: строки связи исчезают с владельцем, сами узлы целы — «связь умерла, данные живы». Through-таблица — обычная сущность: её Required-рёбра без аннотаций дают NO ACTION, чистить руками или аннотировать Cascade.
- **ConstraintError при удалении** — Ребро Required без Cascade (или намеренный Restrict) роняет DELETE родителя с детьми ошибкой внешнего ключа — *ConstraintError, проверка ent.IsConstraintError, та же ошибка, что на дубликатах в восьмом уроке. Лечение: ручной порядок «дети, потом родитель» — или правильный referential action в схеме; составные пути — под транзакциями шестнадцатого урока.
- **Мягкое удаление ent** — Встроенного DeletedAt нет — собирается руками: Optional+Nillable поле deleted_at (field.Time, третий/четвёртый урок), «удаление» = UpdateOneID(id).SetDeletedAt(time.Now()) из тринадцатого, живые = Where(user.DeletedAtIsNil()) из десятого. Восстановление — ClearDeletedAt. Забытый предикат в одном Query — и удалённый всплывёт.
- **Мягкое удаление и уникальность** — UniqueConstraint на email из третьего урока видит и «удалённые» строки: повторная регистрация упадёт, хотя ваш код их не показывает. Лечение — частичный уникальный индекс только по живым строкам (тема двадцатого урока) или перенос уникальности на другой ключ. Ent здесь не помогает автоматически — как и GORM в его девятом уроке.

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

- ent delete — два сгенерированных билдера: DeleteOneID по ключу (Exec → error-only, пустой ключ — *NotFoundError) и Delete().Where() по предикатам десятого урока (Exec → (int, error), число удалённых). n = 0 — нормальный путь, не ошибка.
- Каскадное удаление ent делает не рантайм, а база: ent генерирует referential actions из схемы, пятый урок дал карту — Optional → SET NULL, Required → NO ACTION, аннотация entsql.OnDelete переопределяет на Cascade/Restrict/SetNull/SetDefault.
- Канон курса: ребро posts несёт entsql.OnDelete(entsql.Cascade) — удаление анны стирает её посты одним DELETE; служебные m2m-таблицы каскадят на обеих колонках всегда — строки членства и дружб исчезают, группы и узлы целы.
- Through-таблица — обычная сущность: её Required-рёбра без аннотаций дают NO ACTION, и удаление участника блокируется до чистки или аннотации Cascade.
- Отказ FK — *ConstraintError (ent.IsConstraintError), штатная ветка логики: либо поправить схему, либо удалить детей раньше родителя; составные удаления — под транзакциями шестнадцатого урока.
- Мягкое удаление ent собирается руками: поле deleted_at (Optional, Nillable), удаление = UpdateOneID(id).SetDeletedAt(time.Now()) — мост к тринадцатому уроку, живые = Where(user.DeletedAtIsNil()), восстановление = ClearDeletedAt.
- Уникальность против мягкого удаления: UniqueConstraint видит помеченные строки — повторная регистрация упадёт; лечится частичным индексом на живых, тема двадцатого урока. Автофильтра как у GORM DeletedAt в ent нет — и каждый читающий Query обязан нести предикат.
- Удаление завершает CRUD-кольцо курса: Create из восьмого, Update из тринадцатого, Delete из этого урока — одна и та же билдерная философия, компилятор и схема как страховые полисы.

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

Работайте на базе из восьмого урока — анна, борис, вера, их посты, группа go-ninja — и включите лог SQL драйвера. Первое: удалите анну DeleteOneID и прочитайте след: один DELETE, затем работа базы — постов анны в таблице быть не должно, строки group_users обнулились по её ключу, группы целы. Второе: снимите аннотацию Cascade с ребра posts, перегенерируйте и повторите удаление на свежей копии базы: без аннотации Optional-ребро даст SET NULL — посчитайте сирот с author_id = NULL и опишите словами, чем этот мир отличается от каскадного. Третье: соберите through-сценарий шестого урока — попытайтесь удалить пользователя с дружбами без аннотаций, поймайте ent.IsConstraintError и запишите, какая ссылка держала строку; затем решите: чистить дружбы до родителя или аннотировать Cascade — и объясните выбор. Четвёртое: добавьте deleted_at в схему, «удалите» веру через SetDeletedAt и проверьте три подсчёта: Count без предиката — четыре, с DeletedAtIsNil — два, с DeletedAtNotNil — один; затем восстановите её ClearDeletedAt. Пятое: сделайте массовое удаление с прицелом — Count с тем же Where до Exec — на предикате AgeLT(20) и сверьте числа; затем выполните Delete() без Where на копии базы и зафиксируйте в комменте, сколько строк стоила забытая строка кода. Вопрос на подумать: почему ent не сделал мягкое удаление флагом схемы, и какие две самые дорогие ошибки вы бы получили с автоматическим IS NULL-фильтром?

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

### DeleteOneID и DeleteOne — когда какой?

DeleteOneID(id) берёт первичный ключ — путь для обработчиков, где id пришёл из маршрута. DeleteOne(u) принимает готовую сущность и использует её ID — удобно в циклах, где объект уже на руках из Query. Разницы в SQL нет: оба строят DELETE по ключу и отвечают error-only контрактом; NotFoundError ожидайте в обоих случаях, если строку уже удалили конкурентно.

### Как узнать, что именно удалил массовый Delete?

Ни срез сущностей, ни их id Exec не вернёт — только число. Если состав важен, прочитайте выборку до удаления тем же предикатом: Query().Where(...).All(ctx) с With-цепочкой из одиннадцатого урока, если нужны соседи, — затем Delete с тем же Where. Для аудита «кто именно ушёл» помогает мягкое удаление из этого урока: пометка вместо стирания сохраняет состав навсегда.

### Почему ent не фильтрует мягко удалённые строки автоматически?

Потому что у ent нет поля-конвенции: deleted_at может называться иначе, быть временем или флагом, а правила живости — бизнес-логикой. GORM стандартизировал gorm.DeletedAt и вшивает IS NULL в каждый запрос; ent оставляет предикат вам — DeletedAtIsNil из десятого урока, а автоматизацию отдают интерсепторам восемнадцатого. Так вы сами решаете, в каких запросах помеченные строки живы: в админке — всегда, в каталоге — никогда.
