Команда Go for Devs подготовила перевод статьи о том, как превратить SQL в полноценный API прямо в Go. Автор показывает, как можно безопасно принимать WHERE
-условия в виде SQL-подзапросов, валидировать их и использовать для запросов к базе. Просто, элегантно и почти без лишнего кода.
Вашему API нужно поддерживать запросы, которые слишком сложны для простого CRUD, но при этом недостаточно хитрые, чтобы оправдать использование GraphQL? Попробуйте принять ограниченный набор SQL-условий WHERE
, реализовав необходимые проверки безопасности на Go.
Что за…?! Давать доступ к SQL через API-эндпоинт? Звучит небезопасно. Но всё же, при умеренной сложности запросов простые CRUD-API быстро упираются в потолок. Если не хочется переходить на полноценный GraphQL, то публикация части SQL через REST-API может оказаться рабочей альтернативой.
Валентин Виллшер предложил эту нестандартную идею. В своей статье он объясняет, почему использование SQL-условий WHERE
в контролируемом виде не обязательно связано с рисками и как это может заметно упростить дизайн API.
Эта мысль показалась мне действительно любопытной, но возникла одна проблема: примеры кода написаны на Scala. Прочитав статью, я сразу понял, что должен переписать этот код на Go.
Задача: обработка и проверка конкретного условия WHERE
В статье Валентина рассматривается такой сценарий: интернет-магазин хочет добавить фильтрацию велосипедов по характеристикам, например по весу или материалу. Допустим, вы ищете велосипед из стали весом от 10 до 20 килограммов. Для этого в SQL-условии WHERE
понадобятся всего три оператора: and
, between
и =
:
material = 'steel' and weight between 10 and 20
Можно представить и более сложные запросы, например:
(material = 'steel' and weight between 10 and 20) or
(material = 'carbon' and weight between 5 and 15)
Однако в коде, который я приведу ниже, операция OR
не реализована — оставим эту операцию в качестве упражнения для читателя.
Главная цель кода — убедиться, что входящие условия WHERE
соответствуют ожидаемой структуре.
Подготовка
Хотите написать полноценный SQL-парсер? Я тоже, но не для этой задачи. Поэтому, следуя статье Валентина и ради краткости, будем считать, что у нас уже есть сторонний SQL-парсер, который принимает условие WHERE
и возвращает абстрактное синтаксическое дерево (AST) этого условия.
Таким образом, я начну с структуры данных, которая представляет это AST. Дальнейший код будет обрабатывать и проверять это AST, а затем воссоздавать исходное условие WHERE
. (Валентин в своей статье подробно объясняет, зачем это нужно.)
Шаг 1: Определяем типы
Предположим, что у нас есть воображаемый SQL-парсер, который возвращает структуру данных AST. Следующие типы моделируют вывод этого парсера. В реальном проекте библиотека SQL-парсера определит и экспортирует похожий набор типов.
package sqlasapi
import (
"errors"
"fmt"
"strconv"
"testing"
)
// Expr представляет выражение в SQL.
// Оно может состоять из подвыражений и значений.
type Expr interface{}
// Column — колонка таблицы, к которой можно обращаться в условиях.
type Column struct {
Name string
}
// And — оператор "и", принимает два выражения.
type And struct {
Left, Right Expr
}
// Or — оператор "или", принимает два выражения.
type Or struct {
Left, Right Expr
}
// Between — оператор "между", принимает колонку и два целых значения.
type Between struct {
Column Column
Lower, Upper int
}
// Parenthesis — выражение в скобках.
type Parenthesis struct {
Expr Expr
}
// Equals — оператор "равно", принимает колонку и значение.
// Сравнение колонок между собой не допускается.
type Equals struct {
Column Column
Value Value
}
// Value — интерфейс для представления значений в SQL-выражениях.
type Value interface{}
// StringValue — строковое значение.
type StringValue struct {
Value string
}
// IntegerValue — числовое значение.
type IntegerValue struct {
Value int
}
Шаг 2: Парсинг SQL
Этот шаг можно пропустить, так как выше мы уже смоделировали упрощённый AST для SQL. В реальном проекте, скорее всего, вы бы выбрали библиотеку для парсинга, например github.com/xwb1989/sqlparser, чтобы преобразовать строковое представление SQL-запроса в AST. Но ради краткости будем считать, что это уже сделано.
Шаг 3: Обработка выражения WHERE
Наша цель — поддерживать такие условия WHERE
, которые фильтруют по материалу и весу, используя операторы AND
, BETWEEN
и =
.
Предположим, что наш воображаемый SQL-парсер получил условие:
(material = 'steel' AND weight BETWEEN 10 AND 20)
и вернул на основе наших типов AST вот такой:
And{
Left: Equals{
Column: Column{
Name: "material",
},
Value: StringValue{
Value: "steel",
},
},
Right: Between{
Column: Column{
Name: "weight",
},
Lower: 10,
Upper: 20,
},
}
С этого и начнём (так же, как в оригинальной статье).
Таким образом:
Вход: условие
WHERE
в виде структуры AST-
Выход:
либо строка с условием
WHERE
, которую можно использовать для запроса к базе данных,либо ошибка, если вход содержит недопустимые операции или параметры.
Обработка SQL-выражения выполняется рекурсивно. Функция processSqlExpr()
основана на операторе type switch
, с помощью которого она проходит по структуре AST. Для каждого выражения, которое не является простым значением, processSqlExpr()
рекурсивно вызывает саму себя для подвыражений и затем собирает итоговое строковое представление из текущего выражения и его подвыражений.
Для простых значений processSqlExpr()
вызывает функцию processSqlValue()
, которая определяет, является ли значение строкой или числом, и возвращает соответствующее строковое представление.
Если структура AST соответствует нашим требованиям, processSqlExpr()
возвращает безопасное и корректное условие WHERE
в текстовом виде.
// processSqlExpr обходит AST-структуру и формирует текстовое выражение WHERE.
// Если встречаются неподдерживаемые операции или колонки, возвращает ошибку.
func processSqlExpr(expr Expr, columns map[string]struct{}) (string, error) {
switch e := expr.(type) {
// Проверяем, что имя колонки разрешено (есть в whitelist).
case Column:
if _, ok := columns[e.Name]; !ok {
return "", fmt.Errorf("column %s is unknown and not supported", e.Name)
}
return e.Name, nil
// Обрабатываем оператор AND: рекурсивно вызываем обработку для левого и правого операнда.
case And:
left, err := processSqlExpr(e.Left, columns)
if err != nil {
return "", fmt.Errorf("case And -> e.Left: %w", err)
}
right, err := processSqlExpr(e.Right, columns)
if err != nil {
return "", fmt.Errorf("case And -> e.Right: %w", err)
}
// Собираем итоговое выражение.
return fmt.Sprintf("%s AND %s", left, right), nil
// Пока что оператор OR не реализован.
case Or:
return "", errors.New("OR clauses are not supported yet")
// Обрабатываем оператор BETWEEN: колонку и два граничных значения.
case Between:
column, err := processSqlExpr(e.Column, columns)
if err != nil {
return "", fmt.Errorf("case Between: %w", err)
}
// При необходимости можно добавить дополнительную валидацию границ.
return fmt.Sprintf("%s BETWEEN %d AND %d", column, e.Lower, e.Upper), nil
// Обрабатываем выражения в скобках. Вложенные скобки ((…)) удаляются.
case Parenthesis:
switch e.Expr.(type) {
case Parenthesis:
e = e.Expr.(Parenthesis)
}
inner, err := processSqlExpr(e.Expr, columns)
if err != nil {
return "", fmt.Errorf("case Parenthesis: %w", err)
}
return fmt.Sprintf("(%s)", inner), nil
// Обрабатываем оператор = : слева колонка, справа простое значение.
case Equals:
column, err := processSqlExpr(e.Column, columns)
if err != nil {
return "", fmt.Errorf("case Equals -> e.Column: %w", err)
}
value, err := processSqlValue(e.Value)
if err != nil {
return "", fmt.Errorf("case Equals -> e.Value: %w", err)
}
return fmt.Sprintf("%s = %s", column, value), nil
// Никакие другие типы выражений не поддерживаются.
default:
return "", fmt.Errorf("unsupported expr type: %T", expr)
}
}
// processSqlValue принимает SQL-значение и возвращает его строковое представление.
func processSqlValue(value Value) (string, error) {
switch v := value.(type) {
// Строки заключаются в одинарные кавычки.
case StringValue:
return fmt.Sprintf("'%s'", v.Value), nil
// Для чисел — стандартное преобразование в строку.
case IntegerValue:
return strconv.Itoa(v.Value), nil
// Другие типы значений не допускаются.
default:
return "", fmt.Errorf("unsupported value type: %T", value)
}
}
Шаг 4: Тестируем
Короткий параметризованный (table-driven) тест показывает, как работает логика обработки SQL.
Код должен возвращать ошибку, если встречается оператор
or
— он допустим концептуально, но пока не реализован.Код должен корректно проверять простые выражения с
and
,between
и=
.Имена колонок должны быть из белого списка.
Допускаются только целочисленные и строковые значения.
// TestProcessSqlExpr — набор table-driven тестов для проверки обработки WHERE-выражений.
func TestProcessSqlExpr(t *testing.T) {
tests := []struct {
name string
expr Expr
columns map[string]struct{}
want string
wantErr bool
}{
// Оператор OR пока не поддерживается — ожидаем ошибку.
{
name: "OR clause unsupported",
expr: Or{
Left: And{
Left: Equals{Column: Column{Name: "material"}, Value: StringValue{Value: "steel"}},
Right: Between{Column: Column{Name: "weight"}, Lower: 10, Upper: 20},
},
Right: And{
Left: Equals{Column: Column{Name: "material"}, Value: StringValue{Value: "carbon"}},
Right: Between{Column: Column{Name: "weight"}, Lower: 5, Upper: 10},
},
},
columns: map[string]struct{}{"material": {}, "weight": {}},
wantErr: true,
},
// Корректное выражение AND, дополнительно обёрнутое в скобки — должно пройти.
{
name: "Nested AND with parentheses",
expr: Parenthesis{
Expr: And{
Left: Equals{Column: Column{Name: "material"}, Value: StringValue{Value: "steel"}},
Right: Between{Column: Column{Name: "weight"}, Lower: 10, Upper: 20},
},
},
columns: map[string]struct{}{"material": {}, "weight": {}},
want: "(material = 'steel' AND weight BETWEEN 10 AND 20)",
},
// Попытка подменить имя колонки (например, выбрать по retail_price) — должна привести к ошибке.
{
name: "Wrong column name",
expr: Parenthesis{
Expr: Parenthesis{
Expr: And{
Left: Equals{Column: Column{Name: "material"}, Value: StringValue{Value: "steel"}},
Right: Between{Column: Column{Name: "retail_price"}, Lower: 500, Upper: 1000},
},
},
},
columns: map[string]struct{}{"material": {}, "weight": {}},
wantErr: true,
},
}
// Запуск тестов.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := processSqlExpr(tt.expr, tt.columns)
if (err != nil) != tt.wantErr {
t.Errorf("processSqlExpr() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("processSqlExpr() = %v, want %v", got, tt.want)
}
})
}
}
Следующие шаги
Приведённый выше код — это ядро концепции «SQL как API». Вероятно, вы захотите расширить покрытие тестами и добавить меры безопасности помимо проверки структуры запроса и значений.
Далее можно построить API, которое принимает в качестве входных данных допустимую форму SQL-условия WHERE
, парсит её, обрабатывает, выполняет сгенерированный и очищенный запрос к БД и возвращает результаты.
Русскоязычное Go сообщество
Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Заключение
Реализовать валидатор SQL-условий WHERE на Go довольно просто — почти тривиально.
Больше никаких оправданий для API, которые не принимают SQL-запросы! Больше никаких сложных и хрупких API, пытающихся заново изобрести колесо под названием SQL.
Как получить и запустить код?
Вариант 1: забрать код через git, перейти в репозиторий и запустить тесты.
git clone https://github.com/appliedgo/sqlasapi
cd sqlasapi
go test -v .
Вариант 2: запустить «чистый» код в Go Playground.
Комментарии (9)
Crarkie
06.09.2025 05:45Идея может и интересная академически, но всё ещё непонятно, зачем оно нужно, например, в продакшене? Чтобы увеличить время отработки запроса за счёт парсинга SQL и построения запроса? Чтобы усложнить себе работу за счёт изобретения сферических велосипедов в вакууме? В данном конкретном сценарии запросы простые, и вполне можно обойтись передачей условий как параметров и на стороне backend билдить из них SQL запрос, либо вообще использовать ORM (при желании). Для сколько нибудь сложных запросов придется сюда затащить аналог целого движка SQL и ещё строго проверять, что в этом подзапросе не запросят что-нибудь лишнего из других таблиц, что позволит например методом наблюдения (прошло условие или нет) узнать структуру БД или что-то о данных в этой самой базе. В общем, не вижу практических примеров того, где это реально могло бы помочь и при этом показать себя лучше, чем взять банально тот же GraphQL.
CentariumV
06.09.2025 05:45«Просто, элегантно и почти без лишнего кода» - ну такое себе. Себя не похвалишь, никто не похвалит. Это имеется в виду две этих функции - processSqlValue и processSqlExpr? Сама по себе задача то простая. Это даже не полноценный sqlParser/sqlBuilder. Простенький where/between/and/or реализуется.
«Реализовать валидатор SQL-условий WHERE на Go довольно просто — почти тривиально» - как бы и так очевидно. Любой джун конкретно с указанной в статье задачей справится. Если копнуть глубже, вроде условий IN, подстроки или select с join - чем более функционально, тем более сложно будет, придется также учитывать, как обрабатывают SQL конкретные субд/движки.
Ну то есть если кому - то действительно нужно написать много апи, где используется простейший where/and/or, то вместо кучи однообразных апи с преобразованием в Sql через dbx можно использовать такое решение. Или использовать кодогенерацию. Но на практике чаще сразу используют graphql - и более функционален и полно готовых надстроек для контроля доступа
cmyser
06.09.2025 05:45Есть же sqlc который генерит репозиторий на основе запроса SQL , вот он экономит время
Sipaha
06.09.2025 05:45А как graphql помогает решать подобную задачу с произвольными фильтрами? Насколько помню в стандарте не было ничего об универсальном языке запросов.
ilving
06.09.2025 05:45Есть squirrel, который отлично подходит для таких задач.
У меня есть личная аллергия на формирование SQL запроса через принты.
Только мне в обработке строк чудится заготовка под инъекции?
michael_v89
06.09.2025 05:45Допустим, вы ищете велосипед из стали весом от 10 до 20 килограммов.
// GET /api/products?material=steel&weightFrom=10&weightTo=20 $qb = $this->productRepository->createQueryBuilder(); if ($queryParams->get('material')) { $qb->andWhere('material', '=', $queryParams->get('material')); } if ($queryParams->get('weightFrom')) { $qb->andWhere('weight', '>=', $queryParams->get('weightFrom')); } if ($queryParams->get('weightTo')) { $qb->andWhere('weight', '<=', $queryParams->get('weightTo')); } $results = $this->productRepository->findByQuery($qb);
10 строк кода без всяких парсеров.
flavioad
Hola si quieres pueda darte el codigo si lo ncesitas ?
AnthonyDS
"Здравствуйте, если хотите, я могу дать вам код, если он вам нужен?"