Содержание урока · 12 разделов
- sqlc динамические запросы: WHERE собирается заранее
- Подход 1: COALESCE-фильтры — NULL значит «без фильтра»
- Почему narg обязателен: право на NULL не выводится
- Подход 2: несколько готовых запросов — выбор в Go
- Подход 3: TRUE-условия — выключатель на каждый фильтр
- Ограничения генератора: сортировка и переменный IN
- Сравнение трёх подходов
- Когда sqlc отпускает: Raw через pgx рядом
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
К четырнадцатому уроку трекер отдаёт страницы, которые не врут: 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. Что вернёт метод?
Подход 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 и дисциплина мягкого удаления; на трёх-четырёх комбинациях это терпимо, дальше начните считать повторы — они честный счётчик того, что пора возвращаться к первому приёму.
Предскажите выбранный метод
Прочитайте код и выберите, что он выведет:
// фильтры из 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)
}Подход 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-условие
Прочитайте код и выберите, что он выведет:
-- задачи в базе (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Ограничения генератора: сортировка и переменный 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'. Что произойдёт?
Сравнение трёх подходов#
| Критерий | 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-параметра и колонка-фолбэк.
Соберите диспетчер фильтров
Девять строк: функция-диспетчер выбирает готовый метод по заполненности фильтров, от точечного к общему.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: динамические запросы
Что дальше#
Динамические запросы встали на место: COALESCE-фильтр с narg обслуживает комбинации равенств, где NULL честно значит «без фильтра»; несколько готовых запросов покрывают стабильные сценарии плоскими типами; TRUE-условия дают выключатель на каждый фильтр и не пускают в сигнатуру обёртки; границы названы по именам — сортировка параметром не сортирует, переменный IN живёт в ANY-касте, а произвольные комбинации — территория Raw через pgx, который остаётся рядом с генерированным кодом. Трекер научился показывать список, каким его попросила панель.
С фильтрами связан последний вопрос чтения — а откуда берётся сама схема, по которой генератор всё это проверяет? Шестнадцатый урок «Миграции вместе с sqlc» соединит два мира: golang-migrate и goose ведут миграции, sqlc читает их папку и сверяет запросы со схемой — поговорим о синхронизации схемы и запросов, когда колонки появляются вместе с фильтрами, которые их используют. Опциональные параметры и nullability можно освежить в восьмом уроке; обёртки и трёхсостояние — в девятом; страницы и курсоры — в четырнадцатом; план остальных тем — на странице курса sqlc.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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 с разными наборами флагов; если планы расходятся сильно, разбейте вариант на несколько готовых методов — это второй подход урока, и он существует именно для таких случаев.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
ent: графовая ORM с кодогенерацией
Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.
- Проектировать схемы и связи в ent
- Генерировать код и жить с кодогенерацией
- Писать запросы builder-стилем с типами
- Строить хуки, интерсепторы и privacy-правила