Запись данных
Insert, update, delete и COPY — всё явно.
Insert#
user, err := db.Users.Insert(ctx, User{
Email: "a@example.com",
Active: true,
})
// user.ID заполнен из RETURNINGМного сразу:
users, err := db.Users.InsertMany(ctx, []User{u1, u2, u3})Значения по умолчанию#
Нулевое значение Go — это значение. Запрос значения по умолчанию отдельный и явный:
db.Users.Insert(ctx, User{}, orm.Default(Users.Active, Users.CreatedAt))Названные колонки полностью исключаются из INSERT, поэтому применяется DEFAULT колонки — или её последовательность, или NULL для nullable-колонки без умолчания.
Update#
n, err := db.Users.Update().
Set(Users.Active.Set(false)).
Where(Users.CreatedAt.Lt(cutoff)).
Exec(ctx)Обновление без WHERE отвергается:
_, err := db.Users.Update().Set(Users.Active.Set(false)).Exec(ctx)
// errors.Is(err, orm.ErrMissingWhere)Если только вы не скажете, что имелись в виду все строки:
db.Users.Update().Set(Users.Active.Set(false)).All().Exec(ctx)Присвоение выражения вместо значения:
db.Orders.Update().
Set(Orders.Total.SetExpr(Orders.Net.AddCol(Orders.Tax))).
Where(Orders.ID.Eq(id))Delete#
n, err := db.Users.Delete().Where(Users.ID.Eq(id)).Exec(ctx)То же правило ErrMissingWhere и по той же причине.
Upsert#
db.Users.Insert(ctx, user,
orm.OnConflict(Users.Email).DoUpdateSet(
Users.Active.Set(true),
),
)
db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoNothing())Цель конфликта — список колонок, по которому PostgreSQL его определяет; DO UPDATE видит конфликтную строку и EXCLUDED.
Upsert подробно#
OnConflict называет колонки, по которым PostgreSQL определяет конфликт, —
обычно это колонки уникального ограничения.
// Ничего не делать, если запись уже есть.
db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoNothing())
// Взять значения пришедшей строки для названных колонок.
db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoUpdate(Users.Name, Users.Seen))
// Или задать их самому.
db.Users.Insert(ctx, user, orm.OnConflict(Users.Email).DoUpdateSet(
Users.Seen.Set(time.Now()),
Users.Hits.SetExpr(Users.Hits.Add(1)),
))DoUpdate — частый случай, читается как «эти колонки берут новые значения».
DoUpdateSet — когда новое значение вычисляется: инкремент счётчика, выбор
большего из двух чисел, добавление в массив.
Частичному индексу нужен тот же предикат в конструкции конфликта:
orm.OnConflict(Users.Email).Where(Users.Active.Eq(true)).DoNothing()RETURNING#
PostgreSQL умеет возвращать строки, которых коснулась запись. Библиотека использует это тремя способами, и разницу стоит знать: два из них происходят сами, а третий — нет.
Вставка возвращает всегда#
user, err := db.Users.Insert(ctx, User{Email: "a@example.com"})
// user.ID заполнен; как и CreatedAt, и всё, что заполнила базаПоэтому Insert возвращает (E, error), а не одну ошибку. Ключ identity,
DEFAULT now(), генерируемая колонка и правки триггера — всё приходит в
возвращённом значении, поэтому назад вы получаете строку в том виде, в каком она
существует, а не ту структуру, которую отправили.
InsertMany делает то же для среза, по порядку:
users, err := db.Users.InsertMany(ctx, []User{a, b, c})
// users[1].ID — ключ bСписок колонок всегда явный. RETURNING * определял бы порядок сканирования на
сервере, где сгенерированный сканер его не видит.
Upsert возвращает выжившую строку#
user, err := db.Users.Insert(ctx, incoming,
orm.OnConflict(Users.Email).DoUpdate(Users.Name, Users.Seen))Вставила она или обновила — назад приходит строка, которая теперь лежит в
таблице. Это обычная причина брать DoUpdate, а не DoNothing: при конфликте
DoNothing не возвращает ни одной строки, поэтому вы получаете нулевую
сущность и по ней не отличите «уже была» от «только что записана».
Обновление и удаление — только если попросить#
Exec возвращает счётчик:
n, err := db.Users.Update().
Set(Users.Active.Set(false)).
Where(Users.CreatedAt.Lt(cutoff)).
Exec(ctx)
// n — сколько строк изменилосьСчётчик отвечает на «сколько». Когда нужно «какие», оберните строитель:
updated, err := orm.UpdateReturningEntity(
db.Users.Update().Set(Users.Active.Set(false)).Where(Users.CreatedAt.Lt(cutoff)),
).All(ctx)
// []User — каждая совпавшая строка в том виде, в каком она после обновленияdeleted, err := orm.DeleteReturningEntity(
db.Users.Delete().Where(Users.ID.Eq(id)),
).One(ctx)
// строка в том виде, в каком она была прямо перед тем, как перестать существоватьОбратите внимание на время. Обновление возвращает новые значения, удаление — строку, которой уже нет. И то и другое — единственный шанс: после оператора одну уже не запросить, а другая больше не хранит старых значений.
Вернуть форму, а не сущность#
Когда нужны только две колонки изменившегося:
type Changed struct {
ID int64
Email string
}
var changed = orm.Project2(
Users.ID, Users.Email,
func(id int64, email string) Changed { return Changed{id, email} },
)
rows, err := orm.UpdateReturning(
db.Users.Update().Set(Users.Active.Set(false)).Where(cond),
changed,
).All(ctx)
// []ChangedDeleteReturning принимает проекцию так же.
Терминалы#
У Returning их три и больше никаких:
| Метод | Для чего |
|---|---|
All(ctx) | все строки, которых коснулась запись |
One(ctx) | ровно одна или ErrNotFound; больше одной — ошибка |
SQL() | оператор и аргументы, без выполнения |
Exec у Returning нет: оператор, у которого вы запросили строки и тут же их
выбросили, — это оператор, которому с самого начала был нужен Exec.
Это по-прежнему запись#
ErrMissingWhere действует ровно так же, как и без RETURNING: обёртка не
делает безусловное обновление безопасным:
_, err := orm.UpdateReturningEntity(
db.Users.Update().Set(Users.Active.Set(false)),
).All(ctx)
// errors.Is(err, orm.ErrMissingWhere)И это один оператор. Строки приходят из самой записи, а не из SELECT после
неё, — именно поэтому это те строки, которых коснулась запись, даже при
конкурентном доступе, а не те, что подходят сейчас.
COPY#
Для массовой загрузки COPY на порядок быстрее INSERT:
n, err := db.Events.CopyFrom(ctx, events)Потоком, чтобы строки не существовали все сразу:
n, err := db.Events.CopyFromSeq(ctx, func(yield func(Event, error) bool) {
for scanner.Scan() {
ev, err := parse(scanner.Text())
if !yield(ev, err) {
return
}
}
})Подмножество колонок:
n, err := orm.CopyColumns(ctx, db.Events, events, Events.ID, Events.Kind)Упавший COPY падает как один оператор — не применяется ничего. Если он должен быть успешным вместе с другой работой, оберните в транзакцию.
Разобранные примеры#
Импорт, который запускают дважды#
Идемпотентен по построению: второй запуск обновляет, а не дублирует.
for _, row := range parsed {
_, err := db.Products.Insert(ctx, row,
orm.OnConflict(Products.SKU).DoUpdate(
Products.Name, Products.PriceCents, Products.UpdatedAt))
if err != nil {
return err
}
}Для большого файла — один оператор на пачку вместо одного на строку:
for chunk := range slices.Chunk(parsed, 1000) {
if _, err := db.Products.InsertMany(ctx, chunk,
orm.OnConflict(Products.SKU).DoUpdate(Products.PriceCents)); err != nil {
return err
}
}Бронирование, которое нельзя продать дважды#
Запись и проверка — один оператор, поэтому между ними ничто не вклинится:
seat, err := db.Seats.Update().
Set(Seats.HeldBy.Set(customerID)).
Set(Seats.HeldUntil.Set(time.Now().Add(10*time.Minute))).
Where(Seats.ID.Eq(seatID)).
Where(Seats.HeldBy.IsNull()).
Exec(ctx)
if seat == 0 {
return ErrAlreadyHeld // успел кто-то другой
}HeldBy.IsNull() в WHERE и есть блокировка. У «прочитать, потом записать» был
бы зазор; здесь его нет.
Задача очистки#
Удалить и сохранить удалённое для журнала аудита:
gone, err := orm.DeleteReturningEntity(
db.Sessions.Delete().Where(Sessions.ExpiresAt.Lt(time.Now())),
).All(ctx)
for _, s := range gone {
recordAudit("session.expired", s.ID, s.UserID)
}Счётчик, который не читает сначала#
db.PageViews.Update().
Set(PageViews.Hits.SetExpr(PageViews.Hits.Add(1))).
Where(PageViews.Path.Eq(path)).
Exec(ctx)Прочитать строку, прибавить единицу в Go и записать обратно — значит терять инкременты при конкурентном доступе. Здесь это невозможно.