Команда 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 понадобятся всего три оператора: andbetween и =:

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, которые фильтруют по материалу и весу, используя операторы ANDBETWEEN и =.

Предположим, что наш воображаемый 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 — он допустим концептуально, но пока не реализован.

  • Код должен корректно проверять простые выражения с andbetween и =.

  • Имена колонок должны быть из белого списка.

  • Допускаются только целочисленные и строковые значения.

// 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)


  1. flavioad
    06.09.2025 05:45

    Hola si quieres pueda darte el codigo si lo ncesitas ?


    1. AnthonyDS
      06.09.2025 05:45

      "Здравствуйте, если хотите, я могу дать вам код, если он вам нужен?"


  1. Crarkie
    06.09.2025 05:45

    Идея может и интересная академически, но всё ещё непонятно, зачем оно нужно, например, в продакшене? Чтобы увеличить время отработки запроса за счёт парсинга SQL и построения запроса? Чтобы усложнить себе работу за счёт изобретения сферических велосипедов в вакууме? В данном конкретном сценарии запросы простые, и вполне можно обойтись передачей условий как параметров и на стороне backend билдить из них SQL запрос, либо вообще использовать ORM (при желании). Для сколько нибудь сложных запросов придется сюда затащить аналог целого движка SQL и ещё строго проверять, что в этом подзапросе не запросят что-нибудь лишнего из других таблиц, что позволит например методом наблюдения (прошло условие или нет) узнать структуру БД или что-то о данных в этой самой базе. В общем, не вижу практических примеров того, где это реально могло бы помочь и при этом показать себя лучше, чем взять банально тот же GraphQL.


  1. 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 - и более функционален и полно готовых надстроек для контроля доступа


  1. cmyser
    06.09.2025 05:45

    Есть же sqlc который генерит репозиторий на основе запроса SQL , вот он экономит время


  1. Sipaha
    06.09.2025 05:45

    А как graphql помогает решать подобную задачу с произвольными фильтрами? Насколько помню в стандарте не было ничего об универсальном языке запросов.


  1. ilving
    06.09.2025 05:45

    1. Есть squirrel, который отлично подходит для таких задач.

    2. У меня есть личная аллергия на формирование SQL запроса через принты.

    3. Только мне в обработке строк чудится заготовка под инъекции?


  1. 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 строк кода без всяких парсеров.


    1. Crarkie
      06.09.2025 05:45

      А это точно Golang?)