Filtry#
Skladatelné podmínky nad QueryBuilderem. app/Core/Utils/Filters/.
Je to jediný způsob, jak se v tomhle systému dotazuje. Presenter ani komponenta SQL nepíší — poskládají pole filtrů a předají ho manageru.
$items = $this->newsManager->findAll(
filters: [
new EqualFilter('active', true),
new LikeFilter('name', $hledany),
],
orderBy: ['published' => false], // false = DESC!
limit: 20,
)->execute();
Katalog#
V systému je 34 filtrů (35 souborů z ls app/Core/Utils/Filters/*.php bez továrny
TranslationScopeFilters). Nejpoužívanější:
| Filtr | Co dělá |
|---|---|
EqualFilter, NotEqualFilter |
rovnost / nerovnost |
InFilter, NotInFilter |
hodnota (ne)patří do seznamu |
LikeFilter, NotLikeFilter |
částečná shoda |
IsNullFilter, IsNotNullFilter |
prázdná / neprázdná hodnota |
GreaterThanFilter, GreaterOrEqualFilter, LessThanFilter, LessOrEqualFilter |
porovnání |
BetweenFilter, NotBetweenFilter, RangeFilter, RangeInclusiveFilter |
rozsahy |
DateEqualFilter, DateBeforeFilter, DateBeforeOrEqualFilter, DateAfterFilter, DateAfterOrEqualFilter, DateBetweenFilter, DateRangeFilter |
datumové varianty |
FulltextFilter |
fulltext nad databází |
SearchIdFilter |
id vrácená z Elasticsearch |
DistanceFilter |
geografická vzdálenost |
ParameterRelFilter, ParameterRelLikeFilter, ParameterRelRangeFilter |
dynamické parametry inzerce a katalogu firem |
Filtry s vlastní sémantikou#
U těchhle filtrů nestačí název — rozhoduje, co přesně porovnávají.
| Filtr | Co porovnává | Proč |
|---|---|---|
DateRangeFilter |
$format určuje granularitu: Y-m-d (výchozí) obalí sloupec DATE() a obě meze berou celý den; formát s hodinami (Y-m-d H:i) porovnává celý timestamp |
s DATE() by se čas zahodil a „10:00–12:00“ v rámci jednoho dne by nevrátilo nic. DataGrid posílá Y-m-d H:i pro sloupce FilterDataType::DateTime |
horní mez s minutovou přesností zahrnuje celou minutu (do 12:00 = včetně 12:00:59); formát se sekundami je přesný okamžik; inclusive: false se nedoplňuje nikdy |
stejně jako denní varianta bere mez celou | |
ParameterRelRangeFilter |
průnik intervalů: hledané [od, do] × uložené [value, value2 ?? value], vždy jen vazby daného parameterId, prázdná value nikdy |
bez parameterId by se porovnávala libovolná vazba (výčtové parametry nesou id volby jako text); '' + 0 = 0, takže inzerát bez ceny by prošel každým „do X“ |
DistanceFilter |
okruh $radiusKm od souřadnic přes DQL funkci HAVERSINE nad dvěma vazbami parametrů (šířka, délka) |
hledané souřadnice musí být geokódované předem; funkce je registrovaná v DoctrineExtension |
TranslationScopeFilters::forLanguage() |
dva EqualFilter (languageId, activeTranslation) nad týmž joinem jako FulltextFilter přes translations |
bez nich MATCH … AGAINST hledá ve všech jazycích a SQL fallback vrátí záznam, který ES (dokument per jazyk) správně nevrátí |
DateAfterFilter a DateBeforeFilter jsou ostré
Obě jsou DateRangeFilter s inclusive: false, tedy > a <. S výchozím formátem
Y-m-d se porovnává DATE(sloupec), takže new DateAfterFilter('validityDatetime', new DateTime())
vrátí jen řádky platné od zítřka: platnost končící dnes vypadne už dnes ráno. Pro
„dnes nebo později“ použij DateAfterOrEqualFilter, pro „nejpozději dnes“
DateBeforeOrEqualFilter. Tak to dělá UserPaymentManager::hasActiveMembership():
členství platí včetně posledního dne.
Meze rozsahu bindni jako čísla
value je textový sloupec. S číslem ho MySQL převede na číslo, s řetězcem porovná
lexikograficky ('9' > '80'). ParameterRelRangeFilter bere float, vlastní filtr nad
value musí dělat totéž.
Filtry z TranslationScopeFilters jsou phaseOneOnly
Jen zúží id ve fázi 1. Kdyby propadly do dotazu větve translations, entita by přišla
jen s jedním překladem (viz oříznutá kolekce).
Skládání a závorky#
| Filtr | Význam |
|---|---|
AndFilter |
všechny podmínky |
OrFilter |
kterákoli podmínka |
GroupFilter |
závorka |
Bez GroupFilter se OR rozlije do celého dotazu
A AND (B OR C) se bez závorky změní na A AND B OR C — a to je v SQL něco
jiného, protože AND váže silněji. Dotaz nespadne, jen vrátí víc řádků, než měl.
Tečkové cesty přes vazby#
Potřebné joiny doplní JoinHelper sám a drží si aliasy per QueryBuilder — což je
podstatné, protože jeden dotaz má vedle sebe QueryBuildery víc: hlavní, samostatný pro
computeTotalCount() a klony, které si dělá GroupFilter pro každý podfiltr.
Tečková cesta přes KOLEKCI znásobí řádky
Filtr přes ToMany vazbu udělá join, který jeden záznam vrátí vícekrát. U výpisu s počtem to zkreslí čísla — stránkování pak ukazuje „137 položek“ tam, kde jich je padesát. Nespadne to a na první stránce to vypadá správně.
Filtr vybírá rodiče, kolekce se načtou celé#
Api repository načítá ve dvou fázích (OrmRepository::hydrateByIdentifiers()):
| Fáze | Co se děje | Platí filtry? |
|---|---|---|
| 1. výběr id | dotaz s filtry, řazením a stránkováním vrátí id kořenových záznamů | ano |
| 2a. kořen + ToOne | jeden dotaz s joiny jen přes ToOne vazby z includes |
— |
| 2b. ToMany větve | každá kolekce zvlášť (WHERE parent IN (:ids)), pak se připojí k rodičům |
ne, pokud filtr nepožádá |
Paralelní ToMany joiny by řádky násobily (kartézský součin), proto má každá kolekce vlastní
dotaz. Kontrakt: filtr určuje, kteří rodiče se vrátí; includes je donačtou vždy celé.
Filtr parameters.value = 'x' tedy vrátí inzeráty s tou hodnotou, ale u každého všechny
jeho parametry.
| Přepínač filtru | Účinek |
|---|---|
| bez přepínače | jen fáze 1 |
setApplyToCollections(true) |
tečkový filtr se propaguje i do dotazu větve, kolekce se ořízne |
setPhaseOneOnly() |
jen fáze 1, i když by šlo propagovat (silnější zákaz; hledání v ES, filtry parametrů inzerce) |
GroupFilter (AND/OR) se do větví nepropaguje nikdy.
S oříznutou kolekcí se nesmí ukládat
setApplyToCollections(true) je jen pro čtení. Objektový save nespárované potomky
vloží znovu: kolize UNIQUE, nebo „uloží se jen napoprvé“.
Kolekce se plní, nevyměňuje
Větev doplní existující PersistentCollection přes hydrateAdd() a takeSnapshot().
Kdo by ji nahradil novou ArrayCollection, u vazby s orphanRemoval: true naplánuje
při příštím flushi DELETE všech původních řádků.
Pořadí filtrů se přerovnává#
FilterSortHelper::sortFilters() před sestavením dotazu přeuspořádá filtry podle
pořadí sloupců v entitě a filtr nad víc poli rozbalí na jednotlivé. Volá se
z GetAllTrait i z TotalCountTrait, aby oba dotazy vypadaly stejně.
Není to řazení výsledků
Řazení výsledků řeší orderBy a OrderByHelper — jiná věc, jiný soubor.
Řazení výsledků#
Směr je BOOL, ne řetězec
OrderByHelper dělá doslova $dir = $direction ? 'ASC' : 'DESC'. Takže
['id' => 'DESC'] seřadí VZESTUPNĚ, protože neprázdný řetězec je truthy.
Nikde to nespadne a výpis vypadá seřazeně — jen obráceně, takže „posledních deset“
ukáže deset nejstarších.
(V DataGridu je to jinak: setDefaultOrder() bere řetězec
a převede si ho sám.)
Dva pomocníci z OrderByHelper, bez kterých výpis na frontu stránkuje špatně:
| Metoda | Co udělá |
|---|---|
withIdTiebreak($orderBys) |
doplní na konec id se směrem posledního klíče, pokud tam není; prázdné řazení nechá prázdné (relevance z ES) |
translatedField('translations', 'name', $langId) |
klíč translations[languageId:N].name; addOrderBys() z něj udělá vlastní LEFT JOIN … WITH languageId = N jen pro řazení. null jazyk = holé translations.name |
Řazení bez unikátního klíče stránkuje náhodně
Při shodných hodnotách (cena 0, stejné datum) je pořadí v MySQL/MariaDB
nedeterministické a LIMIT/OFFSET pak položky mezi stránkami opakuje a jiné vynechá.
Každé řazení frontového výpisu proto obal withIdTiebreak().
Holé translations.name řadí podle libovolného jazyka
Join překladů všech jazyků a SELECT DISTINCT id … ORDER BY t.name řadí podle toho
řádku, který databáze vezme. Anglický název se pak objeví mezi českými. Řaď přes
translatedField() s jazykem stránky; LEFT JOIN nic nevyřadí (bez překladu = mezi NULL)
a hydrataci překladů neovlivní.
Postup: píšu vlastní filtr#
- Nejdřív projdi katalog. Většina potřeb je pokrytá; nový filtr má smysl u domény, ne u další varianty porovnání.
- Implementuj
App\Core\Utils\Filters\Interfaces\Filter—toSql(),getBindings(),apply()a settery aliasů. - Vyjdi z nejbližšího existujícího filtru ve stejné složce; tvar je ustálený.
- Když filtr nesmí do cache, označ ho
NonCacheableFilter. - Ověř ho i v součtu. Filtr se aplikuje na hlavní dotaz i na dotaz počítající celkový počet — když se ty dva rozejdou, stránkování ukazuje nesmysly.
NonCacheableFilter je marker pro dotazy, které se nesmí cachovat
Používá ho SearchIdFilter: seznam id z Elasticsearch by v klíči cache vyrobil
unikátní záznam pro každý hledaný výraz (a zaplnil Redis), a hlavně by vznikla
druhá pravda o seznamu vedle ES indexu. Podrobně Cache.
Filtry v administraci a na frontu#
| Kde | Jak vzniknou |
|---|---|
| DataGrid | z filtrů ve sloupcích + addDefaultFilter() |
| front výpisy | z parametrů adresy přes filtrovací komponentu |
| fasety e-shopu | vlastní pravidla — počty se zužují vším kromě vlastní fasety |
Filtry z drátu ověř před RBAC: FilterBuilder::isBuildable()
Externí volání posílá filtry jako pole a FilterBuilder::buildFilters() z nich staví
objekty. Vadná definice ([true], neznámý typ, rozbitá vnořená skupina) by skončila
TypeError nebo InvalidArgumentException, tedy HTTP 500. GetAllTrait, GetByTrait
a DeleteTrait proto volají isBuildable() ještě před RBAC a cache a vrátí 400 přes
sendInvalidRequestParameter('filters'). Vlastní externí akce s filtry od volajícího
musí dělat totéž.
Rozsahový filtr na frontu potřebuje round-trip přes adresu
Bez něj se hodnota mezi požadavky ztratí a filtr tiše nefiltruje — vrátí všechno a tváří se, že je nastavený.
Kam sáhnout#
| Chci | Kde |
|---|---|
| katalog filtrů | app/Core/Utils/Filters/ |
| rozhraní filtru | app/Core/Utils/Filters/Interfaces/Filter.php |
| marker „necachovat“ | …/Interfaces/NonCacheableFilter.php |
| doplňování joinů | app/Core/Utils/Helpers/Filters/JoinHelper.php |
| přerovnání filtrů | app/Core/Utils/Helpers/Filters/FilterSortHelper.php |
| směr řazení, druhý klíč, řazení podle jazyka | app/Core/Utils/Helpers/Sqls/OrderByHelper.php |
| stavbu filtrů z pole | app/Core/Utils/Helpers/Filters/FilterBuilder.php |
funkci HAVERSINE |
app/Core/Utils/Doctrine/HaversineFunction.php |
Navazující kapitoly: Práce s tabulkami · Cache · Elasticsearch · Volání API