Пакет · mui-data-grid

Свои фильтры

По умолчанию каждый оператор превращается в SQL одинаково для всех колонок: contains — это совпадение по шаблону, >= — сравнение, isEmpty — проверка на NULL. Когда конкретной колонке нужен другой SQL, filterUsing() перехватывает нужные операторы, оставляя остальные нетронутыми.

Как это работает

Резолвер получает элемент фильтра и возвращает условие — либо null, что значит «обрабатывай как обычно».

php
GridColumn::for('authorName', 'au.name')
  ->filterable(FilterType::String)
  ->filterUsing(fn (MGFilterItem $i): ?Qb => match ($i->operator) {
      MGOperator::IS_EMPTY => Qb::isNull('a.author_id'),
      default              => null,
  });

Порядок обработки одного элемента фильтра фиксирован:

text
1. поле ищется в схеме            → нет → 400
2. оператор сверяется с FilterType → не разрешён → 400
3. вызывается ваш резолвер         → вернул Qb → используется он
4. вернул null                     → стандартное сопоставление оператора

Из этого следует важное: шлюз по типу срабатывает раньше резолвера. Оператор, не входящий в набор FilterType колонки, до вашего кода не дойдёт — запрос будет отклонён. Если вы хотите обработать > на строковой колонке, объявляйте её типом, который этот оператор допускает. Таблицы наборов — в Операторах.

Частичное переопределение

Самый частый случай — переопределить один-два оператора. Ветка default => null возвращает всё остальное к стандартному поведению:

php
use Flytachi\Winter\Cdo\Qb;
use Flytachi\Winter\MuiDataGrid\Entity\MGFilterItem;
use Flytachi\Winter\MuiDataGrid\Entity\MGOperator;

GridColumn::for('authorName', 'au.name')
  ->filterable(FilterType::String)
  ->sortable()
  ->filterUsing(fn (MGFilterItem $i): ?Qb => match ($i->operator) {
      // «без автора» — это NULL внешнего ключа, а не NULL присоединённого имени
      MGOperator::IS_EMPTY     => Qb::isNull('a.author_id'),
      MGOperator::IS_NOT_EMPTY => Qb::isNotNull('a.author_id'),
      default                  => null,
  });

Пишите только исключения. Резолвер, который перечисляет все двадцать три оператора, — верный признак того, что колонке нужен другой FilterType или другое выражение, а не переопределение.

Логическое поле с тремя состояниями

Классический случай, где стандартного сопоставления не хватает: в базе колонка is_archived может быть true, false или NULL, а пользователь ожидает, что «нет» включает и то, и другое.

php
GridColumn::for('archived', 'a.is_archived')
  ->filterable(FilterType::Boolean)
  ->sortable()
  ->filterUsing(function (MGFilterItem $i): ?Qb {
      if ($i->operator !== MGOperator::IS && $i->operator !== MGOperator::EQUALS) {
          return null;
      }

      return $i->value
          ? Qb::eq('a.is_archived', true)
          : Qb::clip(Qb::or(
              Qb::eq('a.is_archived', false),
              Qb::isNull('a.is_archived'),
          ));
  });

Qb::clip() оборачивает условие в скобки — без него OR внутри общего AND изменил бы смысл всего фильтра.

Поиск с нормализацией

Телефон в базе хранится с разделителями, пользователь вводит цифры подряд. Приведём обе стороны к одному виду:

php
GridColumn::for('phone', 'c.phone')
  ->filterable(FilterType::String)
  ->sortable()
  ->filterUsing(function (MGFilterItem $i, TextMatch $m): ?Qb {
      if ($i->operator !== MGOperator::CONTAINS) {
          return null;
      }

      $digits = preg_replace('/\D+/', '', (string) $i->value);

      return $m->match("regexp_replace(c.phone, '\\D', '', 'g')", "%$digits%");
  });

Здесь видно второй аргумент резолвера — режим совпадения TextMatch, уже разрешённый под драйвер вашей базы. Метод match() пишет ILIKE, LIKE или lower() — то же, что использовало бы стандартное поведение, только по вашему выражению.

Второй параметр объявлять необязательно

Резолверы, написанные с одним параметром (fn (MGFilterItem $i) => …), продолжают работать: лишний аргумент PHP просто игнорирует. Объявляйте TextMatch, только когда он вам нужен.

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

Иногда нужно не изменить логику оператора, а применить её к другому выражению. Для этого у оператора есть собственный метод, тот самый, которым пользуется библиотека:

php
GridColumn::for('name', 'c.name')
  ->filterable(FilterType::String)
  ->filterUsing(fn (MGFilterItem $i, TextMatch $m): ?Qb => $i->operator === MGOperator::CONTAINS
      ? $i->operator->toQb("coalesce(c.nick, c.name)", $i->value, $m)
      : null);

Так вы не переписываете сопоставление руками и не рискуете разойтись с ним при обновлении библиотеки.

Значение фильтра

Резолвер получает значение таким, каким его прислал браузер, — без приведения типов.

Поле Тип Что содержит
$item->field string имя поля из запроса (уже сопоставленное со схемой)
$item->operator MGOperator оператор в виде enum
$item->value mixed значение как пришло: строка, число, null, массив

null приходит для операторов isEmpty и isNotEmpty — значение им не нужно. Массив приходит для isAnyOf. Для остальных операторов интерфейс шлёт скаляр, но полагаться на это не стоит: приводите тип сами, если он важен.

php
->filterUsing(function (MGFilterItem $i): ?Qb {
  if ($i->operator !== MGOperator::IS_ANY_OF) {
      return null;
  }

  $ids = array_map('intval', (array) $i->value);

  return $ids === [] ? Qb::empty() : Qb::in('a.status_id', $ids);
})

Qb::empty() — пустое условие: оно не добавит в запрос ничего. Возвращать его корректно, когда фильтр оказался бессмысленным (пустой набор), но отклонять запрос не за что.

Отказ из резолвера

Если значение не годится, бросьте MuiGridException — роутер ответит 400 с вашим текстом:

php
use Flytachi\Winter\MuiDataGrid\MuiGridException;

->filterUsing(function (MGFilterItem $i): ?Qb {
  if ($i->operator === MGOperator::EQUALS && !is_numeric($i->value)) {
      throw new MuiGridException("Поле 'code' принимает только числовое значение");
  }

  return null;
})

Что дальше