Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Filtry

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#

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

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

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

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#

  1. Nejdřív projdi katalog. Většina potřeb je pokrytá; nový filtr má smysl u domény, ne u další varianty porovnání.
  2. Implementuj App\Core\Utils\Filters\Interfaces\Filter — toSql(), getBindings(), apply() a settery aliasů.
  3. Vyjdi z nejbližšího existujícího filtru ve stejné složce; tvar je ustálený.
  4. Když filtr nesmí do cache, označ ho NonCacheableFilter.
  5. 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

Nastavení cookies