Skip to content

Filters

Filters takes one string off the request and turns it into an array of filter values. It does not build queries and it does not render inputs - the SQL layer reads its output to make a WHERE clause, and the HTML output reads it to prefill the filter row.

<?php
interface FiltersInterface
{
public function __construct(TableInterface &$tableInstance, string $urlPrefix = '', ?string $filterData = null);
public function url(): ?string;
public function setUrl(?string $setUrl = null): void;
public function filterData(): string;
public function parsedData(): array;
public function hasFilter(string $key): bool;
public function filterValue(string $key): mixed;
public function parse(?string $filterData = null, ?\Closure $callback = null): void;
}

Filters implements it and adds two public methods that are not in the interface: setParsedData(string $key, array $value): array and addFilter(string $key, string $query, ?array $params = null, ?array $data = null): void.

You normally do not construct Filters yourself. Table::initData() does it, and only when the filter argument is not null.

A filter string is key=value pairs separated by semicolons, urlencoded:

name=an;state=open;note=x%3By

It is parsed by Table::parseQueryString($filterData, ';'), which urldecodes both halves of each pair, so a value containing a semicolon has to arrive as %3B - when that string is handed to parse() directly. Pairs with no = are dropped. Keys that do not match a column id are kept in the parsed array but ignored by everything downstream.

That is only half the problem, because initData() also builds the urls the output generator links to, and it builds them by concatenating the filter and sort strings into a path:

<?php
$this->filter = new Filters($this, "{$this->urlPrefix}%filter/{$sortData}", $filterData);
$this->sort = new Sort($this, "{$this->urlPrefix}{$filterData}/%sort", $sortData);
$this->pagination = new Pagination($this, "{$this->urlPrefix}{$filterData}/{$sortData}/%pagination", $page);

So a table given a $urlPrefix of /admin/users/index emits sort links the router will 404 on. Filters::setUrl() and Sort::setUrl() can re-point two of the three afterwards; Pagination has no setUrl() at all, so the only lever that reaches all three is $urlPrefix itself.

The shape that works is a $urlPrefix ending in a query parameter, which turns the three concatenated fragments into that parameter’s value rather than into path segments. The worked example is on tables. Until the component learns to build query strings of its own, that is the way in.

parse() does three things in order:

  1. Splits the string into ['title' => $value, 'value' => $value] entries.
  2. Adds an entry for every column with a $filterDefaultValue that the string did not already supply. If that default is an array it is used as the entry verbatim, otherwise it fills both title and value.
  3. If a formatter closure was given, calls $formatter($key, $value) for each entry and replaces the entry with what comes back.

The result is parsedData(). Every entry is a two key array: title is what the user sees in the input, value is what the query binds. They start out identical and diverge when something rewrites one of them - a $filterData closure on the column, or the data an SQL filter returns.

Given the columns name (plain), state (filterDefaultValue: 'open') and note, and the filter string name=an;note=x%3By:

filterData() -> 'name=an;note=x%3By'
parsedData() -> array (
'name' =>
array (
'title' => 'an',
'value' => 'an',
),
'note' =>
array (
'title' => 'x;y',
'value' => 'x;y',
),
'state' =>
array (
'title' => 'open',
'value' => 'open',
),
)
hasFilter('name') -> true
hasFilter('other') -> false
filterValue('state') -> 'open'
filterValue('other') -> false

Two things to read out of that:

  • state is present although the url never mentioned it, because of its default value.
  • filterValue() returns false, not null, for a key that is not there.

hasFilter() is written as empty($this->filterDataParsed[$key]) === false, and the thing it tests is the two key array, not the value inside it. An array holding ['title' => '', 'value' => ''] is not empty, so the method reports true for any key that was parsed, whatever its value:

filter string: 'a=0;b=;c=1'
hasFilter('a') -> true filterValue('a') -> '0'
hasFilter('b') -> true filterValue('b') -> ''
hasFilter('c') -> true filterValue('c') -> '1'
hasFilter('d') -> false filterValue('d') -> false

Read it as “was this filter supplied”, not “does this filter have a usable value”. For the latter, test filterValue($key) yourself - remembering that it is false for a missing key and '' for a supplied but empty one.

The formatter is how a filter value gets translated before anything else sees it - resolving a name to an id, say, so the title keeps showing the name while the value carries the id:

<?php
$table->filter->parse($filterData, function (string $key, array $entry) {
if ($key !== 'owner') {
return $entry;
}
return [
'title' => $entry['value'],
'value' => Db::query('SELECT id FROM users WHERE name = ?', [$entry['value']])->fetchColumn(),
];
});

Calling parse() a second time discards the previous result entirely, including anything setParsedData() had written, so do it before prepareQueries() rather than after.

url() returns the prefix with %filter appended when the prefix does not already contain it:

<?php
public function url(): string
{
return (strpos($this->urlPrefix, '%filter') === false ?
$this->urlPrefix . '%filter' :
$this->urlPrefix
);
}

initData() always supplies a prefix that already contains the placeholder, so in practice url() returns it unchanged - for a table built with initData('name=an', 'age=desc', 3) and a prefix of /users/, that is /users/%filter/age=desc. Replace %filter with a new filter string and you have the url that applies it while keeping the current sort.

Nothing in src/ generates filter links; the filter row is a form and submitting it is the application’s job. setUrl() replaces the prefix, and it normalises a null argument to the empty string - $this->urlPrefix = $setUrl ?? ''; - so the ?string parameter is honest against a protected string $urlPrefix = '' property. Sort::setUrl() has the same shape.

setParsedData($key, $value) overwrites one entry. The SQL layer uses it to push the data part of a filter result back into the filter state, which is what makes a translated title show up in the rendered input. See SQL tables.

addFilter() appends to three protected arrays - $filterQuery, $filterParams and $filterParamsByKey - and optionally writes an entry into the parsed data. Nothing in src/ calls it and nothing reads the first two arrays back; the source carries a TODO: Not sure if we need this above it. The equivalent working path is SQLFilters::prepareQueries().

Enums/FilterType.php, a backed string enum with 11 cases:

Case Value
TEXT text
TEXT_EXACT text_exact
INT8 int
DECIMAL decimal
BOOLEAN boolean
DATE date
DATETIME datetime
DATEINTERVAL dateinterval
DATE_NATIVE date_native
DATETIME_NATIVE datetime_native
DATEINTERVAL_NATIVE dateinterval_native
  • The HTML filter row prefills inputs from parsedData() through Html::filterInputValue(), comparing against value for selects and checkboxes and writing title into the value attribute of text inputs. When the two differ it also emits data-value="...". See the HTML output.
  • SQLFilters::prepareQueries() walks parsedData(), matches each key to a column and turns the value into a query fragment plus bound parameters. See SQL tables.