Package · mui-data-grid

Custom filters

By default every operator turns into SQL the same way for every column: contains is a pattern match, >= is a comparison, isEmpty is a NULL check. When one column needs different SQL, filterUsing() intercepts the operators you name and leaves the rest alone.

How it works

The resolver receives a filter item and returns a condition — or null, which means “handle this the usual way”.

php
GridColumn::for('authorName', 'au.name')
  ->filterable(FilterType::String)
  ->filterUsing(fn (MGFilterItem $i): ?Qb => match ($i->operator) {
      MGOperator::IS_EMPTY => Qb::isNull('a.author_id'),
      default              => null,
  });

One filter item is processed in a fixed order:

text
1. the field is looked up in the schema  → missing → 400
2. the operator is checked against FilterType → not allowed → 400
3. your resolver is called               → returned a Qb → that is used
4. it returned null                      → the standard operator mapping

An important consequence: the type gate fires before the resolver. An operator outside the column’s FilterType set never reaches your code — the request is rejected first. If you want to handle > on a string column, declare it with a type that permits that operator. The sets are tabulated in Operators.

A partial override

The common case is overriding one or two operators. A default => null branch returns everything else to the standard behavior:

php
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) {
      // "no author" is a NULL foreign key, not a NULL joined name
      MGOperator::IS_EMPTY     => Qb::isNull('a.author_id'),
      MGOperator::IS_NOT_EMPTY => Qb::isNotNull('a.author_id'),
      default                  => null,
  });

Write only the exceptions. A resolver that enumerates all twenty-three operators is a sure sign that the column needs a different FilterType or a different expression, not an override.

A three-state boolean

The classic case where the standard mapping falls short: the database column is_archived can be true, false or NULL, and the user expects “no” to include both of the latter.

php
GridColumn::for('archived', 'a.is_archived')
  ->filterable(FilterType::Boolean)
  ->sortable()
  ->filterUsing(function (MGFilterItem $i): ?Qb {
      if ($i->operator !== MGOperator::IS && $i->operator !== MGOperator::EQUALS) {
          return null;
      }

      return $i->value
          ? Qb::eq('a.is_archived', true)
          : Qb::clip(Qb::or(
              Qb::eq('a.is_archived', false),
              Qb::isNull('a.is_archived'),
          ));
  });

Qb::clip() wraps the condition in parentheses — without them the inner OR inside the outer AND would change the meaning of the whole filter.

Searching with normalization

Phone numbers are stored with separators; the user types digits. Normalize both sides:

php
GridColumn::for('phone', 'c.phone')
  ->filterable(FilterType::String)
  ->sortable()
  ->filterUsing(function (MGFilterItem $i, TextMatch $m): ?Qb {
      if ($i->operator !== MGOperator::CONTAINS) {
          return null;
      }

      $digits = preg_replace('/\D+/', '', (string) $i->value);

      return $m->match("regexp_replace(c.phone, '\\D', '', 'g')", "%$digits%");
  });

Here the resolver’s second argument appears — the TextMatch mode, already resolved for your database driver. Its match() method writes ILIKE, LIKE or lower(), the same thing the standard behavior would use, only against your expression.

The second parameter is optional

Resolvers written with a single parameter (fn (MGFilterItem $i) => …) keep working: PHP simply ignores the extra argument. Declare TextMatch only when you need it.

Reusing the standard behavior

Sometimes you don’t want to change an operator’s logic, only apply it to a different expression. The operator exposes the very method the library itself uses:

php
GridColumn::for('name', 'c.name')
  ->filterable(FilterType::String)
  ->filterUsing(fn (MGFilterItem $i, TextMatch $m): ?Qb => $i->operator === MGOperator::CONTAINS
      ? $i->operator->toQb("coalesce(c.nick, c.name)", $i->value, $m)
      : null);

That way you neither rewrite the mapping by hand nor risk drifting away from it on the next upgrade.

The filter value

The resolver receives the value exactly as the browser sent it, with no type coercion.

Field Type Contents
$item->field string the field name from the request (already matched to the schema)
$item->operator MGOperator the operator as an enum case
$item->value mixed the value as sent: string, number, null, array

null arrives for isEmpty and isNotEmpty, which need no value. An array arrives for isAnyOf. For the rest the UI sends a scalar, but don’t rely on it — cast the type yourself when it matters.

php
->filterUsing(function (MGFilterItem $i): ?Qb {
  if ($i->operator !== MGOperator::IS_ANY_OF) {
      return null;
  }

  $ids = array_map('intval', (array) $i->value);

  return $ids === [] ? Qb::empty() : Qb::in('a.status_id', $ids);
})

Qb::empty() is an empty condition: it contributes nothing to the query. Returning it is correct when a filter turned out meaningless (an empty set) but there is nothing to reject.

Refusing from a resolver

If a value won’t do, throw a MuiGridException — the router answers 400 with your message:

php
use Flytachi\Winter\MuiDataGrid\MuiGridException;

->filterUsing(function (MGFilterItem $i): ?Qb {
  if ($i->operator === MGOperator::EQUALS && !is_numeric($i->value)) {
      throw new MuiGridException("Field 'code' only accepts a numeric value");
  }

  return null;
})

Next steps