Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~22 мин +50 XP

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

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

Содержание урока · 12 разделов

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

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

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
Сигнатура Gopgtype-обёртки от 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-параметра и колонка-фолбэк.

-- 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;

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Динамические запросы в sqlc

нажмите, чтобы перевернуть
Определение

Генератор не строит SQL из частей: текст запроса копируется в константу статично, без ветвлений. Переменный состав условий выражают тремя приёмами: COALESCE-фильтры, несколько готовых запросов с выбором в Go, TRUE-условия. Всё, что меняет структуру запроса, — значения, методы или Raw через pgx.

1 / 6

Что дальше#

Динамические запросы встали на место: 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) не ломается; закомментируйте в шапке файла, почему этот запрос живёт мимо генератора.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Динамические запросы: подходы)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

ent25 уроков~13 чпрактика25 уроков уже доступны

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила