golang

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

  • среда, 3 июня 2026 г. в 00:00:21
https://habr.com/ru/articles/1042578/

Мне нравится чистый 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 main

import (
    "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" = $2
ORDER BY "users"."id" ASC
LIMIT 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, age
FROM users
WHERE ...
ORDER BY id
LIMIT 100;

SELECT COUNT(id)
FROM users
WHERE ...;

Сложность обычно не в 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 []UserDTO
if 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_rank
FROM customers
JOIN customer_spend ON customers.id = customer_spend.customer_id
WHERE customers.deleted_at IS NULL
ORDER BY customer_spend.total_spend_cents DESC
LIMIT 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-проектов.