Embedded SQL с группировкой запросов: элегантный подход к управлению SQL в Go
- понедельник, 22 декабря 2025 г. в 00:00:10
Хотелось бы сразу сделать небольшой дисклеймер. Это не супер-экспертная статья. Скажем так - это мой инсайд о том, как еще можно работать с SQL запросами в Go проекте. В этой статье я расскажу о том как удобно хранить запросы в embeded sql файлах. Решение о котором я буду писать - лишь один из инструментов в арсенале разработчика, а не универсальное решение призванное вылечить все болезни. Надеюсь тебе это будет полезно.
При работе с базой данных в Go-приложениях я использовал несколько подходов к организации SQL-запросов:
Query Builder
Inline SQL
Каждый запрос в отдельном embeded sql файле
Группировка в файле

В этой статье опишу последний, паттерн группировки SQL-запросов. Это подход, где все запросы одной сущности хранятся в одном .sql файле с именованными секциями. Вместо того чтобы хранить каждый запрос в отдельном файле или писать SQL прямо в коде, ты:
Группируешь все запросы сущности в один файл (например, department.sql)
Помечаешь каждый запрос именем через комментарий -- name: QueryName
Загружаешь файл один раз при старте приложения через go:embed
Получаешь нужный запрос по имени через getter
package queries
import (
"embed"
"errors"
"fmt"
"regexp"
"strings"
)
// Директива go:embed встраивает все .sql файлы из текущей директории
// в бинарник при компиляции. Файлы будут доступны через sqlFiles.
//go:embed *.sql
var sqlFiles embed.FS
// Регулярное выражение для поиска маркеров вида "-- name: {{queryName}}"
// (?m) - многострочный режим, ^ соответствует началу каждой строки
var queryNameRegex = regexp.MustCompile(`(?m)^--\s*name:\s*(\w+)\s*$`)
// Queries хранит распарсенные SQL-запросы в виде map[имя]запрос
type Queries struct {
queries map[string]string
}
// MustLoad загружает SQL-файл и паникует при ошибке.
// Используй на уровне пакета для fail-fast при старте приложения.
func MustLoad(filename string) *Queries {
q, err := Load(filename)
if err != nil {
panic(err)
}
return q
}
// Load загружает и парсит SQL-файл, возвращая структуру с запросами.
// Читает файл из embedded FS и разбивает на именованные запросы.
func Load(filename string) (*Queries, error) {
// Читаем содержимое файла из встроенной файловой системы
content, err := sqlFiles.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read sql file %s: %w", filename, err)
}
// Парсим содержимое, разбивая на отдельные запросы по маркерам
queries, err := parse(string(content))
if err != nil {
return nil, fmt.Errorf("parse sql file %s: %w", filename, err)
}
return &Queries{queries: queries}, nil
}
// Get возвращает SQL-запрос по имени.
// Паникует если запрос не найден - это защита от опечаток в runtime.
func (q *Queries) Get(name string) string {
query, ok := q.queries[name]
if !ok {
panic(fmt.Sprintf("query %q not found", name))
}
return query
}
// parse разбирает содержимое SQL-файла на отдельные именованные запросы.
// Ищет маркеры "-- name: X" и извлекает текст между ними.
func parse(content string) (map[string]string, error) {
queries := make(map[string]string)
// Находим все маркеры "-- name: X" с их позициями в тексте
matches := queryNameRegex.FindAllStringSubmatchIndex(content, -1)
if len(matches) == 0 {
return nil, errors.New("no queries found")
}
// Проходим по каждому найденному маркеру
for i, match := range matches {
// Извлекаем имя запроса из группы захвата регулярки
nameStart, nameEnd := match[2], match[3]
name := content[nameStart:nameEnd]
// Текст запроса начинается сразу после маркера
queryStart := match[1]
var queryEnd int
// Запрос заканчивается там, где начинается следующий маркер
// или в конце файла, если это последний запрос
if i+1 < len(matches) {
queryEnd = matches[i+1][0]
} else {
queryEnd = len(content)
}
// Убираем лишние пробелы и сохраняем запрос
query := strings.TrimSpace(content[queryStart:queryEnd])
queries[name] = query
}
return queries, nil
}
-- name: Create
INSERT INTO departments (name, description, parent_id)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at;
-- name: GetByID
SELECT id, name, description, parent_id, created_at, updated_at
FROM departments
WHERE id = $1;
-- name: GetAll
SELECT id, name, description, parent_id, created_at, updated_at
FROM departments
ORDER BY name;
-- name: Update
UPDATE departments
SET name = $2, description = $3, parent_id = $4, updated_at = NOW()
WHERE id = $1
RETURNING updated_at;
-- name: Delete
DELETE FROM departments WHERE id = $1;
package postgresql
import (
"context"
"github.com/dsbasko/team-pulse/internal/domain"
"github.com/dsbasko/team-pulse/internal/repositories/postgresql/queries"
"github.com/google/uuid"
)
// Загружаем все запросы при инициализации пакета.
// MustLoad паникует при ошибке - приложение не запустится с битым SQL.
// Это происходит один раз при старте, потом запросы берутся из памяти.
var departmentQueries = queries.MustLoad("department.sql")
// DepartmentRepository - репозиторий для работы с департаментами.
// Встраивает BaseRepository для переиспользования общей логики.
type DepartmentRepository struct {
*BaseRepository
}
// NewDepartmentRepository создает новый репозиторий.
// Принимает интерфейс DB, а не конкретный тип - это позволяет
// подставлять моки в тестах.
func NewDepartmentRepository(db DB) *DepartmentRepository {
return &DepartmentRepository{
BaseRepository: NewBaseRepository(db),
}
}
// Create создает новый департамент в БД.
// Возвращает сгенерированные поля обратно в структуру.
func (r *DepartmentRepository) Create(ctx context.Context, dept *domain.Department) error {
const op = "DepartmentRepository.Create" // Для контекста в ошибках
// departmentQueries.Get("Create") возвращает SQL-строку из файла.
// Аргументы передаются в том же порядке, что и $1, $2, $3 в запросе.
err := r.db.QueryRow(ctx, departmentQueries.Get("Create"),
dept.Name,
dept.Description,
dept.ParentID,
).Scan(
&dept.ID,
&dept.CreatedAt,
&dept.UpdatedAt,
)
if err != nil {
return WrapError(op, err)
}
return nil
}
// GetByID возвращает департамент по UUID.
// Возвращает ошибку если департамент не найден.
func (r *DepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Department, error) {
const op = "DepartmentRepository.GetByID"
// Выполняем запрос с параметром id
rows, err := r.db.Query(ctx, departmentQueries.Get("GetByID"), id)
if err != nil {
return nil, WrapError(op, err)
}
// ScanOne - хелпер, который сканирует одну строку или возвращает ErrNoRows
department, err := ScanOne(rows, scanDepartment)
if err != nil {
return nil, WrapError(op, err)
}
return &department, nil
}
// GetAll возвращает все департаменты.
// Если департаментов нет - возвращает пустой слайс, не ошибку.
func (r *DepartmentRepository) GetAll(ctx context.Context) ([]domain.Department, error) {
const op = "DepartmentRepository.GetAll"
// Запрос без параметров - получаем все записи
rows, err := r.db.Query(ctx, departmentQueries.Get("GetAll"))
if err != nil {
return nil, WrapError(op, err)
}
// ScanMany - хелпер для сканирования нескольких строк в слайс
return ScanMany(rows, scanDepartment)
}
internal/repositories/postgresql/
├── queries/ # Директория с SQL-файлами
│ ├── embed.go # Парсер: go:embed + regex + Load/Get
│ ├── department.sql # Все запросы для таблицы departments
│ ├── team.sql # Все запросы для таблицы teams
│ ├── employee.sql # Все запросы для таблицы employees
│ └── project.sql # Все запросы для таблицы projects
├── department.go # Использует departmentQueries.Get("X")
├── team.go # Использует teamQueries.Get("X")
├── employee.go # Использует employeeQueries.Get("X")
└── project.go # Использует projectQueries.Get("X")
# Принцип: один .sql файл = один .go репозиторий = одна таблица/сущность

Репозиторий содержит только логику работы с данными, без SQL-строк:
// ❌ SQL-запрос прямо в коде
rows, err := r.db.Query(ctx, `
SELECT id, name, description, parent_id, created_at, updated_at
FROM departments
WHERE id = $1
`, id)
// ✅ Запрос загружается из .sql файла по имени
rows, err := r.db.Query(ctx, departmentQueries.Get("GetByID"), id)
IDE распознаёт .sql файлы и предоставляет:
Подсветку синтаксиса
Автодополнение
Проверку ошибок
Форматирование
Все запросы одной сущности в одном файле - легко найти и модифицировать.
go:embed встраивает SQL-файлы в исполняемый файл:
Нет зависимости от внешних файлов
Нет риска потерять SQL-файлы
Ошибка парсинга = ошибка компиляции
MustLoad и panic в Get гарантируют, что:
Ошибки в SQL-файлах обнаруживаются сразу при старте
Опечатки в именах запросов не дойдут до production
Можно тестировать SQL-запросы изолированно от Go-кода.
// Query Builder строит SQL программно через цепочку методов.
// Плюс: можно динамически добавлять условия (if needFilter { .Where(...) })
// Минус: сложнее читать, нет подсветки SQL, overhead на построение
query, args, _ := sq.
Select("id", "name", "description", "parent_id", "created_at", "updated_at").
From("departments").
Where(sq.Eq{"id": id}).
PlaceholderFormat(sq.Dollar).
ToSql()
Когда использовать: Динамические запросы с условиями, фильтрация по разным полям.
// Inline SQL - запрос хранится как константа прямо в Go-коде.
// Плюс: всё в одном месте, не нужен парсер
// Минус: нет подсветки SQL, захламляет код при большом количестве запросов
const getDepartmentByID = `
SELECT id, name, description, parent_id, created_at, updated_at
FROM departments
WHERE id = $1
`
Когда использовать: Простые проекты, прототипы, один-два запроса.
# Каждый SQL-запрос в отдельном файле.
# Плюс: изоляция, удобно для очень длинных запросов
# Минус: много файлов, сложная навигация при 50+ запросах
queries/
├── department_create.sql # INSERT запрос
├── department_get_by_id.sql # SELECT по ID
├── department_get_all.sql # SELECT всех записей
├── department_update.sql # UPDATE запрос
└── department_delete.sql # DELETE запрос
Когда использовать: Очень сложные запросы на 100+ строк с CTE и подзапросами.
# Все запросы одной сущности в одном файле с маркерами -- name: X
# Плюс: баланс между организацией и простотой
# Минус: нужен парсер для разделения запросов
queries/
└── department.sql # Create, GetByID, GetAll, Update, Delete - всё здесь
Когда использовать: Большинство реальных проектов с типовыми CRUD-операциями.
Для себя я вывел несколько best practices, которые позволяют удобно работать с этим подходом. Перечислю то что помню:
Используй глаголы в PascalCase:
Create, GetByID, GetAll, Update, Delete
GetByEmail, GetActiveUsers, CountByStatus
Не спешивай запросы разных таблиц в одном файле (не или хотябы старайся).
-- name: GetEmployeesWithMetrics
-- Описание: Возвращает сотрудников с агрегированными метриками за период.
-- Используется в: отчёты, дашборды, аналитика команды
-- Параметры:
-- $1 = team_id (UUID команды)
-- $2 = start_date (начало периода)
-- $3 = end_date (конец периода)
SELECT
e.id,
e.name,
-- Считаем уникальные коммиты сотрудника за период
COUNT(DISTINCT c.id) as commit_count,
-- Считаем уникальные merge request'ы за период
COUNT(DISTINCT mr.id) as mr_count
FROM employees e
-- LEFT JOIN чтобы показать сотрудников даже без коммитов
LEFT JOIN commits c ON c.author_id = e.id
AND c.created_at BETWEEN $2 AND $3 -- Фильтр по пе��иоду
LEFT JOIN merge_requests mr ON mr.author_id = e.id
AND mr.created_at BETWEEN $2 AND $3
WHERE e.team_id = $1 -- Фильтр по команде
GROUP BY e.id, e.name; -- Группировка для агрегатных функций
Используйте MustLoad вместо Load для критичных запросов:
// MustLoad вызывает panic() если:
// - файл не найден
// - файл не содержит ни одного маркера "-- name: X"
// - ошибка чтения файла
//
// Это гарантирует fail-fast: приложение упадёт при старте,
// а не в runtime когда пользователь попытается выполнить запрос.
// Лучше узнать о проблеме сразу, чем в 3 часа ночи на проде.
//
// Можно также добавить валидацию самого SQL при желании.
var queries = queries.MustLoad("department.sql")
В общем, пора заканчивать статью! Паттерн группировки SQL-запросов - это золотая середина между полным контролем над SQL и чистотой Go-кода. Он особенно хорош для проектов с классическим CRUD и команд где разработчики пишут SQL вручную.

Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.
Вопросы, которые стоит задать себе:
Насколько динамичны мои запросы?
Важнее контроль над SQL или скорость разработки?
Какой уровень экспертизы в SQL у команды?
Как часто меняются требования к запросам?
Правильный ответ всегда зависит от контекста. Паттерн группировки хорошо работает для стабильных CRUD-операций, но для сложной бизнес-логики с динамическими фильтрами может потребоваться комбинация подходов.
Не бойся использовать разные инструменты в одном проекте:
Embedded SQL для стандартных операций
Query Builder для динамических отчётов
Raw SQL для критичных по производительности запросов
Главное - осознанный выбор, а не слепое следование шаблонам.
Спасибо за уделенное время, надеюсь материал был полезен :-)
Кстати, веду небольшой дневник в телеге, вдруг кому интересно...