---
title: "Обновление и Upsert"
description: "ent update по всем правилам: UpdateOneID и Set-мутаторы, массовое обновление через Where, Add и Clear, возврат обновлённой сущности и bulk-upsert."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/13-update/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Обновление и Upsert

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

Восьмой урок дал вам Create и азы OnConflict, двенадцатый научил резать выборку на страницы, но всё, что вы вернули клиенту, было единым и неизменным: записью нельзя было распорядиться. Сегодня — ent update во всех формах: точечное UpdateOneID с Set-мутаторами и возвратом обновлённой сущности, массовое Update().Where(), которое отвечает числом затронутых строк, инкрементное семейство Add для атомарных счётчиков и Clear для nullable-полей из четвёртого урока. Затем — upsert ent как финал темы восьмого урока: bulk-вставка с конфликтом по email, обновляющая пачку строк одним стейтментом. Термин ent обновление записи в поисковиках обычно приводит к драме «SELECT, потом UPDATE или потеря данных» — здесь вы увидите, почему почти вся эта драма лечится правильной формой мутаторов на каноне курса: анна, борис, вера и их посты.

Маршрут урока — путь одной записи от «прочитали» до «изменили». Первый сюжет — две формы обновления: точечная UpdateOneID по первичному ключу и массовая Update().Where() по предикатам из [десятого урока](/courses/ent/lessons/10-predikaty/). Второй — Set-мутаторы изнутри: что попадает в mutation, где живёт валидация и зачем UpdateDefault пересчитывается сам. Третий — семья Add/Clear/SetNillable: инкременты на стороне SQL и работа с Optional-полями четвёртого урока. Четвёртый — что именно возвращают Save у обеих форм: сущность или число, и как ent добывает обновлённые значения. Пятый — гонки: почему SQL-выражение age = age + 1 сильнее связки «прочитал, прибавил, записал», и как это продолжить на денормализованном счётчике из двенадцатого урока. Шестой — upsert-остаток восьмого урока: bulk-upsert через CreateBulk и OnConflict. Данные прежние: анна (26 лет, админ, два поста — «про рёбра» и «черновик»), борис (19), вера (17), группа go-ninja.

### ent update: две формы одной операции

Обновление в ent — это отдельный сгенерированный билдер, а не переклейка флагов на query: в blog/ent/user_update.go живут два строения. UserUpdate — массовая форма: client.User.Update() возвращает её без аргументов, Where принимает предикаты, Save отвечает парой (n int, err) — числом затронутых строк. UserUpdateOne — точечная форма: client.User.UpdateOneID(id) или UpdateOne(u) по готовой сущности; Where у неё нет — ключ и есть условие, а Save возвращает (*User, error). Это главный разворот ent update: выбор формы — выбор контракта возврата, и перепутать их не даст компилятор, потому что типы результата разные. Обе формы собирают мутацию одинаково: Set-методы копятся в поле UserMutation, и исполняется всё только на Save — до него билдер безопасно передаётся между функциями, клонируется и дополняется. Забудете Where в массовой форме — не будет ни ошибки, ни предупреждения: WHERE просто не сгенерируется, и UPDATE перепишет всю таблицу. ent не страхует от широких обновлений — это осознанный контракт SQL, и единственная страховка здесь ваша: сначала предикат, потом Set, и ни строкой иначе. Если вы искали по запросу ent update или «ent обновление записи» — запомните эту вилку первой: по id с возвратом строки, по условию с возвратом числа.

### UpdateOneID: точечно, с возвратом сущности

```go
// анна поправила профиль: точечное обновление по ключу
u, err := client.User.
        UpdateOneID(1). // id анны
        SetName("Анна").
        SetAge(27).
        Save(ctx)
if ent.IsNotFound(err) {
        // такого id нет: *NotFoundError, а не паника
        return err
}
if err != nil {
        return err // соединение, валидация, отмена контекста
}

// u — обновлённая сущность со свежими значениями:
fmt.Println(u.Name, u.Age) // Анна 27
```

UpdateOneID(1) принимает первичный ключ и возвращает UserUpdateOne; SetName и SetAge пишут значения в мутацию, Save исполняет UPDATE и возвращает обновлённую строку. Две ошибки различаются типом: ent.IsNotFound ловит «пользователя с таким id нет» — ent увидел ноль затронутых строк и вернул *NotFoundError, — а вторая проверка принимает всё остальное. Никакого «сначала Get, потом Update» здесь не нужно: одна операция, один поход в базу.

### Проверка: что вернёт Save для отсутствующего id

**Вопрос:** client.User.UpdateOneID(999).SetName("x").Save(ctx) — пользователя с id 999 не существует. Что произойдёт?

- ✅ Save вернёт (nil, *NotFoundError): ноль затронутых строк превращается в NotFoundError
- Save вернёт (nil, nil) — операция прошла тихо, ничего не обновив
- Save создаст пользователя с id 999 и именем x
- Паника: обновлять несуществующую строку запрещено на уровне рантайма

UPDATE затронул ноль строк — ent честно переводит это в *NotFoundError, проверяется хелпером ent.IsNotFound из девятого урока. Тихий (nil, nil) был бы ловушкой для API-обработчиков, авто-вставка — это уже upsert из восьмого урока, а не update. Отсюда правило: точечное обновление всегда обрабатывайте как операцию, которая может «не найти».

### Set-мутаторы изнутри: мутация, валидация, UpdateDefault

Откроем user_update.go и посмотрим, что делает SetName: он пишет значение в m.name и помечает имя как изменённое — u.mutation.SetName(s). Всё остальное — работа Save: сгенерированный код собирает список изменённых полей из мутации, прогоняет их через валидаторы схемы и только затем строит SQL. Это значит, что валидация из [третьего урока](/courses/ent/lessons/03-polya/) работает и на обновлении: MaxLen, Positive, NotEmpty — те же проверки, те же ValidationError до похода в базу. Правило старых значений стоит проговорить явно: SetX всегда перезаписывает поле целиком, независимо от того, что лежало в колонке, и ent не читает старое значение, чтобы «слить» его с новым — обновление это не патч в JavaScript-смысле, а замена. Отдельная магия — поля с UpdateDefault из третьего урока: если у вас в схеме updated_at объявлен с UpdateDefault(time.Now), ent сам добавит его в UPDATE при каждом сохранении, и вы не увидите этого ни в мутации, ни в списке изменённых полей — генератор вставляет дефолт на этапе сборки стейтмента. Побочный эффект той же механики: поля с Immutable из третьего урока в update-билдере отсутствуют как класс — Set-мутатора для них не сгенерировано, и компилятор не даст «случайно» переписать email-ключ или дату создания. Схема дисциплинирует запись ещё до базы.

### Семья Add, Clear и SetNillable

```go
// Optional-поля из четвёртого урока и инкременты:
u, err := client.User.
        UpdateOneID(1).
        AddAge(1).          // SQL: age = age + 1
        SetNillableBio(&bio). // *string: пишет только если не nil
        Save(ctx)

// стереть сайт — NULL в nullable-колонке:
_, err = client.User.
        UpdateOneID(1).
        ClearWebsite(). // website = NULL
        Save(ctx)

// массово почистить пустые био:
n, err := client.User.
        Update().
        Where(user.BioIsNil()).
        SetBio("участник go-ninja").
        Save(ctx) // n — сколько строк получило био
```

AddAge(n) генерирует не «прочитай и запиши», а выражение age = age + n прямо в SET — об этом отдельный сюжет ниже. SetNillableBio принимает *string: nil-указатель означает «поле не трогать», значение — записать; это удобная форма для обработчиков, где параметр пришёл из JSON и может отсутствовать. ClearWebsite ставит NULL — легально, потому что website Optional с четвёртого урока; ClearX для обязательных полей не сгенерирован, компилятор не пустит. BioIsNil — предикат из каталога десятого урока, он же отлично режет mass-update.

### Сколько строк тронет массовое обновление

```go
// анна 26 лет, борис 19, вера 17.
n, err := client.User.Update().
        Where(user.AgeGTE(18)).
        SetBio("взрослый участник").
        Save(ctx)

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

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

- ✅ n = 2, err = nil: совершеннолетних двое — анна и борис
- n = 3, err = nil: SetBio без Where-ограничения внутри затронул всех
- n = 0, err = nil: возраст — не поле био, условие не сработало
- err != nil: массовое обновление требует обязательного Where

Where(user.AgeGTE(18)) отбирает анну (26) и бориса (19); вера с её 17 не проходит, и n равен двум — числу затронутых строк, без ошибки. SetBio применяется только к отобранному: он стоит после Where в цепочке, но порядок вызовов в билдере роли не играет — в SET уедет био, в WHERE — возраст. ent не требует Where: без него обновилась бы вся таблица, поэтому n = 3 описывает как раз форму без предиката.

### Массовое обновление: n как результат и как ответственность

Форма Update().Where().SetX().Save(ctx) — рабочая лошадь миграций, административных скриптов и «пометить всех прочитанными». Её контракт: Save возвращает n — число строк, которые база сообщила как затронутые, — и никогда не возвращает сущности: строки не прочитаны, а переписаны на месте. Из этого следуют три привычки. Первая: n = 0 — не ошибка, а данные «под условие никто не подошёл»; обрабатывайте как нормальный исход, а не как сбой. Вторая: после массового обновления в памяти приложения лежат устаревшие объекты — если страница должна показать новое состояние, перечитайте выборку Query-ем из девятого урока или возвращайте клиенту n, а не данные. Третья — про ответственность: WHERE без ограничений или забытый вовсе переписывает таблицу целиком, и ent оставляет это на совести автора. Практическая страховка — тестировать предикат сначала чтением: тот же Where на Query().Count(ctx) покажет, сколько строк «в зоне поражения», и уже потом тот же предикат уезжает в Update. Дорогие вычисления в SET — выражения, подзапросы — ent прячет за sql.Expr и Modify из седьмого урока, но в девяноста процентах случаев хватает Set-мутаторов и семьи Add/Clear. Массовая форма — это SQL-мышление: одна команда, один поход, число как отчёт.

### Массовое обновление с проверкой прицела

```go
// прицел: сколько строк под условием?
targets, err := client.User.Query().
        Where(user.And(user.AgeGTE(18), user.BioIsNil())).
        Count(ctx)
if err != nil {
        return err
}

// тот же предикат — в обновление:
n, err := client.User.Update().
        Where(user.And(user.AgeGTE(18), user.BioIsNil())).
        SetBio("взрослый участник").
        Save(ctx)

fmt.Println(targets, n) // 2 2 — прицел совпал с отчётом
```

Один и тот же предикат работает в Query и в Update — предикаты ent комбинируются через user.And из десятого урока и переиспользуются как константы. Count перед Update — не двойная работа, а дешёвая страховка на прод-данных: расхождение прицела и отчёта между двумя запросами означает, что данные менялись под вами, и это тоже полезный сигнал. n снова два: анна и борис, у веры био уже не пустое условие BioIsNil отсекло бы её даже по возрасту.

### Возвращение обновлённого: как ent добывает строку

Откуда берётся *User из UpdateOneID, если UPDATE в SQL традиционно не возвращает строк? Сгенерированный Save строит UPDATE с клаузой RETURNING на диалектах, которые умеют возвращать — PostgreSQL и современные SQLite, — и сканирует результат прямо в структуру User. Там, где RETURNING нет, ent делает честную связку: UPDATE, затем SELECT по тому же первичному ключу — два похода, но один вызов API. Для вас разницы нет ни в типах, ни в порядке проверки ошибок, есть разница в SQL-следе, и вы увидите её в логе драйвера. Следствия практические. Первое: u.UpdatedAt и другие поля с UpdateDefault в возвращённой сущности уже свежие — не нужно перечитывать, чтобы узнать время изменения. Второе: рёбра возвращённой сущности пусты — Edges.Groups будет nil, потому что UPDATE не возит соседей; захотите граф — With-вызовы из одиннадцатого урока на отдельном Query. Третье: ExecX и SaveX — паникующие близнецы Save — существуют и здесь, но в сервисном коде держитесь ошибки: потеря пользователя между проверкой и записью — нормальный параллельный мир, а не исключительный случай. И четвёртое: массовая форма RETURNING не использует в принципе — ей нечего сканировать, её отчёт уже описан числом n.

### Обновили — получили — отдали

```go
// HTTP-обработчик PATCH /users/:id (упрощённо):
u, err := client.User.
        UpdateOneID(id).
        SetName(in.Name). // из запроса
        Save(ctx)
if ent.IsNotFound(err) {
        http.NotFound(w, r) // 404: строки нет
        return
}
if err != nil {
        http.Error(w, err.Error(), 500) // соединение/валидация
        return
}

// u.Name уже новое, u.UpdatedAt уже двигался UpdateDefault-ом:
renderUser(w, u) // рёбра пусты — если нужен граф, отдельный Query
```

Порядок веток не случайный: сначала NotFound как бизнес-исход (404), потом всё остальное как сбой (500). Рендерим сущность, которую вернул Save, — без повторного Get; свежие значения гарантированы возвратом. Если обработчику нужен граф пользователя — группы, посты — это отдельный Query с With-цепочкой из одиннадцатого урока, обновление и загрузка соседи не смешиваются.

### Add и гонки: арифметика на стороне базы

Самая коварная ошибка обновления — счётчик через чтение. Классический сценарий: прочитали пользователя, прибавили к age единицу в Go, записали SetAge(u.Age + 1). Между чтением и записью другой воркер успел сделать то же самое — и одна из двух единиц исчезла: оба UPDATE записали значение, вычисленное из одного и того же старого. AddAge(1) решает это на уровне SQL: в SET уезжает выражение age = age + 1, а не литерал — база применяет его атомарно к текущему значению строки, и сколько бы воркеров ни сходилось, каждая единица доезжает. Тот же принцип обслуживает лайки, балансы, номера ревизий и денормализованный счётчик posts_count из двенадцатого урока: поддерживать его надо Add-мутаторами и в хуках, а не связкой «прочитал-пересчитал». Оговорка честности: атомарность выражения не отменяет вопросов конкурентности вокруг других полей той же строки — если операция «прибавить и тут же прочитать результат» должна быть строго согласованной, это территория транзакций, до которых мы дойдём в шестнадцатом уроке. А если валидация схемы требует, чтобы возраст оставался неотрицательным, — проверяйте условие до Add или в хуке, потому что валидаторы ent видят прибавляемое число, а не результат выражения в базе.

### Счётчик постов: продолжение прошлого урока

```go
// поле posts_count из двенадцатого урока (денормализация):
// сортировка лент по нему была Order(ent.Desc(user.FieldPostsCount)).

// анна опубликовала пост — счётчик атомарно +1:
_, err := client.User.
        UpdateOneID(anna.ID).
        AddPostsCount(1).
        Save(ctx)

// пост удалён — и минус:
_, err = client.User.
        UpdateOneID(anna.ID).
        AddPostsCount(-1).
        Save(ctx)

// SQL в обоих случаях: UPDATE users SET posts_count = posts_count + $1
// параллельные публикации не теряются
```

AddPostsCount(1) и AddPostsCount(-1) — один и тот же механизм выражения, знак в аргументе. Это ровно та дисциплина поддержки денормализации, которую просил двенадцатый урок: счётчик двигается в тех же точках кода, где создаются и удаляются посты — руками здесь, автоматом в хуках семнадцатого. Сортировка Order(ent.Desc(user.FieldPostsCount)) из прошлого урока читает уже честное число, без COUNT(*) на каждый запрос ленты.

### Проверка: почему Add, а не Set

**Вопрос:** Два воркера одновременно делают u, _ := Get(ctx, id); client.User.UpdateOneID(id).SetAge(u.Age + 1).Save(ctx) при u.Age = 26. Что будет с age после обоих?

- ✅ 27: второй UPDATE записал то же число, вычисленное из старого чтения
- 28: обе единицы доедут, ent сериализует update-билдеры
- 29: ent сложит оба SetAge в одно выражение
- Ошибка блокировки: ent не даст двум воркерам обновить одну строку

Оба воркера прочитали 26, оба записали 27 — классическая потерянная модификация, и ent тут не блокировщик: его билдеры безвизу, последний UPDATE просто побеждает. AddAge(1) вместо SetAge(u.Age+1) даёт выражение age = age + 1 — обе единицы атомарно доехали бы до 28. SetX переносит значение, AddX описывает приращение — для всего, что «считается», берите Add.

### Upsert ent: что осталось за восьмым уроком

Восьмой урок разобрал upsert на одиночной вставке: OnConflict на create-билдере, смена типа цепочки на *UserUpsertOne, UpdateNewValues, Ignore, DoNothing, свой SET через Update(func(u *ent.UserUpsert)) и возврат id через ID(ctx). Здесь закроем то, что осталось за кадром, — bulk-upsert: пачка строк одним INSERT-ом с общей оговоркой ON CONFLICT. Билдер CreateBulk из [восьмого урока](/courses/ent/lessons/08-sozdanie/) сам не умеет конфликтовать — но у него есть тот же OnConflict, и после него цепочка превращается в bulk-версию upsert: один стейтмент «вставь эти пять, а по конфликту на email обнови». Флаг sql/upsert включался ещё в [седьмом уроке](/courses/ent/lessons/07-generate/) — проверьте features перед go generate, иначе метода просто не будет. Семантика такая же, как у одиночной: UpdateNewValues берёт mutable-поля из вставки, Immutable-поля вроде created_at не переписываются, а Update с UserUpsert позволяет свой SET поверх — например, прибавить возраст инкрементом к существующему значению. Полезно помнить и про упор MySQL: цель конфликта ему не нужна (ON DUPLICATE KEY не принимает колонок), PostgreSQL без цели откажется — это было в predict восьмого урока. Upsert ent — единственный штатный способ «синхронизации каталога», когда клиент присылает полный список сущностей, а база должна заменить состояние без дубликатов и без двух фаз.

### Bulk-upsert: пачка одним стейтментом

```go
// список пришёл от клиента; email — ключ синхронизации:
bs := make([]*ent.UserCreate, 0, 3)
for _, in := range incoming {
        b := client.User.Create().
                SetEmail(in.Email).
                SetName(in.Name).
                SetAge(in.Age)
        bs = append(bs, b)
}

// вставить всё, по конфликту на email — обновить из вставки,
// а возраст прибавить к существующему:
err := client.User.
        CreateBulk(bs...).
        OnConflictColumns(user.FieldEmail).
        UpdateNewValues().
        Update(func(u *ent.UserUpsert) {
                u.AddAge(1) // на каждом конфликте: age = age + 1
        }).
        Exec(ctx)
```

CreateBulk собирает одиночные create-билдеры в один INSERT; OnConflictColumns(user.FieldEmail) задаёт цель конфликта — так же, как в восьмом уроке; UpdateNewValues обновляет mutable-поля значениями из вставки. AddAge(1) внутри Update — инкремент в upsert: для новых строк возраст придёт из вставки, для конфликтующих прибавится к текущему. Итог — один поход в базу на весь каталог: без SELECT-ов, без цикла Save, без двойных фаз.

### Проверка: семантика bulk-upsert

**Вопрос:** В базе анна с email anna@entgo.io и возрастом 26. Bulk-upsert вставляет двух пользователей, один из них — анна с SetAge(27) и UpdateNewValues().Update(u.AddAge(1)). Что станет с возрастом анны?

- ✅ 28: UpdateNewValues записал 27 из вставки, затем AddAge прибавил единицу к существующему
- 27: AddAge при конфликте игнорируется, работает только UpdateNewValues
- 26: конфликтные строки не изменяются вовсе без явного Ignore
- 29: UpdateNewValues и AddAge оба применяются к значению из вставки

Порядок применений фиксирован: сначала базовая политика UpdateNewValues — SET age = 27 из вставки, — затем ваш Update с UserUpsert поверх: SET age = age + 1 к значению, уже записанному стейтментом. Итог 28. Ignore был бы про «ничего не менять», а AddAge в upsert-словаре всегда даёт выражение с текущим значением строки — тот же атомарный инкремент, что и в обычном update.

### Соберите оба маршрута обновления

Впишите точечный билдер, инкрементный мутатор и предикат «сайт пуст» для массовой формы.

```go
// 1. точечно: по id, с возвратом сущности:
u, err := client.User.___(annaID).
        SetName("Анна").
        ___(1). // атомарный инкремент возраста
        Save(ctx)

// 2. массово: у всех без сайта почистить био:
n, err := client.User.Update().
        Where(user.___()). // колонка website — NULL
        ClearBio().
        Save(ctx)
```

Подсказка: Первый пропуск — билдер обновления по первичному ключу; второй — мутатор-приращение для числовых полей; третий — предикат IsNil из каталога десятого урока для nullable-колонки website.

Правильные ответы в пропусках: `UpdateOneID`, `AddAge`, `WebsiteIsNil`

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

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

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

1. `UpdateOneID(1).SetName("Анна").AddAge(1) — билдер UserUpdateOne, значения копятся в UserMutation`
2. `Save(ctx) — сгенерированный код собирает изменённые поля из мутации и добавляет UpdateDefault-поля вроде updated_at`
3. `валидация полей по схеме: MaxLen и Positive из третьего урока, ошибка ValidationError до базы`
4. `sqlgraph.Update строит UPDATE ... WHERE id = 1 с SET-выражениями, Add превращается в age = age + 1`
5. `RETURNING на PostgreSQL и свежих SQLite — строка сканируется в *User; без RETURNING — дополнительный SELECT по ключу`
6. `хуки мутации получают изменение (тизер семнадцатого урока); Save возвращает обновлённую сущность или NotFoundError на пустом ключе`

### Словарь обновления: мутаторы и их SQL

| Вызов | Форма | SQL в SET / поведение | Что вернуть |
| --- | --- | --- | --- |
| SetName("Анна") | точечная и массовая | name = 'Анна' — полная замена | замена значения целиком |
| SetNillableBio(&b) | точечная и массовая | bio = '…' или пропуск при nil | JSON-опциональный параметр |
| ClearWebsite() | точечная и массовая | website = NULL (поле Optional) | ошибки компиляции для Required |
| AddAge(1) | точечная и массовая | age = age + 1 — выражение | атомарный инкремент, без гонок |
| Where(user.BioIsNil()) | только массовая | WHERE bio IS NULL | предикаты из десятого урока |
| Update().Save(ctx) | массовая | UPDATE без предиката — вся таблица | осторожно: страхуйте Count-ом |
| OnConflictColumns(user.FieldEmail) | create + upsert | цель конфликта для PostgreSQL | MySQL цели не требует |
| UpdateNewValues() | upsert | SET из вставки, кроме Immutable | политика «есть — обнови» |
| Update(func(u *ent.UserUpsert)) | upsert | свой SET поверх политики | SetX/AddX/ClearX словарь |
| CreateBulk(bs...).OnConflict(...) | bulk-upsert | один INSERT ... ON CONFLICT | синхронизация каталога |

> **Как проверять себя в этом уроке.** Runner-ов в курсе ent нет, и песочница не появится и здесь: обновлениям нужны настоящий драйвер и база — правило всего курса. Возьмите базу из домашек восьмого урока, включите лог SQL драйвера и исполните каждую форму из урока. Смотрите в лог три вещи: SET-часть UPDATE (литерал или выражение age = age + 1), WHERE-часть массовой формы (совпала ли с прицелом Count) и RETURNING в следе UpdateOneID. Затем умышленно забудьте Where в массовом обновлении на копии базы и посчитайте жертвы — этот шрам полезнее любого конспекта.

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

Вы умеете менять данные обеими формами: точечная UpdateOneID возвращает обновлённую сущность с свежим updated_at, массовая Update().Where() отвечает числом и страхуется прицелом; Add даёт атомарные инкременты, Clear и SetNillable наводят порядок в Optional-полях четвёртого урока, а bulk-upsert довёл до конца тему восьмого. Оставшийся жест — прощание: двенадцатый урок закрыл чтение, тринадцатый — запись, а [четырнадцатый урок 14-delete](/courses/ent/lessons/14-delete/) разберёт удаление во всех видах: DeleteOneID и Delete с предикатами, счёт удалённых строк, каскадное поведение рёбер из пятого урока и мягкое удаление своими руками. До встречи с ним — домашнее задание: переведите PATCH-обработчик из шаблона «Get, потом Update» на UpdateOneID и сравните число запросов в логе. Полный план из 25 уроков — на [странице курса](/courses/ent/), каталог предикатов для Where — в [десятом уроке](/courses/ent/lessons/10-predikaty/), страницы и сортировка для списков — в [двенадцатом](/courses/ent/lessons/12-paginaciya/).

### Карточки: обновление и upsert

- **Две формы обновления в ent** — UpdateOneID(id) — точечная: без Where, Save возвращает (*User, error), для отсутствующего ключа — *NotFoundError. Update().Where(...) — массовая: предикаты из десятого урока, Save возвращает (int, error) — число затронутых строк. Without Where массовая форма переписывает всю таблицу: сначала прицел через Count.
- **Set-мутаторы и валидация** — SetX пишет значение в UserMutation и помечает поле изменённым; Save собирает список, валидирует по схеме (MaxLen, Positive — третий урок) и строит SQL. SetX — полная замена, не патч. Поля с UpdateDefault двигаются сами; у Immutable-полей Set-мутаторов не генерируется вовсе.
- **Add, Clear, SetNillable** — AddX(n) генерирует SET age = age + n — атомарное приращение на стороне базы, без потерянных модификаций при параллельных обновлениях. ClearX ставит NULL и существует только для Optional-полей. SetNillableX(*T) пишет значение только если указатель не nil — форма для JSON-параметров.
- **Что возвращает Save у update** — UpdateOneID: *User со свежими значениями — через RETURNING (PostgreSQL, свежие SQLite) либо UPDATE + SELECT; UpdatedAt уже пересчитан, рёбра пусты. Update().Where(): int — число затронутых строк; n = 0 — норма, не ошибка. Сущности после mass-update в памяти устарели — перечитывайте Query-ем.
- **Почему Add вместо Set для счётчиков** — SetAge(u.Age+1) переносит вычисленное в Go значение: два параллельных воркера, прочитавших одно и то же, теряют одну единицу. AddAge(1) уезжает выражением age = age + 1 — база применяет его атомарно к текущей строке. Так обслуживаются лайки, балансы и денормализованный posts_count из двенадцатого урока.
- **Bulk-upsert через CreateBulk** — CreateBulk(bs...).OnConflictColumns(user.FieldEmail).UpdateNewValues().Exec(ctx): один INSERT с ON CONFLICT на пачку строк. UpdateNewValues обновляет mutable-поля из вставки; Update(func(u *ent.UserUpsert)) добавляет свой SET — вплоть до AddAge-инкремента. PostgreSQL требует цель конфликта, MySQL живёт без неё; флаг sql/upsert из седьмого урока обязателен.

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

- ent update — два сгенерированных билдера: UpdateOneID для точечной записи (Save → (*User, error), NotFoundError на пустом ключе) и Update().Where() для массовой (Save → (int, error), n = число затронутых строк). Выбор формы — выбор контракта возврата, компилятор не даст перепутать.
- Set-мутаторы пишут в мутацию и помечают поля; на Save поля валидируются по схеме третьего урока до похода в базу. UpdateDefault-поля вроде updated_at двигаются сами; Immutable-поля не имеют Set-мутаторов и в обновлении недостижимы.
- Семья Add/Clear/SetNillable: AddX(n) даёт атомарное выражение age = age + n без потерянных модификаций; ClearX ставит NULL и есть только у Optional-полей; SetNillableX принимает указатель и пишет только не-nil.
- Массовое обновление отвечает числом и не возвращает сущности: n = 0 — данные, не сбой; память после него устарела. Страховка — один и тот же предикат сначала в Query().Count, потом в Update.
- UpdateOneID добывает обновлённую строку через RETURNING (PostgreSQL, свежие SQLite) либо UPDATE + SELECT; UpdatedAt свежий, рёбра пусты — граф грузится отдельным Query с With.
- Инкременты счётчиков — только Add: SetAge(u.Age+1) из параллельных воркеров теряет единицы, выражение в SET — никогда. Денормализованный posts_count из двенадцатого урока поддерживается AddPostsCount(±1) в точках создания и удаления постов.
- Bulk-upsert закрывает тему восьмого урока: CreateBulk + OnConflictColumns + UpdateNewValues — один стейтмент на каталог; Update(func(u *ent.UserUpsert)) добавляет свой SET поверх, вплоть до инкремента. PostgreSQL требует цель конфликта, MySQL — нет.
- До похода в базу билдеры безопасны: их можно передавать, клонировать и дополнять; исполняется всё на Save. Формы без Where и с Where — разные типы, и компилятор ловит перепутанные контракты раньше базы.

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

Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja — и включите лог SQL драйвера. Первое: переведите PATCH-обработчик профиля с шаблона «Get, потом SetAge» на UpdateOneID и проверьте в логе, что уехал один UPDATE с RETURNING (или UPDATE + SELECT) вместо двух запросов; обработайте ent.IsNotFound как 404. Второе: массово поставьте био всем совершеннолетним без био — сначала прицел через Count с тем же Where, потом Update, и сверьте два числа; затем уберите Where и повторите на копии базы, чтобы увидеть цену забытого предиката. Третье: соберите счётчик постов из двенадцатого урока — завидите поле posts_count, двигайте его AddPostsCount(1) и AddPostsCount(-1) вокруг создания и удаления поста и запустите два горутины с сотней публикаций: после синхронизации счётчик обязан совпасть с числом постов, при Set-варианте разъедется. Четвёртое: почистите Optional-поля — ClearWebsite и SetNillableBio с nil-ом и значением — и посмотрите в лог разницу SQL. Пятое: соберите bulk-upsert каталога из трёх пользователей с конфликтом по email, UpdateNewValues и AddAge(1) в Update, прогоните дважды и посчитайте возраст анны: после второго прогона он должен вырасти ещё на единицу относительно вставки. Вопрос на подумать: почему валидаторы ent не проверят результат выражения age = age + 1, и в каких местах схемы это делает Add небезопасным?

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

### Чем UpdateOneID отличается от Get + UpdateOne?

UpdateOneID(id) сам знает ключ и уезжает одним UPDATE (с RETURNING или последующим SELECT) — одна операция, один поход в базу. Связка Get(ctx, id) + UpdateOne(u) — два запроса и окно для гонки: строка может исчезнуть между чтением и записью, и UpdateOne ответит NotFoundError на устаревшем объекте. UpdateOne удобен, когда id добывается из самой сущности цикла; для обработчиков по параметру маршрута берите UpdateOneID.

### Как после массового обновления получить обновлённые сущности?

Отдельным Query с тем же предикатом: mass-update строки не читал, поэтому его честный результат — число. ent не вернёт срез «затронутых» — RETURNING у массовой формы не используется в принципе. Если сущности уже лежат в памяти, помните, что они устарели в момент Save: перечитайте их тем же Where из десятого урока, при необходимости — с With-цепочкой из одиннадцатого.

### upsert или «SELECT, потом INSERT или UPDATE» — что выбирать?

Для сценариев «нет — вставь, есть — обнови» upsert из восьмого урока дешевле и безопаснее: один стейтмент, атомарность на стороне базы, нет окна между проверкой и записью. Связка SELECT + ветвление жива там, где решение о вставке зависит от внешней логики — отправки письма, проверки прав, — либо где диалект не поддерживает нужную форму ON CONFLICT. Для массовой синхронизации каталога выбора почти нет: CreateBulk с OnConflict — единственная форма без N запросов.
