Предисловие

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


  1. anaxita
    15.12.2025 22:07

    А что делать в ситуации когда нам нужна фильтрация по полям и в зависимости от запроса мы должны сформировать разный SQL?


    1. stoi Автор
      15.12.2025 22:07

      Не надо разный SQL. Нужно один запрос с масимальным количеством полей. Если поле на задано (пустая строка) - по нему не фильтруем.
      Пример:

      CREATE OR REPLACE FUNCTION get_account_list(
      	IN a_login TEXT,
      	IN a_password TEXT,
      	IN a_limit INT,
      	IN a_offset INT
      )
      RETURNS TABLE (
        list client_accounts,
        full_count BIGINT
      )
      LANGUAGE plpgsql
      AS $$
      BEGIN
          RETURN QUERY
      		SELECT a, count(*) OVER() AS full_count FROM client_accounts AS a
      		WHERE ((a.login = a_login) OR (a_login = '')) and
      			((a.password = a_password) OR (a_password = '')) 
      		ORDER BY a.id  ASC
      		LIMIT a_limit
      		OFFSET a_offset;			
      END;
      $$;
      


      1. granv1
        15.12.2025 22:07

        ох насмотрелся я на такие "запросы" с максимальным количеством атрибутов, универсализированные "конструкторы", натчнутые на непродуманную семантическу модель без нормальной реализации средствами субд. куча тянущихся говноданных, нагрузка на субд, на сеть, на балансир и тд и тп., а если у вас структура поменялась? сколько sp отвалится? а сколько линков и внешних клиентов? нет однозначных решений, типа, вот это таблетка и она работает везде. каждый случай, особенно на хайлоад уникален. где-то можно и процедурами или функциями, а где-то параметризированными запросами, а где-то вьюхами, а в каком то случае и без динамики не обойтись.


        1. stoi Автор
          15.12.2025 22:07

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



  1. paramtamtam
    15.12.2025 22:07

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


    1. stoi Автор
      15.12.2025 22:07

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


      1. paramtamtam
        15.12.2025 22:07

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

        Но магия ключей мап, и оверхед на запуск как минимум для меня выглядят как редфлаги, заставляющие пройти мимо :(

        Удачи с проектом, не бойтесь экспериментировать!


        1. stoi Автор
          15.12.2025 22:07

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


          1. kukymbr
            15.12.2025 22:07

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


            1. stoi Автор
              15.12.2025 22:07

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


              1. kukymbr
                15.12.2025 22:07

                Если embed — да, но в библиотеке используется fs.FS, следовательно, os.DirFS(), например, тоже подойдет.


                1. stoi Автор
                  15.12.2025 22:07

                  Ну так делать не надо, если только нет на то очень веских причин ))))


  1. zelenin
    15.12.2025 22:07

    sqlc уже парсит *.sql и генерит по нему код


  1. antonb73
    15.12.2025 22:07

    Вопрос, в Go до сих пор нет ORM? Хотя бы аналог Dapper для C#, ну или что то более серьёзное типа EF?


    1. stoi Автор
      15.12.2025 22:07

      Вопрос: А зачем в нативных либах (в родном пакете) - ОРМ? ИМХО - перебор. В сторонних либах - есть всё что душе угодно, выбирай - не хочу )


  1. a_cid
    15.12.2025 22:07

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


    1. stoi Автор
      15.12.2025 22:07

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


      1. kukymbr
        15.12.2025 22:07

        huma вроде бы не имеет отношения к БД. goqu?


        1. stoi Автор
          15.12.2025 22:07

          Ты прав! Вот же блин всё время путаю. Дурацкие названия.. ))


  1. stoi Автор
    15.12.2025 22:07

    Недостатки динамических запросов

    • Смешивается в одну кучу приложения GO и код SQL. Портировать будет труднее.

    • Уязвимость к SQL-инъекциям — главная и самая опасная. Если в запрос напрямую вставляются пользовательские данные без правильной обработки, злоумышленник может внедрить вредоносный код. Например, классика вроде ' OR '1'='1 может обойти аутентификацию или вытащить все данные из таблицы. Это приводит к утечкам данных, удалению БД или даже выполнению команд на сервере.

    • Проблемы с производительностью — Каждый динамический запрос часто компилируется заново, что не позволяет эффективно кэшировать планы выполнения. В результате — повышенная нагрузка на CPU, медленные запросы и возможные проблемы с параметр-сниффингом (неоптимальные планы для разных значений параметров).

    • Сложность отладки и поддержки — Код становится менее читаемым, труднее тестировать и логировать. Ошибки в формировании строки могут привести к синтаксическим ошибкам или неожиданному поведению.


    1. kukymbr
      15.12.2025 22:07

      Какая связь динамически составляемого запроса и SQL-инъекции? Что мешает в динамически формируемом запросе использовать подготовленные запросы и SQL параметры?

      «Каждый динамический запрос часто компилируется заново» — но тем не менее, их обычно довольно ограниченное количество, а популярных в рамках приложения комбинаций еще меньше. И происходит эта «компиляция» не каждый раз, а только один при первом выполнении. Так что это всего несколько штук на запущенный инстанс базы, не так это и страшно)


      1. stoi Автор
        15.12.2025 22:07

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


    1. a_cid
      15.12.2025 22:07

      Так sqlbuilder и делает статические запросы, если вы только сами не напишете цепочку сбора этого самого запроса динамической.

      Билдер накидывает абстракцию над SQL и выражает ее в виде Go методов. И все рядовые запросы выглядят читаемо. Там все те же select, where и equals. И подстановки параметров там безопасные(через ?). Захотели переехать на другую бд- меняете диалект в настройках билдера.

      К тому же, он умеет привязываться к гошным структурам через db тэги. Это выглядит ORM, но ограничивается генерацией запроса.


      1. stoi Автор
        15.12.2025 22:07

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


        1. a_cid
          15.12.2025 22:07

          Потому что для рядовых запросов он освобождает от необходимости изобретать велосипед. В запросы надо передавать параметры, да ещё и в правильном порядке. Билдер следит за этим сам, от меня ему нужен только тип структуры. Плюс в коде это выглядит понятно и читаемо.

          Если нужно написать сложный запрос, то я выберу чистый SQL. У билдера есть свой предел, где он становится неудобным. Но такие запросы не в каждом сервисе существуют.

          Я за то чтобы использовать инструмент там, где он хорошо подходит.


  1. stoi Автор
    15.12.2025 22:07

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


  1. stoi Автор
    15.12.2025 22:07

    Ну а про ORM-ы лучше вообще промолчу ))))


  1. stoi Автор
    15.12.2025 22:07

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