Фильтры вне грида
Не всякий фильтр — колонка. Область видимости («только мои»), выбор в дереве категорий, одна строка поиска сразу по нескольким полям — всё это ваши параметры запроса, которые применяются к базовому запросу до того, как грид наложит своё. Схема о них не знает и знать не должна.
Почему их не стоит делать колонками
Колонка схемы — это обещание интерфейсу: «такое поле есть, его можно фильтровать и сортировать». Доменный фильтр обещает другое: он сужает набор данных ещё до того, как пользователь что-то ввёл.
| Фильтр грида | Доменный фильтр | |
|---|---|---|
| Откуда приходит | filterModel таблицы |
ваше поле в запросе |
| Кто проверяет | схема (белый список) | ваш код |
| Когда применяется | внутри wrap() |
до wrap(), в базовый запрос |
| Виден пользователю | как колонка с фильтром | как элемент интерфейса или вовсе никак |
Попытка выдать область видимости за колонку ломает обе стороны: в интерфейсе появляется странная колонка, а на сервере пользователь получает возможность отменить ограничение, просто убрав фильтр.
Объявить свой параметр
Добавьте поле в наследника MuiGridRequest — оно валидируется как любое поле запроса:
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 не нужно — это запретит
такую нормализацию.
Область видимости
Простейший и самый частый случай — сузить набор до того, что пользователю вообще позволено видеть:
$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 по вашему выбору
колонок, а не структурированный фильтр таблицы:
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. Значения из запроса обязаны попасть
в него связанными параметрами или пройти строгую санитизацию:
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(),
));
}
}Результат
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. Он объявляется на репозитории и дальше используется как обычная таблица:
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, — и добавлять условия поздно.
1. instance() + select() + join() + where() ← базовый запрос
2. andWhere() по вашим параметрам ← доменные фильтры
3. MuiGrid::wrap(repo, request, schema) ← фильтры грида, порядок, страница
4. ответПовторное использование репозитория
wrap() изменяет переданный репозиторий. Если он нужен вам после — например, для второго
запроса со сводными числами, — клонируйте его до вызова.
Что дальше
- Свои фильтры — когда фильтр всё-таки колонка, но с нестандартным SQL.
- Колонки из JOIN — связанные таблицы как обычные колонки.
- Справочник API — точные сигнатуры.