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 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#

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.

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#

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.)

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#

  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\Filter — toSql(), 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

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

Cookie settings