Go: SQL
Теория: Вставка, обновление и удаление через sqlc
Операции записи в sqlc строятся вокруг простой идеи. Когда достаточно просто выполнить команду, используется контракт :exec или :execrows. Когда нужно вернуть сущность или её часть, в запрос добавляется RETURNING, а в директиве ставится :one или :many. Такой подход сохраняет типобезопасность, убирает ручной Scan() и позволяет базе выполнить всё за один поход.
Запросы с возвращаемыми значениями (RETURNING)
Начать удобнее с вставки. В файле query/users.sql описывается обычный INSERT, который сразу возвращает созданную строку. Комментарий -- name: задаёт имя будущей функции, а суффикс :one говорит генератору, что придёт ровно одна запись.
После sqlc generate появятся типы User и CreateUserParams, а также метод CreateUser(ctx, params) (User, error). Приложение вызывает его как обычную функцию и получает заполненную структуру без ручного Scan().
Обновление оформляется тем же образом. Если важен только факт выполнения, запрос помечают как :exec, и метод вернёт только error. Если нужно знать, сколько строк затронуто, используют :execrows, и функция вернёт счётчик вместе с ошибкой.
В Go-коде эта разница отражается в сигнатуре.
RETURNING особенно удобно в PostgreSQL и современных SQLite. Запись создаётся и тут же считывает себя обратно в рамках одного запроса и одной транзакции. Такой подход экономит время, исключает гонки и упрощает код в слое сервиса. В запрос можно вернуть как всю строку, так и отдельное поле, например только идентификатор.
Сигнатура функции станет InsertProductReturningID(ctx, params) (int32, error) или int64, error — в зависимости от типа id в схеме. Это фиксируется при генерации, и никакой двусмысленности в рантайме не остаётся.
Upsert с ON CONFLICT вписывается в ту же схему. Запрос остаётся один, контракт по-прежнему :one, а приложение по возвращаемому значению понимает, что именно произошло.
В результате появляется единый метод, который обрабатывает и вставку, и обновление, а бизнес-логика не размазывается по двум отдельным веткам.
Иногда после обновления удобно сразу получить новую версию строки. В PostgreSQL это снова решается RETURNING, и sqlc расценивает такой запрос как :one.
В этом случае сервер формирует окончательную форму данных, а приложение получает консистентный снимок без дополнительного SELECT.
Удаление тоже можно делать «с возвратом», если нужно вывести пользователю удалённый объект, записать его в лог или передать в аудит. Приём такой же: запрос с DELETE, RETURNING и контрактом :one или :many, если удаляется пакет строк.
Массовые операции лучше объединять в транзакцию и использовать методы sqlc поверх qtx := db.New(tx). Такой подход снижает накладные расходы, упорядочивает логику и даёт атомарность на уровне нескольких запросов.
В проектах на 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. Такой маппинг делает поведение сервиса предсказуемым для клиентов.
Команды без результата нужно подтверждать числом изменённых строк. Контракт :execrows как раз для этого. Ноль строк означает, что условие в WHERE не совпало ни с одной записью, и такую ситуацию полезно трактовать как отсутствие объекта, а не как успешное действие.
Вставки и апсерты часто упираются в ограничения базы. С драйвером pgx ошибки приходят как *pgconn.PgError, и по коду SQLSTATE можно отличить конфликт уникальности от нарушения внешнего ключа или CHECK. На этом уровне сервис формирует свои ошибки домена, а деталь СУБД вкладывает внутрь через %w.
Таймауты и отмены запросов нужно переводить в понятные ответы. Контекст всегда идёт первым аргументом в методах sqlc. Если он истёк, появляются context.DeadlineExceeded или context.Canceled. Это не сбой базы, а контролируемое завершение запроса, и для него полезно держать отдельную ошибку сервиса.
При работе с транзакцией нельзя продолжать после первой ошибки. Как только запрос внутри транзакции упал, СУБД переводит её в состояние «failed» и будет отвергать дальнейшие команды до отката. Безопасный шаблон остаётся тем же: defer tx.Rollback() сразу после BeginTx() и tx.Commit() в конце. Любая промежуточная ошибка приводит к раннему return, а откат в отложенном вызове гарантирован.
Проверка результата нужна и для запросов с RETURNING. Если условие не нашло ни одной строки, метод с контрактом :one вернёт sql.ErrNoRows. В коде это следует трактовать как отсутствие записи и аккуратно сообщить вызывающей стороне.
Логи должны сохранять исходную ошибку и контекст вызова, но наружу лучше выдавать свои стабильные коды. Такой разрыв между внутренней причиной и внешним ответом уменьшает связность с конкретной СУБД и упрощает миграции или смену драйвера. Для этого удобно держать слой преобразования ошибок и централизованный middleware, который превращает ErrNotFound, ErrConflict, ErrTimeout и другие доменные ошибки в подходящие HTTP-статусы или gRPC-коды.
В тестах поведение запросов стоит проверять через формальные признаки. Отсутствие данных — по sql.ErrNoRows или n == 0, а не по тексту сообщения драйвера. Конфликты — по коду SQLSTATE, если тесты гоняются против настоящей базы. Такой подход делает проверку устойчивой к локализации, версиям клиента и мелким изменениям формулировок внутрь драйвера.


