Содержание урока · 11 разделов
- sqlc insert: вставка, которая молчит, и вставка с ответом
- Формы вставки: от аннотации к сигнатуре
- returning sqlc: как вернуть сгенерированный id
- Почему RETURNING лучше, чем SELECT max(id)
- Массовая вставка sqlc: unnest и Go-срезы
- Nullable-параметры INSERT: pgtype, указатели и nil
- Ошибки вставки: pgconn.PgError и код 23505
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 исчезнет без предупреждений, и компилятор подсветит все места, где вызов пора обновить. Это не побочный эффект, а рабочая страховка: между запросами и кодом не остаётся ни одного расхождения — сломался компилятор, а не прод.
Формы вставки: от аннотации к сигнатуре#
| Форма | Сигнатура (сокращённо) | Когда брать при вставке |
|---|---|---|
| :exec | error | одиночная вставка, когда ответ не нужен |
| :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 и возвращает значения строк, которые вставил именно этот оператор, атомарно и за одну поездку в базу вместо двух. Побочный бонус — производительность: на нагруженной вставке это вдвое меньше обращений к сети.
Пара INSERT → SELECT max(id) падает не на тестах, а на двух клиентах одновременно: чужой INSERT уезжает между вашими операторами, и max(id) возвращает чужую строку. Разница с RETURNING не в скорости, а в корректности: RETURNING привозит значения строк, вставленных именно этим оператором. И не рассчитывайте поймать гонку в тестах — на одиночном стенде она не воспроизводится почти никогда.
main.go: вставка с ответом
GOpackage 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) // 3unnest разворачивает массивы в строки: два массива становятся двумя колонками результата, и 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[]). Что произойдёт?
Восстановите массовую вставку
Без кастов парсер не определит типы параметров. Впишите типы двух массивов под колонки.
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, вызов, печать ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
SQLite умеет RETURNING с версии 3.35 — тот же приём живёт в конфиге с engine: "sqlite". В MySQL RETURNING нет, там штатный путь — LastInsertId через форму :execresult. Парсер выбранного движка проверит запрос при генерации: неподдерживаемый RETURNING уронит sqlc generate сразу, а не прод.
Что дальше по курсу#
Теперь у трекера вставка с ответом: методы возвращают строки, которые база создала сама, — с номером, серверным временем и дефолтами. Шестой урок — UPDATE и DELETE: условия, число затронутых строк и мягкое удаление руками; там :execrows встретится уже в своей основной роли. Весь план из 25 уроков — на странице курса.
Для контраста: в курсе GORM вставка устроена наоборот — вызов Create() сам дописывает сгенерированный id в поля структуры. В sqlc за этим стоит явный RETURNING, который вы пишете и читаете руками. Обе модели имеют право на жизнь, но sqlc хотя бы не прячет SQL, который выполняется в вашей базе.
Карточки: вставка с ответом
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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.
Личные заметкиПусто
Вопросы и ответы по уроку
Работает ли 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, атомарно и без второй поездки в базу.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
ent: графовая ORM с кодогенерацией
Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.
- Проектировать схемы и связи в ent
- Генерировать код и жить с кодогенерацией
- Писать запросы builder-стилем с типами
- Строить хуки, интерсепторы и privacy-правила