← назад к разделу

Есть два пути получить 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.

Что почитать дальше