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

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)?!! )))
Да это микросекунды. Готов поставить эксперимент.

kukymbr
15.12.2025 22:07Предлагал сообществу чутка ранее вариант с кодогенерацией. Сравнение с sqlc действительно неизбежно :)

zelenin
15.12.2025 22:07sqlc уже парсит *.sql и генерит по нему код

stoi Автор
15.12.2025 22:07Ну, не всем нравится дополнительная кодогенерация )

zelenin
15.12.2025 22:07а что, она есть просит? после описания sql-запроса в файле вы либо снова ручками пишете код, использующий этот запрос, либо выполняете 1-секундную команду, получая единообразный и типизированный код на все запросы.
Ладно бы ваш подход чем-то принципиальным отличался от sqlc. Так вы просто делаете то же самое, только медленнее и хуже.
stoi Автор
15.12.2025 22:07Ну насчет медленнее и хуже - приведите аргументы, прежде чем клеить ярлыки ). Почему я не хочу использовать кодогенерацию, я уже писал. Я ж не навязываю своё мнение.

zelenin
15.12.2025 22:07Ну насчет медленнее и хуже - приведите аргументы
ручками и неединообразно
Почему я не хочу использовать кодогенерацию
Я периодически забываю запускать кодогенерацию
ну вы же кодогенерацию запускаете, чтобы сгенеренный код использовать? как у вас тут выйдет забыть сгенерить?

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 дает полную свободу и весь диапазон функциональных возможностей.

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

stoi Автор
15.12.2025 22:07Ну а про ORM-ы лучше вообще промолчу ))))

antonb73
15.12.2025 22:07ORM позволяет стандартизировать интерфейс доступа к данным, пожертвовав гибкостью конкретной СУБД, в пользу унификации. Такой подход влияет на архитектуру кода.

stoi Автор
15.12.2025 22:07Верно, согласен. Но на мой взгляд минусы перевешивают плюсы. В реальной жизни обходиться только ОРМ получится только для сферического коня в вакууме. Как концепция - классно. Но на практике - больно. ИМХО...

stoi Автор
15.12.2025 22:07Билдеры и ОРМ-ы кажется удобными на старте, пока не появились специфические задачи/требования. А потом - это будет боль, мне кажется. Ну и бойлерплейт конечно.

antonb73
15.12.2025 22:07Боль начинается из за отказа от унификации и острого желания найти уже готовую фичу в конкретной специфичной БД и пользоватся ею независимо от последствий в будущем - привязал проект к конкретной СУБД и точка, зато все быстро сделал - менеджеры таких любят.
Кстати, ни в коем случае не берите на работу тех, кто сможет объяснить менеджерам почему теперь мы не можем отказатся от этой технологии и использовать аналогичное open source решение :)

stoi Автор
15.12.2025 22:07Ну если менять СУБД каждый год... То конечно ). Переписать имплементацию слоя репозитория раз в 10 лет - не криминально поверьте. Особенно если запросы хранятся отдельно от кода. Но мучится все эти 10 лет с минусами, вызванными абстрагированием от СУБД - тоже такое себе. Впрочем - зависит всё от контекста. И к слову, однотипные СУБД принципиально не отличаются по функционалу. 99% всего что можно делать в MS SQL сделаете и в Postgres легко. Я про SQL код.

antonb73
15.12.2025 22:07Конечно нет, коллация в MSSQL по умолчанию регистронезависимая в отличии от Postgres и далее начиная с пагинации, регистронезависимые ILIKE и до оконных функций - много отличий, даже в типах данных дат, я молчу про json функции.

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