⟨/⟩ 40_Arch/Tech/CSharp/ADR_SRV_002_GridQueryContract.cs

200 строк · в начало

using System;
using Ban.Sdaid.Notation.Documents;
using Ban.Sdaid.Icd;

namespace Ban.Sdaid.Icd.Arch.Tech.CSharp
{
    /// <summary>
    /// ADR-SRV-002: Контракт grid-запросов (запрос/ответ). Поверх CQRS-обёртки (ADR-SRV-001)
    /// фиксирует форму списочных запросов: пагинацию, сортировку, классы фильтров, форму ответа.
    /// </summary>
    public class ADR_SRV_002_GridQueryContract : IAdrDocument, IFolder<ArchCSharpFolder>
    {
        public string Name => "ADR-SRV-002. Контракт grid-запросов";

        public string Description =>
            @"Ряд экранов показывает табличные списки сущностей (очередь обработки, карты, документы). Базовый
CQRS (ADR-SRV-001) задаёт обёртку, но не контракт списка: как устроены пагинация и сортировка, как
именуются и применяются фильтры, что в ответе. Фиксируем единый контракт до первого реального grid-хендлера,
чтобы все списки строились по одному шаблону, а фронт имел один клиентский паттерн.";

        public string Version => "1.0";
        public string Status => "accepted";
        public string[] Comments => new[]
        {
            "2026-08-12. Принято вместе с ADR-SRV-001. Первый список (очередь/карты) появится позже — контракт фиксируем заранее. Для справочников ввода (не список) не применяется.",
        };

        public Type? Supersedes => null;

        public static string S1_Context = """
            ## Контекст и постановка задачи

            UI будет показывать табличные представления сущностей (очередь обработки, информационные карты,
            экземпляры фонда). Базовая CQRS-инфраструктура (ADR-SRV-001) даёт обёртку ответа, но не сам
            **контракт списка** — какие поля у запроса и ответа, как именуются фильтры, как ведут себя
            сортировка и пагинация.

            Без единого контракта каждый список мог бы описывать страницы по-своему (0-based vs 1-based,
            offset vs cursor), возвращать или не возвращать общее число, ждать имена колонок сортировки в
            разных форматах, по-разному структурировать фильтры и по-разному сериализовать enum. Контракт
            фиксируется **до** первого реального grid-хендлера.

            > **Термины.** *Offset-пагинация* — сервер пропускает первые N записей (`SKIP/OFFSET`): просто,
            > но дороже на больших объёмах. *Cursor-пагинация* — клиент передаёт «якорь» последней записи,
            > сервер ищет после него: быстро по индексу, но нельзя прыгнуть на произвольную страницу.
            """;

        public static string S2_DecisionDrivers = """
            ## Драйверы решения

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

        public static string S3_ConsideredOptions = """
            ## Рассмотренные варианты

            - **A (выбран)** — 1-based offset-пагинация + `HasMore` через N+1, типизированный `UserFilter`
              отдельным record, whitelist имён сортировки.
            - **B. Cursor-пагинация** — производительнее по индексу, но без прыжков на произвольную страницу
              и сложнее для UI.
            - **C. Всегда возвращать `TotalCount`** — привычно, но `COUNT(*)` на каждый запрос дорог на
              крупных таблицах.
            """;

        public static string S4_DecisionOutcome = """
            ## Решение

            Выбран **Вариант A**: нет дорогого `COUNT(*)`, произвольная навигация сохраняется, фильтры
            самодокументируемы.

            ### 1. Запрос

            Все grid-запросы наследуют базовый:

            ```csharp
            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

            ```csharp
            var items = await sorted
                .ApplyPaging(pageIndex, itemsPerPage, addOneMoreItem: true)  // запросить на 1 больше
                .ToArrayAsync(ct);

            GridItems = items.Take(itemsPerPage).ToArray();
            HasMore   = items.Length > itemsPerPage;
            ```

            ### 5. Whitelist-сортировка в хендлере

            ```csharp
            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).
            """;

        public static string S5_PositiveConsequences = """
            ## Положительные следствия

            - Один клиентский паттерн на все списки.
            - Нет `COUNT(*)` — меньше нагрузки на БД.
            - Whitelist-сортировка защищает от произвольных идентификаторов.
            - Тонкий запрос + самодокументируемый `UserFilter`.
            """;

        public static string S6_NegativeConsequences = """
            ## Отрицательные следствия и компромиссы

            - Нет общего числа страниц — классическая пагинация «1 из N» недоступна (принимаем ради
              производительности).
            - Клиент должен знать имена property для сортировки — приходят через генерируемый клиент,
              рассинхронизации нет.
            - Переход на multi-column sort потребует одновременной правки базового класса и клиентов — путь
              миграции (`SortingModel?` → `SortingModel[]?`) зафиксирован.
            """;

        public static string S7_Validation = """
            ## Проверка

            - Первый grid-хендлер реализован строго по контракту.
            - Ответ содержит `payload { gridItems, hasMore }`, без `totalCount`.
            - Невалидное `Sorting.PropertyName` → 400 с перечнем допустимых полей.
            - Enum в фильтрах передаются строковым идентификатором, не числом.
            """;

        public static string S8_OpenQuestions = """
            ## Открытые вопросы / отложено

            - **Multi-column sort** — расширение до `SortingModel[]?` при первом кейсе вторичной сортировки.
            - **Полнотекстовый поиск** — реализация `SearchString` (движок, индексы) — отдельным ADR при
              первой фиче с ним.
            - **Единый лимит `ItemsPerPage`** — если понадобится защита от выгрузки всей таблицы, вынести в
              общий pipeline-шаг.
            """;

        public static string S99_Related = $"""
            ## Связанные артефакты

            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_001_Cqrs)} — базовый CQRS и обёртка ответа.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.CSharp.ADR_SRV_003_EntityAndEfMapping)} — внутренние модели, на которых строится список.
            - {nameof(Ban.Sdaid.Icd.Arch.Tech.Angular.ADR_UI_015_I18n)} — форматы дат на фронте.
            """;
    }
}