Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 5 из 25 ~18 мин +50 XP

Запросы INSERT и RETURNING

Вставка без ответа — полдела: sqlc insert умеет возвращать то, что база придумала сама, — сгенерированный id, серверное created_at, DEFAULT-значения. Разберём RETURNING с формой :one, покажем, почему SELECT max(id) после INSERT — это гонка, соберём массовую вставку через unnest и посчитаем вставленные строки через :execrows — всё на трекере задач из прошлых уроков.

Содержание урока · 11 разделов

CreateTodo из третьего урока вставлял задачи молча: строка уходила в базу, а метод возвращал только error. Для лога этого хватает, но первый же экран трекера — «задача №N создана» — требует N, а id придумывает база в момент вставки: до INSERT этого номера не существовало ни в Go, ни где-либо ещё. Тема урока — sqlc insert: вставляем строки и забираем назад то, что база сгенерировала сама. Сгенерированные идентификаторы и серверные дефолты приходят через RETURNING, пачки — через unnest с Go-срезами, число вставленных строк отдаёт :execrows, а NULL в необязательных колонках мы передадим честно, без магических значений. Конфиг не меняется: те же аннотации и тот же sqlc generate.

sqlc insert: вставка, которая молчит, и вставка с ответом#

Четыре формы возврата из таблицы третьего урока при вставке работают неравномерно. Немую вставку обслуживают две: sqlc exec — в аннотации :exec — когда ответ не нужен, и :execrows, когда важно число задетых строк. Как только в запросе появляется RETURNING, выбор расширяется: :one вернёт одну строку, :many — срез. CreateTodo из третьего урока был :exec, и для трекера это перестало устраивать: новая задача обязана отвечать своим номером.

Сгенерированный метод ляжет в тот же db/queries/todos.sql.go — правило «один файл запросов — один .sql.go» никуда не делось. Помнить надо другое: sqlc переписывает файл целиком при каждой генерации, поэтому старая сигнатура CreateTodo(ctx, title string) error исчезнет без предупреждений, и компилятор подсветит все места, где вызов пора обновить. Это не побочный эффект, а рабочая страховка: между запросами и кодом не остаётся ни одного расхождения — сломался компилятор, а не прод.

Формы вставки: от аннотации к сигнатуре#

ФормаСигнатура (сокращённо)Когда брать при вставке
:execerrorодиночная вставка, когда ответ не нужен
:one + RETURNING(Todo, error) — вставленная строканужен id или серверные дефолты (created_at)
:many + RETURNING([]Todo, error)пачка строк с возвратом каждой вставленной
:execrows(int64, error)массовая вставка: сколько строк реально легло
:execresult(sql.Result, error)LastInsertId на SQLite и MySQL; в PostgreSQL вместо него RETURNING
INSERT ... SELECT unnestпараметры-срезы: []string, []boolмассовая вставка из Go-срезов одной командой

CreateTodo переучивается: было :exec — стало :one

GO
-- db/queries/todos.sql

-- было (третий урок): вставка молчит
-- name: CreateTodo :exec
INSERT INTO todos (title) VALUES ($1);

-- стало: та же вставка отвечает строкой
-- name: CreateTodo :one
INSERT INTO todos (title, deadline)
VALUES ($1, $2)
RETURNING id, title, done, priority, created_at, deadline;

Форма :exec никуда не уходит — она правильный выбор для вставок, которым ответ не нужен. Но трекеру id нужен сразу, поэтому CreateTodo переучивается: дописываем RETURNING и меняем форму аннотации с :exec на :one. Колонки, которых нет в списке INSERT, получат DEFAULT, как priority, done и created_at из миграций первых уроков. Обратите внимание на VALUES ($1, $2): параметров стало два, и deadline занял своё место в списке колонок.

returning sqlc: как вернуть сгенерированный id#

RETURNING — часть самого INSERT-оператора: в конец запроса дописывается список колонок, и база возвращает те значения, которые получила именно эта вставка. id здесь не «последний в таблице», а конкретный номер, который sequence выдал вашей строке в момент INSERT; created_at — настоящее серверное время now(), а не часы приложения: серверы могут жить в разных часовых поясах, и база остаётся единственным источником правды. Сюда же попадают DEFAULT-значения: не указали priority — вернётся дефолт, проставленный базой, done станет false из миграции. Сокращение RETURNING * тоже работает — sqlc раскроет звёздочку, как и в SELECT из первого урока.

returning sqlc превращает вставку в чтение: набор колонок после RETURNING определяет и Go-тип. Перечислили подмножество полей Todo — метод вернёт Todo с заполненной частью полей; перечислили всё — структуру целиком. А добавив RETURNING к UPDATE или DELETE, вы получите назад изменённые и удалённые строки — этому посвящён шестой урок.

Сгенерированный CreateTodo: чтение сигнатуры

GO
// db/sqlc/todos.sql.go — фрагмент после sqlc generate
// Code generated by sqlc. DO NOT EDIT.

const createTodo = `-- name: CreateTodo :one
INSERT INTO todos (title, deadline)
VALUES ($1, $2)
RETURNING id, title, done, priority, created_at, deadline`

func (q *Queries) CreateTodo(ctx context.Context, title string, deadline *time.Time) (Todo, error) {
        row := q.db.QueryRow(ctx, createTodo, title, deadline)
        var i Todo
        err := row.Scan(&i.ID, &i.Title, &i.Done, &i.Priority, &i.CreatedAt, &i.Deadline)
        return i, err
}

Разбираем как в третьем уроке. Константа createTodo хранит текст запроса вместе с аннотацией. Сигнатура: title string по колонке title, deadline *time.Time — nullable-override из четвёртого урока; RETURNING перечисляет шесть колонок, и Scan раскладывает их по полям Todo ровно в порядке RETURNING. Возврат (Todo, error) — тот же, что у GetTodo: для генератора INSERT с RETURNING неотличим от SELECT, вернувшего одну строку. Одна деталь выдала смену конфига: внутри метода q.db.QueryRow без суффикса Context — после sql_package: "pgx/v5" интерфейс DBTX описан pgx-методами, у которых контекст всегда первый аргумент. В database/sql-варианте тот же метод звался QueryRowContext; логика Scan при этом не меняется.

Почему RETURNING лучше, чем SELECT max(id)#

Гуглите «как получить id после вставки» — и самые залежавшиеся советы будут одинаково плохи: вставьте строку, потом SELECT max(id). На пустой тестовой базе приём работает, и именно в этом его опасность. Разложим гонку по шагам: вы вставили задачу — база выдала ей id 42; пока ваш INSERT доехал до сервера, а SELECT ещё не отправился, второй экземпляр приложения вставил свою строку с id 43; ваш max(id) возвращает 43 — чужую задачу. Дальше по накатанной: CompleteTodo(43) закрывает не вашу задачу, ответ уходит не тому пользователю, и заметят это только люди.

При READ COMMITTED — дефолтном уровне изоляции PostgreSQL — чужие закоммиченные строки видны каждому новому SELECT, так что гонка не теоретическая: два параллельных клиента — уже два участника. RETURNING закрывает вопрос по определению: он выполняется внутри самого INSERT и возвращает значения строк, которые вставил именно этот оператор, атомарно и за одну поездку в базу вместо двух. Побочный бонус — производительность: на нагруженной вставке это вдвое меньше обращений к сети.

main.go: вставка с ответом

GO
package main

import (
        "context"
        "fmt"
        "log"
        "os"
        "time"

        db "tracker/db/sqlc"

        "github.com/jackc/pgx/v5"
)

func main() {
        ctx := context.Background()

        conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
        if err != nil {
                log.Fatal(err)
        }
        defer conn.Close()

        queries := db.New(conn)

        // срока нет: передаём nil — в колонке окажется NULL
        todo, err := queries.CreateTodo(ctx, "разобрать RETURNING", nil)
        if err != nil {
                log.Fatal(err)
        }
        // id и created_at вернул сам INSERT — до него их не существовало
        fmt.Printf("задача №%d создана %s
", todo.ID, todo.CreatedAt.Format("02.01 15:04"))

        // срок стоит: указатель на конкретное время
        soon := time.Now().Add(48 * time.Hour)
        if _, err := queries.CreateTodo(ctx, "сдать отчёт", &soon); err != nil {
                log.Fatal(err)
        }
}

main.go переехал на нативный pgx: pgx.Connect вместо sql.Open. Со sql_package: "pgx/v5" интерфейсу DBTX нужны pgx-методы, а не database/sql, и db.New принимает и одиночное соединение, и пул pgxpool.New одинаково. Дальше — сама вставка: id и created_at вернул INSERT, до него этих значений не существовало ни в Go, ни в базе. Второй вызов передаёт &soon — указатель на конкретное время, и RETURNING привозит его обратно в поле Deadline уже считанным. Print с Format — обычная работа со структурой Todo: снаружи не видно, что строка родилась секунду назад.

Проверка: RETURNING против max(id)

Почему после INSERT с RETURNING не пишут SELECT max(id)?

Предскажите сигнатуру CreateTodo

Прочитайте код и выберите, что он выведет:

-- схема: todos(id BIGSERIAL, title TEXT, done BOOLEAN,
--            priority INTEGER, created_at timestamptz NOT NULL DEFAULT now())

-- name: CreateTodo :one
INSERT INTO todos (title, priority) VALUES ($1, $2)
RETURNING id, title, done, priority, created_at;

Массовая вставка sqlc: unnest и Go-срезы#

Пачка задач через цикл с одиночными INSERT — это N поездок в базу и N отдельных операторов: на локальной базе незаметно, на удалённой — уже заметно на каждом чеке. PostgreSQL умеет вставлять целый срез одной командой через unnest: функция разворачивает массив в колонку строк, и запрос выглядит как INSERT ... SELECT * FROM unnest($1::text[], $2::boolean[]) — два массива становятся двумя колонками. Касты ::text[] и ::boolean[] обязательны: без них парсер не определит тип параметра и генерация упадёт с сообщением о неопределённом типе.

Параметры в Go — обычные срезы: pgx превращает их в массивы PostgreSQL нативно; с database/sql и lib/pq срезы пришлось бы заворачивать в pq.Array — ещё один довод в копилку pgx/v5 из четвёртого урока. Нюанс сигнатуры: пока у параметров типы разные ([]string и []bool), sqlc отдаёт плоские аргументы; сделайте массивы одного типа — и параметры свернутся в структуру вида BulkCreateTodosParams, имена полей sqlc возьмёт из колонок. Массовая вставка sqlc этим не ограничена: для сотен тысяч строк существует CopyFrom — он есть в интерфейсе DBTX, живёт в pgx и работает мимо sqlc, но быстрее всего на свете; unnest-пачки хватает на порядки меньших объёмов.

BulkCreateTodos: пачка одним вызовом

GO
-- db/queries/todos.sql
-- name: BulkCreateTodos :execrows
INSERT INTO todos (title, done)
SELECT * FROM unnest($1::text[], $2::boolean[]);

// сгенерировано рядом с CreateTodo в db/sqlc/todos.sql.go
func (q *Queries) BulkCreateTodos(ctx context.Context, titles []string, done []bool) (int64, error) {
        result, err := q.db.Exec(ctx, bulkCreateTodos, titles, done)
        if err != nil {
                return 0, fmt.Errorf("exec BulkCreateTodos: %w", err)
        }
        return result.RowsAffected()
}

// main.go: три задачи одной командой
titles := []string{"помыть кота", "дописать отчёт", "позвонить в сервис"}
done := []bool{false, false, false}
n, err := queries.BulkCreateTodos(ctx, titles, done)
if err != nil {
        log.Fatal(err)
}
fmt.Println("вставлено строк:", n) // 3

unnest разворачивает массивы в строки: два массива становятся двумя колонками результата, и INSERT ... SELECT вставляет их пачкой. Сгенерированная сигнатура плоская — []string и []bool: типы разные, сводить их в структуру причин нет. :execrows возвращает RowsAffected — сколько строк реально вставилось; добавьте в SELECT условие WHERE title <> '' — и число станет меньше длины среза, а ноль строк ошибкой не будет. Хотите пачку с ответом — допишите RETURNING id, title и смените форму на :many: метод вернёт []Todo по всем вставленным строкам сразу.

Проверка: несовпавшие длины срезов

BulkCreateTodos вызвали с titles из трёх строк и done из двух значений: INSERT ... SELECT * FROM unnest($1::text[], $2::boolean[]). Что произойдёт?

Восстановите массовую вставку

Без кастов парсер не определит типы параметров. Впишите типы двух массивов под колонки.

-- name: ImportTodos :execrows INSERT INTO todos (title, done) SELECT * FROM unnest($1::[], $2::[]);

Nullable-параметры INSERT: pgtype, указатели и nil#

Вставка — первое место, где типы четвёртого урока выходят из Scan и становятся параметрами. Nullable колонка deadline timestamptz в параметре CreateTodo живёт той же жизнью, что и в структуре: с database/sql это sql.NullTime, в чистом pgx/v5 — pgtype.Timestamptz, где NULL — нулевая структура с Valid: false, а после pointer-override — *time.Time, где NULL — nil. Для вставки указатели даже удобнее обёрток: «срока нет» выражается буквально nil. Паттерн тот же, что у SetNillable-мутаторов в ent: передали nil — колонка пуста, передали значение — записалось; выбор между Valid-флагом и указателем делает один и тот же override и для чтения, и для записи.

Главное — не изобретать магических значений. Нулевое time.Time{} — это вполне себе дата 1 января первого года, и колонка с ней не «пустая», а заполненная хламом, который потом придётся отфильтровывать. «Срока нет» в таблице со nullable-колонкой кодируется ровно одним способом — NULL, и RETURNING честно привезёт его обратно в поле-указатель.

Два состояния deadline: nil и указатель

GO
// параметр deadline — *time.Time после pointer-override из четвёртого урока

// срока нет: nil превратится в NULL ещё на уровне драйвера
t1, err := queries.CreateTodo(ctx, "разобрать RETURNING", nil)
if err != nil {
        log.Fatal(err)
}
fmt.Println("срок:", t1.Deadline) // <nil> — колонка пустая

// срок стоит: указатель на конкретное время
soon := time.Now().Add(48 * time.Hour)
t2, err := queries.CreateTodo(ctx, "сдать отчёт", &soon)
if err != nil {
        log.Fatal(err)
}
fmt.Println("срок:", t2.Deadline.Format("02.01.2006"))

Два вызова — два состояния колонки. nil уходит в базу NULL-ом: драйвер видит пустой указатель и передаёт NULL, ничего не выдумывая. &soon — значение по адресу: оно уедет в колонку, и RETURNING привезёт его обратно уже считанным. Печать через Format показывает дату в привычном виде, а t1.Deadline печатается как <nil> — поле-указатель без значения. Будь конфиг без override-ов, параметр вышел бы pgtype.Timestamptz, и «срока нет» выражалось бы нулевой структурой pgtype.Timestamptz{} — тот же NULL другими буквами.

Проверка: NULL через указатель

Параметр deadline после pointer-override из четвёртого урока вышел *time.Time. Как вставить задачу без срока?

Ошибки вставки: pgconn.PgError и код 23505#

У INSERT больше способов упасть, чем у SELECT: NULL в NOT NULL-колонке, нарушение уникальности, ссылка на отсутствующую строку соседней таблицы. И тут честная оговорка: sqlc в классификации ошибок не участвует. Он генерирует вызов и возвращает err как есть — вся разница между «дубль» и «авария» живёт в слое драйвера. В pgx ошибка базы — это структура *pgconn.PgError с полями Code, Message, ConstraintName; достаётся она из цепочки обёрток через errors.As стандартной библиотеки, причём и через database/sql-обёртку stdlib-драйвера из третьего урока тоже.

Код 23505 — unique_violation из официального каталога кодов PostgreSQL. Заучивать числа не обязательно: в пакете github.com/jackc/pgerrcode есть именованные константы на все коды, и pgerrcode.UniqueViolation читается лучше, чем строковый литерал. Поле ConstraintName подскажет, какой именно индекс воспротивился — полезно, когда ограничений в таблице несколько.

Ловим 23505: дубль под уникальным индексом

GO
// вставка с дублем title под уникальным индексом
_, err := queries.CreateTodo(ctx, "разобрать RETURNING", nil)

var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
        // 23505 = unique_violation из каталога кодов PostgreSQL
        fmt.Println("такая задача уже есть:", pgErr.ConstraintName)
        return
}
if err != nil {
        log.Fatal(err) // всё остальное — авария
}

Дубль приезжает не как абстрактная ошибка, а как *pgconn.PgError с кодом из каталога: 23505 — unique_violation. errors.As достаёт структуру из цепочки обёрток — неважно, пришла она через нативный pgx или через stdlib-обёртку database/sql. sqlc тут ни при чём: сгенерированный метод вернул err как есть, классификация живёт в драйвере. В трекере UNIQUE стоит только на id, но добавьте миграцией уникальный индекс на title — и этот код станет вашим будним днём.

Соберите путь запроса

От аннотации в todos.sql до вызова в main.go: расставьте строки по порядку — аннотация, оператор с RETURNING, вызов, печать ответа.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Что дальше по курсу#

Теперь у трекера вставка с ответом: методы возвращают строки, которые база создала сама, — с номером, серверным временем и дефолтами. Шестой урок — UPDATE и DELETE: условия, число затронутых строк и мягкое удаление руками; там :execrows встретится уже в своей основной роли. Весь план из 25 уроков — на странице курса.

Для контраста: в курсе GORM вставка устроена наоборот — вызов Create() сам дописывает сгенерированный id в поля структуры. В sqlc за этим стоит явный RETURNING, который вы пишете и читаете руками. Обе модели имеют право на жизнь, но sqlc хотя бы не прячет SQL, который выполняется в вашей базе.

Карточки: вставка с ответом

Термин

RETURNING

нажмите, чтобы перевернуть
Определение

Часть INSERT (а также UPDATE и DELETE) в PostgreSQL: возвращает значения строк, которые вставил именно этот оператор, — включая сгенерированные id и DEFAULT-значения. Один атомарный оператор, одна поездка в базу.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • INSERT без RETURNING обслуживают :exec (ответ не нужен) и :execrows (важно число строк); с RETURNING — :one на одну строку или :many на срез.
  • RETURNING возвращает значения, которые получил именно этот INSERT: сгенерированный id, серверное created_at, DEFAULT-значения.
  • SELECT max(id) после вставки — гонка: между операторами уедет чужой INSERT, и max(id) вернёт не ваш id.
  • Массовая вставка sqlc — INSERT ... SELECT * FROM unnest($1::text[], $2::boolean[]): Go-срезы приедут массивами, pgx делает это нативно.
  • :execrows после пачки скажет, сколько строк реально вставилось; несовпавшие длины срезов unnest дополняет NULL — NOT NULL-колонка уронит вставку.
  • Nullable-параметры вставки повторяют типы четвёртого урока: pgtype с Valid, sql.Null* или *time.Time, где nil — это NULL.
  • Ошибки вставки классифицирует драйвер: *pgconn.PgError и код 23505 через errors.As; sqlc тут агностик.

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

Переучите вставку трекера под себя: допишите в CreateTodo RETURNING cost и убедитесь после генерации, что Cost вернулся типом decimal.Decimal — column-override из четвёртого урока действует и на чтение, и на параметры. Затем напишите ImportTodos :execrows с unnest($1::text[], $2::int[]) под приоритеты и прогоните его на срезах разной длины: объясните себе, почему длинный titles и короткий priorities роняют вставку целиком, а не вставляют часть строк. В финале повесьте миграцией уникальный индекс на title и поймайте в main.go код 23505 через pgconn.PgError.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Запросы INSERT и RETURNING)

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

Работает ли RETURNING где-нибудь ещё, кроме PostgreSQL?

Да: SQLite поддерживает RETURNING с версии 3.35, и тот же приём живёт в sqlc-конфиге с engine: "sqlite". В MySQL RETURNING нет — там выручает LastInsertId через форму :execresult. Парсер выбранного движка проверит запрос на генерации: неподдерживаемый RETURNING уронит sqlc generate сразу, а не рантайм.

Что лучше для пачки: unnest или цикл с одиночными INSERT?

unnest: один оператор и одна поездка в базу против N, плюс атомарность — пачка вставляется целиком или не вставляется вовсе. Цикл оправдан, только когда между вставками нужна логика приложения. Объёмы в сотни тысяч строк — территория CopyFrom из pgx: он работает мимо sqlc и быстрее всего.

Как получить id вставленной строки без RETURNING?

На SQLite и MySQL — через :execresult и LastInsertId. Для PostgreSQL LastInsertId драйверы фактически не поддерживают, и именно поэтому RETURNING там не украшение, а основной механизм: значения возвращает сам INSERT, атомарно и без второй поездки в базу.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

ent25 уроков~13 чпрактика25 уроков уже доступны

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила