---
title: "Динамические запросы и builder-стиль — сборка WHERE по условиям и безопасные фильтры API в GORM"
description: "gorm builder: динамические запросы со сборкой WHERE по условиям, безопасные фильтры API с белым списком сортировки и защитой от инъекции в Order."
course: "GORM: ORM-библиотека для Go"
url: "https://golangmaster.ru/courses/gorm/lessons/20-builder/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Динамические запросы и builder-стиль — сборка WHERE по условиям и безопасные фильтры API в GORM

*Курс «GORM: ORM-библиотека для Go», урок 20 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gorm/lessons/20-builder/*

Девятнадцатый урок дал запросам память — скопы переиспользуют готовые фильтры, но предполагают, что форма запроса известна заранее. В реальном API всё наоборот: параметры каталога — статус, приоритет, выполненность, сортировка, страница — приходят произвольным набором, и заготовить скоп на каждую комбинацию невозможно. На сцену выходит gorm builder — динамические запросы в builder-стиле: цепочка собирается по условиям, каждый клоз добавляется отдельным if, значения едут плейсхолдерами. В уроке — сборка переменной и переприсваивание против chaining-ловушки из шестого урока, структурные условия с указателями и честная механика isZero из исходников, хелпер на []clause.Expression с групповыми условиями из доки, безопасные фильтры API с белым списком сортировки против инъекции в Order, пагинация и фильтры в одном конвейере, DryRun-тесты сборщика и честное сравнение скоупов с builder-стилем. Одна оговорка о формате: GORM требует живую СУБД, поэтому runner-ов в уроке нет — код не исполнить в песочнице, вас проверяют quiz, predict, fill, order и карточки, а SQL-следы рассчитаны на локальный Go с PostgreSQL или SQLite.

### gorm builder: когда скопы перестают угадывать

У каталога задач появился настоящий эндпоинт: /tasks?status=todo&priority=3&sort=created — и вот характер запроса перестал быть известным на этапе написания кода. Пять параметров дают десятки непустых комбинаций: со статусом и без, с приоритетом и без, с сортировкой и с дефолтом, плюс страница. Девятнадцатый урок отвечал на такую волатильность заготовками — кирпич-скоп на статус, фабрика на значение, композитный скоп на готовую политику списка, — и до поры до времени этого хватало. Но правило честное: как только комбинаций становится M на N, заготовленные скопы перестают угадывать форму запроса — надо было бы написать скоп на каждую перестановку, и реестр превратился бы в свалку. Ответ жанра — динамические запросы, то есть динамический WHERE: цепочка собирается по условиям прямо во время обработки запроса. Каждый клоз добавляет отдельный if: параметр пришёл — условие появилось, параметра нет — клоза нет. Это и называют builder-стилем: вы строите запрос по кирпичику из данных, а не выбираете из заранее собранных. Важная оговорка, чтобы не кидаться переписывать всё: builder не отменяет скопы из девятнадцатого урока — стабильные политики вроде «только невыполненные, свежие вперёд, двадцать штук» остаются именованными скопами; builder забирает себе волатильную часть — те клозы, что зависят от ввода пользователя. Седьмой урок дал строительный материал (три формы условия, плейсхолдеры, ИЛИ группой), шестой — предупреждение о хранении цепочки в переменной, семнадцатый — пагинацию; теперь всё это собирается в один конвейер безопасных фильтров API.

### Канон: TodoFilter и сборка переменной

```go
// Фильтр каталога: нулевое значение поля означает "не задано".
type TodoFilter struct {
    Status   string // ""    - не фильтруем
    Priority int    // 0     - не фильтруем
    Done     bool   // false - не фильтруем
    Sort     string // имя сортировки из API
}

// buildQuery - сборщик динамического WHERE: база плюс клозы по условиям.
// Каждый сработавший if добавляет клоз, переменная переприсваивается.
func buildQuery(q *gorm.DB, f TodoFilter) *gorm.DB {
    q = q.Where("done = ?", false) // обязательная база каталога
    if f.Status != "" {
        q = q.Where("status = ?", f.Status)
    }
    if f.Priority != 0 {
        q = q.Where("priority >= ?", f.Priority)
    }
    return q
}

// Применение: сборка + сортировка с лимитом из семнадцатого урока.
var items []TodoItem
buildQuery(db, TodoFilter{Status: "todo", Priority: 2}).
    Order("created_at DESC").Limit(20).Find(&items)
// SELECT * FROM todo_items
// WHERE done = false AND status = 'todo' AND priority >= 2
// ORDER BY created_at DESC LIMIT 20
```

Три ключевые детали канона. Первая: фильтр — обычная структура, ноль поля означает «не задано», а не «фильтруй по нулю». Вторая: каждый if переприсваивает q — вы всегда работаете с последним инстансом цепочки. Третья: обязательная база done = false добавляется безусловно, поэтому даже при пустом фильтре WHERE не исчезнет — Find не вернёт всю таблицу целиком.

### Сборка переменной: переприсваивание против ловушки

Строка q = q.Where(...) выглядит как рискованное трюкачество — шестой урок приучил бояться сохранённых цепочек. Разберём, почему здесь дисциплина соблюдена. Механика из chainable_api.go: Where вызывает getInstance(), прогоняет условие через tx.Statement.BuildCondition(query, args...) и дописывает результат в Statement вызовом tx.Statement.AddClause(clause.Where{Exprs: conds}) — других действий нет. Переприсваивание q = q.Where(...) безопасно, потому что вы в каждой строке берёте свежий результат и старый инстанс тут же забываете: хранится ровно одна версия цепочки, и каждый клоз добавлен ровно один раз. Ловушка из шестого урока возвращается в другой раскладке: база собирается один раз в переменную, а финишируется из разных веток. Если из одного q сделать два Find — без переприсваивания, из двух мест кода, — второй запрос доедет с условиями первого: условия остались на инстансе и склеились. Builder-стиль закрывает проблему архитектурно: сборщик — функция, её зовут заново от db на каждый запрос, и каждая сборка стартует с чистого листа. Если же многоразовая база всё-таки нужна — храните инстанс после Session(&gorm.Session{}), как показывал шестой урок: каждая новая цепочка получит копию Statement, а не допишет условия в общий. Правило сборки переменной: один построенный запрос — один финишер; нужно два запроса — зови сборщик дважды или берись за Session.

### Проверка: два финишера из одной базы

**Вопрос:** В хендлере сделали q := db.Where("done = ?", false), затем в одной ветке q.Where("priority >= ?", 3).Find(&a), в другой — q.Where("status = ?", "todo").Find(&b). Что вернёт второй запрос?

- ✅ WHERE done = false AND priority >= 3 AND status = 'todo' — условия первой ветки остались на инстансе и склеились со вторым
- WHERE done = false AND status = 'todo' — каждый Find стартует с чистым Statement
- WHERE status = 'todo' — база done = false расходуется первым Find
- Ошибка компиляции: одну цепочку нельзя финишировать дважды

Это chaining-ловушка из шестого урока в builder-обстановке: q хранит добавленные условия, каждый Where дописывает в тот же Statement, Find ничего не сбрасывает. Лечение — дисциплина сборщика: функция buildQuery(q, f) зовётся заново от db на каждый финишер, либо база создаётся через Session(&gorm.Session{}), которая отдаёт инстанс с копией Statement на каждую новую цепочку.

### Указатели в фильтре: nil вместо флага

Канонический TodoFilter скрывает дыру: bool не отличает «не задано» от «выполнено нет», а int — «не задано» от «приоритет ноль». База done = false спасает каталог, но представьте админский фильтр «показать только выполненные»: нужен предикат done = true при Done = true и отсутствие клоза при «не выбрано» — а у bool лишь два значения. Классическое решение Go — указатели: Status *string, Priority *int, Done *bool, где nil означает «параметр не пришёл», а непустой указатель — «фильтруй по значению, даже если оно нулевое». Красота в том, что структурную форму Where можно оставить как есть — механика уже всё умеет. Седьмой урок показывал правило нулевых полей: BuildCondition собирает условие только по ненулевым полям, пропуская нули проверкой isZero. Заглянем глубже в исходники, чтобы понять, как указатели проходят эту проверку. В schema/field.go функция ValueOf возвращает значение поля и флаг: fieldValue.Interface(), fieldValue.IsZero(). Для указательного поля fieldValue — сам указатель, и IsZero() для него означает nil, а не разыменованный ноль! А BuildCondition в statement.go решает так: if v, isZero := field.ValueOf(stmt.Context, reflectValue); !isZero || selected — добавляет clause.Eq только для ненулевых полей. Итог: nil-указатель — ноль, клоза нет; *bool, указывающий на false, — не ноль, условие done = false честно попадает в WHERE. Указатель = явное «поле выбрано», и false больше не теряется.

### PtrFilter: nil пропускается, false доезжает

```go
// Указательный фильтр: nil - параметр не задан.
type PtrFilter struct {
    Status   *string
    Priority *int
    Done     *bool
}

done := false                      // из запроса: done=false
f := PtrFilter{Done: &done}        // Status и Priority - nil

var items []TodoItem
db.Where(&f).Order("created_at DESC").Find(&items)
// SELECT * FROM todo_items
// WHERE done = false ORDER BY created_at DESC

// Что произошло внутри statement.go:
// if v, isZero := field.ValueOf(...); !isZero || selected {
//     conds = append(conds, clause.Eq{Column: clause.Column{Table: curTable, Name: field.DBName}, Value: v})
// }
// ValueOf вернул fieldValue.IsZero(): для *bool это nil-проверка,
// &done не nil - флаг false - условие done = false в WHERE.
```

Один указатель на false дал полноценный клоз — структурная форма проигнорировала только nil-поля. В этом пункте часто путаются: нулевым для указательного поля считается nil, а не разыменованное значение. Хотите условие по отсутствующему параметру — не заполняйте указатель; хотите done = false — кладите адрес переменной.

### Что уедет в базу

```go
done := false
prio := 2
f := PtrFilter{Done: &done, Priority: &prio}

var items []TodoItem
db.Where(&f).Find(&items)
```

**Что выведет программа?**

- ✅ SELECT * FROM todo_items WHERE done = false AND priority = 2 — оба указателя ненулевые, оба условия в WHERE
- SELECT * FROM todo_items WHERE priority = 2 — false нулевой, Done пропущен
- SELECT * FROM todo_items — все поля «нулевые», WHERE пустой
- SELECT * FROM todo_items WHERE done = true AND priority = 2

fieldValue.IsZero() для указательных полей проверяет сам указатель: &done и &prio не nil — флаги нулевые у обоих, поэтому done = false и priority = 2 едут в WHERE через clause.Eq. Было бы поле nil — его бы пропустили, как в шестом блоке. Указательный фильтр различает «не пришло» и «пришло нулевое» без единого if в вызывающем коде.

### Builder-хелпер: []clause.Expression

Строки с плейсхолдерами — не единственная валюта Where. В statement.go BuildCondition разбирает аргументы switch-ем, и среди веток — не только строки, карты и структуры: case clause.Expression: conds = append(conds, v) и case []clause.Expression: conds = append(conds, v...). Вторая ветка — готовый хук под builder-хелпер: соберите условия в срез выражений и отдайте весь срез одним вызовом db.Where(filterExprs(f)) — он развернётся в один WHERE-клоз с конъюнкцией через AND. Выражения типизированы: clause.Eq{Column: "status", Value: f.Status} — равенство, clause.Gte — «не меньше», clause.IN{Column: "status", Values: statuses} — перечисление, причём пустой срез значений даёт IN (NULL), как и строковая форма из седьмого урока. Для сложных предикатов есть групповые условия — отдельная фича доки: Group Conditions in GORM provide a more readable and maintainable way to write complex SQL queries involving multiple conditions. Пример из доки собирает вложенные цепочки: db.Where(db.Where("pizza = ?", "pepperoni").Where(db.Where("size = ?", "small").Or("size = ?", "medium")),).Or(db.Where("pizza = ?", "hawaiian").Where("size = ?", "xlarge"),).Find(&Pizza{}) — и получает WHERE (pizza = "pepperoni" AND (size = "small" OR size = "medium")) OR (pizza = "hawaiian" AND size = "xlarge"): вложенный db.Where выступает скобками. В пакете clause есть фабрики Or и And — clause.Or(expr1, expr2) складывает условия скобочной ИЛИ-группой прямо внутри одного клоза. Выражения композицибельны, как функции: хелпер возвращает []clause.Expression, его можно расширять, тестировать и переиспользовать независимо от цепочки.

### filterExprs: сборка выражений и IN из параметра

```go
// Сборщик выражений: колонки - константы, значения - аргументы.
func filterExprs(f TodoFilter) []clause.Expression {
    var exprs []clause.Expression
    if f.Status != "" {
        exprs = append(exprs, clause.Eq{Column: "status", Value: f.Status})
    }
    if f.Priority != 0 {
        exprs = append(exprs, clause.Gte{Column: "priority", Value: f.Priority})
    }
    return exprs
}

// Срез выражений едит одним вызовом Where.
db.Where(filterExprs(f)).Where("done = ?", false).Find(&items)

// IN из списка статусов API: "status=todo,doing" -> срез строк.
statuses := strings.Split(f.StatusList, ",")
db.Where(clause.IN{Column: "status", Values: toAny(statuses)}).Find(&all)
// пустой список даст IN (NULL) - ничего не найдёт, как и в 07-м

// ИЛИ-группа внутри одного клоза: фабрика clause.Or.
q = q.Where(clause.Or(
    clause.Eq{Column: "status", Value: "todo"},
    clause.Gte{Column: "priority", Value: 3},
))
// WHERE (status = 'todo' OR priority >= 3)
```

Срез выражений — типизированная альтернатива строкам: BuildCondition разворачивает его в один клоз, склейка AND достаётся бесплатно. clause.IN повторяет поведение строковой формы на пустом срезе — IN (NULL). ИЛИ прячьте в один клоз фабрикой clause.Or — размазанный по цепочке Or теряет скобки, как показывал седьмой урок.

### Проверка: почему срез выражений безопасен

**Вопрос:** В input юзера вписали jinzhu;drop table users; и передали в clause.Eq{Column: "status", Value: input}. Почему инъекции нет?

- ✅ Значение едёт аргументом: Build добавляет его через AddVar плейсхолдером, дока говорит — аргументы экранируются автоматически
- Eq сам удаляет точки с запятой из значений
- Опасно: clause.Eq склеивает значение прямо в строку SQL
- Страховка только если добавить Where("safe = ?", true)

Страница доки о безопасности формулирует это как базу: GORM uses the database/sql's argument placeholders to construct the SQL statement, which will automatically escape arguments to avoid SQL injection. В clause.Eq колонка — константа кода, значение — аргумент плейсхолдера; строка jinzhu;drop table users; доедет значением и просто не совпадёт ни с одним статусом. Опасна конкатенация вида fmt.Sprintf("name = %v", userInput), а не передача аргументом.

### Безопасные фильтры API: белый список для сортировки

Остался самый коварный параметр — Sort. У WHERE есть плейсхолдеры, у сортировки их нет: строка Order попадает в SQL как есть, и седьмой урок уже перечислял методы, чьи строковые аргументы не экранируются, а семнадцатый показывал подноготную — clause.Column{Name: v, Raw: true}. Страница Security ставит условие раньше всех примеров: User's input should be only used as an argument — ввод пользователя должен быть только аргументом запроса, а не частью строки SQL. Дока страхуется отдельным разделом: SQL injection Methods приводит db.Order("name; drop table users;").First(&user) в списке методов, куда ввод подставляется без экранирования, — сортировка соседствует с Select, Group, Table и Raw. Общее правило там сформулировано честно и широко: The general rule to avoid SQL injection is don't trust user-submitted data, you can perform whitelist validation to test user input against an existing set of known, approved, and defined input, and when using user's input, only use them as an argument. Белый список — это и есть безопасные фильтры API в чистом виде: пользователь выбирает не колонку, а имя из каталога, которое вы сами перевели в колонку. Реализация — карта имя-колонка: sortColumns := map[string]string{"created": "created_at", "priority": "priority", "title": "title"}. Параметр сначала ищется в карте; нашли — берём значение колонки, не нашли — это не ошибка БД, а понятный ответ клиенту: дефолтная сортировка или 400 с внятным сообщением. Направление сортируем отдельным белым списком из двух констант — asc и desc, а не «что пришло, то и в SQL». Пользовательский ввод в Order не должен появиться никогда — ни целиком, ни по частям.

### sortColumns: имя API превращается в колонку

```go
// Белый список сортировки: имя из API -> колонка таблицы.
var sortColumns = map[string]string{
    "created":  "created_at",
    "priority": "priority",
    "title":    "title",
}

// Направление - тоже белый список из двух констант.
func sortClause(sort, dir string) (clause.OrderByColumn, bool) {
    col, ok := sortColumns[sort]
    if !ok {
        return clause.OrderByColumn{}, false // чужое имя - отказ
    }
    desc := dir == "desc" // всё, что не desc, - asc
    return clause.OrderByColumn{
        Column: clause.Column{Name: col}, Desc: desc,
    }, true
}

// Хендлер: Sort из API проходит карту, иначе - дефолт.
// Дефолт целиком: clause.OrderByColumn{Column: clause.Column{Name: "created_at"}, Desc: true}
order, ok := sortClause(f.Sort, "desc")
if !ok {
    order = clause.OrderByColumn{
        Column: clause.Column{Name: "created_at"}, Desc: true,
    }
}
buildQuery(db, f).Order(order).Limit(20).Find(&items)
// SELECT * FROM todo_items
// WHERE done = false AND status = 'todo'
// ORDER BY created_at DESC LIMIT 20
```

В SQL попадает только то, что лежит в карте, — строковая инъекция невозможна физически. clause.OrderByColumn — структурная форма сортировки из семнадцатого урока: она ставит кавычки вокруг имени за вас, а вы гарантируете, что имя существует в схеме. Тай-брейк по первичному ключу добавьте вторым Order — лента не будет терять строки на границах страниц.

### Проверка: почему Order нельзя пускать сырым

**Вопрос:** Коллега предлагает упростить: buildQuery(db, f).Order(f.Sort).Find(&items) —.Sort ведь просто строка из запроса. Что не так?

- ✅ Order не экранирует строковый аргумент — он в списке SQL injection Methods доки; параметр обязан пройти белый список
- Ничего: GORM проверяет имя колонки по схеме и игнорирует лишнее
- Опасно только направление сортировки, имя колонки безопасно
- Сработает, но Order нельзя вызывать после Where

Дока приводит db.Order("name; drop table users;") в списке методов без экранирования, а исходники показывают причину: строка оборачивается в clause.Column{Name: v, Raw: true} и вписывается в SQL как есть. Сортировка вычисляется по всему набору строк, поэтому канал утечки даже опаснее, чем в WHERE. Единый вывод доки: не доверяйте вводу, прогоняйте через whitelist validation и передавайте только аргументами.

### Скопы против builder: честное сравнение

Пришло время свести счета девятнадцатого урока с этим. Сначала неудобная правда: buildQuery(q *gorm.DB) *gorm.DB — функция с тем же контрактом, что у скопа; её можно передать в db.Scopes и она честно сработает. Разница не в механике, а в организации знания. Скоп — это именованная политика: фильтр живёт отдельно от вызывающего кода, тесты и правки в одной точке, вызов — одна строка. Builder — это конвейер: форма запроса вычисляется из данных на каждом вызове, if-ы видны прямо в сборщике. Выбирайте скоп, когда политика стабильна: «каталог — это невыполненные, свежие вперёд, двадцать штук» не зависит от ввода, и менять её на лету не нужно. Выбирайте builder, когда комбинаций много и все они равноправны: пять параметров API дают десятки сочетаний — заготовленный скоп на каждую перестановку будет свалкой, а builder описывает всё семейство одним алгоритмом. Честно и про цену: builder с десятью if-ами труднее читать, чем скоп в пять строк, — поэтому крупный builder разбивают на мелкие функции-условия, каждая из которых, кстати, снова скоп по форме. Гибрид работает лучше всего: волатильный динамический WHERE собирает buildQuery, а стабильные хвосты наклеиваются скопами — пагинация из семнадцатого урока ложится следом одной строкой, сортировка-белый список сидит в карте, обязательная база — в композитном скопе списка. Это не война парадигм, а деление труда: скопы для словаря политик, builder для произвольных фильтров API.

### Скопы против builder: пять критериев

| Критерий | Скопы (19-й) | Builder-стиль (20-й) |
| --- | --- | --- |
| Форма запроса | известна заранее, стабильна | вычисляется из ввода на каждом вызове |
| Комбинации фильтров | немного, именованные политики | M на N — один алгоритм вместо свалки скопов |
| Контракт | func(db *gorm.DB) *gorm.DB, вызов через db.Scopes | та же функция, но зовётся напрямую: buildQuery(db, f) |
| Условная логика | замыкание-фабрика на каждый параметр | if-ы в одном сборщике, nil-указатели в структуре |
| Типовой гость | каталог с фиксированной политикой списка | эндпоинт /tasks?status=&priority=&sort= |

### Пагинация и фильтры вместе

Настоящий список-эндпоинт отвечает двумя запросами: сколько всего и одна страница. Builder здесь раскрывается полностью: сборщик зову дважды, и оба раза от чистого db — ловушка сохранённой цепочки не при делах, потому что ни одна сборка не финишируется дважды. Первая сборка считает total: buildQuery(db.Model(&TodoItem{}), f).Count(&total) — те же фильтры, что и у страницы, поэтому число честное. Вторая собирает страницу: buildQuery(db, f).Order(order).Offset((page-1)*size).Limit(size).Find(&items) — offset-формула и потолок размера страницы остались в семнадцатом уроке; туда же по ссылку, если нужно освежить границы и метку «есть ещё». Для глубокой ленты offset уступает курсору: добавьте в фильтр указатель-курсор и в buildQuery один if — if f.After != nil { q = q.Where("id < ?", *f.After) } — и лента станет keyset-пагинацией, где сортировка обязана иметь тай-брейк по ключу. Обратите внимание, как органично курсор-указатель сочетается с темой nil-фильтров: первая страница приходит с nil-курсором и клоз не добавляется вовсе. Обе сборки делят один filterExprs и один sortColumns — фильтры и порядок вычисляются в одном месте, а Count и Find лишь выбирают, что с этим делать: посчитать или отрезать страницу.

### listTasks: Count и страница из одного сборщика

```go
// listTasks - два запроса одного эндпоинта: total и страница.
func listTasks(db *gorm.DB, f TodoFilter, page, size int) (
    []TodoItem, int64,
) {
    var total int64
    // Сборка №1: те же фильтры, финишер Count.
    buildQuery(db.Model(&TodoItem{}), f).Count(&total)

    // Сборка №2: свежая цепочка от db, ловушки нет.
    order, ok := sortClause(f.Sort, "desc")
    if !ok {
        order = clause.OrderByColumn{
            Column: clause.Column{Name: "created_at"}, Desc: true,
        }
    }
    var items []TodoItem
    buildQuery(db, f).
        Order(order).
        Offset((page - 1) * size).Limit(size).
        Find(&items)
    return items, total
}
// SELECT count(*) FROM todo_items WHERE done = false AND status = 'todo'
// SELECT * FROM todo_items WHERE done = false AND status = 'todo'
//   ORDER BY created_at DESC LIMIT 20 OFFSET 20
```

Два вызова buildQuery — два независимых Statement: именно так безопасно строить парные запросы из одного фильтра. Count не видит Order и Limit — они на порядок не влияют; страница получает сортировку через белый список и окно из семнадцатого урока. Для бесконечной ленты замените Offset на курсорный if с указателем из предыдущего блока.

### Соберите SQL глазами

```go
f := TodoFilter{Status: "todo", Sort: "priority"}

var items []TodoItem
buildQuery(db, TodoFilter{}). // пустой фильтр
    Order("created_at DESC").
    Limit(20).
    Find(&items)
```

**Что выведет программа?**

- ✅ SELECT * FROM todo_items WHERE done = false ORDER BY created_at DESC LIMIT 20 — из if-ов не сработал ни один, осталась обязательная база
- SELECT * FROM todo_items ORDER BY created_at DESC LIMIT 20 — пустой фильтр отменяет и базу
- SELECT * FROM todo_items WHERE status = 'todo' ORDER BY created_at DESC LIMIT 20 — Sort подтянул статус
- Запрос не выполнится: пустой фильтр — ошибка сборки

Ни один if в buildQuery не сработал — Status пуст, Priority нулевой, — но обязательная база done = false добавляется безусловно, поэтому WHERE не пустеет. Это защита от самой дорогой ошибки каталога: пустой фильтр не превращает страницу в выборку всей таблицы. Поля Sort в WHERE нет места — сортировка живёт в отдельном клозе и проходит белый список.

### DryRun: тесты для сборщика

Сборщик — чистая логика над клозами, и его хочется тестировать без базы. Это ровно режим DryRun из доки: DryRun Generates SQL without executing. It can be used to prepare or test generated SQL. Подаёте цепочке сессию db.Session(&gorm.Session{DryRun: true}) — финишер соберёт Statement и вернётся без поездки в СУБД, а след читается из stmt.SQL.String() и аргументы из stmt.Vars. Десятый урок уже показывал DryRun в деле; теперь у него системная работа — таблица тестов для сборщика: строка фильтров на входе, ожидаемый SQL и ожидаемые Vars на выходе. Собрали buildQuery — прогнали три случая: пустой фильтр с одной базой, полный набор клозов, чужое имя сортировки с дефолтом. Честности ради — две оговорки доки. Первая: разные диалекты генерируют разный SQL — PostgreSQL подставляет $1, MySQL знак вопроса, поэтому тест прикалывается к конкретному драйверу, как и SQL-следы этого курса. Вторая: итоговый SQL не всегда безопасно исполнять руками — GORM only uses it for logs, а из логгера строка выходит не полностью экранированной; тест сверяет плейсхолдеры и Vars, а не собранный со значениями текст. DryRun закрывает ещё один сценарий: логирование сборки в CI — падение теста покажет ровно тот клоз, который съел ваши фильтры.

> **Пять ловушек builder-стиля.** Первая: два финишера из одной собранной базы — второй запрос доедет с чужими условиями; либо сборщик-функция, зовущаяся заново от db, либо Session(&gorm.Session{}) для многоразовой базы, как в шестом уроке. Вторая: Sort строкой прямо в Order — метод без экранирования из SQL injection Methods; единственный путь — белый список имя-колонка и список из двух направлений. Третья: размазанное ИЛИ — два Where с Or между ними дают не то, что читается в коде, без скобок предикат расползается; ИЛИ прячьте внутрь одного клоза фабрикой clause.Or или групповыми условиями доки. Четвёртая: потерянный false — обычный bool-фильтр не отличает «не выбрано» от «выключено», структурная форма молча пропустит ноль; переводите неоднозначные поля фильтра на указатели, nil — не выбрано, адрес — фильтруй. Пятая: незакрытая страница — builder собрал WHERE, но пагинации не наклеили; без Limit тяжёлый фильтр выкачивает таблицу, а без Order окна нет вовсе — LIMIT и сортировка обязательны, как в семнадцатом уроке.

### Заполните пропуски: хелпер выражений

Три пропуска: тип среза выражений, поле равенства и метод-приёмник среза.

```go
// Сборщик выражений: значение - аргумент плейсхолдера.
func filterExprs(f TodoFilter) []clause.___ {
    var exprs []clause.Expression
    if f.Status != "" {
        exprs = append(exprs,
            clause.Eq{Column: "status", ___: f.Status})
    }
    return exprs
}

// Срез выражений принимает один вызов.
db.___(filterExprs(f)).Find(&items)
```

Подсказка: Срез типизирован как []clause.Expression; у clause.Eq два поля — Column и Value; срез выражений едит обычным Where, ветка case []clause.Expression в statement.go его развернёт.

Правильные ответы в пропусках: `Expression`, `Value`, `Where`

### Соберите путь запроса от HTTP до SQL

Расставьте шаги безопасного фильтра API — от разбора параметров до проверки следа.

Правильный порядок строк:

1. `разобрать query-параметры в TodoFilter: нулевые значения означают «не задано»`
2. `прогнать Sort через белый список sortColumns — чужое имя заменить дефолтом`
3. `проверить направление сортировки по списку из двух констант asc и desc`
4. `собрать динамический WHERE: база плюс клозы по сработавшим if-ам`
5. `добавить ИЛИ-предикаты внутрь одного клоза, не размазывая по цепочке`
6. `наклеить пагинацию из семнадцатого урока: Order, Limit, Offset или курсор`
7. `финишировать Count и Find двумя сборками от чистого db`
8. `сверить след сборщика DryRun-тестом: SQL и Vars против эталона`

### Чек-лист урока

- gorm builder — сборка цепочки по условиям: каждый клоз добавляет отдельный if, нулевое поле фильтра означает «не задано»
- механика Where из chainable_api.go: getInstance(), tx.Statement.BuildCondition(query, args...), tx.Statement.AddClause(clause.Where{Exprs: conds})
- переприсваивание q = q.Where(...) безопасно; ловушка из шестого урока возвращается при двух финишерах из одной базы — лечение: сборщик зову заново или Session(&gorm.Session{})
- указательные фильтры: nil — параметр не пришёл; fieldValue.IsZero() для указателя проверяет nil, поэтому *bool со значением false честно даёт done = false
- Where принимает clause.Expression и []clause.Expression (ветки BuildCondition в statement.go); срез выражений — один клоз с AND
- групповые условия доки: вложенные db.Where — скобки; clause.Or(expr1, expr2) — ИЛИ внутри одного клоза
- сортировка из API — только через белый список: карта имя-колонка, направление из двух констант; Order в списке SQL injection Methods
- правило доки: don't trust user-submitted data — whitelist validation и ввод только аргументом; DryRun-тесты сверяют stmt.SQL.String() и stmt.Vars без базы

### Что дальше по курсу

Builder-стиль держится на соседних уроках всем составом. Материал клозов — строки с плейсхолдерами, структурная форма и её нулевые поля, карта, IN с пустым срезом, ИЛИ и скобки — это [седьмой урок про Where](/courses/gorm/lessons/07-where/). Окно страницы, offset-формула, курсор и тай-брейк сортировки остались в [семнадцатом уроке](/courses/gorm/lessons/17-paginaciya/) — пагинация наклеивается на собранный builder-ом WHERE одной строкой. Кирпичи-политики, замыкания-фабрики и композитный скоп списка разобраны в [девятнадцатом уроке](/courses/gorm/lessons/19-skoupy/) — там же честный контракт func(db *gorm.DB) *gorm.DB, который builder унаследовал. Ловушка сохранённой цепочки и Session(&gorm.Session{}) — шестой урок, DryRun — десятый, индексы под сортировку и фильтры — восемнадцатый. Карта всех тем курса — на [странице курса](/courses/gorm/). Дальше строится мост к данным, которых в Go нет: двадцать первый урок — «Работа с NULL и указателями» — заберёт указательные фильтры этого урока в модель и покажет sql.Null-типы, nullable-теги и то, как NULL из базы превращается в ноль или указатель в коде. Динамические запросы собраны — теперь научимся честно хранить отсутствующие значения.

### Карточки: builder-стиль и фильтры API

- **Сборка переменной (gorm builder)** — q := db.Where("done = ?", false); if f.Status != "" { q = q.Where("status = ?", f.Status) } — каждый if переприсваивает q. Один построенный запрос — один финишер. Два финишера из одной базы = chaining-ловушка шестого урока: лечение Session(&gorm.Session{}) или двойной вызов сборщика buildQuery(db, f).
- **Указатели в структурном условии** — Status *string, Done *bool: nil — параметр не задан, клоза нет. Механика: schema/field.go ValueOf возвращает fieldValue.IsZero(), для указателя это nil-проверка; BuildCondition: if v, isZero := field.ValueOf(...); !isZero — поэтому *bool, указывающий на false, даёт WHERE done = false.
- **[]clause.Expression как хелпер** — BuildCondition в statement.go имеет ветки case clause.Expression и case []clause.Expression — срез выражений едит одним db.Where и разворачивается в один WHERE с AND. Кирпичи: clause.Eq, clause.Gte, clause.IN (пустой срез — IN (NULL)), фабрика clause.Or для ИЛИ внутри одного клоза.
- **Групповые условия (Group Conditions)** — Дока: Group Conditions in GORM provide a more readable and maintainable way to write complex SQL queries. Вложенный db.Where работает скобками: db.Where(db.Where("pizza = ?", "pepperoni").Where(db.Where("size = ?", "small").Or("size = ?", "medium"))).Or(...).Find(&Pizza{}) — (pizza = "pepperoni" AND (size = "small" OR size = "medium")) OR (...).
- **Белый список сортировки** — Order не экранирует строку (Raw: true из 17-го, метод в списке SQL injection Methods). Карта sortColumns: "created" -> "created_at", чужое имя — дефолт или 400; направление — список {asc, desc}. Правило доки: don't trust user-submitted data — whitelist validation, ввод только аргументом.
- **DryRun для тестов сборщика** — db.Session(&gorm.Session{DryRun: true}) — финишер собирает SQL без исполнения (дока: Generates SQL without executing). След: stmt.SQL.String(), аргументы: stmt.Vars. Разные диалекты дают разный SQL ($1 у PostgreSQL, ? у MySQL). SQL из логгера не полностью экранирован — сверяйте плейсхолдеры, а не собранный текст.

## Коротко: что запомнить

- gorm builder — динамические запросы: цепочка собирается по условиям, каждый клоз добавляет отдельный if, обязательная база добавляется безусловно и не даёт WHERE опустеть.
- Механика Where: getInstance(), BuildCondition(query, args...) и AddClause(clause.Where{Exprs: conds}) — Where лишь копит клозы, финишер собирает SQL.
- Переприсваивание q = q.Where(...) безопасно; два финишера из одной базы — chaining-ловушка шестого урока: сборщик зову заново от db или база через Session(&gorm.Session{}).
- Указательные фильтры: nil пропускается, адрес на false даёт done = false — fieldValue.IsZero() для указателя проверяет nil, а не разыменованное значение.
- Where принимает []clause.Expression одной веткой BuildCondition: clause.Eq, clause.Gte, clause.IN, ИЛИ — фабрикой clause.Or внутри одного клоза.
- Сортировка из API — только белый список: карта имя-колонка плюс направление из двух констант; Order в списке SQL injection Methods, его строки не экранируются.
- Пагинация и фильтры вместе: Count и страница — два вызова сборщика от чистого db; для ленты — курсорный if с указателем и тай-брейк по ключу.
- Скопы и builder делят труд: стабильные политики — именованные скопы девятнадцатого урока, произвольные комбинации из API — сборка по if-ам; тесты — DryRun со сверкой SQL и Vars.

## Домашнее задание

Соберите пакет buildfilter поверх локального канона. Шаг первый: перенесите TodoFilter, buildQuery и sortColumns, добавьте PtrFilter с указательными полями и проверьте на локальной базе, что db.Where(&PtrFilter{Done: &done}) при done = false даёт WHERE done = false, а nil-поля не дают ничего. Шаг второй: добавьте filterExprs с clause.Eq и clause.Gte и переключите buildQuery на срез выражений, сверив след с прежним через DryRun: db.Session(&gorm.Session{DryRun: true}) и stmt.SQL.String(). Шаг третий: заведите эндпоинт /tasks?status=&priority=&sort=&dir= и проверьте белый список: sort=created даёт ORDER BY created_at DESC, sort= hacked; -- даёт дефолт и не ломает SQL. Шаг четвёртый: напишите listTasks с Count и страницей двумя сборками и таблицу тестов DryRun: пустой фильтр, полный, чужая сортировка — SQL и Vars против эталона. Финал: добавьте курсорный if с указателем и превратите ленту в keyset-пагинацию из семнадцатого урока.

## Вопросы и ответы по уроку

### Чем builder-стиль отличается от скопов из девятнадцатого урока?

Механика одна — та же функция с контрактом func(db *gorm.DB) *gorm.DB. Разница в организации: скоп — именованная стабильная политика, вызываемая через db.Scopes; builder — алгоритм, вычисляющий клозы из ввода на каждом вызове. Скопы выигрывают на фиксированных политиках, builder — когда комбинаций фильтров M на N и форма запроса приходит из API.

### Почему Where с указателями пропускает false, а без указателей — нет?

Проверка нулевых полей идёт через fieldValue.IsZero() в schema/field.go. Для обычного bool значение false нулевое — поле выпадает из условия. Для *bool поле хранит указатель, и IsZero() проверяет nil: непустой указатель на false не нулевой, поэтому clause.Eq с done = false попадает в WHERE. Nil-указатель по-прежнему пропускается.

### Можно ли доверить Order строку из запроса, если она выглядит как имя колонки?

Нельзя: строковый аргумент Order не экранируется — в chainable_api.go он оборачивается в clause.Column{Name: v, Raw: true} и вписывается в SQL как есть; дока приводит db.Order("name; drop table users;") в списке SQL injection Methods. Единственный безопасный путь — белый список: карта имя-колонка и отдельный список направлений asc и desc.
