Ментальная модель
Одна мысль объясняет всё остальное: библиотека не строит ваш запрос, она на него
накладывается. Запрос остаётся вашим — со всеми JOIN, подзапросами и бизнес-логикой,
— а грид добавляет к нему ровно три вещи: условия фильтров, порядок и границы страницы.
Идея: оверлей, а не генератор
Большинство «таблиц с сервера» устроены как генераторы: вы описываете модель, а библиотека
сама решает, какой SQL получится. Пока задача типовая, это удобно; как только нужен
хитрый JOIN, вычисляемая колонка или CTE — вы упираетесь в границы генератора и начинаете
воевать с ним.
Здесь наоборот. Вы отдаёте готовый репозиторий — с SELECT, JOINами и базовым
WHERE, — и библиотека дописывает поверх:
ваш репозиторий что добавляет MuiGrid::wrap()
───────────────────── ────────────────────────────
SELECT a.id, a.title, au.name
FROM articles a
LEFT JOIN authors au ON …
WHERE a.is_published = true
AND (a.title ILIKE :v0) ← фильтры из схемы
ORDER BY a.views DESC ← сортировка из схемы
LIMIT 25 OFFSET 50 ← страница
+ отдельный COUNT(*) ← общее число строкТри следствия, которые стоит держать в голове:
- Всё, что выразимо в SQL, работает. Рекурсивные CTE, оконные функции,
EXISTS, вычисляемые выражения — библиотека их не видит и не может им помешать. - Базовый
WHEREнеприкосновенен. Фильтры пользователя добавляются черезandWhere(), то есть сужают ваш набор, а не заменяют его. Строку, которую вы отсекли бизнес-условием, через грид достать нельзя. ORDER BYперезаписывается. Сортировка — это то, чем управляет пользователь, поэтому порядок ставится, а не дописывается. Если порядок важен для вашей логики, он должен быть вdefaultOrder()схемы, а не в базовом запросе.
Следствие: схема — это дверь
Раз запрос ваш, встаёт вопрос: что из присланного браузером вообще имеет право попасть в этот запрос? Ответ — только то, что перечислено в схеме.
GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable();
// ↑ имя из браузера ↑ SQL из вашего кодаСлева — строка, пришедшая от пользователя. Справа — выражение, написанное вами. Схема переводит одно в другое, и в SQL уходит только правая часть. Имя поля из запроса в текст запроса не попадает никогда.
Отсюда — граница доверия, которую полезно проговорить явно:
| Источник | Куда попадает | Как обрабатывается |
|---|---|---|
field из запроса |
никуда | используется как ключ поиска в схеме |
operator из запроса |
выбор ветки | приводится к enum, неизвестный — ошибка |
value из запроса |
в SQL | всегда связанным параметром |
| SQL-выражение колонки | в SQL | подставляется как есть — это ваш код |
| выражение сортировки | в SQL | подставляется как есть — это ваш код |
Практический вывод один: никогда не собирайте SQL-выражение колонки из данных запроса. Всё остальное библиотека сделает безопасно сама.
Следствие: белый список, а не чёрный
Свежесозданная колонка не умеет ничего — ни фильтроваться, ни сортироваться. Возможности включаются явно:
GridColumn::for('body', 'a.body'); // объявлена, но бесполезна
GridColumn::for('body', 'a.body')->filterable(FilterType::String); // можно фильтровать
GridColumn::for('body', 'a.body')->sortable(); // можно сортироватьЭто не мелочь стиля. При чёрном списке («запрещаем опасное») забытая колонка становится дырой; при белом — забытая колонка просто не работает, и вы узнаёте об этом от тестировщика, а не от злоумышленника.
Следствие: фильтр отклоняется, сортировка игнорируется
Реакция на неизвестное поле у этих двух операций разная — и это сделано намеренно.
| Ситуация | Фильтрация | Сортировка |
|---|---|---|
| поле объявлено и включено | применяется | применяется |
| поле объявлено, но операция не включена | 400 | молча пропускается |
| поля нет в схеме | 400 | молча пропускается |
| оператор не разрешён для типа | 400 | — |
Причина в том, кто эти запросы порождает. Фильтр пользователь ставит осознанно: если он не
сработал, показать пустую таблицу вместо ошибки — значит соврать. А состояние сортировки
таблица шлёт сама, в том числе для скрытых и переставленных колонок, — падать на этом было
бы враждебно к интерфейсу. Когда пригодной сортировки не осталось, применяется
defaultOrder(). Подробнее — в
Белом списке и безопасности.
Следствие: два вида фильтров
Не всякий фильтр — колонка таблицы. Полезно с самого начала разделять:
- Фильтры грида — то, что пользователь набирает в интерфейсе таблицы. Приходят в
filterModel, проверяются схемой, накладываются библиотекой. - Доменные фильтры — область видимости запроса: «только мои», «в этой категории»,
строка глобального поиска. Это ваши поля в запросе, применяются к базовому запросу
до вызова
wrap(), в схеме не участвуют.
if ($request->authorId) {
$repo->andWhere(Qb::eq('a.author_id', $request->authorId)); // доменный фильтр
}
return MuiGrid::wrap($repo, $request, $schema); // фильтры гридаРецепты для второго вида — в Фильтрах вне грида.
Когда это подходит, а когда нет
Подходит, если набор данных больше, чем разумно отдавать в браузер, а пользователю нужны привычные пагинация, сортировка и фильтры по колонкам — то есть практически любой административный список.
Не подходит, если строк заведомо немного (сотни): тогда проще отдать всё разом и дать
таблице фильтровать на клиенте — это дешевле и по коду, и по числу запросов. Также не
подходит, если пагинация нужна бесконечной лентой: там уместнее курсор, а не OFFSET с
COUNT.
Что дальше
- Колонки из JOIN — как модель ложится на связанные таблицы.
- Белый список и безопасность — та же граница доверия, но подробно и по коду.
- Справочник API — точные сигнатуры.