Меньше кода, больше результата: применяем sqlc для работы с БД

от автора

Привет, Хабр! Инструмент, который генерирует производительный и безопасный код для работы с базой данных — миф или реальность? В этой статье обсудим, что такое sqlc, откуда он появился и какие идеи в него заложены. Разберём его возможности и ограничения, а также кейсы, когда он подходит лучше всего.

Меня зовут Евгений Конечный, я Cluster Lead в Uzum Market, самом большом маркетплейсе в Узбекистане. В этой статье расскажу про инструмент для кодогенерации, который называется sqlc. Сразу оговорюсь, что инструкций по установке и настройке не будет, а ещё не будет холивара на тему «ORM vs чистые запросы». Каждый сам волен выбирать инструмент, который ему подходит.

Работа с SQL в Go

Работа с SQL в Go лежит в основе большинства серверных приложений, и подходы к ней сильно зависят от выбранных инструментов. На одной стороне спектра находятся чистые SQL‑запросы, которые требуют минимальных абстракций, но дают максимальный контроль. На другой стороне — query builder, а за горизонтом уже находятся ORM, предоставляющие высокоуровневые API для работы с базой данных, но часто в ущерб производительности и гибкости.

Давайте вспомним, как выглядит обычное исполнение SQL-запросов в Go:

func SelectAll(db *sql.DB) ([]Product, error) { rows, err := db.Query("SELECT id, name, price FROM products") if err != nil { return nil, err } defer rows.Close()  var products []Product for rows.Next() { var p Product if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil { return nil, err } products = append(products, p) }  if err := rows.Err(); err != nil { return nil, err }  return products, nil }

Такой код даёт полный контроль над SQL, но сопровождается рутинными задачами и типичными ошибками, которые могут значительно усложнить сопровождение приложения:

  • Забытые вызовы rows.Close() могут привести к исчерпанию пула соединений и, как следствие, к снижению производительности.

  • Подход с использованием SELECT * делает приложение хрупким: добавление новых колонок в таблицу может вызвать ошибки маппинга, такие как «expected 6 destination arguments in Scan, not 3».

  • Поскольку Go не проверяет соответствие типов на этапе компиляции, ошибки маппинга данных выявляются только при выполнении, что может привести к неожиданным сбоям в работе.

  • В проектах с большим количеством SQL‑запросов сложно поддерживать единообразие и чистоту кода, особенно если запросы и маппинг данных реализованы вручную.

  • Ручная сборка SQL‑запросов с учётом различных условий требует значительных усилий и повышает риск ошибок.

Для упрощения работы разработчики прибегают к помощи ORM, таких как GORM или Bun. Эти инструменты предлагают высокий уровень абстракции и «коробочные» решения для управления базой данных. Однако их использование часто сопровождается следующими недостатками:

  1. ORM создаёт дополнительный слой абстракции, что увеличивает накладные расходы при выполнении запросов.

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

  3. Несмотря на абстракции, ORM не всегда корректно работает с типами, и ошибки могут проявляться при выполнении.

  4. Go ориентирован на простоту, производительность и безопасность типов. ORM, напротив, часто требует изучения сложных API и оборачивает SQL в слои абстракций, что идёт вразрез с философией языка.

Вот тут на сцену выходит sqlc — инструмент, который сочетает лучшее из обоих миров: мощь чистого SQL с удобством кодогенерации.

Sqlc

Sqlc — это современный инструмент для автоматической генерации безопасного и производительного кода на Go на основе SQL‑запросов. Он обеспечивает высокую типобезопасность и помогает разработчикам сосредоточиться на бизнес‑логике, избавляя от рутинной работы с маппингом и проверками. Давайте посмотрим, как он работает:

Пример: есть SQL‑запросы в файле query.sql и схема базы данных в schema.sql. После выполнения команды sqlc generate генерируются три файла:

  • db.go — управляющая структура Queries для работы с базой данных;

  • models.go — модели данных, соответствующие структурам таблиц;

  • query.sql.go — методы, которые реализуют выполнение запросов.

Если вспомнить первоначальный запрос, то можно просто написать SelectAll из таблички products, sqlc сгенерирует DTO и метод, которые будем вызывать из кода. То есть из такого запроса:

-- name: SelectAll :many SELECT * FROM products;

мы получим такую DTO:

type Product struct {     ID      int64 Namestring Priceint64 }

и такую функцию, которая исполнит наш код:

func (q *Querier) SelectAll(ctx context.Context, id int) ([]Product, error) { rows, err := q.db.QueryContext(ctx, allProducts, id) if err != nil { return nil, err } defer rows.Close()  var items []Product for rows.Next() { var i Product if err := rows.Scan(&i.ID, &i.Name, &i.Price); err != nil { return nil, err } items = append(items, i) }  if err := rows.Close(); err != nil { return nil, err } if err := rows.Err(); err != nil { return nil, err }  return items, nil }

Так sqlc сэкономил нам 10 минут времени на написание одного запроса. А если представить, что запросов 30, то экономия становится гораздо больше. Но главное, мы серьёзно экономим время на обновлении всех запросов и написании новых.

Заодно sqlc делает код чуточку безопаснее. Разберём это на примере работы sqlc generate.

Что происходит во время sqlc generate

Чтение конфига sqlc.yaml 

Первым действием команда sqlc generate находит конфигурационный файл. Он может называться как угодно и быть в формате YAML или JSON, но в общем случае это sqlc.yaml, и выглядит он примерно так:

# Конфигурация для sqlc version: "2"  sql:   # Настройка для PostgreSQL   - engine: "postgresql"     # Путь к директории с SQL-запросами     queries: "queries"     # Путь к файлам миграций/схемы БД     schema: "../migrations"     gen:       go:         # Название пакета сгенерированного кода         package: "dbqueries"         # Выходная директория для сгенерированного кода         out: "../internal/storages/dbqueries"         # Переопределения типов         overrides:           - db_type: "vendor"             go_type:               type: "Vendor"

В этом примере используется вторая версия конфигурации. Здесь же указан движок, с которым мы будем работать. В моём случае это Postgres (почти все примеры кода, которые я буду показывать, — на Postgres). Укажем значение queries, где лежит папка с файлами с SQL, по которым и будем генерировать код. В schema лежит либо схема нашей БД, которую мы выгрузили из базы данных, либо все миграции, которые есть в сервисе. Далее укажем язык, для которого будем генерировать код, название пакета, путь к нему и набор переопределений (overrides) — благодаря этому можем заменить типы и какие‑то колонки при генерации.

Инициализация компилятора

Компилятор в sqlc отвечает за генерацию кода на основе схемы базы данных. Первым шагом он находит и объединяет все файлы миграций, вне зависимости от того, написаны ли они на чистом SQL или с использованием миграционных инструментов, таких как goose или golang‑migrate. Чтобы корректно воссоздать актуальное состояние базы, компилятор удаляет все rollback‑инструкции (migration down), оставляя только up‑модификации.

Далее происходит разбор SQL‑операторов с использованием популярных парсеров: pg_query_go для PostgreSQL, tidb/parser для MySQL и antlr4 для SQLite. Эти инструменты позволяют построить синтаксическое дерево схемы базы и проверить её целостность. Каждый SQL‑оператор анализируется на предмет корректности: например, нельзя модифицировать или удалить колонку, если она отсутствует в текущем состоянии схемы. Таким образом, sqlc создаёт в памяти полную картину структуры базы данных, гарантируя, что все изменения соответствуют её актуальному состоянию.

Парсинг запросов

Для парсинга SQL‑запросов и построения синтаксического дерева (AST) sqlc использует те же библиотеки, что и при анализе схемы базы данных. Разбор запроса проходит в несколько этапов: сначала извлекаются комментарии, параметры и запрашиваемые данные, затем выполняется валидация запроса и подготовка метаданных. На этом этапе проверяется наличие всех упомянутых таблиц, связей, колонок и корректность типов данных.

Используя синтаксические деревья, sqlc не только анализирует структуру запроса, но и проверяет его корректность относительно текущей схемы базы. Это позволяет гарантировать, что запрос не содержит ошибок, например, обращений к несуществующим полям или несовместимых типов. Такой подход помогает выявлять проблемы на этапе генерации кода, снижая вероятность ошибок во время выполнения программы.

Запросы выглядят обычно так:

-- name: <MethodName> <Command> SELECT * FROM table;

Запросы, подготовленные для sqlc, всегда начинаются с комментария, который называется query annotation. Первым пунктом идёт name — имя метода кодогенерации. Далее следует команда — какой способ используется для генерирования и какой результат мы ожидаем. Давайте разберем подробнее:

  • <MethodName> — определяет название метода, который будет сгенерирован. Это имя, по которому вы сможете вызывать запрос из кода.

  • <Command> — указывает sqlc, как интерпретировать этот запрос. Доступны следующие типы команд:

    • Команды для операций без возврата строк:

      • :exec — выполняет запрос без возвращения результата;

      • :execresult — выполняет запрос и возвращает результат операции;

      • :execrows — выполняет запрос и возвращает количество затронутых строк;

      • :execlastid — выполняет запрос и возвращает последний вставленный идентификатор.

    • Команды для выборки данных:

      • :many — генерирует метод, возвращающий несколько строк;

      • :one — генерирует метод, возвращающий одну строку.

    • Команды для пакетного выполнения (pgx):

      • :batchexec — выполняет пакет запросов без возврата результатов;

      • :batchmany — выполняет пакет запросов и возвращает несколько результатов;

      • :batchone — выполняет пакет запросов и возвращает один результат.

Кодогенерация

Затем генерируется код. В нём отсутствует кастомизация шаблонов. Единственный способ изменить кодогенерацию, кроме overrides, — использование плагинов, но мы не будем рассматривать этот случай.

Внутри sqlc поддерживается три СУБД: Postgres, MySQL и SQLite, а для Postgres — два драйвера:

  • lib/pq, который сейчас не поддерживается, а значит не рекомендован к использованию;

  • pgx, самый популярный драйвер.

Кроме этого, можно генерировать код для четырёх языков — Go, Python, Kotlin и TS. А через систему плагинов реализована поддержка для C#, F# и так далее.

Есть интеграция с популярными инструментами для миграции схем — Goose, Atlas, Golang‑Migrate, +3.Также поддерживается весь SQL. Можно использовать View, хранимые процедуры и прочие фичи СУБД, но, конечно, с осторожностью.

sqlc safe

А сейчас я покажу несколько примеров того, как sqlc делает код безопаснее.

Пример 1

Начнём с простого примера: допустим, мы пытаемся получить продукт, но случайно ошиблись в синтаксисе:

-- name: GetProduct :one SELECT * from products WHERE id=$1 AND;

Тогда при кодогенерации мы получим ошибку вида:

queries.sql:3:25: syntax error at or near ";"

Sqlc на этапе генерации сразу напишет, что синтаксис неправильный.

Пример 2

Если захотим извлечь поле description из таблицы products, которого нет в изначальной схеме, — тоже получим ошибку. Sqlc сообщит, что колонка отсутствует и её нужно добавить. На этом этапе наш код действительно становится безопасным, потому что мы не можем написать и скомпилировать некорректный запрос.

Допустим у нас есть такая схема для продуктов:

CREATE TABLE products (    id          serial  primary key,    price       float   not null );

При попытке извлечь из неё несуществующее поле:

-- name: SelectDescription :one SELECT description from products WHERE id=$1;

мы получим ошибку вида:

queries.sql:2:8: column "description" does not exist

Поэтому sqlc проверяет не только синтаксис SQL, но и возможность исполнять запросы.

Пример 3

Теперь давайте проверим реакцию sqlc на ошибки в миграциях. Они проверяются только на поднятие БД. 

Допустим у нас есть первая миграция 0001-init-migration.sql:

CREATE TABLE products (    id          serial  primary key,    price       float   not null );

Дальше создадим вторую миграцию 0002-second-migration.sql, которая будет изменять несуществующую таблицу:

ALTER TABLE categories ADD COLUMN brand TEXT NOT NULL;

Sqlc это заметит, и вот что мы получим при кодогенерации:

0002-second-migration.sql:1:1: relation "categories" does not exist

К сожалению, такие ошибки sqlc будет отлавливать только на up‑миграциях, поэтому от ошибках в rollback мы не будем застрахованы.

Пример 4

И последний пример, который был для меня особенно актуален при переезде с sqlx на sqlc. Допустим у нас в базе данных уже есть такая таблица:

CREATE TABLE products (    id          serial  primary key,    price       float );

В результате кодогенерации мы получим следующую DTO:

type Product struct {   ID    int32   Price sql.NullFloat64 }

Меня смущает sql.NullFloat64, который не очень удобно использовать в коде, так еще и цена у продуктов всегда должна быть. Значит, у меня ошибка в коде. Какая? Я забыл указать not null в исходной схеме.

Sqlc помогает отлавливать такие ошибки в схемах с помощью генерации правильных структур данных, которые учитывают возможность значения null в полях.

Performance

В рамках бенчмарка go-db-bench, который я сделал по аналогии со статьей на портале JetBrains, я тестировал sqlc на различных выборках данных (SELECT) и вставках (INSERT) в PostgreSQL, сравнивая с database/sql, GORM, SQLx, Bun и XO.

SELECT: sqlc быстрее ORM

Тесты показали, что sqlcs на основе pgx уверенно обходит ORM-библиотеки по скорости.

  • При выборке 10 строк sqlc работает примерно на 7 % быстрее, чем GORM, при этом требуя в 3 раза меньше выделений памяти.

  • При выборке 100 строк sqlc выполняет запрос на 18 % быстрее, чем GORM, и почти на 40 % быстрее, чем SQLx.

  • При выборке 100 000 строк sqlc остаётся одним из лидеров, обходя GORM на 40 % и SQLx на 32 %, при этом потребляя меньше памяти.

INSERT: sqlc против GORM и SQLx

Sqlc показывает отличные результаты и при вставке данных.

  • При вставке 100 записей sqlc выполняет операцию примерно на 30 % быстрее, чем GORM.

  • При вставке 1000 записей sqlc тоже остаётся конкурентоспособным, выполняя вставку в 2,3 раза быстрее, чем GORM.

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

Почему использование sqlc повышает производительность приложения?

  • Генерация Go-кода на этапе компиляции позволяет избежать динамической обработки SQL-запросов.

  • Sqlc не использует рефлексию, что снижает накладные расходы по сравнению с ORM.

  • Минимальные выделения памяти за счёт строго типизированного кода.

  • Нет абстракций уровня ORM, sqlc работает напрямую с database/sql и pgx.

  • Производительность предсказуема, так как код sqlc ближе к database/sql.

  • Эффективное использование подготовленных запросов снижает нагрузку на базу данных.

  • Нет затрат на динамическое определение схемы, как в ORM.

  • Драйвер pgx быстрее lib/pq и поддерживает connection pooling.

  • Работа с бинарными форматами PostgreSQL сокращает накладные расходы.

  • Потребление памяти ниже, чем у ORM, за счёт более редких выделений памяти.

Давайте остановимся отдельно на рефлексии, так как она больше всего влияет на производительность кода. Возьмем пример из SQLx, где используется функция scanAny. Вот его упрощенная версия:

func (r *Row) scanAny(dest interface{}) error { v := reflect.ValueOf(dest) if v.Kind() != reflect.Ptr || v.IsNil() { return errors.New("destination must be a non-nil pointer") }  columns, err := r.Columns() if err != nil { return err }  values := make([]interface{}, len(columns)) for i := range values { var val interface{} values[i] = &val }  if err := r.Scan(values...); err != nil { return err }  return mapValues(dest, values) }

Естественно, чтобы сканировать любое значение, надо использовать reflect. При больших объёмах данных чем больше колонок и строк, тем сильнее это влияет на код. В случае с sqlc такой проблемы нет, потому что мы знаем, какие типы генерируем, сколько там аргументов и как их правильно сканировать. Поэтому при большом объёме данных код, сгенерированный sqlc, производительней, ведь это чистый database/sql.

Пример работы sqlc:

func (q *Queries) GetObject(ctx context.Context, id int) (Row, error) {     row := q.db.QueryRowContext(ctx, query, id)     var i Row     err := row.Scan(&i.ID, &i.Value)     return i, err }

А еще есть набор субъективных причин, почему sqlc быстрее:

  1. Sqlc побуждаем писать оптимальные запросы на чистом SQL. Мы используем подход SQL-first, то есть, сначала объяснили, поставили индексы и оптимизировали запрос.

  2. Sqlc не даёт лишних абстракций. Мы работаем только с моделями, которые он сгенерировал, и обычно это быстрее.

Примеры использования sqlc

Теперь давайте рассмотрим различные примеры использования, и начнём с самой болезненной темы — с динамических запросов.

Dynamic Queries

Возьмём таблицу products, у которой есть название и цена. Мы хотим сортировать либо по названию, либо по цене в прямом и обратном порядке. А ещё  — фильтровать и по названию, и по цене. Например, мы хотим иметь опциональные параметры в условиях:

SELECT * FROM products; SELECT * FROM products WHERE name = $1; SELECT * FROM products WHERE price = $2; SELECT * FROM products WHERE name = $1 AND price = $2;

А еще мы хотим сортировки по всем этим полям:

SELECT * FROM products ORDER BY name; SELECT * FROM products ORDER BY name DESC; SELECT * FROM products ORDER BY price; SELECT * FROM products ORDER BY price DESC;

Для каждого нового поля и операции над ним нам придётся написать ещё N отдельных SQL-запросов, что превращает это всё в комбинаторный взрыв. Тут важно отметить, что sqlc — это не query builder. На GitHub об этом с 2020 года до сих пор идёт дискуссия, но в ней так и нет общего рабочего решения.

Если нам нужно использовать динамические параметры, есть всего три подхода:

CASE-THEN

CASE WHEN @isName::bool THEN name = @name ELSE TRUE END

Берём красивые SQL-запросы и помещаем в них CASE-THEN. В этот момент они перестают быть красивыми.

Макрос sqlc.narg

sqlc.narg(name)::text IS NULL  OR name = sqlc.narg(name)::text

Это nullable-аргумент. То есть мы проверяем, задан ли он, и если да, то отфильтруем по нему. SQL-запросы в этот момент тоже выглядят не очень красиво.

query builder

Прямо в сгенерированный модуль дописываем статические файлы, где используем query builder. В результате получаем зоопарк, в котором есть и sqlc, генерирующий код, и запросы, написанные вручную или через query builder. Тоже некрасиво, но больше вариантов у нас нет.

Некоторые пользователи sqlc написали свои собственные обёртки для работы с динамическими запросами, но они ограничены в возможностях применения и использовать их в проде можно только на свой страх и риск:

Transaction Agnostic

Теперь поговорим про транзакции. Допустим у нас есть два запроса:

-- name: GetProduct :one SELECT * FROM products WHERE id = @id;  -- name: UpdateProduct :exec UPDATE products SET price = @price  WHERE id = @id;

Если работаем без транзакции, то нам надо получить продукт и обновить его:

func (q *Queries) UpdateProductByID(ctx context.Context, id int, newValue string) error {     // Получаем продукт по ID     product, err := q.GetProduct(ctx, id)     if err != nil {         return err     }      // Обновляем продукт с новыми данными     err = q.UpdateProduct(ctx, UpdateProductParams{         ID:    product.ID,         Value: newValue,     })     if err != nil {         return err     }      return nil }

Это может повлечь за собой неконсистентность данных, поэтому хотим всё же использовать транзакции.

func (q *Queries) UpdateProductByIDTx(ctx context.Context, db *sql.DB, id int, newValue string) error {     // Начинаем транзакцию     tx, err := db.BeginTx(ctx, nil)     if err != nil {         return err     }     defer tx.Rollback() // Откат в случае ошибки      // Создаем экземпляр Queries, связанный с транзакцией     qtx := q.WithTx(tx)      // Получаем продукт по ID     product, err := qtx.GetProduct(ctx, id)     if err != nil {         return err     }      // Обновляем продукт с новыми данными     err = qtx.UpdateProduct(ctx, UpdateProductParams{         ID:    product.ID,         Value: newValue,     })     if err != nil {         return err     }      // Фиксируем транзакцию     return tx.Commit() }

Sqlc предполагает работу с transaction agnostic кодом. То есть неважно, исполняется этот код на чистом SQL-подключении или под транзакцией. Сгенерированный код будет работать в обоих случаях одинаково, без необходимости в дополнительной магии кодогенерации. Работа с транзакциями в sqlc довольно удобная. Там есть специальный декоратор WithTx, который используют для генерирования интерфейса для работы с транзакциями.

Bulk Operations

Когда я работал в Delivery Club, мы делали каталог продуктов, в котором синхронизировались все данные о товарах из «Магнита», «Вкусвилла» и еще 30 тысяч сетевых магазинов. Эти данные нужно было постоянно синхронизировать. Естественно, мы пользовались массовыми вставками, и это был один из блокеров в sqlc, потому что на тот момент там не было ничего для работы с ним. Сейчас мы нашли два способа:

  1. Из документации для драйвера pgx. Драйвер генерирует корректный код для pgx, который можно вызывать и передавать в него множество значений для вставки. Для MySQL можно рассмотреть отдельную команду :copyfrom, но здесь я не буду показывать пример.

    Запрос:

    -- name: InsertCopyFrom :copyfrom INSERT INTO authors (name, bio) VALUES ($1, $2);

    Кусочек из кодогенерации:

    // iteratorForInsertCopyFrom реализует pgx.CopyFromSource для вставки данных type iteratorForInsertCopyFrom struct {     rows []InsertCopyFromParams     idx  int }  // InsertCopyFrom вставляет данные через COPY FROM func (q *Queries) InsertCopyFrom(ctx context.Context, arg []InsertCopyFromParams) error {     _, err := q.db.CopyFrom(         ctx,         pgx.Identifier{"authors"},         []string{"name", "bio"},         &iteratorForInsertCopyFrom{rows: arg},     )     return err }
  2. Использование Unnest. В Unnest мы используем кастинг и получаем корректный код, который можно использовать. Пример запроса:

    -- name: InsertUnnest :exec INSERT INTO authors (name, bio) SELECT name, bio FROM UNNEST(@authors::author[]);

    Результат кодогенерации:

    func (q *Queries) InsertUnnest(ctx context.Context, authors []Author) error {     _, err := q.db.Exec(ctx, insertUnnest, authors)     return err }

    Важно не забыть здесь зарегистрировать в конфиге отдельный тип Author при использовании.

Проблема с макросом

Макрос Embed позволяет делать композитные типы. Например, есть таблица с книгами и их авторами. Желая извлечь всех авторов и все книги в одну структуру, мы должны написать чистым кодом, как обходим все строки и маппим их структуру. При этом можно использовать sqlc embed, которая даст на выходе композитную строку.

Пример запроса с использованием sqlc.embed:

-- name: Composite :many SELECT    sqlc.embed(a),    array_agg(b)::book[] as books FROM authors a    LEFT JOIN books b ON b.author_id = a.id GROUP BY a.id;

Мы получим следующее определение функции:

func (q *Queries) Composite(ctx context.Context) ([]CompositeRow, error) 

и DTO CompositeRow:

// CompositeRow представляет результат запроса Composite type CompositeRow struct {     Author // Встроенная структура, содержащая все поля authors     Books  []Book }

Что не так с «IN»

Во всех SQL-фреймворках часто встречается одна и та же проблема — как правильно работать с оператором IN. Давайте посмотрим, какая ситуация в sqlc. Предположим, что мы хотим получить автора 1 и 2, но не можем просто написать в других библиотеках для генерирования кода IN (аргумент). То есть, чтобы получить такой запрос:

SELECT * FROM authors WHERE id IN (1, 2);

Нельзя просто взять и написать::

-- name: ListAuthorsByIDs :many SELECT * FROM authors WHERE id IN $1::int[];

Чтобы это работало, надо снова взять any для PostgreSQL:

-- name: ListAuthorsByIDs :many SELECT * FROM authors WHERE id = any(@ids::int[]);

 или макрос sqlc.slice для MySQL:

-- name: ListAuthorsByIDs :many SELECT * FROM authors WHERE id = (sqlc.slice('ids'));

И вдруг мы видим, что чистый SQL обрастает новым синтаксисом, — синтаксисом макросов sqlc. Конкретно в этом случае для MySQL у нас, кажется, нет альтернативы. Придётся использовать макросы. В этот момент чистый SQL перестает быть таким, но это всё равно удобно.

Проблема с сопоставлением

Эта проблема очень досаждает одному из моих знакомых тимлидов.

Давайте рассмотрим сначала нормальный вариант. Мы берём SELECT со звездочкой и ограничением в один:

-- name: GetAuthor :one SELECT * FROM authors WHERE id = $1 LIMIT 1;

и получаем следующий результат работы sqlc с полем Author:

func (q *Queries) GetAuthor(ctx context.Context, id int) (Author, error) 

А если мы напишем тот же SELECT со звёздочкой и получим всех авторов:

-- name: ListAuthors :many SELECT * FROM authors ORDER BY name;

то у кодогенератора тоже будет результат со слайсом Author:

func (q *Queries) ListAuthors(ctx context.Context) ([]Author, error)

Тут все правильно, для всех вариантов использования * мы получаем поле Author.

Но теперь мы хотим извлечь поля, которые относятся к автору, например, id и name.

-- name: GetAuthor :one SELECT id, name FROM authors WHERE id = $1 LIMIT 1;

И вот здесь после кодогенерации мы уже получаем GetAuthorRow вместо Author:

func (q *Queries) GetAuthor(ctx context.Context, id int) (GetAuthorRow, error)

И когда хотим получить всех авторов, то получаем слайс объектов, который называется ListAuthorsRow:

-- name: ListAuthors :many SELECT id, name FROM authors ORDER BY name;   // result func (q *Queries) ListAuthors(ctx context.Context, id int) ([]ListAuthorsRow, error)

То есть разные запросы одних и тех же данных будут давать нам разные DTO:

type GetAuthorRow struct {     ID   int64     Name string }  type ListAuthorsRow struct {     ID   int64     Name string }

Если есть код, который хочется переиспользовать, то эта проблема может быть неприятной, потому что придётся добавить слой для маппинга. Так происходит потому, что sqlc создаёт общую DTO либо на основе таблицы, либо на основе запрашиваемых данных для каждого отдельного запроса. Чтобы это решить, нужно написать мапперы — придётся поработать руками. Есть странные решения через with и overrides, но они все довольно сложные, поэтому лучше просто смаппить.

Наследование таблиц

Эту проблему я называю feature gap. Покажу её на примере наследования таблиц. Допустим, у нас есть таблица «авторы», состоящая из id, name и bio. Мы хотим создать отдельную таблицу блогеров, наследуемую от таблицы авторов, а потом добавить в родительскую таблицу статус.

Пример миграции:

CREATE TABLE authors (    idBIGSERIAL PRIMARY KEY,    nametext      NOT NULL,    biotext );  CREATE TABLE bloggers ( nicknametext      NOT NULL ) INHERITS (authors);  ALTER TABLE authors ADD COLUMN status TEXT;

Генерируя код, получим следующий результат — одно из полей пропадает:

type Author struct {     ID     int64     Name   string     Bio    sql.NullString     Status sql.NullString }  type Blogger struct {     ID       int64     Name     string     Bio      sql.NullString     Nickname string }

В БД статус есть в двух таблицах, а в коде, который сгенерировал sqlc, статус есть только в одной таблице. Это баг, для которого есть issue, и даже PR, его исправляющий.

Используя специфичные фичи, которые редко используются в базах данных, можно столкнуться с проблемами: багами или неподдерживаемостью. Причём как в случае с SQL, так и в случае с фичами конкретных СУБД.

Итоги: когда использовать sqlc

Sqlc отлично подходит для тех, кто предпочитает писать SQL-запросы вручную и хочет получить производительный, безопасный и типизированный код. Инструмент генерирует чистый database/sql, обеспечивая минимальные накладные расходы и высокую скорость работы с базой данных. Кодогенерация помогает не только ускорить разработку, но и выявлять ошибки на этапе компиляции: если SQL-запрос содержит проблему или несовместимость типов, это будет обнаружено ещё до запуска программы. Такой подход особенно полезен при работе со сложными, но статичными запросами, где важно гарантировать корректность взаимодействия с базой данных.

Однако sqlc не всегда удобен. Если в проекте предполагается много динамических запросов, например, в админ-панели со множеством фильтров и сортировок, то проще использовать query builder. Также sqlc не лучшим образом подходит для случаев, когда необходимо работать сразу с несколькими СУБД — придётся вручную поддерживать совместимость SQL-кода. Если проект требует специфичных возможностей конкретной базы данных или, наоборот, максимально абстрагируется от SQL, то классический ORM может быть более удобным. В конечном счёте, выбор зависит от архитектуры приложения и предпочтений разработчиков.

Если вы предпочитаете смотреть, а не читать, то вот видеозапись моего доклада с конференции Saint HighLoad++ 2024, по которому написана эта статья:


ссылка на оригинал статьи https://habr.com/ru/articles/890654/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *