Мне нравится чистый 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"` }
Нужно получить топ клиентов по сумме покупок:
Взять только оплаченные заказы;
Посчитать количество заказов;
Посчитать дату последнего заказа;
Посчитать сумму;
Исключить удалённых клиентов;
Добавить rank по сумме;
Отдать топ-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’ы, копирование |
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:
Буду рад issues, PR и особенно реальным примерам запросов из ваших Go-проектов.
Комментарии (6)

Emulyator
02.06.2026 09:57Я не go разработчик, но с удивлением вынужден накинуть тезис, что в плане работы с sql запросами в коде современный якобы заточенный под бэкенд язык как-то из коробки не сильно удобнее, чем VBA в том же MS Access 2003. Упомянутые в статье подходы визуально не сильно улучшают ситуацию, хотя, вероятно, это дело привычки.

anaxita
02.06.2026 09:57Ради того чтобы просто не писать таблицу руками, которая никогда не меняется вы вместо https://github.com/masterminds/squirrel пишете своё....удачи)
При этом вы можете просто к DTO добавить метод TableName и в целом писпользовать тот же squirel, либо тупо ограничиться константами репозитория

paramtamtam
02.06.2026 09:57Ох, как я вас понимаю, дорогой топикстартер. С год/два назад в своем продуктовом проекте тоже написал свой билдер (qb), а сейчас, когда все более и менее устаканилось - буду переписывать обратно на raw sql, ибо все билдеры по итогу от лукавого. В отличии от эпохи “до билдера” - будет минимум рантайм манипуляций, все по максимуму на go generate + embed.
foggy-f
Интересно. А в sqlx есть же namedQuery , он как будто решает частично то же самое. В чем отличия с вашим билдером, какое преимущество? Не ради похаять, действительно интересно. Я сам таких динамично меняющихся запросов не пишу, предпочитаю чистый SQL в отдельном файле, чтобы код не засорял визуально. Но вдруг понадобится.
SennovE Автор
В sqlx namedQuery помогает в сборе ответа от БД в структуру, но сам запрос все равно пишется на сыром SQL. Я хотел отойти от чистого SQL и перейти в сторону кода на Go с использованием полей структур как колонок БД.
Могу посоветовать посмотреть на sqlc, если используете SQL в отдельных файлах. Sqlc как раз хорошо с таким работает.
foggy-f
Ну звучит это Go Way на мой скромный взгляд. Если там под капотом рефлексии дикой нет, то может даже взлетит. Удачи вам.