SQL-поиск, фильтры и пагинация
SQL-операции :search строят через app/common/search. Handler валидирует транспорт, action выполняет запрос, search.Definition в пакете queries задаёт разрешённые операции, mapper собирает API DTO. Примеры ниже основаны на CMS-Go и Catalog Cache Go; проверьте сигнатуры общего кода в своём checkout-е.
Эта схема относится к поиску по PostgreSQL. Для поиска по Elasticsearch или прямого проксирования upstream используйте соответствующий адаптер либо HTTP-клиент и фактический контракт операции.
1. Зафиксировать контракт ресурса
Найдите операцию и все её $ref в resources/openapi/, затем маршрут, handler и request. Выпишите:
- method/URI и
operationId; - JSON-поля запроса, required/nullable, допустимые формы
sort,include,filter,pagination; - публичные filters, sorts, includes и значения по умолчанию;
- поля DTO,
data,meta.paginationи ошибки; - локальные и внешние идентификаторы, таблицы и реальные ключи связей.
Для каждого фильтра и include должно быть понятно соответствие публичное имя → query → SQL-колонка/ORM-связь → mapper → тест. OpenAPI с произвольным filter: object не описывает этот набор полностью; сверяйте описание операции и определение query.
При изменении API редактируют исходную спецификацию и выполняют elc ensi-gog server. Правила владения generated-файлами описаны в генераторе, модели и миграции — в работе с БД.
2. Подготовить модель и query
Модель и её фабрика находятся в app/adapters/db, query — в app/modules/<module>/queries. Для новой таблицы нужна зарегистрированная миграция. Связи определяются схемой и foreignKey/references; суффикс ID не говорит, хранится ли локальный PK или ID мастер-системы.
Пример определения из app/modules/nameplates/queries/nameplates.go в CMS:
func Nameplates() search.Definition {
return search.Definition{
Includes: map[string]string{
"product_links": "Products",
},
Sorts: []string{"id", "name", "is_active", "created_at", "updated_at"},
DefaultSort: []string{"id"},
Filters: search.MergeFilters(
map[string]search.Filter{
"id": search.Exact("id"),
"is_active": search.Exact("is_active"),
"product_id": search.WhereHas[db.Nameplate]("Products", "product_id"),
},
search.Contain("name"),
search.DateRange("created_at"),
search.DateRange("updated_at"),
),
}
}
search — goravel/app/common/search, db — goravel/app/adapters/db. В определении перечислены только серверные имена колонок и отношений. Данные клиента используются как значения параметров; не подставляйте пользовательское имя поля в SQL.
Includes отображает публичный alias на ORM-путь: здесь product_links загружает Products. Имя фильтра по связи задаётся независимо — include alias не переименовывает фильтр автоматически.
Поведение готовых фильтров
| Helper общего каркаса | Поведение и ограничение |
|---|---|
Exact(column) | Скаляр или список IN; nil и пустой список не ограничивают запрос. Запятая в строке остаётся частью значения |
Contain(column) | Добавляет ключ <column>_like с ILIKE; nil и пустая строка игнорируются |
Partial(column) | Аналогичный поиск по подстроке под явно выбранным публичным ключом |
NumericRange(column) | Ключи _gte и _lte; сверяйте допустимые типы и точность, особенно для больших целых и денежных значений |
DateRange(column) | Ключи _gte и _lte с включением всего граничного дня |
WhereHas[Model](relation, column) | Фильтр через объявленную has-many связь; nil и пустой список игнорируются |
WhereHasPath[Model](relations, column) | Фильтр через цепочку has-many отношений |
Значение nil в фильтре Exact не означает IS NULL. Если контракт требует поиск SQL NULL, отдельный фильтр должен выразить именно эту семантику.
Текущий GORM-адаптер relationship-фильтров проверяет связанные строки напрямую и не применяет автоматически scopes и soft-delete правила связанных моделей. Поддерживаются неполиморфные has-many отношения; для другого типа связи или дополнительных ограничений нужен проверенный query/helper. Сравните это с Laravel-поведением до переиспользования.
Границы дат
DateRange принимает дату или RFC3339 date-time, но преобразует её в начало/конец календарного дня в указанном часовом поясе, затем в UTC. Верхняя граница заканчивается за микросекунду до следующего дня. Для списка границ текущий helper выбирает наиболее раннюю нижнюю или наиболее позднюю верхнюю.
Это поведение подходит для фильтра по дню. Для сравнения точных моментов времени реализуйте соответствующий фильтр и тесты; переданный timestamp сам по себе не меняет семантику DateRange.
3. Связать action, mapper и handler
Пример action поиска страниц CMS; aliases соответствуют пакетам этого модуля:
func (a *SearchPagesAction) Execute(
ctx context.Context,
params search.Params,
) (*commondto.SearchResult[dto.Page], error) {
query := facades.Orm().WithContext(ctx).Query().Model(&db.Page{})
data, pagination, err := search.Execute(
query,
queries.Pages(),
params,
mappers.PageFromModel,
)
if err != nil {
return nil, err
}
return &commondto.SearchResult[dto.Page]{Data: data, Pagination: pagination}, nil
}
search.Execute возвращает три значения: элементы DTO, pagination meta и ошибку. Mapper имеет сигнатуру func(Model, search.Params) DTO. Он преобразует даты/enum/nullable и включает только запрошенные отношения; SQL-запросы внутри mapper создают скрытые N+1 и затрудняют контроль include.
В Catalog Cache для условного маппинга используется search.MapIfIncluded. Он проверяет наличие имени в params.Include; сама подгрузка должна быть настроена через Definition.Includes или явную подготовку query.
Handler использует общий request и HTTP-хелперы:
func (c *PagesController) Search(ctx http.Context) http.Response {
var req commondto.SearchRequest
if failResponse := commonhttp.Validate(ctx, &req); failResponse != nil {
return failResponse
}
result, err := actions.NewSearchPagesAction().Execute(ctx.Context(), req.Params())
if err != nil {
return commonhttp.HandleError(ctx, err)
}
return commonhttp.Page(ctx, result.Data, result.Pagination)
}
commondto — goravel/app/common/dto, commonhttp — goravel/app/common/http. Неизвестный filter/sort/include общий executor отклоняет через InvalidArgumentError. Ошибки объявления query или БД нужно возвращать, а не превращать в успешный пустой список. Формат ответа и статусы описаны в HTTP-ответах.
4. Согласовать нормализацию и пагинацию
Пример запроса страниц CMS:
{
"filter": { "name_like": "About", "is_active": true },
"sort": ["id"],
"pagination": { "type": "offset", "limit": 10, "offset": 0 }
}
SearchRequest.PrepareForValidation нормализует компактную строковую сортировку; request validation и OpenAPI должны принимать одинаковые формы. Для схемы oneOf проверяйте, что каждый допустимый запрос, включая запрос без pagination.type, соответствует ровно одной ветке.
SearchRequest.Params() читает pagination.default_type и pagination.default_limit; без настроек используются offset и limit 10. HTTP-правила текущего request требуют положительный limit, хотя низкоуровневые функции пагинации имеют дополнительные режимы для нуля и отрицательных значений. Эти внутренние режимы не становятся публичным API автоматически.
| Режим | Что проверить |
|---|---|
| Offset | total до limit/offset, пустую страницу, offset вне результата, defaults и сортировку |
| Cursor | Устойчивый порядок, следующий/предыдущий cursor, отсутствие дублей и пропусков, ошибочный cursor |
| Search-one | Один DTO, поведение при отсутствии совпадений, sort/include и отдельный формат ответа |
Cursor строится из PreparedQuery.orders. Уникальный компонент порядка должен участвовать и в SQL, и в кодировании/сравнении cursor. В текущем коде Definition.TieBreakers добавляет SQL OrderBy, но не дополняет orders; наличие этого поля само по себе не гарантирует корректный cursor для повторяющихся значений. Проверяйте весь разрешённый sort, nullable-значения и несколько страниц; необходимое исправление общего executor выполняют с проверкой затронутых ресурсов.
Для :search-one в CMS используется req.ParamsOne(), limit становится 1. Handler возвращает первый элемент через Data, а при пустом результате — 404. Это пример конкретного контракта; GET-коллекции и другие операции могут иметь иной формат.
5. Проверить поведение через HTTP
Используйте ComponentSuite, один TestMain с RunComponentTests на пакет и SearchSuite[DTO]. Базовый сценарий должен создавать данные, вызывать настоящий route и сравнивать значения и ID ответа.
В ручном конструкторе SearchSuite после SetT(t) и SetupTest() обязательно зарегистрируйте t.Cleanup(s.TearDownTest): обычный t.Run не вызывает suite hooks. Для фикстур используйте s.CreateQuietly(...), чтобы observers подготовки не оставляли непроверенные Kafka-события. Если чтение не должно публиковать события, после запроса можно явно проверить s.AssertKafkaMessages().
| Сценарий | Данные и утверждение |
|---|---|
| Каждый filter | Подходящая и неподходящая запись; сравнение ID. Для boolean — true и false |
| Sort | Различимые значения, обе стороны сортировки, точная последовательность ID |
| Include | Явно созданные отношения и посторонние записи; состав вложенного DTO и его отсутствие без include |
| Dates | Ниже, на и выше каждой границы, часовой пояс и точность PostgreSQL |
| Pagination | Несколько страниц, defaults, пустой результат и ошибочный cursor |
| Ошибки | Недопустимые поля/типы; статус и тело ошибки при включённой response validation |
Фабричные ключи — имена Go-полей (IsActive), JSON-фильтры — публичные имена (is_active). Используйте реальные ID созданных родителей и проверьте, какой именно ключ хранит FK. Не запускайте тесты с общей БД или fake registry через t.Parallel().
Подробности lifecycle и OpenAPI wrapper — в автотестах. Для реализации используйте add-sql-search-resource, для матрицы тестов — test-search-endpoint, для аудита существующего ресурса — review-search-contract из .agents/skills.
Исходные примеры
- CMS: query nameplates — alias include и relationship filter.
- CMS: search action и handler страниц — общий pipeline и search-one.
- Catalog Cache: mapper offer — условные отношения.
Команды выполняются из соответствующего сервиса; для CMS пример узкой проверки:
elc go test ./app/modules/contents/tests/pages --env=.env.testing -count=1
elc mise run check:generated
elc go build -o build/app