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”.
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:
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 mappingAn 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:
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.
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:
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:
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.
->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:
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
- Operators — the full list and the per-type sets.
- Match modes — what
TextMatchis. - Filters outside the grid — when a filter shouldn’t be a column at all.