ADR-SRV-002. Контракт grid-запросов

ADR Версия: 1.0 accepted

Ряд экранов показывает табличные списки сущностей (очередь обработки, карты, документы). Базовый 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. Драйверы решения

  1. Единообразие клиента — фронт пишет один «строитель grid-запросов», а не логику в каждой фиче.
  2. Производительность БД — избегаем COUNT(*) на больших таблицах.
  3. Безопасность сортировки — имя колонки приходит с клиента; нужен whitelist, чтобы имя идентификатора не стало вектором инъекции.
  4. Расширяемость без ломки — multi-column sort и новые типы фильтров должны добавляться без breaking change.

3. Рассмотренные варианты

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. Положительные следствия

6. Отрицательные следствия и компромиссы

7. Проверка

8. Открытые вопросы / отложено

Связанные артефакты

Документы