Я люблю SQL, но устал собирать WHERE через fmt.Sprintf: зачем я сделал qrafter

от автора

Мне нравится чистый SQL.

Не «нравится, потому что пришлось», а правда нравится. В хорошем SQL‑запросе обычно видно, что происходит с данными: откуда берём, как фильтруем, где соединяем, что агрегируем и в каком порядке отдаём наружу. И мне нравится Go за похожее качество: код обычно прямой, явный и без лишних церемоний. Поэтому долгое время способ работы с базой выглядел так:

rows, err := db.QueryContext(ctx, `    SELECT id, user_name, age    FROM users    WHERE status = $1    ORDER BY id    LIMIT $2`, status, limit)

Всё честно. SQL виден. Аргументы отдельно. Никакой магии. Но потом в запрос приходят фильтры. Потом ещё фильтры. Потом сортировка из API. Потом пагинация. Потом такой же WHERE нужен для COUNT(*) для подсчета общего количества строк.

Потом локальные тесты на SQLite, хотя продакшен на PostgreSQL. И внезапно код, который начинался как «просто сырой SQL», превращается в маленький самописный query builder:

query := `    SELECT id, user_name, age    FROM users    WHERE 1 = 1`args := make([]any, 0)if filter.Status != "" {    args = append(args, filter.Status)    query += fmt.Sprintf(" AND status = $%d", len(args))}if filter.MinAge != nil {    args = append(args, *filter.MinAge)    query += fmt.Sprintf(" AND age >= $%d", len(args))}if filter.CreatedAfter != nil {    args = append(args, *filter.CreatedAfter)    query += fmt.Sprintf(" AND created_at >= $%d", len(args))}query += " ORDER BY id LIMIT 100"

Это не катастрофа. Такой код работает. Я сам писал так много раз.

Но с ним есть проблема: он требует постоянной ручной синхронизации между SQL-фрагментом, номером placeholder’а и позицией аргумента в []any.

Добавил условие — проверь номера. Поменял порядок условий — проверь args.

Скопировал фильтр в COUNT(*) — проверь, что через месяц оба запроса всё ещё одинаковые.

Переименовал колонку — удачи найти все строки "user_name" по проекту.

В какой-то момент я понял, что меня раздражает не SQL. Меня раздражает бухгалтерия вокруг SQL.

Так появился qrafter.


Что такое qrafter

qrafter — это небольшой типобезопасный построитель SQL-запросов для Go.

Ключевая идея простая:

SQL должен оставаться явным, но имена колонок, placeholder’ы и повторяющиеся части запросов не должны быть ручной строковой работой.

Qrafter строит параметризованный SQL из типизированных Go структур и отдаёт обычные строки-запросы и аргументы, которые можно подставить в запрос через database/sql, sqlx и похожие инструменты.

Например:

package mainimport (    "fmt"    q "github.com/SennovE/qrafter"    "github.com/SennovE/qrafter/dialect")type User struct {    q.Table `table:"users"`    ID       q.Column[int] `db:"id"`    UserName q.Column[string]    Age      q.Column[int]}func main() {    users := q.MustNewTable[User]()    sql, args, err := q.Select(users.ID, users.UserName).        Where(            users.Age.Ge(18),            users.UserName.Eq("Alice"),        ).        OrderBy(users.ID.Asc()).        Limit(10).        Render(dialect.PostgreSQL{})    if err != nil {        panic(err)    }    fmt.Println(sql)    fmt.Println(args)}

На выходе:

SELECT "users"."id", "users"."user_name"FROM "users"WHERE "users"."age" >= $1 AND "users"."user_name" = $2ORDER BY "users"."id" ASCLIMIT 10

И аргументы:

[]any{18, "Alice"}

То есть код всё ещё читается как SQL: SELECT, WHERE, ORDER BY, LIMIT.

Но я больше не пишу "user_name" руками в каждом месте. Не считаю $1, $2, $3. Не думаю, какие кавычки подставлять для какого SQL-диалекта.

Почему не ORM

Первый очевидный вопрос: «Зачем ещё один инструмент, если есть ORM?»

ORM — нормальный выбор, когда вам нужны модели, связи, жадная загрузка, автоматические миграции и CRUD вокруг доменных объектов.

Но qrafter решает другую задачу.

Я не хотел прятать SQL за объектной моделью. Не хотел загрузку связей. Не хотел, чтобы библиотека решала, когда и какие запросы выполнять

Мне нужен был инструмент уровнем ниже: типизированные выражения на Go в SQL string и аргументы.

А дальше я сам решаю, где использовать запрос: database/sql, sqlx, транзакция, свое логирование, мидлвары, трейсинг и так далее.

database/sql в стандартной библиотеке Go уже даёт общий интерфейс к SQL-like базам, умеет работать с контекстом, транзакциями и пулом соединений. qrafter не заменяет этот слой, а встаёт перед ним как безопасный способ собрать запрос.

Почему не sqlc

Второй очевидный вариант — кодогенерация из sql-файлов.

Например, sqlc генерирует строго типизированный Go-код из SQL. Это сильный подход: SQL остаётся источником истины, а Go API получается на выходе генерации.

Если у вас много заранее известных SQL-запросов, которые удобно держать в .sql файлах, sqlc может быть отличным выбором.

Но динамические запросы из API-фильтров — другой сценарий.

Например, есть эндпоинт:

GET /users?status=active&min_age=18&created_after=2026-01-01

В таком случае запрос часто собирается в Go-коде:

query := q.Select(users.ID, users.UserName, users.Age)if filter.Status != "" {    query = query.Where(users.Status.Eq(filter.Status))}if filter.MinAge != nil {    query = query.Where(users.Age.Ge(*filter.MinAge))}if filter.CreatedAfter != nil {    query = query.Where(users.CreatedAt.Ge(*filter.CreatedAfter))}sql, args, err := query.    OrderBy(users.ID.Asc()).    Limit(100).    Render(dialect.PostgreSQL{})
То же запрос через обычную сборку SQL-строки
sql := `    SELECT id, user_name, age    FROM users`where := make([]string, 0)args := make([]any, 0)if filter.Status != "" {    args = append(args, filter.Status)    where = append(where, fmt.Sprintf("status = $%d", len(args)))}if filter.MinAge != nil {    args = append(args, *filter.MinAge)    where = append(where, fmt.Sprintf("age >= $%d", len(args)))}if filter.CreatedAfter != nil {    args = append(args, *filter.CreatedAfter)    where = append(where, fmt.Sprintf("created_at >= $%d", len(args)))}if len(where) > 0 {    sql += " WHERE " + strings.Join(where, " AND ")}sql += " ORDER BY id ASC LIMIT 100"

Здесь мне не хочется заранее заводить отдельный SQL-файл на каждую комбинацию фильтров. Мне хочется собрать запрос из условий, но не превращать это в конкатенацию строк. Вот эта зона и есть место qrafter.

Почему не Squirrel

Есть и классические сборщики запросов.

Например, Squirrel — SQL-генератор для Go. Он хорошо убирает ручную склейку строк:

sq.Select("id", "user_name").    From("users").    Where(sq.Eq{"status": "active"})

Это уже сильно лучше, чем strings.Builder, fmt.Sprintf и ручное добавление AND.

Но мне хотелось другого. В Squirrel имена таблиц и колонок часто остаются строками. А я хотел видеть явно типизированные колонки, в которые можно будет и записать результат запроса:

q.Select(users.ID, users.UserName).    Where(users.Status.Eq("active"))

Это не делает qrafter «лучше всегда». Это просто другой выбор: чуть больше описания таблицы, зато дальше по коду ходят типизированные дескрипторы столбцов.

Таблица описывается один раз

В qrafter таблица — это обычная Go-структура:

type UserTable struct {    q.Table `table:"users"`    ID        q.Column[int64]  `db:"id"`    UserName  q.Column[string] `db:"user_name"`    Age       q.Column[int]    Status    q.Column[string]    CreatedAt q.Column[time.Time] `db:"created_at"`    DeletedAt q.Column[*time.Time] `db:"deleted_at"`}

Потом один раз создаём переменную, которая будет представлением SQL-таблицы и будет использоваться в запросах:

users := q.MustNewTable[UserTable]()

После этого users.Age, users.Status, users.CreatedAt — это не просто строки. Это значения, из которых можно строить выражения:

users.Age.Ge(18)users.Status.Eq("active")users.DeletedAt.IsNull()users.CreatedAt.Ge(since)

qrafter сам связывает экспортируемые поля с типом Column с именами колонок: через тег db или через snake_case маппинг имени поля.

Сценарий 1: динамические фильтры без ручных placeholder’ов

Допустим, фильтр выглядит так:

type UserFilter struct {    Status         string    MinAge         *int    CreatedAfter   *time.Time    IncludeDeleted bool}

На raw SQL я бы раньше держал рядом query, args и len(args). С qrafter можно писать так:

func listUsers(ctx context.Context, db *sql.DB, filter UserFilter) error {    users := q.MustNewTable[UserTable]()    query := q.Select(users.ID, users.UserName, users.Age, users.Status, users.CreatedAt)    if filter.Status != "" {        query = query.Where(users.Status.Eq(filter.Status))    }    if filter.MinAge != nil {        query = query.Where(users.Age.Ge(*filter.MinAge))    }    if filter.CreatedAfter != nil {        query = query.Where(users.CreatedAt.Ge(*filter.CreatedAfter))    }    if !filter.IncludeDeleted {        query = query.Where(users.DeletedAt.IsNull())    }    sqlText, args, err := query.        OrderBy(users.ID.Asc()).        Limit(100).        Render(dialect.PostgreSQL{})    if err != nil {        return err    }    rows, err := db.QueryContext(ctx, sqlText, args...)    if err != nil {        return err    }    defer rows.Close()    // scan rows...    return rows.Err()
То же запрос через обычную сборку SQL-строки
func listUsers(ctx context.Context, db *sql.DB, filter UserFilter) error {    query := `        SELECT id, user_name, age, status, created_at        FROM users    `    where := make([]string, 0)    args := make([]any, 0)    if filter.Status != "" {        args = append(args, filter.Status)        where = append(where, fmt.Sprintf("status = $%d", len(args)))    }    if filter.MinAge != nil {        args = append(args, *filter.MinAge)        where = append(where, fmt.Sprintf("age >= $%d", len(args)))    }    if filter.CreatedAfter != nil {        args = append(args, *filter.CreatedAfter)        where = append(where, fmt.Sprintf("created_at >= $%d", len(args)))    }    if !filter.IncludeDeleted {        where = append(where, "deleted_at IS NULL")    }    if len(where) > 0 {        query += " WHERE " + strings.Join(where, " AND ")    }    query += " ORDER BY id ASC LIMIT 100"    rows, err := db.QueryContext(ctx, query, args...)    if err != nil {        return err    }    defer rows.Close()    // scan rows...    return rows.Err()}

Важное здесь не то, что кода стало в два раза меньше. Иногда не становится. Важное другое: из кода исчезла хрупкая часть.

Больше нет:

fmt.Sprintf("$%d", len(args))

Больше нет ручного:

args = append(args, value)

Больше нет строковых названий колонок в десяти местах, которые придется искать, если потребуется переименовать, например, deleted_at на removed_at.

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

Сценарий 2: один и тот же фильтр для списка и count

Пагинация почти всегда приносит два запроса:

SELECT id, user_name, ageFROM usersWHERE ...ORDER BY idLIMIT 100;SELECT COUNT(id)FROM usersWHERE ...;

Сложность обычно не в COUNT. Сложность в том, что WHERE должен быть одинаковым.

Если фильтры собраны строками, вы либо копируете условия, либо пишете функцию, которая возвращает SQL-фрагмент и аргументы []any. Эта функция постепенно превращается в query builder, только без типизированных колонок и зависимостью от диалекта.

В qrafter можно вынести применение фильтра в функцию:

func applyUserFilter(    query q.SelectQuery,    users UserTable,    filter UserFilter,) q.SelectQuery {    if filter.Status != "" {        query = query.Where(users.Status.Eq(filter.Status))    }    if filter.MinAge != nil {        query = query.Where(users.Age.Ge(*filter.MinAge))    }    if filter.CreatedAfter != nil {        query = query.Where(users.CreatedAt.Ge(*filter.CreatedAfter))    }    if !filter.IncludeDeleted {        query = query.Where(users.DeletedAt.IsNull())    }    return query}

И далее использовать ее в для нескольких запросов:

users := q.MustNewTable[UserTable]()listQuery := applyUserFilter(    q.Select(users.ID, users.UserName, users.Age),    users,    filter,).OrderBy(users.ID.Asc()).    Limit(100)countQuery := applyUserFilter(    q.Select(q.Count(users.ID)),    users,    filter,)listSQL, listArgs, err := listQuery.Render(dialect.PostgreSQL{})if err != nil {    return err}countSQL, countArgs, err := countQuery.Render(dialect.PostgreSQL{})if err != nil {    return err}

Для меня это один из главных выигрышей. Фильтр перестаёт быть куском строки. Он становится частью Go-кода, которую можно переиспользовать, тестировать и читать.

Сценарий 3: dialect-aware SQL без попытки «обмануть» SQL

У SQL-диалектов есть различия, и qrafter не делает вид, что их нет.

Но часть различий скучная и техническая.

PostgreSQL использует такие placeholder’ы:

WHERE age >= $1 AND status = $2

MySQL и SQLite обычно используют такие:

WHERE age >= ? AND status = ?

Идентификаторы тоже выделяются по-разному:

-- PostgreSQL / SQLite"users"."id"-- MySQL`users`.`id`

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

query := q.Select(users.ID, users.UserName).    Where(        users.Age.Ge(18),        users.Status.Eq("active"),    ).    OrderBy(users.ID.Asc()).    Limit(100)

А потом отрендерить под конкретную базу:

pgSQL, pgArgs, err := query.Render(dialect.PostgreSQL{})mySQL, myArgs, err := query.Render(dialect.MySQL{})sqliteSQL, sqliteArgs, err := query.Render(dialect.SQLite{})

Сейчас в qrafter есть BaseDialect, PostgreSQL, MySQL и SQLite. Диалект отвечает за кавычки, плейсхолдеры, особенности вроде LIMIT/OFFSET, RETURNING, DELETE USING, JOIN и некоторые другие отличия. Если потребуется изменить СУБД или в тестах захочется использовать SQLite в оперативной памяти, вместо полноценного контейнера с PostgreSQL, то достаточно будет поменять вызов рендера, а не писать аналогичный запрос для другого диалекта.

Сценарий 4: repository layer без нового фреймворка

qrafter не говорит, как вам писать repository layer, где держать транзакции, как называть методы и каким логгером пользоваться.

Например, можно оставить обычный database/sql:

type UserRepository struct {    db      *sql.DB    dialect dialect.Renderer    users   UserTable}func NewUserRepository(db *sql.DB, d dialect.Renderer) *UserRepository {    return &UserRepository{        db:      db,        dialect: d,        users:   q.MustNewTable[UserTable](),    }}func (r *UserRepository) List(    ctx context.Context,    filter UserFilter,) ([]UserDTO, error) {    query := applyUserFilter(        q.Select(            r.users.ID,            r.users.UserName,            r.users.Age,            r.users.Status,        ),        r.users,        filter,    ).OrderBy(r.users.ID.Asc()).        Limit(100)    sqlText, args, err := query.Render(r.dialect)    if err != nil {        return nil, fmt.Errorf("render users query: %w", err)    }    rows, err := r.db.QueryContext(ctx, sqlText, args...)    if err != nil {        return nil, fmt.Errorf("query users: %w", err)    }    defer rows.Close()    result := make([]UserDTO, 0)    for rows.Next() {        var user UserDTO        if err := rows.Scan(            &user.ID,            &user.UserName,            &user.Age,            &user.Status,        ); err != nil {            return nil, fmt.Errorf("scan user: %w", err)        }        result = append(result, user)    }    if err := rows.Err(); err != nil {        return nil, fmt.Errorf("iterate users: %w", err)    }    return result, nil}
Или через удобную запись в структуру
func (r *UserRepository) List(    ctx context.Context,    filter UserFilter,) ([]UserDTO, error) {    // То же что в функции выше    result := make([]UserDTO, 0)      for rows.Next() {        var user UserDTO        dest, err := q.ScanDest(&user)        if err != nil {log.Fatal(err)}if err := rows.Scan(dest...); err != nil {log.Fatal(err)}        result = append(result, user)    }    if err := rows.Err(); err != nil {        return nil, fmt.Errorf("iterate users: %w", err)    }    return result, nil}

В текущей реализации q.ScanDest(&user) работает позиционно. Он возвращает dest в порядке экспортируемых Column-полей в Go-структуре, так что такой способ будет работать, только если порядок полей в структуре совпадает с порядком в q.Select.

Да, здесь всё ещё обычный Go-код. qrafter не пытается стать вашим фреймворком для работы с базой данных. Он просто собирает SQL и аргументы. Это позволяет максимально просто, с минимальными изменениями заменить склейку SQL строки на более приятную типизированную генерацию.

Сценарий 5: qrafter + sqlx

sqlx хорошо ложится рядом с qrafter, потому что решает другую задачу. sqlx — это расширение поверх стандартного database/sql: оно добавляет удобные методы и struct scanning, при этом не меняет интерфейсы sql.DB, sql.Tx, sql.Stmt.

То есть разделение получается таким:

qrafter → собрать SQL → sqlx → выполнить SQL и удобно просканировать результат

Пример:

sqlText, args, err := q.Select(    users.ID,    users.UserName,    users.Age,).    Where(users.Status.Eq("active")).    OrderBy(users.ID.Asc()).    Render(dialect.PostgreSQL{})if err != nil {    return err}var result []UserDTOif err := db.SelectContext(ctx, &result, sqlText, args...); err != nil {    return err}

Более интересный пример: отчёт с CTE и window function

Простые SELECT ... WHERE ... LIMIT ... показывают идею, но не показывают, зачем всё это может пригодиться в реальном коде.

Поэтому давайте посмотрим на более сложный пример, который при помощи qrafter можно написать более понятно, чем через сырой SQL.

Допустим, есть таблицы:

type CustomerTable struct {    q.Table `table:"customers"`    ID        q.Column[int64]      `db:"id"`    Name      q.Column[string]     `db:"name"`    DeletedAt q.Column[*time.Time] `db:"deleted_at"`}type OrderTable struct {    q.Table `table:"orders"`    ID         q.Column[int64]     `db:"id"`    CustomerID q.Column[int64]     `db:"customer_id"`    Status     q.Column[string]    `db:"status"`    CreatedAt  q.Column[time.Time] `db:"created_at"`}type OrderItemTable struct {    q.Table `table:"order_items"`    ID        q.Column[int64] `db:"id"`    OrderID   q.Column[int64] `db:"order_id"`    Quantity  q.Column[int64] `db:"quantity"`    UnitPrice q.Column[int64] `db:"unit_price_cents"`}

Нужно получить топ клиентов по сумме покупок:

  1. Взять только оплаченные заказы;

  2. Посчитать количество заказов;

  3. Посчитать дату последнего заказа;

  4. Посчитать сумму;

  5. Исключить удалённых клиентов;

  6. Добавить rank по сумме;

  7. Отдать топ-20.

На SQL это обычно просится в CTE:

WITH customer_spend AS (    SELECT        orders.customer_id,        COUNT(orders.id) AS orders_count,        MAX(orders.created_at) AS last_order_at,        SUM(order_items.quantity * order_items.unit_price_cents) AS total_spend_cents    FROM orders    JOIN order_items ON orders.id = order_items.order_id    WHERE orders.status = 'paid'      AND orders.created_at >= $1    GROUP BY orders.customer_id)SELECT    customers.id,    customers.name,    customer_spend.orders_count,    customer_spend.last_order_at,    customer_spend.total_spend_cents,    RANK() OVER (ORDER BY customer_spend.total_spend_cents DESC) AS spend_rankFROM customersJOIN customer_spend ON customers.id = customer_spend.customer_idWHERE customers.deleted_at IS NULLORDER BY customer_spend.total_spend_cents DESCLIMIT 20;

В qrafter это можно собрать так:

customers := q.MustNewTable[CustomerTable]()orders := q.MustNewTable[OrderTable]()items := q.MustNewTable[OrderItemTable]()since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)lineTotal := items.Quantity.Mul(items.UnitPrice)customerSpend := q.Select(    orders.CustomerID,    q.Count(orders.ID).As("orders_count"),    q.Max(orders.CreatedAt).As("last_order_at"),    q.Sum(lineTotal).As("total_spend_cents"),).    Join(items, orders.ID.Eq(items.OrderID)).    Where(        orders.Status.Eq("paid"),        orders.CreatedAt.Ge(since),    ).    GroupBy(orders.CustomerID).    CTE("customer_spend").    WithColumns(        "customer_id",        "orders_count",        "last_order_at",        "total_spend_cents",    )spend := customerSpend.Column("total_spend_cents")rank := q.Rank().    Over(q.Window().OrderBy(spend.Desc())).    As("spend_rank")sqlText, args, err := q.Select(    customers.ID,    customers.Name,    customerSpend.Column("orders_count"),    customerSpend.Column("last_order_at"),    spend,    rank,).    Join(customerSpend, customers.ID.Eq(customerSpend.Column("customer_id"))).    Where(customers.DeletedAt.IsNull()).    OrderBy(spend.Desc()).    Limit(20).    Render(dialect.PostgreSQL{})if err != nil {    return err}

Это уже не игрушечный пример.

Здесь есть CTE, join, aggregate functions, arithmetic expression, GROUP BY, window function, сортировка и параметризация.

При этом код всё ещё похож на SQL, но можно легко изменить названия полей, выделить общие части в отдельную функцию, а главное, по последнему Select’у понять типы возвращаемых из базы данных значений.

Небольшое отступление про SQL в Go

На мой взгляд, Go исторически хорошо дружит с явностью.

database/sql не пытается быть ORM. Он даёт общий интерфейс, пул соединений, QueryContext, ExecContext, транзакции, Rows, Scan. Всё остальное вы выбираете сами. Это одновременно плюс и минус.

Плюс: нет обязательного «главного способа» работать с базой.

Минус: в каждом проекте рано или поздно появляется свой маленький слой вокруг SQL.

Кто-то выбирает ORM.

Кто-то выбирает raw SQL.

Кто-то выбирает sqlc.

Кто-то хранит запросы в .sql файлах через embed.

Кто-то пишет свой helper для WHERE.

qrafter — это попытка занять довольно узкую нишу между этими подходами:

  • хочу видеть SQL

  • не хочу ORM

  • не хочу codegen

  • не хочу отличать диалекты

  • не хочу размазывать имена колонок строками

  • хочу обычный SQL + []any на выходе

Что qrafter даёт на практике

Для себя я формулирую плюсы так.

Первое: типизированные колонки вместо строк.

users.Status.Eq("active")

читается лучше, чем:

"status = $1"

И при рефакторинге Go-поля у вас хотя бы часть ошибок ловит компилятор.

Второе: параметризация по умолчанию.

Обычные Go-значения становятся аргументами драйвера, а не интерполируются в SQL-строку. Это упрощает работу с литералами, надо меньше задумываться об экранировании.

Третье: dialect layer.

PostgreSQL, MySQL и SQLite отличаются. qrafter не стирает эти отличия, но позволяет держать quoting и placeholders в одном месте.

Четвёртое: композиция.

Фильтры, join’ы, CTE и сортировки становятся не кусками строки, а объектами запроса. Их проще передавать, переиспользовать и тестировать.

Пятое: совместимость с тем, что уже есть.

На выходе обычный строковой sql-запрос и аргументы к нему. Дальше можно использовать database/sql, sqlx, транзакции, логирование, метрики — что угодно.

Сравнение подходов

Подход

Когда хорош

Где начинает болеть

Raw SQL

Простые и статические запросы

Динамические фильтры, placeholder’ы, копирование WHERE

ORM

CRUD, связи, hooks, быстрая разработка поверх моделей

SQL становится менее явным, появляется тяжёлая абстракция, труднее делать сложные запросы с CTE

Squirrel

Нужен гибкий конструктор запросов

Имена таблиц и колонок часто остаются строками

sqlx

Нужно удобное выполнение и сканирование

Не решает typed-сборку SQL

qrafter

Нужен динамический SQL в Go с типизированными колонками

Не ORM, не codegen, не полная compile-time проверка схемы

Я не считаю эти инструменты взаимоисключающими. В одном проекте вполне может быть raw SQL для простых запросов, sqlc для стабильных сложных запросов, sqlx для scanning и qrafter для динамических фильтров. Главное — не выбирать инструмент по принципу «модно / не модно», а смотреть на боль.

Где qrafter не нужен

Теперь честная часть.

qrafter не заменит ORM, если вам нужны связи, жадная загрузка, автоматические миграции и полноценная модель предметной области поверх базы.

qrafter не заменит sqlc, если вам нравится SQL-first workflow и вы хотите генерировать Go-код из .sql файлов.

qrafter не даёт стопроцентную compile-time проверку реальной production-схемы. q.Column[int] помогает держать колонку в Go-коде, но не доказывает, что в базе действительно есть такая колонка с таким типом.

qrafter сейчас pre-v1, и API может меняться. Это прямо указано в README проекта.

Я специально пишу это здесь, потому что не хочу продавать библиотеку как серебряную пулю.

Это инструмент для конкретной зоны: типизированный динамический SQL без ORM и без codegen.

Что дальше: DDL и миграции

Отдельная тема, которую я хочу развивать дальше, — DDL и генерация миграций.

У типизированных структур, которые представляют таблицы, есть интересное следствие: если таблица уже описана в Go-коде, это описание можно использовать не только для SELECT, INSERT, UPDATE и DELETE, но и для schema-level задач.

Например, потенциально хочется прийти к workflow в духе Alembic (библиотека для генерации миграций на Python):

Текущее состояние базы + typed table definitions в Go → diff → черновая миграция →
ручная проверка и правка → файл с миграцией

Ключевое слово здесь — черновая.

Я не хочу, чтобы инструмент молча сам менял production-схему. Хорошая генерация миграций должна помогать, но не заменять ревью. В этом смысле мне нравится подход Alembic: автогенерация сравнивает метадату приложения с текущим состоянием базы и создаёт черновую миграцию, которую разработчик затем проверяет и дорабатывает руками.

Для qrafter это пока направление, а не обещание магии. Миграции — сложная область: переименование колонок нельзя надёжно отличить от удаления старой + добавления новой, миграции данных часто требуют ручного SQL, а поведение DDL сильно зависит от диалекта.

Но мне кажется, что типизированные схемы в Go могут стать хорошей основой для такого инструмента.

Как попробовать

Установка:

go get github.com/SennovE/qrafter

Я бы не советовал начинать с большого переписывания. Лучший способ попробовать — взять один неприятный запрос:

  • 3 — 5 опциональных фильтров

  • sort/order

  • pagination

  • COUNT(*) с теми же условиями

  • один join

  • PostgreSQL или SQLite

И переписать только его. Если код стал понятнее — qrafter попал в ваш сценарий. Если нет — возможно, raw SQL, sqlc, Squirrel, sqlx или ORM будут лучше.

Что мне особенно интересно от пользователей

Проект молодой, и мне сейчас важнее реальные кейсы, чем абстрактные пожелания.

Например:

у меня есть такой запрос

я хотел выразить его вот так

в qrafter сейчас неудобно вот здесь

Особенно интересны:

  • API naming

  • динамические фильтры

  • joins

  • CTE / recursive CTE

  • интеграция с database/sql

  • интеграция с другими библиотеками

  • dialect-specific поведение

Если вы попробуете qrafter на реальном запросе и упрётесь в шероховатость API — это как раз тот фидбек, ради которого я и пишу эту статью.

Заключение

Я не писал qrafter потому, что миру срочно нужен ещё один query builder. Я написал его потому, что мне нравится raw SQL, но не нравится ручная работа вокруг него. Я хочу видеть SQL. Хочу контролировать запрос. Хочу использовать свой database/sql, sqlx, транзакции, connection pool и логирование. Но я не хочу руками считать $1, $2, $3. Не хочу копировать "user_name" по проекту. Не хочу собирать WHERE через конкатенацию строк. Не хочу дублировать один и тот же фильтр между list и count. qrafter — это попытка занять маленькое пространство между raw SQL, ORM и codegen:

Репозиторий в GitHub

Буду рад issues, PR и особенно реальным примерам запросов из ваших Go-проектов.

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