Однажды в компанию, где я работал, пришел новый тимлид. И «го уберем SQL запросы из кода» стало одной из самых популярных фраз на ревью. Так что посвящается ему :-)

Обращения к базе — одно из самых популярных действий бэкенд приложений, и чаще всего оно происходит с помощью SQL запросов. И есть несколько способов хранить запросы в коде: строка или константа непосредственно в качестве аргумента функции, билдер запросов или отдельно лежащий файл с SQL запросом, который эмбедится в Go код в момент сборки. Этот последний способ чаще всего можно найти по запросу типа «Golang embed SQL» и он действительно довольно хорош:

// Content of the repository.go

package repository

import (
  _ "embed" 
) 

//go:embed user_list.sql
var userListQuery string

func GetUsersList() []User {
  rows, err := db().Query(userListQuery)
  //...
}
-- Content of user_list.sql
SELECT * FROM users;

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

// Content of the repository.go

package repository

import (
  _ "embed" 
) 

//go:embed user_list.sql
var userListQuery string

//go:embed user_get_data.sql
var userGetDataQuery string

//go:embed user_activity_stat.sql
var userActivityStatQuery string

//go:embed user_update_status.sql
var userUdateStatusQuery string

// ... some other 100500 queries

С недавнего времени в своих проектах я использую подход, который положил в оперсорс и скромно хочу предложить читателю: хранение SQL запросов в отдельных файлах, из которых генерируется пакет Go файлов с помощью go:generate.

Набор SQL файлов, структурированных по директориям.
Набор SQL файлов, структурированных по директориям.
Сгенерированные go файлы.
Сгенерированные go файлы.

И тогда код из примера выше меняется на такой:

// Content of the repository.go

package repository

import (
  "queries"
) 

func GetUsersList() []User {
  rows, err := db().Query(queries.Users().GetListQuery())
  //...
}

Запросы по-прежнему не засоряют Go код и лежат отдельными SQL файлами, а кроме того становятся read-only и логически структурированными. Кроме того, у такого подхода есть преимущество по сравнению с эмбедом всей директории: вся логика по обработке файлов запросов выполняется только во время генерации и никак не затрагивается по время исполнения кода приложения.

Небольшой туториал по применению

Минимальная версия go для sqlamble — это 1.24, так что рекомендуемый способ установки — это go tool:

go get -tool github.com/kukymbr/sqlamble/cmd/sqlamble@latest

Затем нам понадобится директория с нашими SQL файлами, например, sql/. В нее нужно положить .sql файл запроса (или не один, или много в разных директориях), а также .go файл с вызовом go:generate внутри:

// Content of the sql/generate.go file

package sql

//go:generate go tool sqlamble --target=../internal/queries

И потом вызвать go generate:

go generate ./sql

Sqlamble создаст директорию internal/queries с go пакетом queries, в котором директории и запросы из папки sql распределены по следующей логике:

  • поддиректории становятся экспортируемыми функциями, например, sql/users/ будет доступна как queries.Users(), а sql/users/single-user/ — как queries.Users().SingleUser();

  • запросы доступны по имени с суффиксом «Query», например, запрос из файла sql/users/get-list.sql будет доступен как queries.Users().GetListQuery().

Изменить пути к SQL файлам, к папке для генерируемых файлов и имя пакета можно с помощью аргументов к команде sqlamble:

  • --source: путь к папке с SQL файлами, по умолчанию это .;

  • --target: путь к папке для go файлов, по умолчанию internal/queries;

  • --package: имя пакета, по умолчанию queries;

  • --ext: список расширений имен (разделитель — запятая) для фильтрации файлов в исходной директории, по умолчанию .sql;

  • --fmt: форматер для сгенерированного кода, можно gofmt, можно выключить (none), по умолчанию gofmt;

  • --silent или -s: не выводить сообщения в stdout, по умолчанию false;

  • --query-suffix: суффикс имен функций-геттеров запросов, по умолчанию Query.

И, конечно, есть встроенная справка:

go tool sqlamble --help

Ссылки

Репозиторий с ридми

Example

P.S.

Тулза свежая, ревью, комментарии, ПРы и прочее приветствуется.

Комментарии (2)


  1. gudvinr
    05.07.2025 07:29

    Это можно сделать и без кодогенерации, если помнить что embed позволяет использовать embed.FS

    Тогда враппер может делать Query("user_select.sql", 1, 2, "abc"). У этого есть свои минусы, но минусы есть и в кодогенерации.


  1. evgeniy_kudinov
    05.07.2025 07:29

    Спасибо за идею и её реализацию.

    Я тоже пришёл к тому, чтобы хранить SQL-код отдельно в файлах с embed. Этот подход позволяет подключать различные инструменты для анализа и проверки SQL, а также использовать современные технологии, такие как LLM. Лично мне нравится работать с LLM, так как она действительно помогает выявлять потенциальные проблемы и уведомляет о них в MR (PR).

    Конечно, есть сложности с переменными, но, на мой взгляд, их можно размещать в зависимости от контекста в более подходящих местах.

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