Есть два пути получить SQL-запросы в виде Go-кода. Первый — sqlc: SQL пишут в файлах, инструмент читает их, проверяет против схемы базы и генерирует типобезопасные функции. Второй путь старше и универсальнее: шаблоны. Пишете запрос как шаблон с параметрами, компилятор шаблонов превращает его в Go-функцию, которая собирает готовый SQL. Самый известный такой инструмент — qtc из пакета quicktemplate, от того же автора, что fasthttp и zerolog.
Разберём, что именно генерирует qtc, чем это отличается от sqlc и какие грабли ждут команду, которая решит собрать свой кодоген поверх шаблонов.
Что генерирует qtc
quicktemplate — это шаблонизатор общего назначения: он компилирует файлы *.qtpl в обычный Go-код, где сборка текста идёт через strings.Builder — быстро и без промежуточных аллокаций. Изначально его брали для HTML, но шаблон ничего не знает о языке назначения, поэтому шаблоном может быть и SQL-запрос:
{% func InsertOrder(id string, total int) %}
INSERT INTO orders (id, total)
VALUES ('{%s id %}', {%d total %})
{% endfunc %}
После запуска qtc рядом появляется сгенерированный Go-файл, и запрос собирается вызовом функции — с тем же привычным для zerolog и fasthttp цепочечным стилем этого автора:
// сгенерировано qtc, руками не правим
query := InsertOrderSQL("a-1024", 1250)
// query: "INSERT INTO orders (id, total) VALUES ('a-1024', 1250)"
Такой кодоген есть в реальных проектах: шаблоны .sql.tmpl и свой генератор поверх них — не редкость в компаниях, где запросы сложные и многосоставные, а sqlc с его моделью «одна аннотация — один запрос» неудобен.
Чем это отличается от sqlc
Разница принципиальная, и в ней корень всех граблей. sqlc знает схему базы: он парсит SQL, сверяется с миграциями и генерирует не строку, а функцию с типизированными аргументами и типизированным результатом. Ошибка в имени колонки — это ошибка sqlc generate, до компиляции и до прода.
qtc не знает про базу ничего. Он превращает шаблон в функцию, которая возвращает строку. Пока строка не уедет в PostgreSQL, никто не проверит, существует ли таблица orders и называется ли колонка total. Ошибка выяснится в рантайме — в лучшем случае в тестах, в худшем — в проде:
// qtc сгенерировал ровно то, что написано в шаблоне — проверять некому
query := ListOrdersByStatusSQL("NEW")
rows, err := pool.Query(ctx, query) // упадёт здесь, если колонку переименовали
Поэтому правило такое: свой кодоген на шаблонах обязан компенсировать потерянную проверку. Компенсируют её интеграционными тестами на реальной схеме — например, через testcontainers: каждый сгенерированный запрос прогоняют против настоящей PostgreSQL в CI. Без этого кодоген из шаблонов — генерация багов, а не защита от них.
Что происходит под капотом: strings.Builder
Смысл qtc не в магии, а в скорости сборки строк. Саму идею можно показать на стандартной библиотеке — этот пример выполняется сам по себе:
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
b.WriteString("INSERT INTO orders (id, total) VALUES ('")
b.WriteString("a-1024")
b.WriteString("', ")
b.WriteString(fmt.Sprint(1250))
b.WriteString(")")
fmt.Println(b.String())
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
qtc генерирует именно такой код — только аккуратнее и с развёртыванием циклов {% for %} в циклы Go. Понимание этого снимает страх перед «своим генератором»: это тот же strings.Builder, собранный заранее.
Грабли своего кодогена
Сгенерированный код в репозитории. Если qtc запускают локально, а результат коммитят, — на ревью появляются диффы, которые никто не читает, но которые ломают сборку, когда разъезжаются с шаблоном. Принято запускать генерацию через go:generate и в CI проверять, что закоммиченный код совпадает со свежесгенерированным: go generate ./... && git diff --exit-code.
Инъекции через конкатенацию. Шаблон собирает SQL строкой — если в шаблон подставляют значение пользователя напрямую, получают SQL-инъекцию. Значения всегда идут через плейсхолдеры $1, $2, а подстановка в шаблоне — только для имён таблиц и колонок из белого списка:
{# так надо: значения — плейсхолдерами, подстановка — только белый список #}
{% func SelectBy(table string, limit int) %}
SELECT * FROM {%s table %} ORDER BY created_at DESC LIMIT {%d limit %}
{% endfunc %}
Миграции и шаблоны разъезжаются. Колонку переименовали в миграции, шаблон остался старым — sqlc поймал бы это на генерации, qtc нет. Единственная защита — тесты против реальной схемы в CI.
Коротко
- qtc компилирует
*.qtpl-шаблоны в Go-функции, собирающие SQL черезstrings.Builder— быстро и без аллокаций. - В отличие от sqlc, qtc не проверяет запросы против схемы — строка уезжает в базу как есть, ошибки всплывают в рантайме.
- Свой кодоген на шаблонах оправдан для сложных многосоставных запросов и обязан компенсировать потерю проверки интеграционными тестами на реальной схеме.
- Грабли: разъезд генерации с шаблонами в CI, SQL-инъекции через подстановку значений, рассинхрон с миграциями.
- Порядок: пишете запросы в
*.sqlподdb/queries/— sqlc; сложная сборка запросов — шаблоны + testcontainers.
Что почитать дальше
- Персистентность в Go: sqlc и pgx — основной путь: типобезопасные запросы с проверкой против схемы.
- sqlc codegen и query-файлы — правила R-SQLC-QF: аннотации, overrides, nullable.
- Интеграционное тестирование в Go — как тестировать против реальной базы через testcontainers.