Package · mui-data-grid

Mental model

One thought explains everything else: the library does not build your query, it layers onto it. The query stays yours — with every JOIN, subquery and piece of business logic — and the grid adds exactly three things: filter conditions, an order, and page bounds.

The idea: an overlay, not a generator

Most “server-side tables” are generators: you describe a model and the library decides what SQL comes out. That is convenient until the task stops being typical; the moment you need a tricky JOIN, a computed column or a CTE, you hit the generator’s edges and start fighting it.

This is the other way round. You hand over a finished repository — with SELECT, JOINs and a base WHERE — and the library writes on top of it:

text
your repository                    what MuiGrid::wrap() adds
─────────────────────             ─────────────────────────
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)      ← filters from the schema
                                ORDER BY a.views DESC        ← sort from the schema
                                LIMIT 25 OFFSET 50           ← the page
                                + a separate COUNT(*)        ← the total

Three consequences worth keeping in mind:

  • Anything expressible in SQL works. Recursive CTEs, window functions, EXISTS, computed expressions — the library never sees them and cannot get in their way.
  • The base WHERE is untouchable. A user’s filters are added with andWhere(), so they narrow your set rather than replacing it. A row your business condition excluded cannot be reached through the grid.
  • ORDER BY is overwritten. Sorting is what the user controls, so the order is set rather than appended. If order matters to your logic, it belongs in the schema’s defaultOrder(), not in the base query.

Consequence: the schema is the door

Since the query is yours, the question becomes: what from the browser has any right to enter it? The answer is: only what the schema lists.

php
GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable();
//               ↑ name from the browser  ↑ SQL from your code

On the left, a string that came from the user. On the right, an expression you wrote. The schema translates one into the other, and only the right-hand side reaches SQL. The field name from the request never enters the query text.

That gives a trust boundary worth stating explicitly:

Source Where it goes How it is handled
field from the request nowhere used as a lookup key into the schema
operator from the request branch selection cast to an enum; unknown ones fail
value from the request into SQL always as a bound parameter
a column’s SQL expression into SQL inserted verbatim — it is your code
a sort expression into SQL inserted verbatim — it is your code

There is one practical rule: never assemble a column’s SQL expression from request data. Everything else the library handles safely on its own.

Consequence: a whitelist, not a blacklist

A freshly created column can do nothing — neither filter nor sort. Capabilities are opted into:

php
GridColumn::for('body', 'a.body');                                  // declared but useless
GridColumn::for('body', 'a.body')->filterable(FilterType::String);  // may be filtered
GridColumn::for('body', 'a.body')->sortable();                      // may be sorted

This is not a stylistic detail. Under a blacklist (“forbid the dangerous things”) a forgotten column is a hole; under a whitelist a forgotten column simply doesn’t work, and you hear about it from a tester rather than from an attacker.

Consequence: filters are rejected, sorts are ignored

The two operations react differently to an unknown field — deliberately.

Situation Filtering Sorting
field declared and enabled applied applied
field declared, operation not enabled 400 silently skipped
field not in the schema 400 silently skipped
operator not allowed for the type 400

The reason is who produces those requests. A filter is a deliberate act: if it didn’t apply, showing an empty table instead of an error would be a lie. A sort state, on the other hand, is emitted by the table itself — including for hidden and reordered columns — and failing on that would be hostile to the UI. When no usable sort remains, defaultOrder() takes over. More in Whitelist & safety.

Consequence: two kinds of filter

Not every filter is a table column. It pays to separate them from the start:

  • Grid filters — what the user types into the table’s UI. They arrive in filterModel, are checked against the schema and applied by the library.
  • Domain filters — the query’s scope: “mine only”, “in this category”, a global search box. These are your own request fields, applied to the base query before wrap(), and they take no part in the schema.
php
if ($request->authorId) {
  $repo->andWhere(Qb::eq('a.author_id', $request->authorId));   // domain filter
}

return MuiGrid::wrap($repo, $request, $schema);                    // grid filters

Recipes for the second kind are in Filters outside the grid.

When this fits, and when it doesn’t

It fits when the data set is larger than is sensible to ship to a browser and the user expects familiar pagination, sorting and per-column filters — that is, nearly any admin listing.

It doesn’t fit when there are only a few hundred rows: shipping everything at once and letting the table filter on the client is cheaper in both code and round trips. Nor does it fit an infinite feed: a cursor suits that better than OFFSET plus COUNT.

Next steps