---
title: "Динамические запросы: подходы"
description: "sqlc динамические запросы: COALESCE-фильтры через narg, несколько готовых методов, TRUE-условия, границы генератора и Raw-запрос через pgx рядом."
course: "sqlc: типобезопасный код из SQL-запросов"
url: "https://golangmaster.ru/courses/sqlc/lessons/15-dinamicheskie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Динамические запросы: подходы

*Курс «sqlc: типобезопасный код из SQL-запросов», урок 15 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/sqlc/lessons/15-dinamicheskie/*

sqlc динамические запросы — тема, на которой выясняются отношения с самой идеей генератора: sqlc превращает статический текст запроса в статический же Go-код, а живой продукт хочет WHERE, состав которого меняется на ходу. Панель фильтров трекера — самый будничный пример: пользователь то включает фильтр по приоритету, то выключает, то добавляет поиск по названию, и каждый набор галочек — это фактически другой запрос. Обещания выданы дважды. Четырнадцатый урок закончил пагинацию фразой о том, что страница редко приходит без условий и что где-то надо разобрать, как собирать WHERE из опциональных частей. Восьмой урок, закрывая тему параметров, отвёл COALESCE-фильтрам отдельную главу будущей встречи: механика sqlc.narg там уже была, а применение на чтении — «здесь». Выполняем оба обещания. Урок отвечает на пять вопросов. Первый: почему генератор не собирает SQL из частей и что вместо этого делать. Второй: как работают COALESCE-фильтры — NULL-параметр как знак «без фильтра» — и почему для них обязателен narg. Третий: когда честнее завести несколько готовых методов и выбирать между ними в Go. Четвёртый: как устроены TRUE-условия — выключатель на каждый фильтр. Пятый: где проходят границы генератора — сортировка, переменный IN — и когда запрос лучше вывести из-под sqlc, оставив Raw через pgx рядом. Все приёмы называются условные фильтры sqlc, и это последний крупный приём чтения в курсе: дальше — миграции, схемы, движки и организация. План остальных тем — на странице [курса sqlc](/courses/sqlc/); правила вывода имён и nullability параметров лежат в [восьмом уроке](/courses/sqlc/lessons/08-parametry/).

К четырнадцатому уроку трекер отдаёт страницы, которые не врут: LIMIT и OFFSET свернуты в Params-структуру, keyset листает по ключу, курсор упакован в непрозрачную строку. Но каждая страница этого списка приходит с одним и тем же неизменным WHERE — deleted_at IS NULL — и ровно здесь кроется недосказанность. Настоящий список живёт с панелью фильтров: «показать только открытые с высоким приоритетом», «показать всё», «показать открытые без фильтра по приоритету». Между «показать всё» и «показать с фильтром» разница не в значениях — она в самом составе условий. Статический запрос такого не умеет: у него WHERE прибит к тексту.

Пятнадцатый урок закрывает недосказанность тремя приёмами и одним честным признанием. Приём первый — COALESCE-фильтры: NULL-параметр означает «этот фильтр выключен», и один метод обслуживает все комбинации. Приём второй — несколько готовых запросов: каждая частая комбинация получает собственный метод, а выбор происходит в Go. Приём третий — TRUE-условия: у каждого фильтра появляется булев выключатель прямо в WHERE. Признание — у генератора есть границы: сортировку и списки переменной длины он не соберёт, а для действительно произвольных комбинаций рядом с sqlc честно живёт обычный pgx. По дороге разберём, почему narg здесь не украшение, а необходимость, и какая у каждого приёма цена.

> **Как проверять себя в этом уроке.** Честно о формате, как и во всём курсе: sqlc-генератору нужны файлы схемы, поэтому уроки не запускаются в песочнице — runner-ов здесь нет. Понимание проверяют quiz, predict, fill, order и флешкарты: они гоняют то же, что вы повторите командой sqlc generate у себя. Вывод типов параметров и приоритеты nullability сверены с исходниками sqlc 1.27, семантика COALESCE и сравнения с NULL — с документацией PostgreSQL: не пересказ, а проверенные факты.

### sqlc динамические запросы: WHERE собирается заранее

Начнём со слов. Динамическим запросом в мире ORM и ручного SQL называют запрос, текст которого строится программой: срез условий, склейка, подстановка. У sqlc этот механизм отсутствует принципиально, и это не недоработка, а следствие устройства. Генератор читает .sql-файл, проверяет текст по схеме и копирует его в Go-константу байт в байт; внутри этой константы нет места ветвлениям — ни IF, ни циклов, ни конкатенации. Схема «один запрос — одна константа — один метод» держится на статичности: именно потому, что текст известен на этапе генерации, sqlc успевает проверить каждую колонку, вывести тип каждого плейсхолдера и отдать компилятору строгую сигнатуру. Собранный на лету текст — слепая зона: в нём нельзя проверить ни колонки, ни типы.

Отсюда точная формулировка темы: sqlc динамические запросы — это не про то, как заставить генератор клеить SQL, а про то, как выразить переменный состав условий статическими средствами. Весь арсенал называется условные фильтры sqlc — приёмы, при которых один запрос (или небольшая группа запросов) обслуживает много комбинаций фильтров, а «динамика» перетекает туда, где ей место: в значения параметров или в выбор Go-кода. Файл заводим по конвенции курса — одна тема выборок — один файл: db/queries/search.sql, все запросы урока живут в нём. Сценарий один на весь урок: панель фильтров трекера с тремя фильтрами — готовность (done, BOOLEAN NOT NULL DEFAULT false), приоритет (priority, INTEGER NOT NULL DEFAULT 0) и поиск по названию.

### Наивный фильтр: тип, в котором нет места «без фильтра»

```go
-- db/queries/search.sql: первый кандидат на метод панели

-- name: SearchTodosByPriority :many
SELECT * FROM todos
WHERE priority = $1 AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC;

// сгенерировано (pgx/v5, v1.27)
func (q *Queries) SearchTodosByPriority(ctx context.Context, priority int32) ([]Todo, error)
```

Сигнатура знакомая и правильная: параметр называется priority и имеет тип int32, потому что сравнение с колонкой даёт параметру имя и nullability колонки — а priority в каноне NOT NULL. Проблема не в типе, а в вопросе панели «а если фильтр не выбран?». Значения, которые можно передать в int32, все заняты: 0 — это честный фильтр «приоритет ноль», ведь у колонки DEFAULT 0 и таких задач много; -1 — тоже честный фильтр, задач с отрицательным приоритетом просто нет. Ноль в роли «нет фильтра» — это магическое значение, и любой магический код значения однажды встречает данные, для которых он ложь. Аналогия из восьмого урока: там NULL в UPDATE означал «не менять» — и для этого понадобился отдельный макрос. На чтении та же история, только цену платит список.

### Подход 1: COALESCE-фильтры — NULL значит «без фильтра»

Идея приёма — договориться о смысле NULL и записать договор прямо в SQL. Если параметр пришёл NULL, фильтр считается выключенным, и вместо сравнения с ним в условии участвует сама колонка: priority = COALESCE(sqlc.narg('priority'), priority). Читается слева направо: «приоритет равен параметру, а если параметра нет — самому себе». Когда параметр NULL, выражение вырождается в priority = priority, а это истина для каждой строки: условие исчезло, не перестав существовать. Именно поэтому приём зовут COALESCE-фильтр — сравнение и подмена живут в одной строке WHERE, и генератору не нужно ничего знать о наших намерениях.

Две связки делают приём родным. Первая — из восьмого урока: там coalesce(sqlc.narg('title'), title) обслуживал частичное обновление, где NULL читался «не трогать»; здесь та же формула работает на чтении, где NULL читается «не фильтровать» — симметрия, которую стоит запомнить. Вторая — из девятого урока: анатомия COALESCE как функции «первый ненулевой аргумент» и обёртки pgtype разобраны там по буквам, повторять не будем. Важно другое: трёхсостояние не отменяется. Пока параметр не NULL, сравнение обычное; NULL здесь не участвует в сравнении никогда — он перехватывается COALESCE до него.

У приёма есть жёсткая граница: он работает для равенства и не работает для подстроки. Попытка search-фильтра в том же стиле — title ILIKE COALESCE(sqlc.narg('search'), title) — при NULL-параметре вырождается в title ILIKE title, а шаблон без процентов означает точное совпадение: вместо «показать всё» список превратится в «показать задачи, название которых совпадает с самим собой без учёта регистра» — то есть во все строки, но лишь случайно, и любое изменение условия сломает эту случайность. Правильная форма для подстроки — OR-вариант: sqlc.narg('search') IS NULL OR title ILIKE '%' || sqlc.narg('search') || '%'. Первый член союз OR при NULL-параметре даёт истину, и до ILIKE дело не доходит. Обратите внимание: OR-форма — это уже половина третьего подхода, к которому мы придём после промежуточной станции.

### SearchTodos: один метод на все комбинации

```go
-- name: SearchTodos :many
SELECT * FROM todos
WHERE done = COALESCE(sqlc.narg('done'), done)
  AND priority = COALESCE(sqlc.narg('priority'), priority)
  AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC;

// сгенерировано (pgx/v5, v1.27)
type SearchTodosParams struct {
        Done     pgtype.Bool // narg: NULL — «не фильтровать по готовности»
        Priority pgtype.Int4 // narg: NULL — «не фильтровать по приоритету»
}

func (q *Queries) SearchTodos(ctx context.Context, arg SearchTodosParams) ([]Todo, error)
```

Два опциональных параметра — Params-структура с полями по числу плейсхолдеров. Оба поля — pgtype-обёртки, и это заслуга narg: макрос игнорирует вывод nullability и принудительно делает параметр nullable, что восьмой урок зафиксировал таблицей. Без него параметр сравнения с NOT NULL-колонкой вышел бы плоским int32 или bool, и передать NULL было бы нечем — «без фильтра» не выразить вовсе. На nullable-колонке вроде note вывод и так дал бы обёртку, но narg и там полезен: он фиксирует имя и явно заявляет намерение. Вызов: pgtype.Int4{Int32: 3, Valid: true} — фильтр «приоритет три»; pgtype.Bool{} — фильтр выключен. ORDER BY прежний — сортировка с тай-брейкером из четырнадцатого урока; deleted_at IS NULL — дисциплина мягкого удаления в каждой живой выборке.

### Почему narg обязателен: право на NULL не выводится

Здесь стоит задержаться, потому что вопрос «а зачем narg, если COALESCE и так работает?» задают постоянно. Восьмой урок показал: nullability параметра sqlc выводит из контекста — сравнение с NOT NULL-колонкой делает параметр без нулей, сравнение с nullable-колонкой даёт обёртку. Значит, в выражении priority = COALESCE($1, priority) параметр наследует у NOT NULL-колонки право «быть без NULL», и Go-тип выходит плоским int32. А у int32 нет значения «отсутствует»: ноль занят, минус единица занята, любая константа — магия. COALESCE в такой паре мёртв: у него просто не бывает NULL на входе, чтобы сработала подмена.

sqlc.narg разрубает узел явным разрешением. В исходниках генератора у параметра есть целая шкала nullability: выведенная и указанная пользователем, причём комментарий гласит, что указанная пользователем имеет приоритет над выведенной — narg ставит флаг nullable, который старше любого вывода. Это ровно тот случай, ради которого макрос существует: вывод честен (колонка вправду NOT NULL), но нам нужен NULL в параметре, а не в колонке — база-то по-прежнему сравнивает не-NULL значения, подмена происходит до сравнения. Заметьте и асимметрию с @-сокращением: у именованного @параметра nullable-эквивалента нет вовсе — нужен NULL, пишите sqlc.narg, как договорились в восьмом уроке. Итог: COALESCE-фильтр по NOT NULL-колонке — всегда пара narg + COALESCE; короче не бывает, и это не многословие, а явная запись договора «NULL здесь значит без фильтра».

### Проверка: что вернёт выключенный фильтр

**Вопрос:** SearchTodos построен на WHERE done = COALESCE(sqlc.narg('done'), done). В Go передали Done: pgtype.Bool{} — нулевая обёртка, параметр уедет в базу как NULL. Что вернёт метод?

- Только задачи с done = NULL: сравнение с NULL сработало как фильтр
- Пустой срез и ошибку: NULL-параметр запрещён проверкой генератора
- ✅ Все неудалённые задачи: COALESCE подставил колонку, условие стало done = done
- Только открытые задачи: NULL читается как false по умолчанию

До сравнения с NULL дело не доходит: narg отдал параметр как NULL, COALESCE увидел первым аргументом NULL и вернул второй — колонку done. Предикат вырождается в done = done, что истинно для каждой строки (колонка NOT NULL, никакого трёхсостояния внутри). Фильтр выключен, работают остальные условия и сортировка. Пункт про «задачи с done = NULL» — ловушка из девятого урока: сравнение с NULL никогда не истинно, но здесь оно даже не выполняется.

### Подход 2: несколько готовых запросов — выбор в Go

Второй приём самый старомодный и оттого самый прочный: вместо одного запроса с опциональными параметрами — несколько готовых, по одному на каждую частую комбинацию. SearchOpenTodos для «только открытые», SearchTodosByPriority для «фильтр по приоритету», SearchTodos для «показать всё»; панель вызывает тот, который соответствует состоянию галочек. Выбор — это Go-код: пара if по заполненности структуры фильтров, читаемая сверху вниз. Дорога не нова: четырнадцатый урок уже показывал её для пагинации — пара TodosFirstPage и TodosAfterCursor вместо одного запроса с хитрым условием; здесь тот же принцип на фильтрах.

Чем платить придётся — это комбинаторика. Фильтров N — комбинаций 2 в степени N: три фильтра дают восемь наборов, и если покрыть методом каждый, search.sql разбухнет до восьми почти одинаковых SELECT с продублированными WHERE и ORDER BY. Правило гигиены: готовыми методами покрывают реальные сценарии панели, а не декартово произведение галочек; «показать всё с сортировкой» и «один точечный фильтр» — сценарии, «приоритет два плюс поиск плюс только открытые» — состояние, которое обслужит первый приём. Взамен вы получаете сигнатуры без обёрток: SearchTodosByPriority принимает честный int32, и ни один вызывающий код не видит pgtype. Там, где комбинации известны и стабильны, это самая читаемая цена на рынке; там, где комбинации растут с каждой доработкой панели, — первый или третий приём.

### Два метода вместо одного параметра-трансформера

```go
-- db/queries/search.sql: готовые комбинации, выбор в Go

-- name: SearchOpenTodos :many
SELECT * FROM todos
WHERE done = false AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC;

-- name: SearchTodosByPriority :many
SELECT * FROM todos
WHERE priority = $1 AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC;

// main.go: вызывающий код выбирает метод, а не собирает запрос
if f.Priority != nil {
        rows, err = q.SearchTodosByPriority(ctx, *f.Priority)
} else {
        rows, err = q.SearchOpenTodos(ctx)
}
```

Оба метода несут плоские типы: у SearchOpenTodos параметров нет вовсе, у SearchTodosByPriority — честный int32 от сравнения с NOT NULL-колонкой. nil в структуре фильтров остаётся в Go-мире: это сигнал «фильтр не выбран», а не значение для базы — граница между слоем HTTP и слоем данных проходит ровно здесь. WHERE done = false читается прямо — «только открытые», никакой догадки о смысле NULL. Цена видна на экране: два раза написан один и тот же ORDER BY и дисциплина мягкого удаления; на трёх-четырёх комбинациях это терпимо, дальше начните считать повторы — они честный счётчик того, что пора возвращаться к первому приёму.

### Предскажите выбранный метод

```go
// фильтры из HTTP-запроса: чего нет — того и nil
f := Filters{
        Priority: new(int32), // указатель на нулевое int32
        Done:     nil,
}

if f.Priority != nil {
        rows, err = q.SearchTodosByPriority(ctx, *f.Priority)
} else if f.Done != nil {
        rows, err = q.SearchTodosByDone(ctx, *f.Done)
} else {
        rows, err = q.SearchOpenTodos(ctx)
}
```

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

- SearchOpenTodos: значение приоритета нулевое, фильтр считается выключенным
- ✅ SearchTodosByPriority со значением 0: nil у указателя нет, есть указатель
- SearchTodosByDone: Done проверяется вторым и всегда срабатывает
- Ошибка компиляции: new(int32) нельзя разыменовать без проверки

new(int32) возвращает не-nil указатель на нулевое значение: f.Priority != nil — истина, и диспетчер уходит в первую ветку со значением *f.Priority, то есть 0. Это отдельная ловушка панели фильтров: «выбрано» и «выбрано с нулевым значением» для указателя неразличимы. Если ноль — легальное значение фильтра (а в трекере это так: DEFAULT 0), то для опциональных параметров нужен либо явный флаг «выбрано», либо один из запросов с nullable-типами из первого приёма — там «не выбран» выражен честно, обёрткой без Valid-флага.

### Подход 3: TRUE-условия — выключатель на каждый фильтр

Третий приём переносит выключатель в сам SQL: у каждого фильтра появляется булев флаг, и условие записывается парой «включатель или сравнение»: (@only_priority::bool = false OR priority = @priority). Читается так: если флаг говорит «фильтр выключен», первая половина союза OR истинна — условие пропускает любые значения приоритета, до сравнения дело не доходит; если флаг говорит «включён», первая половина ложна и работает обычное сравнение. Никакого NULL в этих запросах нет: флаги и значения — плоские типы, и в этом главный выигрыш подхода — сигнатура остаётся без pgtype-обёрток даже поверх NOT NULL-колонок.

Имена флагам даёт связка из восьмого урока: @имя — сокращение sqlc.arg, а каст ::bool подсказывает тип — флешкарта того урока обещала, что с кастами сочетается, и вот применение. Семантику запоминайте как «false = выключен»: выражение построено так, что именно ложный флаг делает первую половину истинной. Ошибиться в направлении дёшево, поэтому полезно держать формулу в голове: (флаг = false OR условие) — «флаг погашен, значит условие необязательно». Форма OR-условий, кстати, та же, что выписала подстрочный поиск из ловушки COALESCE: sqlc.narg('search') IS NULL OR title ILIKE '%' || sqlc.narg('search') || '%' — только роль выключателя играет проверка на NULL. Семейство одно, и когда скажете «TRUE-условие», имейте в виду весь его род.

### SearchTodosFlexible: два фильтра, два выключателя

```go
-- name: SearchTodosFlexible :many
SELECT * FROM todos
WHERE deleted_at IS NULL
  AND (@only_priority::bool = false OR priority = @priority)
  AND (@only_done::bool = false OR done = @done)
ORDER BY created_at DESC, id DESC;

// сгенерировано (pgx/v5, v1.27)
type SearchTodosFlexibleParams struct {
        OnlyPriority bool  // true — фильтр по приоритету включён
        Priority     int32 // значение фильтра (игнорируется при false)
        OnlyDone     bool  // true — фильтр по готовности включён
        Done         bool  // NOT NULL-колонка — параметр без обёрток
}

func (q *Queries) SearchTodosFlexible(ctx context.Context, arg SearchTodosFlexibleParams) ([]Todo, error)
```

Четыре параметра — четыре поля, и ни одной обёртки: касты дали флагам bool, сравнения с NOT NULL-колонками дали значениям int32 и bool. Порядок полей — порядок плейсхолдеров: сначала флаг и значение приоритета, потом пара готовности. Вызов «только открытые, приоритет любой»: OnlyDone: true, Done: false, OnlyPriority: false, Priority: 0 — нулевой Priority при погашенном флаге честно игнорируется, и это не магическое значение, а задокументированный строкой выше смысл. Привратная правда: база выполняет все условия всегда, просто при погашенном флаге предикат истинен для каждой строки. На индексах это отражается отдельно — об этом в разговоре о границах.

### Проверка: выключатель в FALSE-положении

**Вопрос:** В SearchTodosFlexible условие фильтра записано (@only_priority::bool = false OR priority = @priority). Что нужно передать в OnlyPriority, чтобы фильтр по приоритету пропускал любые значения?

- true — тогда всё условие истинно при любом приоритете
- ✅ false — первая половина OR станет истинной и погасит сравнение
- nil — отсутствие значения автоматически выключает фильтр
- Ничего: фильтр выключается только перегенерацией запроса

Механика выключателя: при OnlyPriority = false сравнение false = false даёт истину, союз OR уже истинен, и сравнение с параметром не рассматривается. При OnlyPriority = true первая половина становится ложной (true = false — нет), и условие сводится к priority = @priority — фильтр работает. nil не передать: поле bool, не обёртка — в этом и смысл третьего подхода. Формула для запоминания: (флаг = false OR условие) — «погашенный флаг делает условие необязательным».

### Предскажите строки через TRUE-условие

```go
-- задачи в базе (id, priority), все не удалены:
--   (1, 2), (2, 0), (3, 1)

-- name: SearchTodosFlexible :many
SELECT id, priority FROM todos
WHERE deleted_at IS NULL
  AND (@only_priority::bool = false OR priority = @priority);

-- вызов: OnlyPriority = true, Priority = 1
```

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

- ✅ id 3 — единственная строка с приоритетом 1
- id 1 и id 3 — флаг true включает обе ветки условия
- id 1, id 2 и id 3 — true означает «без фильтра»
- Пустой срез: включённый флаг запрещает выборку без значений

OnlyPriority = true: первая половина true = false — ложь, работает сравнение priority = 1. Из трёх строк подходит только id 3. Обратите внимание, как clean работает пара «флаг-значение»: значение 1 без флага было бы фильтром по умолчанию, с флагом — осознанным выбором, а выключенное состояние вообще не участвует в сравнении. Если бы флаг был false, вернулись бы все три строки при любом Priority — проверьте в уме обе половины OR на каждом id, это лучшая тренировка на TRUE-условия.

### Ограничения генератора: сортировка и переменный IN

Приёмы выше имитируют динамику WHERE. У генератора есть два места, где имитация не работает вовсе, и о них честно. Первое — сортировка. Параметр в ORDER BY не сортирует: ORDER BY @col с параметром 'priority' упорядочивает строки по константному значению 'priority', которое одинаково у всех строк, — база сравнивает значение параметра как выражение, а не читает его как ссылку на колонку, и список уходит в порядке чтения. Обходной трюк через условное выражение CASE внутри ORDER BY существует, но на живом проекте он ломается трижды: ветки CASE обязаны иметь один тип (timestamptz и bigint не поделят слот), направление сортировки одно на всё выражение (ASC и DESC попарно не разведёшь), и планировщик не может использовать индекс под выражение, которое меняется параметром. Правильный ответ на «сортировка по выбору пользователя» — второй подход: несколько готовых запросов с разными ORDER BY, по одному на каждую сортировку; их конечное число, в отличие от комбинаций фильтров.

Второе ограничение — переменный IN. Число плейсхолдеров в запросе фиксировано на генерации: IN ($1, $2) навсегда двухместный, а список id, который приходит из интерфейса, длиннее не станет. Десятый урок уже дал рабочую замену для PostgreSQL: ANY($1::int[]) — массив-параметр любой длины, срез Go-а превращается в массив одной поездкой; для постгресового движка это и есть канонический переменный IN. У sqlc есть и собственный макрос sqlc.slice — документация честно описывает его как средство для драйверов без нативной передачи массивов: он перестраивает запрос на рантайме под фактическую длину списка и потому несовместим с prepared statements; в pgx-мире курса ANY-каст проще и предсказуемее. Итоговая рамка темы: генератор не строит SQL — он его типизирует; всё, что меняет структуру запроса (состав условий, состав сортировки, длину списков), выражается значениями, готовыми методами или выходит из-под sqlc.

### Проверка: ORDER BY с параметром

**Вопрос:** В команду добавили ORDER BY @col, чтобы панель выбирала сортировку. При вызове передали @col = 'priority'. Что произойдёт?

- Список отсортируется по колонке priority — генератор подставит имя
- Генерация упадёт: параметр в ORDER BY запрещён
- ✅ Сортировки не будет: параметр — константное значение, одинаковое для всех строк
- База вернёт ошибку выполнения: ORDER BY читает только имена колонок

Запрос сгенерируется и выполнится — но параметр в ORDER BY есть значение, а не ссылка на колонку. Ключ сортировки одинаков у каждой строки ('priority'), сравнение не различает их, и база вправе вернуть строки в любом порядке — фактически в порядке чтения. Это SQL-семантика, а не причуда sqlc: параметры — про значения, имена колонок — про структуру. Сортировку по выбору пользователя дают готовые запросы с разными ORDER BY — второй подход урока; CASE-трюк на индексах и типах веток ломается трижды.

### Сравнение трёх подходов

| Критерий | COALESCE-фильтры | Несколько запросов | TRUE-условия |
| --- | --- | --- | --- |
| Состав SQL | один запрос, короткие условия | N почти одинаковых SELECT | один запрос, многословный WHERE |
| Сигнатура Go | pgtype-обёртки от narg | плоские типы, свой метод | флаги bool + плоские значения |
| «Без фильтра» выражается | NULL-параметром | выбором метода в Go | флагом false |
| Рост при новых фильтрах | +1 строка WHERE и одно поле | +2^N методов и повторы | + пара флаг-значение |
| Дубли ORDER BY | нет | в каждом методе | нет |
| Читаемость SQL | высокая, но с договором о NULL | самая высокая: всё явно | средняя: шум OR-пар |
| Когда выбирать | много комбинаций, равенства | немного стабильных сценариев | много фильтров без обёрток |

### Когда sqlc отпускает: Raw через pgx рядом

Есть экраны, на которых приёмы заканчиваются: отчётный конструктор, где пользователь комбинирует фильтры с сортировками произвольно, экспорты с условиями из бизнес-правил, админ-интерфейсы, у которых список критериев меняется вместе с регламентом. Комбинации там не перечислить — их генерирует сам пользователь. В этой точке честный ответ звучит так: для конкретного запроса sqlc лучше отпустить. В .sql-файле нет «сырого» люка — и не надо: статистически проверяемый SQL со строгими сигнатурами и произвольно собранный текст — разные задачи. Raw-запрос живёт рядом с генерированным кодом: тот же пул *pgxpool.Pool, который вы передаёте в New из сгенерированного db.go, те же соединения и транзакции, только текст и скан — руками.

Две дисциплины делают Raw безопасным соседом. Первая — параметры всегда через плейсхолдеры: пользовательский ввод едет в срезе аргументов, а не в тексте; склеиваются только имена колонок из белого списка констант, никогда — значения. Вторая — место: Raw-запросы живут в отдельном файле или пакете с ясной шапкой, чтобы код-ревью видело границу — что проверено генератором, а что руками. И держите пропорцию: Raw — это про редкие динамические экраны; девяносто пять процентов списка — статические запросы, ради которых sqlc и существует. Двадцать четвёртый урок сравнит подходы и возьмёт в компанию query-билдеры вроде squirrel — там будет видно, что покупается вместе с ними и что теряется.

### buildFilters: условный WHERE руками, но безопасно

```go
// buildFilters собирает условия и аргументы вместе:
// сколько условий — столько плейсхолдеров, значения не склеиваются
func buildFilters(f Filters) (string, []any) {
        conds := []string{"deleted_at IS NULL"} // дисциплина мягкого удаления
        args := []any{}
        if f.Priority != nil {
                args = append(args, *f.Priority)
                conds = append(conds, fmt.Sprintf("priority = $%d", len(args)))
        }
        if f.Search != "" {
                args = append(args, "%"+f.Search+"%") // шаблон — данные, не текст запроса
                conds = append(conds, fmt.Sprintf("title ILIKE $%d", len(args)))
        }
        return strings.Join(conds, " AND "), args
}

// вызов: тот же пул, что и у сгенерированного New()
where, args := buildFilters(f)
rows, err := pool.Query(ctx,
        "SELECT id, title, done, priority, created_at FROM todos WHERE "+where,
        args...)
```

Вот и вся «динамика», которой не хватает sqlc: срез условий растёт вместе со срезом аргументов, номер плейсхолдера считается через len(args) — перепутать нумерацию нельзя. Значения ввода едут параметрами: f.Search попадает в args, а не в текст; в конкатенации живут только фиксированные фрагменты и имена колонок, которые вы контролируете. Обратите внимание, сколько здесь знаний из sqlc-курса: дисциплина мягкого удаления, шаблон с процентами как данные, сортировка не в WHERE. Raw — это не побег от курса, а его применение там, где генератор честно останавливается.

### Восстановите COALESCE-фильтр

Четыре пропуска: проверка мягкого удаления, функция подстановки, макрос nullable-параметра и колонка-фолбэк.

```go
-- name: SearchTodos :many
SELECT * FROM todos
WHERE deleted_at ___ NULL
  AND priority = ___(sqlc.___('priority'), priority)
  AND done = COALESCE(sqlc.narg('done'), ___)
ORDER BY created_at DESC, id DESC;
```

Подсказка: Параметр без фильтра — это NULL: функция подбирает первый ненулевой аргумент, а макрос даёт параметру право на NULL. Фолбэк у COALESCE — сама колонка.

Правильные ответы в пропусках: `IS`, `COALESCE`, `narg`, `done`

### Соберите диспетчер фильтров

Девять строк: функция-диспетчер выбирает готовый метод по заполненности фильтров, от точечного к общему.

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

1. `func listTodos(ctx context.Context, f Filters) ([]Todo, error) {`
2. `	if f.Priority != nil { // фильтр выбран — точечный метод`
3. `		return q.SearchTodosByPriority(ctx, *f.Priority)`
4. `	} // priority-ветка закрыта`
5. `	if f.Done != nil {`
6. `		return q.SearchTodosByDone(ctx, *f.Done)`
7. `	} // done-ветка закрыта`
8. `	return q.SearchTodos(ctx) // ни один фильтр не выбран`
9. `} // диспетчер готов`

### Карточки: динамические запросы

- **Динамические запросы в sqlc** — Генератор не строит SQL из частей: текст запроса копируется в константу статично, без ветвлений. Переменный состав условий выражают тремя приёмами: COALESCE-фильтры, несколько готовых запросов с выбором в Go, TRUE-условия. Всё, что меняет структуру запроса, — значения, методы или Raw через pgx.
- **COALESCE-фильтр** — priority = COALESCE(sqlc.narg('priority'), priority): NULL-параметр читается «без фильтра», условие вырождается в priority = priority. Работает для равенства; для ILIKE не годится — вырождается в точное совпадение, там нужна OR-форма с проверкой IS NULL. Родственник частичного обновления из восьмого урока.
- **Почему обязателен narg** — Nullability параметра выводится из сравнения с колонкой: NOT NULL-колонка даёт плоский int32/bool без права на NULL — «без фильтра» невыразимо. sqlc.narg ставит пользовательский флаг nullable, который в генераторе старше выведенного. У @-сокращения nullable-эквивалента нет вовсе.
- **Несколько готовых запросов** — Частые комбинации фильтров получают собственные методы, выбор — пара if в Go. Плюсы: плоские сигнатуры, всё явно. Минус: комбинаторика 2^N и дублированный ORDER BY. Покрывать сценарии, а не декартово произведение галочек; та же дорога, что пара «первая страница — продолжение» из пагинации.
- **TRUE-условие** — (@only_priority::bool = false OR priority = @priority): false погашает фильтр (первая половина OR истинна), true включает сравнение. Имена — через @имя с кастом ::bool. Ценность: плоские типы без pgtype-обёрток; цена: многословный WHERE и предикаты, которые база вычисляет всегда.
- **ORDER BY и переменный IN** — Параметр в ORDER BY не сортирует: это константа, одинаковая для всех строк; CASE-трюк ломается о типы веток, направления и индексы — сортировки даёт второй подход. IN фиксированной длины заменяет ANY($1::int[]) из десятого урока; макрос sqlc.slice перестраивает запрос на рантайме и несовместим с prepared statements. Произвольные комбинации — Raw через pgx рядом.

### Что дальше

Динамические запросы встали на место: COALESCE-фильтр с narg обслуживает комбинации равенств, где NULL честно значит «без фильтра»; несколько готовых запросов покрывают стабильные сценарии плоскими типами; TRUE-условия дают выключатель на каждый фильтр и не пускают в сигнатуру обёртки; границы названы по именам — сортировка параметром не сортирует, переменный IN живёт в ANY-касте, а произвольные комбинации — территория Raw через pgx, который остаётся рядом с генерированным кодом. Трекер научился показывать список, каким его попросила панель.

С фильтрами связан последний вопрос чтения — а откуда берётся сама схема, по которой генератор всё это проверяет? Шестнадцатый урок «Миграции вместе с sqlc» соединит два мира: golang-migrate и goose ведут миграции, sqlc читает их папку и сверяет запросы со схемой — поговорим о синхронизации схемы и запросов, когда колонки появляются вместе с фильтрами, которые их используют. Опциональные параметры и nullability можно освежить в [восьмом уроке](/courses/sqlc/lessons/08-parametry/); обёртки и трёхсостояние — в [девятом](/courses/sqlc/lessons/09-null/); страницы и курсоры — в [четырнадцатом](/courses/sqlc/lessons/14-paginaciya/); план остальных тем — на странице [курса sqlc](/courses/sqlc/).

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

- sqlc не строит SQL из частей: текст запроса копируется в Go-константу статично, и «динамические запросы» — это условные фильтры: статические приёмы для переменного состава WHERE.
- COALESCE-фильтр: priority = COALESCE(sqlc.narg('priority'), priority) — NULL-параметр значит «без фильтра», условие вырождается в сравнение колонки с собой; для ILIKE-поиска приём не годится — там OR-форма с IS NULL.
- narg обязателен на NOT NULL-колонках: nullability параметра выводится из сравнения, плоский int32/bool не умеет «отсутствовать»; пользовательский nullable-флаг narg старше выведенного.
- Несколько готовых запросов: свой метод на частую комбинацию, выбор парой if в Go; плоские типы и максимум читаемости ценой комбинаторики 2^N и дублированного ORDER BY.
- TRUE-условие: (@only_priority::bool = false OR priority = @priority) — флаг false погашает фильтр, true включает; сигнатура без pgtype-обёрток, но база вычисляет все предикаты всегда.
- Границы генератора: параметр в ORDER BY — константа и не сортирует (сортировки — готовыми методами); переменный IN заменяется ANY($1::int[]) из десятого урока; sqlc.slice перестраивает запрос на рантайме и не дружит с prepared statements.
- Для произвольных комбинаций фильтров и сортировок sqlc отпускает: Raw через pgx рядом — тот же пул, параметры только плейсхолдерами, значения не склеиваются, место — отдельный файл с ясной границей.

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

Проведите трекер через условные фильтры. Первое: соберите db/queries/search.sql с SearchTodos на двух COALESCE-фильтрах (done и priority, оба через sqlc.narg), прогоните sqlc generate и сверьте SearchTodosParams поле за полем — обе обёртки pgtype. Второе: напишите в Go диспетчер панели, который при выключенных фильтрах передаёт pgtype.Bool{} и pgtype.Int4{}, а при включённых — значения с Valid: true, и убедитесь на живой базе, что «всё» и «фильтр по нулю» — разные результаты. Третье: воспроизведите ILIKE-ловушку — напишите title ILIKE COALESCE(sqlc.narg('search'), title), найдите в ответе не «все строки», а точные совпадения, затем перепишите OR-формой и объясните разницу. Четвёртое: заведите SearchOpenTodos и SearchTodosByPriority рядом с SearchTodos и соберите order-диспетчер из урока; посчитайте, сколько раз в файле повторился ORDER BY, и сформулируйте, при каком числе комбинаций вы вернулись бы к первому приёму. Пятое: соберите SearchTodosFlexible с двумя TRUE-условиями, сверьте сигнатуру (четыре плоских поля, ни одной обёртки) и проверьте на базе обе крайности: оба флага false — полный список, оба true — пустой при несуществующей комбинации. Шестое: попытайтесь отсортировать через ORDER BY @col с параметром 'priority' и опишите фактический порядок строк; затем сделайте два готовых метода с разными ORDER BY и сравните. Седьмое: добавьте фильтр по списку исполнителей через ANY($1::int[]) и проверьте срезы длины 0, 1 и 5 — как ведёт себя пустой срез и что вернёт ANY(NULL). Восьмое: напишите buildFilters из урока, добавьте третий фильтр по диапазону created_at (два параметра на BETWEEN) и убедитесь, что нумерация плейсхолдеров через len(args) не ломается; закомментируйте в шапке файла, почему этот запрос живёт мимо генератора.

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

### Почему нельзя просто склеить WHERE строками в Go, как в ORM?

Собранный на лету текст — слепая зона генератора: в нём нельзя проверить колонки по схеме и типы параметров, а склейка значений открывает инъекции. Если запросу действительно нужна динамика, Raw-вариант из этого урока делает то же самое дисциплинированно: значения едут параметрами через плейсхолдеры, склеиваются только фиксированные фрагменты и имена колонок из белого списка.

### COALESCE-фильтр или TRUE-условие — что выбрать?

Начните с типов. Если хотите видеть в сигнатуре nullable-обёртки и выражать «нет фильтра» nil-ом — COALESCE с narg: короче, чище читается. Если обёртки нежелательны (NOT NULL-домен, много вызывающего кода с плоскими типами) — TRUE-условия: флаги bool и плоские значения, но WHERE шумнее, а база вычисляет выключенные предикаты всегда. Для подстрочного поиска COALESCE не подходит вовсе — только OR-форма.

### TRUE-условия портят производительность?

Умеренно и проверяемо. Каждый выключенный фильтр остаётся предикатом, истинным для всех строк, — планировщик обычно выкидывает его после вычисления параметров, но план строится для всех комбинаций сразу. На горячих запросах проверяйте EXPLAIN с разными наборами флагов; если планы расходятся сильно, разбейте вариант на несколько готовых методов — это второй подход урока, и он существует именно для таких случаев.
