Предисловие

Однажды мне пришлось участвовать в переводе большого старого проекта на новую СУБД. Это заняло несколько месяцев тогда. И этот урок я запомнил на всю жизнь. В проекте повсеместно код приложения был перемешан с кодом SQL-запросов. При этом они во многих местах еще и генерировались динамически из фрагментов текста. С тех пор я являюсь ярым сторонником отделения SQL-кода от непосредственно кода программы и патологически не перевариваю динамическую генерацию запросов.

Мухи - отдельно, котлеты - отдельно

Очевидным и хорошим способом отделения SQL от кода является тотальное использование хранимых процедур. Так я первое время и делал - весь SQL-код хранился непосредственно в БД. Тем не менее, такой подход имеет ряд недостатков:

  • Необходимость в дополнительных миграциях и контроле за ними

  • Меньшая портируемость

  • Более сложный синтаксис

  • Перенесение низкоуровневой логики проекта в слой данных

Кроме того, есть СУБД, которые не поддерживают использование хранимых процедур, такие как SQLite, DuckDB, до недавнего времени CockroachDB.
В связи с этим напрашивается решение - добавить в проект функционал, аналогичный хранимым процедурам - именованный набор текстов SQL-запросов.

SQLSet

Хочу представить вам простую библиотеку sqlset для удобного хранения и использования SQL-запросов в проектах Golang. При старте приложения мы натравливаем её на каталог с файлами наборов SQL-запросов (можно использовать embedded filesystem). Она их загружает в мапу, чтобы потом можно было получать из неё текст запроса по его имени. Каждый sql-файл может содержать произвольное количество запросов и загружается в мапу как отдельный набор. Таким образом, наборы запросов разбиты на категории (например - "user", "payment", "order" и т. д). Собственно, это и всё практически.

Пример использования

// Create a new SQLSet from the embedded filesystem.
sqlSet, err := sqlset.New(queriesFS)
if err != nil {
	log.Fatalf("Failed to create SQL set: %v", err)
}

...

// Get a specific query
query, err := sqlSet.Get("users", "GetUserByID")
if err != nil {
	fmt.Errorf("failed to get query: %w", err)
}

Постскриптум

Буду безмерно благодарен за советы, здоровую аргументированную критику!

Обновление

С момента первой публикации библиотека sqlset была расширена и получила несколько практических улучшений, направленных на упрощение API и снижение количества ошибок при работе с SQL-запросами.

Гибкое указание параметров для Get

Метод Get стал менее строгим к форме аргументов. Помимо классического варианта с указанием набора и имени запроса, теперь поддерживаются более компактные формы.

// Классический вариа��т: набор + запрос
q, err := sqlSet.Get("users", "GetUserByID")

// Один аргумент с нотацией через точку
q, err := sqlSet.Get("users.GetUserByID")

// Если существует единственный набор запросов, набор можно не указывать
q, err := sqlSet.Get("GetUserByID")

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

Опциональная кодогенерация констант идентификаторов

В проект добавлена опциональная кодогенерация Go-констант для идентификаторов SQL-наборов и запросов.
Генерируемые значения используют строковый формат ".".

Пример сгенерированного файла:

package consts

// Code generated by sqlset-gen. DO NOT EDIT.

const (
	// users.sql
	UsersCreateUser  = "users.CreateUser"
	UsersGetUserByID = "users.GetUserByID"
)

Использование таких констант позволяет отказаться от «магических строк» и получить более безопасный и удобный доступ к SQL-запросам:

q, err := sqlSet.Get(consts.UsersGetUserByID)
if err != nil {
	return err
}

Кодогенерация не является обязательной и может использоваться только там, где это действительно оправдано — например, в крупных проектах или при активном рефакторинге.