Пакет · mui-data-grid

Фильтры вне грида

Не всякий фильтр — колонка. Область видимости («только мои»), выбор в дереве категорий, одна строка поиска сразу по нескольким полям — всё это ваши параметры запроса, которые применяются к базовому запросу до того, как грид наложит своё. Схема о них не знает и знать не должна.

Почему их не стоит делать колонками

Колонка схемы — это обещание интерфейсу: «такое поле есть, его можно фильтровать и сортировать». Доменный фильтр обещает другое: он сужает набор данных ещё до того, как пользователь что-то ввёл.

Фильтр грида Доменный фильтр
Откуда приходит filterModel таблицы ваше поле в запросе
Кто проверяет схема (белый список) ваш код
Когда применяется внутри wrap() до wrap(), в базовый запрос
Виден пользователю как колонка с фильтром как элемент интерфейса или вовсе никак

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

Объявить свой параметр

Добавьте поле в наследника MuiGridRequest — оно валидируется как любое поле запроса:

php
class ArticleGridRequest extends MuiGridRequest
{
  public function __construct(
      #[Positive] public ?int $authorId = null,
      public ?string $search = null,
      public array $tagIds = [],

      int $page = 0,
      int $pageSize = 20,
      #[ListOf(MGSortItem::class)] array $sortModel = [],
      #[Valid] MGFilterModel $filterModel = new MGFilterModel(),
  ) {
      parent::__construct($page, $pageSize, $sortModel, $filterModel);
      $this->search = $this->search !== null ? trim($this->search) : null;
  }
}

Почему базовый класс не readonly-класс

MuiGridRequest помечает readonly каждое свойство по отдельности, а не весь класс. Это сделано ровно для строки выше: наследник должен иметь возможность нормализовать свои поля в конструкторе. Объявлять дочерний класс как readonly class не нужно — это запретит такую нормализацию.

Область видимости

Простейший и самый частый случай — сузить набор до того, что пользователю вообще позволено видеть:

php
$repo = ArticleRepository::instance('a')
  ->select('a.id, a.title, a.views')
  ->where(Qb::eq('a.is_published', true));

if ($request->authorId) {
  $repo->andWhere(Qb::eq('a.author_id', $request->authorId));
}

return MuiGrid::wrap($repo, $request, $schema);

Фильтры грида добавятся поверх этого через andWhere(), то есть только сузят набор. Расширить его обратно из браузера невозможно — см. Ментальную модель.

Строка глобального поиска

Одно поле ввода, которое ищет сразу по нескольким колонкам, — это OR по вашему выбору колонок, а не структурированный фильтр таблицы:

php
if ($request->search) {
  $s = $request->search;
  $m = TextMatch::forRepository($repo);   // тот же диалект, что и у грида

  $repo->andWhere(Qb::clip(Qb::or(
      $m->match('a.title', "%$s%"),
      $m->match('a.body',  "%$s%"),
      $m->match('au.name', "%$s%"),
  )));
}

Два момента, на которых легко ошибиться:

  • Qb::clip() обязателен. Без скобок OR смешается с остальными условиями через AND, и запрос начнёт возвращать не то. Скобки здесь — не стиль, а семантика.
  • Шаблон передаётся в каждый вызов отдельно. Не переиспользуйте один объект-байнд на несколько условий: COUNT-запрос оборачивает ваш SELECT, и повторно использованный плейсхолдер ломает подготовленные выражения.

Метод TextMatch::forRepository() определяет написание регистронезависимого совпадения по драйверу репозитория — так ваш поиск остаётся портируемым между PostgreSQL, MySQL и SQLite без изменений. Подробности — в Режимах совпадения.

EXISTS и связи многие ко многим

Отбор по тегам через промежуточную таблицу — EXISTS. Значения из запроса обязаны попасть в него связанными параметрами или пройти строгую санитизацию:

php
if ($request->tagIds) {
  $ids = array_values(array_filter(array_map('intval', $request->tagIds)));

  if ($ids !== []) {
      $in = Qb::in('at.tag_id', $ids);   // строит плейсхолдеры и байнды за вас

      $repo->andWhere(Qb::raw(
          "EXISTS (SELECT 1 FROM article_tags at
                   WHERE at.article_id = a.id AND {$in->getQuery()})",
          $in->getBinds(),
      ));
  }
}

Результат

sql
EXISTS (SELECT 1 FROM article_tags at
      WHERE at.article_id = a.id AND at.tag_id IN (:iqb0, :iqb1, :iqb2))

Приём с Qb::in() избавляет от ручной сборки списка плейсхолдеров: условие даёт готовый текст и готовые байнды, которые Qb::raw() принимает вторым аргументом как есть.

Qb::raw не экранирует

Первый аргумент попадает в запрос как есть — это ваш SQL. Всё, что пришло от пользователя, должно уходить вторым аргументом: либо CDOBind-объектами, либо парами имя => значение. Плейсхолдеры у Qb::raw() именованные (:tag), позиционные ? он не поддерживает. Приведение к int в примере — второй рубеж, а не замена привязке.

Фильтр по дереву

Отбор «узел и все его потомки» — рекурсивный CTE. Он объявляется на репозитории и дальше используется как обычная таблица:

php
if ($request->categoryId) {
  $repo->withRecursive('descendants',
      CategoryRepository::instance()
          ->select('id')
          ->where(Qb::eq('id', $request->categoryId))
          ->unionAll(
              CategoryRepository::instance('c')
                  ->joinInner('descendants d', 'c.parent_id = d.id')
                  ->select('c.id')
          )
  );

  $repo->andWhere(Qb::raw('a.category_id IN (SELECT id FROM descendants)'));
}

COUNT-запрос пагинации оборачивает ваш SELECT целиком, поэтому CTE остаётся на месте и общее число строк считается по тому же набору.

Порядок вызовов

Доменные фильтры применяются до wrap(). После него репозиторий уже изменён — пагинатор выставил на нём LIMIT/OFFSET, — и добавлять условия поздно.

text
1. instance() + select() + join() + where()   ← базовый запрос
2. andWhere() по вашим параметрам             ← доменные фильтры
3. MuiGrid::wrap(repo, request, schema)       ← фильтры грида, порядок, страница
4. ответ

Повторное использование репозитория

wrap() изменяет переданный репозиторий. Если он нужен вам после — например, для второго запроса со сводными числами, — клонируйте его до вызова.

Что дальше