0 / 63 (0%)

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. Первый — одиночная вставка, где сервер сам заполняет три значения:

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 каждой:

sql
-- 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:

go
card, err := queries.IssueCard(ctx, db.IssueCardParams{CustomerID: 1, CardNo: "BREW-0001"})
// card.ID, card.Points, card.CreatedSet — всё из RETURNING

Запуск

Подними песочницу (из корня репозитория) и накати схему Brew + таблицу юнита:

sh
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.)

Вывод:

plaintext
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-пагинация»: научимся доставать ровно нужные строки в нужном порядке и листать меню постранично так, чтобы глубокие страницы не превращались в полное сканирование таблицы.

·Модуль 04

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
CRUD-беглость / INSERT и RETURNING