Вашому 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. Наступні типи моделюють вивід цього парсерa. У реальному проекті бібліотека 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: Тести
Короткий параметризований (таблично-орієнтований) тест показує, як працює логіка обробки 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
, парсить її, обробляє, виконує згенерований та очищений запит до БД і повертає результати.
Висновок
Реалізувати валідатор SQL-умов WHERE на Go досить просто — майже тривіально.
Більше не треба виправдовуватись перед API, які не приймають SQL-запити! Більше немає складних та крихких API, що намагаються заново винайти колесо під назвою SQL.
Коментарі