INSERT и RETURNING
Brew запускает программу лояльности. Приложение выдаёт новому клиенту карту, и сразу после вставки ему нужен id этой карты — чтобы показать его клиенту, привязать к нему бонусы, записать в лог. Наивный код делает два запроса: сначала INSERT, потом SELECT ... WHERE card_no = ..., чтобы узнать сгенерированный id. Это лишний round-trip к базе, лишний шанс на гонку (между вставкой и чтением кто-то мог изменить строку) и просто больше кода.
Цель юнита — закрыть это одним запросом. У INSERT (как и у UPDATE/DELETE) есть RETURNING: он возвращает строки, которые команда только что записала, включая значения, проставленные сервером — сгенерированный id, колонки по DEFAULT. Никакого второго SELECT.
RETURNING отдаёт то, что присвоил сервер
Когда вставляешь строку, часть значений приходит не от тебя: id раздаёт GENERATED ALWAYS AS IDENTITY, points и created_at подставляет DEFAULT. Чтобы узнать их, классически делают отдельный SELECT — но он видит уже другую (возможно, изменённую) строку и стоит ещё одного обращения к серверу.
RETURNING решает это в корне: он отдаёт значения ровно тех строк, которые команда записала, в том же запросе и в той же транзакции. INSERT ... RETURNING id — это «вставь и сразу скажи, какой id ты дал». Можно вернуть любые колонки записанной строки, в том числе вычисленные выражения над ней.
RETURNING работает и для многих строк
RETURNING — не «трюк для одной строки». Команда, которая пишет несколько строк (многострочный INSERT ... VALUES (...), (...), INSERT ... SELECT, массовый UPDATE), вернёт по строке результата на каждую записанную. В sqlc такой запрос помечается :many и приезжает в Go срезом — по элементу на вставленную карту.
Что показывает наш код
Два запроса в query.sql. Первый — одиночная вставка, где сервер сам заполняет три значения:
-- name: IssueCard :one
INSERT INTO loyalty_cards (customer_id, card_no)
VALUES ($1, $2)
RETURNING id, points, (created_at IS NOT NULL)::boolean AS created_set;id мы не передаём — его присваивает IDENTITY; points и created_at не передаём — их подставляет DEFAULT. RETURNING возвращает их сразу. Значение created_at (это now()) недетерминированно, поэтому в демо мы печатаем не само время, а факт «колонка заполнена» (created_set) — чтобы вывод воспроизводился дословно. Второй запрос вставляет сразу две карты и возвращает id каждой:
-- name: IssueCardsBulk :many
INSERT INTO loyalty_cards (customer_id, card_no)
VALUES (sqlc.arg(cust_a), sqlc.arg(card_a)),
(sqlc.arg(cust_b), sqlc.arg(card_b))
RETURNING id, card_no;В main.go всё тонко: вызвать сгенерированный метод и распечатать то, что вернул RETURNING. Ни одного отдельного SELECT за id:
card, err := queries.IssueCard(ctx, db.IssueCardParams{CustomerID: 1, CardNo: "BREW-0001"})
// card.ID, card.Points, card.CreatedSet — всё из RETURNINGЗапуск
Подними песочницу (из корня репозитория) и накати схему Brew + таблицу юнита:
docker compose up -d
make lecture L=03-crud-fluency/03-01-insert-and-returning T=db-reset
make lecture L=03-crud-fluency/03-01-insert-and-returning(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) INSERT ... RETURNING — серверные значения обратно одним запросом:
выдали карту: id=1, points=0 (по DEFAULT), created_at заполнен=true
→ id и points не передавали — их вернул RETURNING, без второго SELECT.
2) Многострочный INSERT ... RETURNING — то же и для многих строк:
ID CARD_NO
2 BREW-0002
3 BREW-0003
→ одна команда вставила обе карты; RETURNING вернул id каждой.Карта id=1 пришла обратно сразу со сгенерированным id, points=0 (значение DEFAULT) и подтверждением, что created_at заполнен. Многострочная вставка вернула id обеих карт — 2 и 3. Ни одного дополнительного SELECT.
Заборчик
Что мы упростили — четыре производственные заботы вокруг RETURNING:
- Не тащи
RETURNING *, если нужен толькоid.RETURNINGотдаёт столько колонок, сколько попросил, — лишние гоняются по сети зря. - Переменное число строк — через
unnest, не многострочныйVALUES. АрностьVALUES (...), (...)фиксирована; чтобы вставить Go-срез произвольной длины одной командой, берутINSERT ... SELECT ... FROM unnest($1::bigint[])— разворот массива в строки. Здесь обе карты заданы явно, чтобы вывод был воспроизводим. - Массовая загрузка — это
COPY, а неINSERT. На десятках тысяч строк и большеINSERTлюбой формы проигрывает протоколуCOPY(в pgx —CopyFrom); разберём его в 09-01. RETURNING— не аудит. Он отдаёт строки той же команды в той же транзакции; когда историю надо хранить независимо от того, какой код сделал запись, нужен триггер с отдельной таблицей истории (вернёмся к этому в 03-05 и модуле 09).
Что забрать с собой
INSERT ... RETURNINGотдаёт значения только что записанных строк — сгенерированныйid, колонки поDEFAULT— в одном запросе, без второгоSELECT.- Это убирает лишний round-trip и класс гонок «вставил → прочитал не ту строку».
RETURNINGработает и для многих строк: команда, записавшая N строк, вернёт N строк результата (в sqlc —:many, в Go — срез).- Возвращай только нужные колонки;
RETURNING *тащит всю строку по сети. RETURNINGесть и уUPDATE/DELETE— это сквозная идиома всего модуля.
Дальше — юнит 03-02 «SELECT: WHERE / ORDER / LIMIT и keyset-пагинация»: научимся доставать ровно нужные строки в нужном порядке и листать меню постранично так, чтобы глубокие страницы не превращались в полное сканирование таблицы.