ADR-SRV-002. Контракт grid-запросов
Ряд экранов показывает табличные списки сущностей (очередь обработки, карты, документы). Базовый CQRS (ADR-SRV-001) задаёт обёртку, но не контракт списка: как устроены пагинация и сортировка, как именуются и применяются фильтры, что в ответе. Фиксируем единый контракт до первого реального grid-хендлера, чтобы все списки строились по одному шаблону, а фронт имел один клиентский паттерн.
1. Контекст и постановка задачи
UI будет показывать табличные представления сущностей (очередь обработки, информационные карты, экземпляры фонда). Базовая CQRS-инфраструктура (ADR-SRV-001) даёт обёртку ответа, но не сам контракт списка — какие поля у запроса и ответа, как именуются фильтры, как ведут себя сортировка и пагинация.
Без единого контракта каждый список мог бы описывать страницы по-своему (0-based vs 1-based, offset vs cursor), возвращать или не возвращать общее число, ждать имена колонок сортировки в разных форматах, по-разному структурировать фильтры и по-разному сериализовать enum. Контракт фиксируется до первого реального grid-хендлера.
Термины. Offset-пагинация — сервер пропускает первые N записей (
SKIP/OFFSET): просто, но дороже на больших объёмах. Cursor-пагинация — клиент передаёт «якорь» последней записи, сервер ищет после него: быстро по индексу, но нельзя прыгнуть на произвольную страницу.
2. Драйверы решения
- Единообразие клиента — фронт пишет один «строитель grid-запросов», а не логику в каждой фиче.
- Производительность БД — избегаем
COUNT(*)на больших таблицах. - Безопасность сортировки — имя колонки приходит с клиента; нужен whitelist, чтобы имя идентификатора не стало вектором инъекции.
- Расширяемость без ломки — multi-column sort и новые типы фильтров должны добавляться без breaking change.
3. Рассмотренные варианты
- A (выбран) — 1-based offset-пагинация +
HasMoreчерез N+1, типизированныйUserFilterотдельным record, whitelist имён сортировки. - B. Cursor-пагинация — производительнее по индексу, но без прыжков на произвольную страницу и сложнее для UI.
- C. Всегда возвращать
TotalCount— привычно, ноCOUNT(*)на каждый запрос дорог на крупных таблицах.
4. Решение
Выбран Вариант A: нет дорогого COUNT(*), произвольная навигация сохраняется, фильтры
самодокументируемы.
1. Запрос
Все grid-запросы наследуют базовый:
public abstract record IcdBaseGridQuery<TResult> where TResult : IIcdQueryResult
{
public required PagingModel Paging { get; init; } // обязательна
public SortingModel? Sorting { get; init; } // опциональна
}
Конкретный запрос добавляет только типизированный UserFilter?.
Пагинация (PagingModel): PageIndex (int, 1-based, default 1), ItemsPerPage
(int, default 20). Ограничения min/max — на усмотрение конкретного хендлера, не в базовом классе.
Сортировка (SortingModel?): PropertyName (string, имя property элемента списка,
без учёта регистра) + Direction (SortDirection: Ascending/Descending). Пока одна колонка;
путь расширения до SortingModel[]? — без breaking change, когда UI потребует вторичную
сортировку. Недопустимое PropertyName → ошибка 400 с перечнем допустимых полей. При
Sorting == null хендлер применяет дефолт (обычно — по убыванию даты создания; для справочников
допустим естественный порядок с явным обоснованием).
2. Классы фильтров
| Класс | Кто задаёт | В контракте | Пример |
|---|---|---|---|
| User | пользователь через UI | да, UserFilter? |
поиск по имени, выбор статуса |
| Context | UI автоматически из контекста | да, отдельное поле запроса | текущая организация/сессия |
| System | хендлер по бизнес-правилам | нет, применяется внутри | ограничение по правам |
User-фильтр — отдельный record {QueryName}UserFilter, все поля nullable (применяются
независимо): строки — вхождение без учёта регистра; enum — как строковый идентификатор (EID),
не число (разрешается через домен); Guid — точное совпадение по FK; диапазоны дат —
DateOnlyRangeModel { Min?, Max? }.
3. Ответ (без TotalCount)
QueryResult : IcdBaseGridQueryResult<TPayload, TItem>
└── Payload { GridItems: TItem[]; HasMore: bool }
TotalCount не возвращаем — устраняем COUNT(*); UI работает в режиме «загрузить ещё» /
факт наличия следующей страницы. Нумерация «страница N из M» намеренно не поддерживается.
4. HasMore через N+1
var items = await sorted
.ApplyPaging(pageIndex, itemsPerPage, addOneMoreItem: true) // запросить на 1 больше
.ToArrayAsync(ct);
GridItems = items.Take(itemsPerPage).ToArray();
HasMore = items.Length > itemsPerPage;
5. Whitelist-сортировка в хендлере
private static readonly SortingApplier<TDbEntity> Sorting =
new SortingApplier<TDbEntity>()
.AddSortingOption(nameof(GridItem.Name), x => x.Name);
Ключ — nameof property элемента списка; значение — выражение на внутренней модели.
SortingApplier служит whitelist-защитой от произвольных имён колонок.
6. Даты в контракте
Дата в ответе — DateYmdModel (Year/Month/Day, сериализуется как ISO YYYY-MM-DD);
диапазонный фильтр — DateOnlyRangeModel с независимыми Min?/Max?. Совместимо с генератором
клиента и локалью фронта (ADR-UI-015).
5. Положительные следствия
- Один клиентский паттерн на все списки.
- Нет
COUNT(*)— меньше нагрузки на БД. - Whitelist-сортировка защищает от произвольных идентификаторов.
- Тонкий запрос + самодокументируемый
UserFilter.
6. Отрицательные следствия и компромиссы
- Нет общего числа страниц — классическая пагинация «1 из N» недоступна (принимаем ради производительности).
- Клиент должен знать имена property для сортировки — приходят через генерируемый клиент, рассинхронизации нет.
- Переход на multi-column sort потребует одновременной правки базового класса и клиентов — путь
миграции (
SortingModel?→SortingModel[]?) зафиксирован.
7. Проверка
- Первый grid-хендлер реализован строго по контракту.
- Ответ содержит
payload { gridItems, hasMore }, безtotalCount. - Невалидное
Sorting.PropertyName→ 400 с перечнем допустимых полей. - Enum в фильтрах передаются строковым идентификатором, не числом.
8. Открытые вопросы / отложено
- Multi-column sort — расширение до
SortingModel[]?при первом кейсе вторичной сортировки. - Полнотекстовый поиск — реализация
SearchString(движок, индексы) — отдельным ADR при первой фиче с ним. - Единый лимит
ItemsPerPage— если понадобится защита от выгрузки всей таблицы, вынести в общий pipeline-шаг.