Package · mui-data-grid

Filters outside the grid

Not every filter is a column. Query scope (“mine only”), a selection in a category tree, a single search box spanning several fields — these are your own request parameters, applied to the base query before the grid layers anything on. The schema neither knows nor should know about them.

Why they shouldn’t be columns

A schema column is a promise to the UI: “this field exists, it can be filtered and sorted”. A domain filter promises something else: it narrows the data set before the user types anything.

Grid filter Domain filter
Where it comes from the table’s filterModel your own request field
Who validates it the schema (whitelist) your code
When it applies inside wrap() before wrap(), on the base query
Visible to the user as a column filter as a UI control, or not at all

Dressing scope up as a column breaks both sides: the UI grows a strange column, and on the server the user gains the ability to lift the restriction simply by clearing the filter.

Declare your own parameter

Add a field to your MuiGridRequest subclass — it is validated like any request field:

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;
  }
}

Why the base class isn't a readonly class

MuiGridRequest marks each property readonly individually rather than the whole class. That is precisely for the line above: a subclass must be able to normalize its own fields in the constructor. Don’t declare the child as a readonly class — that would forbid exactly this.

Query scope

The simplest and most common case: narrow the set to what the user is allowed to see at all.

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);

Grid filters are added on top with andWhere(), so they can only narrow further. Widening the set back from the browser is impossible — see the Mental model.

One input that searches several columns at once is an OR over columns you choose, not a structured table filter:

php
if ($request->search) {
  $s = $request->search;
  $m = TextMatch::forRepository($repo);   // the same dialect the grid will use

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

Two things are easy to get wrong here:

  • Qb::clip() is mandatory. Without parentheses the OR mixes with your other conditions through AND and the query starts returning the wrong rows. The brackets are semantics, not style.
  • Pass the pattern into each call separately. Don’t reuse one bind object across conditions: the COUNT query wraps your SELECT, and a reused placeholder breaks native prepared statements.

TextMatch::forRepository() resolves the spelling of case-insensitive matching from the repository’s driver, keeping your search portable across PostgreSQL, MySQL and SQLite with no changes. Details in Match modes.

EXISTS and many-to-many relations

Selecting by tags through a junction table means EXISTS. Values from the request must travel as bound parameters or pass strict sanitization:

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

  if ($ids !== []) {
      $in = Qb::in('at.tag_id', $ids);   // builds the placeholders and binds for you

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

Result

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

Borrowing Qb::in() saves you from assembling a placeholder list by hand: the condition yields ready-made text and ready-made binds, which Qb::raw() accepts as its second argument untouched.

Qb::raw does not escape

The first argument enters the query verbatim — it is your SQL. Everything that came from the user must travel in the second argument: either as CDOBind objects or as name => value pairs. Qb::raw() placeholders are named (:tag); positional ? is not supported. The int cast in the example is a second line of defense, not a substitute for binding.

Filtering by a tree

Selecting “a node and all its descendants” is a recursive CTE. It is declared on the repository and then used like an ordinary table:

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)'));
}

The pagination COUNT query wraps your whole SELECT, so the CTE stays intact and the total is computed over the same set.

Order of calls

Domain filters apply before wrap(). After it the repository has already been mutated — the paginator set LIMIT/OFFSET on it — and it is too late to add conditions.

text
1. instance() + select() + join() + where()   ← the base query
2. andWhere() for your parameters             ← domain filters
3. MuiGrid::wrap(repo, request, schema)       ← grid filters, order, page
4. the response

Reusing the repository

wrap() mutates the repository it is given. If you need it afterwards — for a second query with summary figures, say — clone it beforehand.

Next steps