Содержание урока · 11 разделов
- ent delete: два билдера и их контракты
- Ноль строк, NotFoundError и отсутствие мягкого режима
- Каскадное удаление ent: кто стирает соседей
- Когда база отказывает: NO ACTION и ConstraintError
- Чеклист удаления перед продом
- Мягкое удаление ent: руками из поля и предиката
- Карта удалений: форма, след, судьба соседей
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Маршрут урока — от одного DELETE до полной политики прощания. Первый сюжет — два билдера: точечный UserDeleteOne и массовый UserDelete, их контракты Exec и счёт удалённого. Второй — каскадное удаление ent: карта OnDelete из пятого урока со стороны исполнения — что база делает с постами при удалении анны и кто чистит строки в group_users, служебной таблице из шестого урока. Третий — отказ: когда внешний ключ блокирует 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 у точечной и массовой форм удаления?
Сколько строк удалил предикат
Прочитайте код и выберите, что он выведет:
// анна 26 лет, борис 19, вера 17.
n, err := client.User.Delete().
Where(user.AgeLT(20)).
Exec(ctx)
// чему равно n?Ноль строк, 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(анна)?
Когда база отказывает: 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) при трёх живых пользователях и одном помеченном?
Соберите оба пути удаления
Впишите точечный билдер удаления, хелпер ошибки внешнего ключа и предикат живых строк.
Путь одного DeleteOneID
Соберите жизненный цикл жёсткого удаления анны: что происходит и в каком порядке от вызова до пустого лога.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карта удалений: форма, след, судьба соседей#
| Сценарий | 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-кольцо курса: создание, обновление, удаление — все три операции собираются из одних и тех же билдеров и отвечают компилятору за каждый вызов. Дальше — мир вокруг него: пятнадцатый урок разберёт миграции Schema.Create и atlas — как схемы из ваших файлов вообще оказываются констрейнтами в базе, — а шестнадцатый завернёт составные операции в транзакции. Домашнее задание ниже проведёт оба пути удаления по вашей базе и заставит прочитать лог как улику. Полный план из 25 уроков — на странице курса, предикаты для Where — в десятом уроке, страницы для списков — в двенадцатом.
Карточки: удаление
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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 из десятого урока, а автоматизацию отдают интерсепторам восемнадцатого. Так вы сами решаете, в каких запросах помеченные строки живы: в админке — всегда, в каталоге — никогда.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker