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 34 filters in the system (35 files from ls app/Core/Utils/Filters/*.php
without the TranslationScopeFilters factory). 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, DateBeforeOrEqualFilter, DateAfterFilter, DateAfterOrEqualFilter, 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 |
Filters with their own semantics#
For these filters the name is not enough — what matters is what exactly they compare.
| Filter | What it compares | Why |
|---|---|---|
DateRangeFilter |
$format sets the granularity: Y-m-d (default) wraps the column in DATE() and both bounds take the whole day; a format with hours (Y-m-d H:i) compares the whole timestamp |
with DATE() the time would be dropped and "10:00–12:00" within one day would return nothing. The DataGrid sends Y-m-d H:i for FilterDataType::DateTime columns |
with minute precision the upper bound includes the whole minute (until 12:00 = up to 12:00:59); a format with seconds is an exact instant; inclusive: false is never extended |
the same as the day variant taking the whole day | |
ParameterRelRangeFilter |
an intersection of intervals: searched [from, to] × stored [value, value2 ?? value], always only relations of the given parameterId, never an empty value |
without parameterId any relation would be compared (enum parameters hold the option id as text); '' + 0 = 0, so an advert without a price would pass every "up to X" |
DistanceFilter |
a radius of $radiusKm from coordinates through the DQL function HAVERSINE over two parameter relations (latitude, longitude) |
the searched coordinates must be geocoded beforehand; the function is registered in DoctrineExtension |
TranslationScopeFilters::forLanguage() |
two EqualFilters (languageId, activeTranslation) over the same join as a FulltextFilter across translations |
without them MATCH … AGAINST searches all languages and the SQL fallback returns a record that ES (one document per language) correctly does not |
DateAfterFilter and DateBeforeFilter are strict
Both are DateRangeFilter with inclusive: false, that is > and <. With the default
format Y-m-d they compare DATE(column), so new DateAfterFilter('validityDatetime', new DateTime())
returns only rows valid from tomorrow: a validity ending today drops out already this
morning. For "today or later" use DateAfterOrEqualFilter, for "today at the latest"
DateBeforeOrEqualFilter. UserPaymentManager::hasActiveMembership() does exactly that:
a membership is valid through its last day.
Bind range bounds as numbers
value is a text column. Compared with a number MySQL casts it to a number; compared with a
string it compares lexicographically ('9' > '80'). ParameterRelRangeFilter takes a
float; your own filter over value must do the same.
The TranslationScopeFilters filters are phaseOneOnly
They only narrow the ids in phase 1. If they leaked into the query of the translations
branch, the entity would arrive with a single translation (see
a trimmed collection).
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#
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.
A filter selects parents, collections load in full#
The Api repository loads in two phases (OrmRepository::hydrateByIdentifiers()):
| Phase | What happens | Do filters apply? |
|---|---|---|
| 1. id selection | a query with filters, ordering and paging returns the root record ids | yes |
| 2a. root + ToOne | one query with joins over the ToOne relations from includes only |
— |
| 2b. ToMany branches | each collection separately (WHERE parent IN (:ids)), then attached to the parents |
no, unless the filter asks |
Parallel ToMany joins would multiply rows (a cartesian product), so every collection gets
its own query. The contract: a filter decides which parents come back; includes always
load them in full. A filter parameters.value = 'x' thus returns adverts with that value,
but each with all of its parameters.
| Filter switch | Effect |
|---|---|
| none | phase 1 only |
setApplyToCollections(true) |
the dotted filter also propagates into the branch query, the collection is trimmed |
setPhaseOneOnly() |
phase 1 only, even where it could propagate (the stronger prohibition; ES search, advert parameter filters) |
GroupFilter (AND/OR) never propagates into branches.
Never save through a trimmed collection
setApplyToCollections(true) is for reading only. An object-graph save inserts the
unmatched children again: a UNIQUE violation, or "it saves only the first time".
Collections are filled, not replaced
A branch fills the existing PersistentCollection through hydrateAdd() and
takeSnapshot(). Replacing it with a new ArrayCollection would, for a relation with
orphanRemoval: true, schedule a DELETE of all original rows on the next flush.
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#
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.)
Two helpers from OrderByHelper without which a front-end listing paginates wrongly:
| Method | What it does |
|---|---|
withIdTiebreak($orderBys) |
appends id with the direction of the last key unless it is there; an empty ordering stays empty (ES relevance) |
translatedField('translations', 'name', $langId) |
the key translations[languageId:N].name; addOrderBys() turns it into its own LEFT JOIN … WITH languageId = N used for sorting only. A null language = bare translations.name |
Ordering without a unique key paginates at random
With equal values (price 0, same date) the order in MySQL/MariaDB is non-deterministic and
LIMIT/OFFSET then repeats some items across pages and skips others. Wrap every ordering
of a front-end listing in withIdTiebreak().
A bare translations.name sorts by any language
A join of translations in all languages and SELECT DISTINCT id … ORDER BY t.name sort by
whichever row the database picks, so an English name shows up among Czech ones. Sort through
translatedField() with the page language; the LEFT JOIN removes nothing (no translation =
among NULLs) and does not affect translation hydration.
Procedure: writing my own filter#
- Go through the catalogue first. Most needs are covered; a new filter makes sense for a domain concept, not for yet another comparison variant.
- Implement
App\Core\Utils\Filters\Interfaces\Filter—toSql(),getBindings(),apply()and the alias setters. - Start from the closest existing filter in the same folder; the shape is settled.
- When the filter must not be cached, mark it
NonCacheableFilter. - 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 |
Check filters from the wire before RBAC: FilterBuilder::isBuildable()
An external call sends filters as an array and FilterBuilder::buildFilters() builds
objects from it. A malformed definition ([true], an unknown type, a broken nested group)
would end as a TypeError or InvalidArgumentException, i.e. HTTP 500. GetAllTrait,
GetByTrait and DeleteTrait therefore call isBuildable() before RBAC and cache and return
400 through sendInvalidRequestParameter('filters'). Your own external action with filters
from the caller must do the same.
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, the tiebreak key, sorting by language | app/Core/Utils/Helpers/Sqls/OrderByHelper.php |
| building filters from an array | app/Core/Utils/Helpers/Filters/FilterBuilder.php |
the HAVERSINE function |
app/Core/Utils/Doctrine/HaversineFunction.php |
Follow-up chapters: Working with tables · Cache · Elasticsearch · Calling the API