golang

Embedded SQL с группировкой запросов: элегантный подход к управлению SQL в Go

  • понедельник, 22 декабря 2025 г. в 00:00:10
https://habr.com/ru/articles/978974/

Хотелось бы сразу сделать небольшой дисклеймер. Это не супер-экспертная статья. Скажем так - это мой инсайд о том, как еще можно работать с SQL запросами в Go проекте. В этой статье я расскажу о том как удобно хранить запросы в embeded sql файлах. Решение о котором я буду писать - лишь один из инструментов в арсенале разработчика, а не универсальное решение призванное вылечить все болезни. Надеюсь тебе это будет полезно.

При работе с базой данных в Go-приложениях я использовал несколько подходов к организации SQL-запросов:

  1. Query Builder

  2. Inline SQL

  3. Каждый запрос в отдельном embeded sql файле

  4. Группировка в файле

В этой статье опишу последний, паттерн группировки SQL-запросов. Это подход, где все запросы одной сущности хранятся в одном .sql файле с именованными секциями. Вместо того чтобы хранить каждый запрос в отдельном файле или писать SQL прямо в коде, ты:

  1. Группируешь все запросы сущности в один файл (например, department.sql)

  2. Помечаешь каждый запрос именем через комментарий -- name: QueryName

  3. Загружаешь файл один раз при старте приложения через go:embed

  4. Получаешь нужный запрос по имени через getter

Реализация

Парсер SQL-файлов

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
}

SQL-файл с запросами

-- 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 репозиторий = одна таблица/сущность

Преимущества подхода

1. Чистый 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)

2. SQL с подсветкой синтаксиса

IDE распознаёт .sql файлы и предоставляет:

  • Подсветку синтаксиса

  • Автодополнение

  • Проверку ошибок

  • Форматирование

3. Группировка по сущности

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

4. Компиляция в бинарник

go:embed встраивает SQL-файлы в исполняемый файл:

  • Нет зависимости от внешних файлов

  • Нет риска потерять SQL-файлы

  • Ошибка парсинга = ошибка компиляции

5. Fail-fast при старте

MustLoad и panic в Get гарантируют, что:

  • Ошибки в SQL-файлах обнаруживаются сразу при старте

  • Опечатки в именах запросов не дойдут до production

6. Лёгкое тестирование

Можно тестировать SQL-запросы изолированно от Go-кода.

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

Query Builder (squirrel)

// 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

// 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, которые позволяют удобно работать с этим подходом. Перечислю то что помню:

1. Именование запросов

Используй глаголы в PascalCase:

  • Create, GetByID, GetAll, Update, Delete

  • GetByEmail, GetActiveUsers, CountByStatus

2. Один файл = одна сущность

Не спешивай запросы разных таблиц в одном файле (не или хотябы старайся).

3. Комментарии для сложных запросов

-- 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;  -- Группировка для агрегатных функций

4. Валидация при старте

Используйте MustLoad вместо Load для критичных запросов:

// MustLoad вызывает panic() если:
// - файл не найден
// - файл не содержит ни одного маркера "-- name: X"
// - ошибка чтения файла
//
// Это гарантирует fail-fast: приложение упадёт при старте,
// а не в runtime когда пользователь попытается выполнить запрос.
// Лучше узнать о проблеме сразу, чем в 3 часа ночи на проде.
//
// Можно также добавить валидацию самого SQL при желании.
var queries = queries.MustLoad("department.sql")

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

Философия выбора

Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.

Вопросы, которые стоит задать себе:

  1. Насколько динамичны мои запросы?

  2. Важнее контроль над SQL или скорость разработки?

  3. Какой уровень экспертизы в SQL у команды?

  4. Как часто меняются требования к запросам?

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

Не бойся использовать разные инструменты в одном проекте:

  • Embedded SQL для стандартных операций

  • Query Builder для динамических отчётов

  • Raw SQL для критичных по производительности запросов

Главное - осознанный выбор, а не слепое следование шаблонам.

Спасибо за уделенное время, надеюсь материал был полезен :-)

Кстати, веду небольшой дневник в телеге, вдруг кому интересно...