Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Filters

Filters#

Composable conditions over the QueryBuilder. app/Core/Utils/Filters/.

This is the only way anything is queried in this system. A presenter or a component writes no SQL — it assembles an array of filters and hands it to a manager.

$items = $this->newsManager->findAll(
    filters: [
        new EqualFilter('active', true),
        new LikeFilter('name', $searched),
    ],
    orderBy: ['published' => false],   // false = DESC!
    limit: 20,
)->execute();

The catalogue#

There are 33 filters in the system (ls app/Core/Utils/Filters/*.php). The most used ones:

Filter What it does
EqualFilter, NotEqualFilter equality / inequality
InFilter, NotInFilter the value is (not) in a list
LikeFilter, NotLikeFilter a partial match
IsNullFilter, IsNotNullFilter an empty / non-empty value
GreaterThanFilter, GreaterOrEqualFilter, LessThanFilter, LessOrEqualFilter comparisons
BetweenFilter, NotBetweenFilter, RangeFilter, RangeInclusiveFilter ranges
DateEqualFilter, DateBeforeFilter, DateAfterFilter, DateBetweenFilter, DateRangeFilter date variants
FulltextFilter full text over the database
SearchIdFilter ids returned by Elasticsearch
DistanceFilter geographic distance
ParameterRelFilter, ParameterRelLikeFilter, ParameterRelRangeFilter the dynamic parameters of classifieds and the company catalogue

Composition and parentheses#

Filter Meaning
AndFilter every condition
OrFilter any condition
GroupFilter a parenthesis

Without GroupFilter an OR spills into the whole query

A AND (B OR C) without the parenthesis becomes A AND B OR C — a different thing in SQL, because AND binds more tightly. The query does not fail, it just returns more rows than it should.

Dotted paths across relations#

new EqualFilter('category.active', true)
new LikeFilter('translations.name', $searched)

The necessary joins are added by JoinHelper itself, and it keeps its aliases per QueryBuilder — which matters, because a single query has several QueryBuilders side by side: the main one, a separate one for computeTotalCount(), and clones that GroupFilter makes for every sub-filter.

A dotted path across a COLLECTION multiplies rows

A filter over a ToMany relation creates a join that returns one record several times. On a listing with a count that skews the numbers — pagination then claims "137 items" where there are fifty. It does not crash and the first page looks right.

The filter order gets rearranged#

Before the query is assembled, FilterSortHelper::sortFilters() reorders the filters by the column order in the entity and expands a multi-field filter into individual ones. It is called from GetAllTrait and from TotalCountTrait, so both queries come out alike.

This is not result ordering

Result ordering is handled by orderBy and OrderByHelper — a different thing in a different file.

Ordering the results#

orderBy: ['published' => false, 'id' => true]   // published DESC, id ASC

The direction is a BOOL, not a string

OrderByHelper does literally $dir = $direction ? 'ASC' : 'DESC'. So ['id' => 'DESC'] sorts ASCENDING, because a non-empty string is truthy. Nothing crashes and the listing looks sorted — just the other way round, so "the last ten" shows the ten oldest.

(In the DataGrid it is different: setDefaultOrder() takes a string and converts it itself.)

Procedure: writing my own filter#

  1. Go through the catalogue first. Most needs are covered; a new filter makes sense for a domain concept, not for yet another comparison variant.
  2. Implement App\Core\Utils\Filters\Interfaces\FiltertoSql(), getBindings(), apply() and the alias setters.
  3. Start from the closest existing filter in the same folder; the shape is settled.
  4. When the filter must not be cached, mark it NonCacheableFilter.
  5. Verify it in the count too. A filter is applied to the main query and to the query computing the total — when those two diverge, pagination shows nonsense.

NonCacheableFilter is a marker for queries that must not be cached

SearchIdFilter uses it: a list of ids from Elasticsearch would produce a unique cache key for every search term (filling up Redis), and above all it would create a second truth about the list next to the ES index. In detail: Cache.

Filters in the administration and on the front end#

Where How they arise
DataGrid from the column filters plus addDefaultFilter()
front-end listings from URL parameters through the filter component
e-shop facets their own rules — counts are narrowed by everything except the facet itself

A front-end range filter needs a round trip through the URL

Without it the value is lost between requests and the filter silently does not filter — it returns everything while looking as if it were set.

Where to look#

I want Where
the filter catalogue app/Core/Utils/Filters/
the filter interface app/Core/Utils/Filters/Interfaces/Filter.php
the "do not cache" marker …/Interfaces/NonCacheableFilter.php
adding the joins app/Core/Utils/Helpers/Filters/JoinHelper.php
rearranging the filters app/Core/Utils/Helpers/Filters/FilterSortHelper.php
the sort direction app/Core/Utils/Helpers/Sqls/OrderByHelper.php

Follow-up chapters: Working with tables · Cache · Elasticsearch · Calling the API