RETURNING old/new
Заказ Brew меняет статус: created → paid → shipped. Для аудита и для уведомлений приложению нужно знать не только новое значение, но и прежнее: «было created, стало paid». Классически за этим лезут двумя путями — либо SELECT статуса до UPDATE (лишний запрос плюс гонка: между чтением и записью статус мог измениться), либо триггер, который в отдельной таблице пишет OLD/NEW (мощно, но это уже логика на стороне БД — см. модуль 09).
PG18 добавил третий, прямой путь: в RETURNING теперь можно ссылаться на строку до изменения и после — через префиксы old. и new.. UPDATE ... RETURNING old.status, new.status возвращает оба значения одной командой, в той же транзакции, без второго запроса и без триггера.
old и new в RETURNING
До PG18 RETURNING отдавал строку в её итоговом виде (после изменения). Теперь у каждой колонки есть две «версии»:
new.col— значение после команды (дляUPDATE/INSERT);old.col— значение до команды (дляUPDATE/DELETE).
Без префикса (просто status) сохраняется старое поведение: для UPDATE/INSERT это new, для DELETE — old. Можно возвращать и выражения над обеими версиями: (old.paid_at IS NULL), new.amount - old.amount и т.п.
Симметрия INSERT / UPDATE / DELETE
Префиксы раскрывают красивую симметрию трёх команд через «есть ли строка до и после»:
INSERT— строки «до» нет, поэтомуold.*всюдуNULL;new.*— то, что вставили.UPDATE— существуют обе версии:old.*(до) иnew.*(после).DELETE— строки «после» нет, поэтомуnew.*всюдуNULL;old.*— то, что удалили.
То есть old/new — это единый язык для «что было и что стало» поверх любой модифицирующей команды.
Матрица old/new: симметрия трёх команд
У каждой строки две «версии» — до команды (old) и после (new). Какая из них существует, решает сама команда:
old.* (до) new.* (после)
┌───────────────┬───────────────┐
INSERT │ ∅ нет │ вставленная │ строки «до» не было
├───────────────┼───────────────┤
UPDATE │ прежняя │ изменённая │ существуют обе
├───────────────┼───────────────┤
DELETE │ удалённая │ ∅ нет │ строки «после» не осталось
└───────────────┴───────────────┘
▲ ▲
без префикса DELETE → old без префикса INSERT/UPDATE → new| Команда | old.* (до) | new.* (после) | Колонка без префикса |
|---|---|---|---|
INSERT | NULL (строки «до» нет) | вставленная строка | = new |
UPDATE | прежняя версия | изменённая версия | = new |
DELETE | удалённая строка | NULL (строки «после» нет) | = old |
Почему этот юнит — без sqlc
Остальные CRUD-юниты модуля написаны через sqlc, но здесь sqlc мешает: его парсер (версия v1.30.0) ещё не знает PG18-синтаксис old./new. и падает с ошибкой column "status" does not exist. А урок именно про эту фичу. Поэтому юнит — escape-hatch: пишем запросы строкой и сканируем результат руками через pgx (как в 00-04/00-06), без query.sql и сгенерированного internal/db. Когда уроку нужна возможность БД, которую инструмент ещё не поддерживает, мы выбираем возможность, а не инструмент.
Что показывает наш код
В main.go три модифицирующие команды, каждая с RETURNING old/new. UPDATE (обе версии существуют):
pool.QueryRow(ctx, `
UPDATE order_status_lab SET status = 'paid', paid_at = now()
WHERE id = 1
RETURNING old.status, new.status,
(old.paid_at IS NULL) AS was_unpaid,
(new.paid_at IS NOT NULL) AS now_paid`,
).Scan(&oldStatus, &newStatus, &wasUnpaid, &nowPaid)И INSERT / DELETE, где одна сторона пуста:
INSERT INTO order_status_lab (id, status) VALUES (4, 'created')
RETURNING old.status, new.status; -- old.status → NULL (строки «до» нет)
DELETE FROM order_status_lab WHERE id = 2
RETURNING old.status, new.status; -- new.status → NULL (строки «после» нет)«Пустую» сторону (NULL) в выводе мы печатаем как ∅. Демо работает на лабораторном столе order_status_lab, который пересоздаётся в начале (CREATE IF NOT EXISTS + TRUNCATE + три заказа) — вывод детерминирован.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=03-crud-fluency/03-05-returning-old-new T=db-reset
make lecture L=03-crud-fluency/03-05-returning-old-new(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) Стол order_status_lab засеян: заказы #1, #2, #3 в статусе 'created'.
2) UPDATE #1: created → paid (RETURNING old/new одним запросом):
old.status=created new.status=paid было неоплачено=true стало оплачено=true
3) INSERT #4 'created' (RETURNING old/new):
old.status=∅ new.status=created → на INSERT строки «до» нет, old.* пуст
4) DELETE #2 (RETURNING old/new):
old.status=created new.status=∅ → на DELETE строки «после» нет, new.* пустUPDATE отдал и старый статус (created), и новый (paid), и два предиката над обеими версиями (was_unpaid, now_paid) — всё одним запросом. INSERT показал пустой old.* (строки до не было), DELETE — пустой new.* (строки после не осталось). Та самая симметрия «до/после».
Заборчик
RETURNING old/new отдаёт «до и после» только для тех строк, которые эта команда трогает, и только в её транзакции — это не журнал изменений. Отсюда границы:
- Это не замена аудиту. Когда историю надо хранить независимо от того, какой код сделал запись (и для
UPDATEиз любого места, и для прямых правок вpsql), берут триггерAFTERсOLD/NEW, пишущий в audit-таблицу — это модуль 09-05, там же обсудим, когда логику стоит держать в БД, а когда нет. old/new— фича именно PG18. На более старых версиях её нет: «до-значение» добываютSELECT-ом доUPDATE(с риском гонки) или триггером.- sqlc (v1.30.0) её пока не понимает — поэтому в проде с sqlc такой запрос придётся либо писать «сырым» через
pgx(как здесь), либо ждать поддержки в инструменте.
Что забрать с собой
- PG18: в
RETURNINGможно ссылаться на строку до изменения (old.col) и после (new.col) — оба значения одной командой. UPDATEдаёт обе версии; наINSERTпустold.*(строки «до» нет), наDELETEпустnew.*(строки «после» нет).- Без префикса колонка ведёт себя как раньше:
newдляUPDATE/INSERT,oldдляDELETE. - Это убирает «
SELECTдоUPDATE» (лишний запрос + гонка) для аудита перехода в рамках одной команды. - Это не замена полноценному аудиту (триггер + history-таблица, → 09-05) и пока не поддержано sqlc — отсюда raw-pgx в этом юните.
Дальше — юнит 03-06 «трезвая семантика NULL»: расплата за тизер из 01-02 — ловушка NOT IN с NULL, которая молча возвращает «ничего», и инструменты COALESCE/NULLIF/IS DISTINCT FROM, которыми с NULL работают безопасно.