Колонки из JOIN
Почти в любом реальном списке часть колонок приходит не из основной таблицы: имя автора, название категории, счётчик из связанной сущности. Всё это — обычные колонки схемы, достаточно сопоставить их правильному SQL-выражению.
Отображаемая колонка из связанной таблицы
Соединение объявляете вы, в базовом запросе. В схеме колонка указывает на алиас присоединённой таблицы:
$repo = ArticleRepository::instance('a')
->select('a.id, a.title, au.name author_name')
->joinLeft(AuthorRepository::instance('au'), 'au.id = a.author_id');
$schema = GridSchema::make(
GridColumn::for('title', 'a.title')->filterable(FilterType::String)->sortable(),
GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable(),
)->defaultOrder('a.created_at DESC, a.id DESC');Пользователь фильтрует по authorName, в SQL уходит au.name. Никакой особой поддержки
для JOIN не требуется: библиотека вставляет выражение колонки в WHERE и ORDER BY, а
откуда оно берётся — её не касается.
Алиас должен существовать в запросе
Выражение колонки подставляется как есть. Если в схеме написано au.name, а JOIN с
алиасом au в базовом запросе не объявлен, база вернёт ошибку про неизвестный алиас. Схема
и базовый запрос — две половины одного контракта, держите их рядом.
Фильтр «пусто» по внешнему ключу
При LEFT JOIN отсутствие связи даёт NULL в присоединённой колонке — значит,
isEmpty на au.name формально работает. Но проверять внешний ключ дешевле и честнее:
это индексированная колонка основной таблицы, и её значение не зависит от того, как
написан JOIN.
filterUsing() позволяет переопределить только те операторы, которые вам нужны, оставив
остальные на автопилоте:
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) {
MGOperator::IS_EMPTY => Qb::isNull('a.author_id'),
MGOperator::IS_NOT_EMPTY => Qb::isNotNull('a.author_id'),
default => null, // contains / equals / … — как обычно, по au.name
});Возврат null означает «ничего особенного, действуй по умолчанию». Так вы пишете только
исключения, а не весь набор операторов заново. Подробный разбор — в
Своих фильтрах.
Вычисляемое выражение
Колонке необязательно соответствовать одной колонке базы. Подойдёт любое выражение,
допустимое в WHERE и ORDER BY:
GridColumn::for('fullName', "au.first_name || ' ' || au.last_name")
->filterable(FilterType::String)
->sortable();
GridColumn::for('displayName', 'coalesce(au.nick, au.name)')
->filterable(FilterType::String)
->sortable();Фильтр contains по fullName сработает по склеенной строке — ровно так, как пользователь
видит её в таблице. Это часто и есть цель: искать по тому, что показано, а не по тому, как
оно хранится.
Выражение — это ваш код
SQL-выражение колонки подставляется в запрос без экранирования: оно должно приходить из исходников, а не из данных запроса. Значение фильтра всегда уходит связанным параметром — вот его собирать вручную не нужно и не надо.
Сортировка отдельно от фильтрации
sortable() принимает необязательное выражение: колонка может фильтроваться по одному, а
сортироваться по другому.
// поиск по исходному тексту, порядок — регистронезависимый
GridColumn::for('title', 'a.title')
->filterable(FilterType::String)
->sortable('lower(a.title)');
// показываем имя, сортируем по фамилии
GridColumn::for('fullName', "au.first_name || ' ' || au.last_name")
->filterable(FilterType::String)
->sortable('au.last_name, au.first_name');Второй пример стоит запомнить: список людей, отсортированный по склеенной строке, для пользователя выглядит случайным — он ожидает алфавит по фамилии.
Агрегат из связанной таблицы
Счётчик комментариев, сумма заказа, максимальная дата — всё это обычные колонки, если
агрегат уже посчитан в базовом запросе. Проще всего — подзапросом в SELECT:
$repo = ArticleRepository::instance('a')
->select("a.id, a.title,
(SELECT count(*) FROM comments c WHERE c.article_id = a.id) comment_count");
$schema = GridSchema::make(
GridColumn::for('title', 'a.title')->filterable(FilterType::String)->sortable(),
GridColumn::for('commentCount', '(SELECT count(*) FROM comments c WHERE c.article_id = a.id)')
->filterable(FilterType::Number)
->sortable(),
);Выражение в схеме повторяет подзапрос, а не ссылается на алиас comment_count: алиасы из
SELECT в WHERE недоступны — таково правило SQL, а не библиотеки. Для ORDER BY алиас
подошёл бы, но одинаковое выражение в обеих ролях проще сопровождать.
Когда подзапрос дорог
Коррелированный подзапрос считается для каждой строки — на большой таблице это заметно, и
COUNT-запрос пагинации выполнит его тоже. Если фильтровать по счётчику не нужно,
объявляйте колонку только sortable(), а лучше — заранее агрегируйте GROUP BY или
материализованным полем.
Фильтр по связи «многие ко многим»
Отбор по тегам, ролям или другой связи через промежуточную таблицу выражается через
EXISTS. Такой фильтр обычно не колонка таблицы, а доменный — см.
Фильтры вне грида.
Стабильность страниц
Чем больше колонок приходит из JOIN, тем выше шанс, что у многих строк совпадут значения
сортировки. Уникальный «хвост» в порядке обязателен:
->defaultOrder('a.created_at DESC, a.id DESC')Без него строки с одинаковой датой могут переставиться между запросами двух соседних страниц, и пользователь увидит одну статью дважды, а другую не увидит вовсе.
Что дальше
- Свои фильтры — полный разбор
filterUsing(). - Фильтры вне грида — область видимости,
глобальный поиск,
EXISTS, CTE. - Справочник API — сигнатуры
GridColumn.