Предисловие
Однажды мне пришлось участвовать в переводе большого старого проекта на новую СУБД. Это заняло несколько месяцев тогда. И этот урок я запомнил на всю жизнь. В проекте повсеместно код приложения был перемешан с кодом 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)
}
Постскриптум
Буду безмерно благодарен за советы, здоровую аргументированную критику!
Комментарии (29)

paramtamtam
15.12.2025 22:07Вы наверняка рассматривали вероятность использования кодогена, чтоб не зависеть от магических ключей мапы и не иметь оверхеда на запуск, когда надо добро все прочитать да распарсить. Почему вместо генерации обертки над sql в виде как раз go кода отказались? Ведь такой подход выглядел бы логичнее, строже, и гибче?

stoi Автор
15.12.2025 22:07Да, вы угадали. И долго не мог выбрать - как лучше. Но хотелось иметь максимально простой вариант - добавил в sql-файл запрос и всё. Я периодически забываю запускать кодогенерацию ). Решающим аргументо было: Тесты на все запросы в любом случае должны быть. И ошибки в именах запросов они в любом случае должны выявлять. Если так - кодогенерация становится еще одной дополнительной и не обязательной подстраховкой. Но в целом, я считаю что плюсы и минусы кодогенерации равны 50/50. И это уже личный выбор, вкусовщина.

paramtamtam
15.12.2025 22:07Чтоб не забывать запускать кодоген, часто достаточно не хранить сгенерированные файлы под гитом, и писать тесты ;)
В общем и целом, выбрали бы вы дорожку с генерацией кода - ваш покорный (и, уверен, много кто еще) прониклись и глубже бы стали смотреть ваш проект. И сравнивать его с sqlc, разумеется :DНо магия ключей мап, и оверхед на запуск как минимум для меня выглядят как редфлаги, заставляющие пройти мимо :(
Удачи с проектом, не бойтесь экспериментировать!

stoi Автор
15.12.2025 22:07Оверхед на запуск?! Лишние сколько-то миллисекунд при запуске - редфлаг?... Ну даже не знаю, что сказать )

kukymbr
15.12.2025 22:07Смотря сколько там этих файлов, иногда и листинг может в секунды уйти, а если говорить про, например, микросервис, который предполагается, что должен быренько стартовать на нодах — получается уже не такая и безобидная задержка.

stoi Автор
15.12.2025 22:07В секунду чтение в мапу из памяти (ambedded FS)?!! )))
Да это микросекунды. Готов поставить эксперимент.

a_cid
15.12.2025 22:07Есть ещё sqlbuilder. Умеет в разные диалекты. Таким образом, вместо того чтобы собирать SQL из разных фрагментов, собирается все с помощью Go-кода. Скорее всего, довольно сложные запросы он собрать не поможет, и понадобится решение наподобие вашего.

stoi Автор
15.12.2025 22:07Есть еще huma - тоже билдер. Мне подобный сахар не нравится по нескольким причинам. Напишу в отдельном комментарии. Запросы должны быть статичными если только это возможно. А это возможно и целесообразно в 99,999% случаев.
Вот: https://habr.com/ru/articles/977046/#comment_29259296

stoi Автор
15.12.2025 22:07Недостатки динамических запросов
Смешивается в одну кучу приложения GO и код SQL. Портировать будет труднее.
Уязвимость к SQL-инъекциям — главная и самая опасная. Если в запрос напрямую вставляются пользовательские данные без правильной обработки, злоумышленник может внедрить вредоносный код. Например, классика вроде ' OR '1'='1 может обойти аутентификацию или вытащить все данные из таблицы. Это приводит к утечкам данных, удалению БД или даже выполнению команд на сервере.
Проблемы с производительностью — Каждый динамический запрос часто компилируется заново, что не позволяет эффективно кэшировать планы выполнения. В результате — повышенная нагрузка на CPU, медленные запросы и возможные проблемы с параметр-сниффингом (неоптимальные планы для разных значений параметров).
Сложность отладки и поддержки — Код становится менее читаемым, труднее тестировать и логировать. Ошибки в формировании строки могут привести к синтаксическим ошибкам или неожиданному поведению.

kukymbr
15.12.2025 22:07Какая связь динамически составляемого запроса и SQL-инъекции? Что мешает в динамически формируемом запросе использовать подготовленные запросы и SQL параметры?
«Каждый динамический запрос часто компилируется заново» — но тем не менее, их обычно довольно ограниченное количество, а популярных в рамках приложения комбинаций еще меньше. И происходит эта «компиляция» не каждый раз, а только один при первом выполнении. Так что это всего несколько штук на запущенный инстанс базы, не так это и страшно)

stoi Автор
15.12.2025 22:07Со вторым кейсом на прошлой работе сталкивался мой коллега. И это реально будет проблемой, если нарвешься. Но мне в первую очередь не нравится смешивание GO и SQL. Я люблю порядок, когда всё на своих полках )

a_cid
15.12.2025 22:07Так sqlbuilder и делает статические запросы, если вы только сами не напишете цепочку сбора этого самого запроса динамической.
Билдер накидывает абстракцию над SQL и выражает ее в виде Go методов. И все рядовые запросы выглядят читаемо. Там все те же select, where и equals. И подстановки параметров там безопасные(через ?). Захотели переехать на другую бд- меняете диалект в настройках билдера.
К тому же, он умеет привязываться к гошным структурам через db тэги. Это выглядит ORM, но ограничивается генерацией запроса.

stoi Автор
15.12.2025 22:07а зачем тогда билдер? Почему не писать запросы руками? См https://habr.com/ru/articles/977046/comments/#comment_29260880

a_cid
15.12.2025 22:07Потому что для рядовых запросов он освобождает от необходимости изобретать велосипед. В запросы надо передавать параметры, да ещё и в правильном порядке. Билдер следит за этим сам, от меня ему нужен только тип структуры. Плюс в коде это выглядит понятно и читаемо.
Если нужно написать сложный запрос, то я выберу чистый SQL. У билдера есть свой предел, где он становится неудобным. Но такие запросы не в каждом сервисе существуют.
Я за то чтобы использовать инструмент там, где он хорошо подходит.

stoi Автор
15.12.2025 22:07Признаюсь, что мне подсознательно кажется, что за динамику топят те, кто не любит конструировать сложные SQL запросы, процедуры. Ну или не умеет и не хочет заморачиваться. Кажется так, потому что несколько лет назад был таким же. Но теперь я считаю, что лучше не строить над SQL сладкие надстройки. В любом случае, когда ты пишешь найтивный SQL-код - у тебя больше гибкости и контроля. Юзать билдер - это всё равно, что на Python писать go-код с помощью фреймворка. Всё равно что разговаривать с иностранцем через Google Translate. Ну или гланду удалять через одно место. Изврат )
В самом Postgres, кстати можно создавать динамические запросы в процедурах. Да и вообще, в Postgres сейчас столько всего напихали - трудно найти то, что он не умеет делать. Невозможно. И делает это он по-любому оптимальнее обычно. Как-то так...

stoi Автор
15.12.2025 22:07Билдеры и ОРМ-ы кажется удобными на старте, пока не появились специфические задачи/требования. А потом - это будет боль, мне кажется. Ну и бойлерплейт конечно.
anaxita
А что делать в ситуации когда нам нужна фильтрация по полям и в зависимости от запроса мы должны сформировать разный SQL?
stoi Автор
Не надо разный SQL. Нужно один запрос с масимальным количеством полей. Если поле на задано (пустая строка) - по нему не фильтруем.
Пример:
granv1
ох насмотрелся я на такие "запросы" с максимальным количеством атрибутов, универсализированные "конструкторы", натчнутые на непродуманную семантическу модель без нормальной реализации средствами субд. куча тянущихся говноданных, нагрузка на субд, на сеть, на балансир и тд и тп., а если у вас структура поменялась? сколько sp отвалится? а сколько линков и внешних клиентов? нет однозначных решений, типа, вот это таблетка и она работает везде. каждый случай, особенно на хайлоад уникален. где-то можно и процедурами или функциями, а где-то параметризированными запросами, а где-то вьюхами, а в каком то случае и без динамики не обойтись.
stoi Автор
Всё что угодно, но лучше избегать динамики ИМХО. Помимо всего, СУБД не любят уникальные запросы и это всё может ощутимо сказаться на производительности в целом.
stoi Автор
Вот: https://habr.com/ru/articles/977046/#comment_29259296