Go: SQL

Теория: Вставка, обновление и удаление через sqlc

Операции записи в sqlc строятся вокруг простой идеи. Когда достаточно просто выполнить команду, используется контракт :exec или :execrows. Когда нужно вернуть сущность или её часть, в запрос добавляется RETURNING, а в директиве ставится :one или :many. Такой подход сохраняет типобезопасность, убирает ручной Scan() и позволяет базе выполнить всё за один поход.

Запросы с возвращаемыми значениями (RETURNING)

Начать удобнее с вставки. В файле query/users.sql описывается обычный INSERT, который сразу возвращает созданную строку. Комментарий -- name: задаёт имя будущей функции, а суффикс :one говорит генератору, что придёт ровно одна запись.

-- name: CreateUser :one
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id, email, name, created_at;

После sqlc generate появятся типы User и CreateUserParams, а также метод CreateUser(ctx, params) (User, error). Приложение вызывает его как обычную функцию и получает заполненную структуру без ручного Scan().

u, err := q.CreateUser(ctx, db.CreateUserParams{
	Email: "alex@example.com",
	Name:  sql.NullString{String: "Алексей", Valid: true},
})
if err != nil {
	return err
}
log.Println("created:", u.ID, u.CreatedAt)

Обновление оформляется тем же образом. Если важен только факт выполнения, запрос помечают как :exec, и метод вернёт только error. Если нужно знать, сколько строк затронуто, используют :execrows, и функция вернёт счётчик вместе с ошибкой.

-- name: UpdateUserName :exec
UPDATE users
SET name = $2
WHERE id = $1;

-- name: DeleteUser :execrows
DELETE FROM users
WHERE id = $1;

В Go-коде эта разница отражается в сигнатуре.

if err := q.UpdateUserName(ctx, db.UpdateUserNameParams{
	ID:   id,
	Name: sql.NullString{String: "Иван", Valid: true},
}); err != nil {
	return err
}

n, err := q.DeleteUser(ctx, id)
if err != nil {
	return err
}
log.Println("deleted rows:", n)

RETURNING особенно удобно в PostgreSQL и современных SQLite. Запись создаётся и тут же считывает себя обратно в рамках одного запроса и одной транзакции. Такой подход экономит время, исключает гонки и упрощает код в слое сервиса. В запрос можно вернуть как всю строку, так и отдельное поле, например только идентификатор.

-- name: InsertProductReturningID :one
INSERT INTO products (name, price)
VALUES ($1, $2)
RETURNING id;

Сигнатура функции станет InsertProductReturningID(ctx, params) (int32, error) или int64, error — в зависимости от типа id в схеме. Это фиксируется при генерации, и никакой двусмысленности в рантайме не остаётся.

Upsert с ON CONFLICT вписывается в ту же схему. Запрос остаётся один, контракт по-прежнему :one, а приложение по возвращаемому значению понимает, что именно произошло.

-- name: UpsertUserByEmail :one
INSERT INTO users (email, name)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE
SET name = excluded.name
RETURNING id, email, name, created_at;

В результате появляется единый метод, который обрабатывает и вставку, и обновление, а бизнес-логика не размазывается по двум отдельным веткам.

Иногда после обновления удобно сразу получить новую версию строки. В PostgreSQL это снова решается RETURNING, и sqlc расценивает такой запрос как :one.

-- name: UpdatePrice :one
UPDATE products
SET price = $2
WHERE id = $1
RETURNING id, name, price, created_at;

В этом случае сервер формирует окончательную форму данных, а приложение получает консистентный снимок без дополнительного SELECT.

Удаление тоже можно делать «с возвратом», если нужно вывести пользователю удалённый объект, записать его в лог или передать в аудит. Приём такой же: запрос с DELETE, RETURNING и контрактом :one или :many, если удаляется пакет строк.

-- name: DeleteAndReturn :one
DELETE FROM products
WHERE id = $1
RETURNING id, name, price, created_at;

Массовые операции лучше объединять в транзакцию и использовать методы sqlc поверх qtx := db.New(tx). Такой подход снижает накладные расходы, упорядочивает логику и даёт атомарность на уровне нескольких запросов.

tx, err := conn.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

qtx := db.New(tx)

for _, u := range batch {
	if err := qtx.UpdatePrice(ctx, db.UpdatePriceParams{
		ID:    u.ID,
		Price: u.Price,
	}); err != nil {
		return err
	}
}

return tx.Commit()

В проектах на MySQL идентификатор после INSERT традиционно получают через Result.LastInsertId(). В экосистеме sqlc это обычно решается либо отдельным SELECT last_insert_id() в запросе, либо повторным чтением строки по естественному ключу. В PostgreSQL проще оставаться на RETURNING: оно выразительнее, безопаснее и отлично типизируется генератором.

Главный принцип не меняется. Когда достаточно факта изменения, выбирается :exec или :execrows. Когда нужна сущность или её проекция, запрос пишет RETURNING, а контракт ставит :one или :many. sqlc берёт схему, выводит точные типы, создаёт параметры и убирает ручной Scan(), чтобы код на Go занимался логикой, а не маппингом полей.

Обработка ошибок и проверка результатов

Ошибки при работе через sqlc обрабатываются на уровне сервиса, где вызывается метод. Генератор возвращает ровно то, что описано контрактом: :one — структуру и ошибку, :many — срез и ошибку, :exec — только ошибку, :execrows — число изменённых строк и ошибку. Дальше ответственность на доменном слое: отличить «ничего не нашлось» от «упала база», конфликт уникальности от ошибки валидации, таймаут от логической ошибки.

Когда запрос ожидает одну строку, а данных нет, sqlc вернёт sql.ErrNoRows. Это не авария, а нормальный сценарий, который удобно переводить в свою ошибку уровня домена, например ErrNotFound. Такой маппинг делает поведение сервиса предсказуемым для клиентов.

var ErrNotFound = errors.New("not found")

func (s *UserService) GetByEmail(ctx context.Context, email string) (db.User, error) {
	u, err := s.q.GetUserByEmail(ctx, email)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return db.User{}, ErrNotFound
		}
		return db.User{}, fmt.Errorf("get user by email: %w", err)
	}
	return u, nil
}

Команды без результата нужно подтверждать числом изменённых строк. Контракт :execrows как раз для этого. Ноль строк означает, что условие в WHERE не совпало ни с одной записью, и такую ситуацию полезно трактовать как отсутствие объекта, а не как успешное действие.

func (s *UserService) Delete(ctx context.Context, id int64) error {
	n, err := s.q.DeleteUser(ctx, id) // :execrows
	if err != nil {
		return fmt.Errorf("delete user: %w", err)
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

Вставки и апсерты часто упираются в ограничения базы. С драйвером pgx ошибки приходят как *pgconn.PgError, и по коду SQLSTATE можно отличить конфликт уникальности от нарушения внешнего ключа или CHECK. На этом уровне сервис формирует свои ошибки домена, а деталь СУБД вкладывает внутрь через %w.

var (
	ErrConflict   = errors.New("conflict")
	ErrBadRequest = errors.New("bad request")
)

func (s *UserService) Register(ctx context.Context, email string, name *string) (int64, error) {
	params := db.CreateUserParams{Email: email}
	if name != nil {
		params.Name = sql.NullString{String: *name, Valid: true}
	}

	u, err := s.q.CreateUser(ctx, params)
	if err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) {
			switch pgErr.Code {
			case pgerrcode.UniqueViolation:
				// нарушен уникальный индекс (например, email уже занят)
				return 0, fmt.Errorf("%w: email already exists", ErrConflict)
			case pgerrcode.ForeignKeyViolation:
				// ссылка на несуществующую запись
				return 0, fmt.Errorf("%w: relation missing", ErrBadRequest)
			case pgerrcode.CheckViolation:
				// нарушено условие CHECK
				return 0, fmt.Errorf("%w: violates check constraint", ErrBadRequest)
			}
		}

		// остальные ошибки БД
		return 0, fmt.Errorf("create user: %w", err)
	}

	return int64(u.ID), nil
}

Таймауты и отмены запросов нужно переводить в понятные ответы. Контекст всегда идёт первым аргументом в методах sqlc. Если он истёк, появляются context.DeadlineExceeded или context.Canceled. Это не сбой базы, а контролируемое завершение запроса, и для него полезно держать отдельную ошибку сервиса.

var ErrTimeout = errors.New("timeout")

func (s *UserService) List(ctx context.Context, page, perPage int32) ([]db.User, error) {
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	users, err := s.q.ListUsers(ctx, db.ListUsersParams{
		Limit:  perPage,
		Offset: (page - 1) * perPage,
	})
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
			return nil, ErrTimeout
		}
		return nil, fmt.Errorf("list users: %w", err)
	}
	if users == nil {
		users = []db.User{}
	}
	return users, nil
}

При работе с транзакцией нельзя продолжать после первой ошибки. Как только запрос внутри транзакции упал, СУБД переводит её в состояние «failed» и будет отвергать дальнейшие команды до отката. Безопасный шаблон остаётся тем же: defer tx.Rollback() сразу после BeginTx() и tx.Commit() в конце. Любая промежуточная ошибка приводит к раннему return, а откат в отложенном вызове гарантирован.

func (s *OrderService) Pay(ctx context.Context, id int64, cents int64) error {
	tx, err := s.conn.BeginTx(ctx, &sql.TxOptions{
		Isolation: sql.LevelReadCommitted,
	})
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	qtx := db.New(tx)

	if err := qtx.MarkOrderPaid(ctx, db.MarkOrderPaidParams{
		ID:          id,
		AmountCents: cents,
	}); err != nil {
		return fmt.Errorf("mark paid: %w", err)
	}

	if err := qtx.WriteLedgerEntry(ctx, db.WriteLedgerEntryParams{
		OrderID:    id,
		DeltaCents: cents,
	}); err != nil {
		return fmt.Errorf("ledger: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}

Проверка результата нужна и для запросов с RETURNING. Если условие не нашло ни одной строки, метод с контрактом :one вернёт sql.ErrNoRows. В коде это следует трактовать как отсутствие записи и аккуратно сообщить вызывающей стороне.

u, err := s.q.UpdateUserName(ctx, db.UpdateUserNameParams{
	ID:   id,
	Name: sql.NullString{String: "Новый", Valid: true},
})
if err != nil {
	if errors.Is(err, sql.ErrNoRows) {
		return ErrNotFound
	}
	return fmt.Errorf("update name: %w", err)
}

Логи должны сохранять исходную ошибку и контекст вызова, но наружу лучше выдавать свои стабильные коды. Такой разрыв между внутренней причиной и внешним ответом уменьшает связность с конкретной СУБД и упрощает миграции или смену драйвера. Для этого удобно держать слой преобразования ошибок и централизованный middleware, который превращает ErrNotFound, ErrConflict, ErrTimeout и другие доменные ошибки в подходящие HTTP-статусы или gRPC-коды.

В тестах поведение запросов стоит проверять через формальные признаки. Отсутствие данных — по sql.ErrNoRows или n == 0, а не по тексту сообщения драйвера. Конфликты — по коду SQLSTATE, если тесты гоняются против настоящей базы. Такой подход делает проверку устойчивой к локализации, версиям клиента и мелким изменениям формулировок внутрь драйвера.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845