ORM v Jet - vytváření dotazů a efektivní nahrávání dat

29. 6. 2023 21:35 Mirek Marek

V minulém díle jsem nastínil mechanismus modelování a propojení entit a subentit do celistvých datových modelů.

Dnes se podíváme na něco řekl bych možná i zajímavějšího. Ukážu vám jak je možné propojovat cizí entity pomocí tzv. vnějších relací. Dalším tématem bude vytváření dotazů ( obecných podmínek WHERE a HAVING) a dnes stihnu alespoň stručně ukázat i různé mechanismy nahrávání dat a jejich použití.

Vnější relaci

Jaký je hlavní rozdíl mezi vnitřní a vnější relací v kontextu ORM v PHP Jet? Vnitřní relace znamená, že daná entita poskládaná z několika subentit je chápana jako jeden celek. A to znamená, že při výchozím způsobu nahrávání dat (ne však jediném, jak si dnes ukážeme) jsou nahrávána všechna potřebná data ze všech potřebných tabulek a vytváří se adekvátní počet instancí tříd, které pak budou tvořit jeden hierarchicky uspořádaný celek.

To je naprosto v pořádku pro řadu situací, ale rozhodně ne pro všechny. V některých situacích spolu mají datové entity nějaký logický kontext a souvislost, ale to neznamená, že tato související data chceme vždy hned nahrávat z databáze a operovat s nimi když nahráváme a operujeme s jinou související entitou.

V ukázkové aplikaci PHP Jet samozřejmě příklad takové situace je. Součástí ukázkové aplikace je malá primitivní obrázková galerie. Jednotlivé galerie jsou uspořádány do stromové struktury s jejich popisky jsou samozřejmě připravené pro mezinárodní prostředí. Tedy entita galerie je tvořena dvěma dílčími entitami (hlavní entitou a jednou subentitou). Stejně jako u článků, které jsme si ukazovali minule.

Ovšem v obrázkové galerii musí samozřejmě být nějaké obrázky. Možná by se hned nabízelo to řešení, že obrázek může být další subentitou samotné galerie. Teoreticky by to tak být mohlo, ale není to úplně ideální řešení. Proč? Jak již bylo řečeno, tak když se entita nahrává, tak se (pokud vývojář neurčí jinak – viz dále) nahrává vše – včetně subentit. A zde by to mohlo znamenat nahrávání velkého množství dat, tedy brzdu a problém.

Proto je ukázková entita obrázek (třída JetApplication\Content_Gallery_Image) samostatnou entitou, nikoliv subentitou entity galerie (třída JetApplication\Content_Gallery).

Ovšem když si ukázkovou aplikaci nainstalujete a vytvoříte si nějaké testovací galerie a nahrajete do nich obrázky, tak zjistíte, že funkce vyhledávání najde galerii i podle názvu souboru obrázku.

A když se zadíváte do zdrojáků, tak zjistíte, že to vyhledávání je implementováno velice jednoduše:

public static function search( string $search ): iterable
{


    $search = '%' . $search . '%';


    return static::fetchInstances(
        [
            'gallery_localized.title *' => $search,
            'OR',
            'image.file_name *'         => $search
        ]
    );

}

Tedy ORM ví o tom, že obrázek nějak souvisí s galerií a umí vytáhnout instance galerií na základě jména obrázku. A při tvorbě dotazu není nutné jakkoliv specifikovat ono propojení, protože to je již známe.

Vnější relace jsou totiž také součástí definice. Definice, kterou si vývojář samozřejmě může naklikat v Jet Studiu, ale které mají ve finále tuto podobu:

#[DataModel_Definition(
        name: 'gallery',
        database_table_name: 'image_galleries',
        id_controller_class: DataModel_IDController_UniqueString::class,
        relation: [
                'related_to_class_name' => Content_Gallery_Image::class,
                'join_by_properties'    => ['id' => 'gallery_id'],
                'join_type'             => DataModel_Query::JOIN_TYPE_LEFT_OUTER_JOIN
        ]
)]
class Content_Gallery extends DataModel
{
}

Tedy klasika. Opět je to definováno atributem třídy popisujícím hlavní parametry modelu.

Vnější relaci lze vytvořit mezi jakýmikoliv třídami. Podmínkou je aby existovaly vlastnosti tříd (ve finále sloupce tabulek) pomocí kterých je možné entity propojit. Těchto vázacích vlastností může být 1 až X. Podmínkou je samozřejmě kompatibilita datového typu provázaných vlastností.

Netřeba se tím moc trápit, Jet Studio to pohlídá a pro vytvoření vazby nabídne pohodlný dialog.

Je samozřejmě i možné definovat zda se jedná / nejedná o OUTER JOIN ve smyslu SQL.

V uvedeném příkladu je definována pouze jedna vnější relace. Ale to není pevně dáno. Samozřejmě jich může být definováno několik:

#[DataModel_Definition(
        name: 'gallery',
        database_table_name: 'image_galleries',
        id_controller_class: DataModel_IDController_UniqueString::class,
        id_controller_options: [
                'id_property_name' => 'id'
        ],
        relations: [
                [
                        'related_to_class_name' => Content_Gallery_Image::class,
                        'join_by_properties' => [
                                'id' => 'gallery_id'
                        ],
                        'join_type' => DataModel_Query::JOIN_TYPE_LEFT_OUTER_JOIN
                ],
                [
                        'related_to_class_name' => Model_A1::class,
                        'join_by_properties' => [
                                'id' => 'id'
                        ],
                        'join_type' => DataModel_Query::JOIN_TYPE_LEFT_JOIN
                ]
        ]
)]
class Content_Gallery extends DataModel
{
}

Tvorba dotazů (podmínek pro WHERE a HAVING)

Kde mě sleduje pravidelně, tak si jistě všiml, že dotazy jsou tvořeny pomocí polí. Ostatně příklad je i zde v tomto článku. Ještě jednou pro pořádek:

return static::fetchInstances(
    [
        'gallery_localized.title *' => $search,
        'OR',
        'image.file_name *'         => $search
    ]
);

Proč zrovna tak? Vysvětlím jak jsem před lety uvažoval:

Když už ORM, tak to znamená, že aplikace musí být přenositelná na různé databázové systémy. To naprosto vylučuje použití nějakého konkrétního SQL dialektu. Však i pravidla pro názvy tabulek a sloupců a jejich uvádění v dotazech jsou velice rozmanitá.

Hned jako první myšlenka se nabízí udělat si nějaký vlastní SQL dialekt (třeba i odvozený od již existujícího dialektu) jak to mají kolegové v jiném ORM. Fajn, to vypadá na první pohled dobře, ale mě hlavně zajímá co to obnáší po po stránce technické. “Vypadá to dobře” není objektivně měřitelné kritérium.

A obnáší to udělat nějaký parser, ten udržovat a tak dále. Tedy dost práce. Ale co by to přineslo? Jak by to fungovalo? V aplikaci by vývojář sestavil textový řetězec jako dotaz, ten by ORM parsovalo a následně z něj sestavilo jiný textový řetězec v požadovaném SQL dialektu. Tedy to čemu rád říkám rovnák na ohýbák. Spousta kódu pro nic, spousta strojového času pro nic. Tedy je to řešení na pohled zajímavé a respektuhodné, ale řešení které vyžaduje nějaké prostředky a zdroje ve všech oblastech.

Další možností je různé volání metod a třeba i postupné tvoření dotazu přidáváním výrazů. A právě postupné tvoření dotazu je zajímavé téma.

Jaké vlastně používáme dotazy? Ano, často stačí pouze nějaká jednoduchá podmínka, vlastně jeden výraz. Ale to je v praxi málo.

Koukněte se třeba do ukázkové aplikace na jeden z prohlížečů událostí. To je hezká ukázka práce se seznamy dat, která pochopitelně zahrnuje řadu filtrů. A tyto filtry pochopitelně generují jednotlivé segmenty WHERE podmínky. Na to už by možná bylo lepší ono generování dotazu ve speciálním dialektu – jako řetězce. Proč? Protože jinak by filtry musely znát referenci na nějakou instanci / objekt reprezentující dotaz. A závislostí je dobré mít co nejméně – zjednodušuje to architekturu systému, což je věc žádoucí.

Tak co s tím? Jedna z věcí co mám na PHP rád je práce s poli. Asociovaná pole, indexovaná, ale ono to lze i kombinovat. Už hodně (opravdu hodně) let ani nemusíme psát array(), ale bohatě stačí zkrácený zápis []. To je moc fajn. Co toto vše naplno využít?

Jak je patrné z příkladu, tak princip je vlastně naprosto jednoduchý:

Klíč pole představuje název vlastnosti které se výraz týká. Tento název vlastnosti může být buď prostý (bez explicitního určení modelu) pokud se WHERE / HAVING má týkat pouze jedné entity, nebo název může (respektive musí) být tvořen názvem modelu (viz minulý článek, nebo dokumentace) následovaného tečkou a pak názvem vlastnosti.

Za názvem “vlastnosti” / “modelu.vlastnosti” pak může následovat operátor. Výchozí operátor je “=” a ten nemusí být uváděn. Další operátory jsou “!=”, “<”, “>”, “<=”, “>=” a tak dále. Nebo operátory představující LIKE – “*” a NOT LIKE – “!*”.

Hodnota pole je pak hodnota ve výrazu. Zajímavostí je, že hodnotou může být i pole, nebo samozřejmě i instance Jet\Locale či Jet\Data_DateTime. Ale zajímavé je právě použití pole. I s tím si ORM poradí pro operátory “=” a “!=”.

Je samozřejmě možné vytvářet subsegmenty WHERE / HAVING. Z hlediska SQL tedy závorky (), ovšem zde to bude opět pole. Například takto:

$where = [
    [
       ‘property_a’ => 1,
       ‘AND’,
       ‘property_b’ => 2,
    ],
    ‘OR’,
    [
       ‘property_a’ => 3,
       ‘AND’,
       ‘property_b’ => 4,
    ]
];

Subsegmenty / tedy pole lze samozřejmě libovolně zanořovat.

No a použití ‘AND’ a ‘OR’? Toho jste si již zajisté všimli.

Tento systém už používám mnoho let – ještě ve verzích z éry před PHP8. A musím říct, že se mi to velice osvědčilo. Zejména dynamická tvorba dotazů je pohodlná a to pohodlnější než skládání nějakého textového řetězce. Zároveň nemusím řešit nepotřebné závislosti v systému.

A ano, v ORM DataModel je SQL dotaz fakticky opravdu reprezentován instancí k tomu určené třídy – objektem. A je možné výrazy tvořit i pomocí volání metod – ostatně tak to ORM dělá vnitřně. Backend ORM pak již dostane k dispozici onen objekt reprezentující dotaz a z něj vytvoří konkrétní dotazy pro daný DB systém. Tolik pro zajímavost a doplnění.

Po řadě let používání výše uvedeného principu bych fakt neměnil.

Nahrávání dat

Kámen úrazu online aplikací … To kolik dotazů bude vůbec provedeno, jak budou optimalizované a kolik se bude přenášet dat – to determinuje výkonnost online aplikace, kolik požadavků dokáže odbavit, jak těžké bude ji “sestřelit”, ale i jaký bude uživatelský zážitek.

A je téměř jedno v jakém jazyku je aplikace napsána a jaký databázový systém se o data stará. Logika platí univerzálně. Jasně, technologie má vliv a to ne zrovna zanedbatelný, ale naprosto rozhodující je jak je technologie použita. Ostatně to nemusím nikomu říkat …

Je tedy nutné myslet na to jak data tahám, co vlastně doopravdy dělám. O tom jsem psal již minule: je nutné to vědět co přesně děláte a co se děje (zejména pokud děláte něco jen trochu většího) a proto nemám rád, když někdo vytváří dojem, že ORM vyřeší vše za nás.

To znamená, že ani ORM DataModel nevyřeší vše a nechci aby se to tvářilo jako nějaká kouzelná hůlka co řeší vše nějakou magií. Od přemýšlení je tu vždy vývojář aplikace / projektu. DataModel nabízí několik možností jak řešit různé situace, mezi kterými se může / má vývojář rozhodnout. Pojďme si je alespoň v rychlosti ukázat.

Prosté nahrání jedné instance entity

Začnu tím základním a to nahráním instance jedné konkrétní entit, která se samozřejmě může skládat z mnoha subentit k jejichž nahrání a inicializaci dojde samozřejmě také.

Základní nahrání je úplně jednoduché:

$article = Content_Article::load( $id );

Ovšem metoda load toho umí víc.

Jak víte z minulého článku, tak ukázková entita článek v ukázkové aplikaci má subentity představující jednotlivé národní a jazykové mutace. A pochopitelně je možné nahrávat i pouze tyto subentity.

Tedy dejme tomu, že potřebujeme data národní a jazykové mutace konkrétního článku a ne celého článku se všemi jeho mutacemi. Lze udělat toto:

$article_localized = Content_Article_Localized::load([
        'article_id' => $article_id,
        'AND',
        'locale' => $locale
]);

Pro pořádek uvedu, že existuje ještě jedna možnost a to taková že jako ID bude předána instance ID kontroleru (viz minulý článek) s nastavenými hodnotami ID, který reprezentuje identifikaci konkrétního záznamu. Ale to je pouze okrajová a v praxi ne moc používaná možnost, ale je možné to použít v kombinaci s iterátorem pro nahrání ID (viz dále).

Filtr nahrávání

Pojďme na něco zajímavějšího. Metoda load má ještě jeden parametr a to tak zvaný filtr nahrávání. Je důležité říct, že princip filtru nahrávání se netýká jen metody load, ale i dalších metod určených k nahrávání instancí entit.

Ukažme si to rovnou prakticky. Dejme tomu, že chcete nahrát instanci článku, ale víte, že budete potřebovat pouze základní informace článku (ID a datum, atd) a titulky – ty ovšem ve všech existujících mutacích.

Nic složitého, stačí použít filtr nahrání dat:

$article = Content_Article::load(
        $article_id,
        [
                'article.*',
                'article_localized.title'
        ]
);

Filtr určuje které části entity požadujeme nahrát z databáze. Lze určit že z dané dílčí entity chceme vše a z jiné dílčí entity pouze určitou vlastnost / vlastnosti. Přesně jak je uvedeno v příkladu.

Tedy ušetří se zde přenos dat a alokace paměti a celkovou režii nahrání a instancování pro data která nejsou potřebná.

Jak jsem již psal, tak toto je univerzální princip použitelný i na dalších místech. A pojí se s tím ještě jeden důležitý rys.

Pokud je instance entity takto nahrána, tak je ve speciálním stavu který znemožňuje její ukládání (aktualizaci dat) a mazání – prostě aby nedošlo omylem k znehodnocení dat. Při pokusu o uložení (úpravě) či smazání takto částečně nahrané entity je vyhozena výjimka.

Nahrání několika instancí – 1. možnost

Klasický problém je nahrání určité množiny dat. Ideálně s možností stránkování, řazení a samozřejmě definice WHERE.

K tomu slouží metoda:

public static fetchInstances( array $where = [], array $load_filter = [] ):DataModel_Fetch_Instances

Použití je tedy jednoduché:

$articles = Content_Article::fetchInstances( $where );

Pozornému čtenáři jistě neuniklo, že metoda nevrací pole instancí dané entity, ale instanci třídy DataModel_Fetch_Instances, což je iterátor sloužící k procházení dat výsledku dotazu.

Důležité je, že tato metoda bezoristředně fakticky pouze vytvoří instanci onoho iterátoru, ale žádný dotaz neprovede.

Iterátor je tedy možné dále nastavovat (stránkování, filtr nahrání, řazení, …), ale podrobným popisem bych již překročil nějaký rozumný rozsah článku.

Princip je prostě ten, že vznikne iterátor, který je možné dále nastavovat a který provede dotaz až v momentě, kdy aplikace začne data opravdu procházet.

A pozor! To není vše. Iterátor na začátku nevytáhne z databáze všechna data. Vůbec ne. Vytáhne nejprve pouze ID záznamů, respektive sloupce identifikující záznamy (jak jsem psal minule – počítá se s tím, že záznam může být identifikován složenými klíči – více jak jedním sloupcem).

Až když je vytažena nejmenší možná množina ID záznamů (např. 50 položek z tisíců při sránkování), již seřazených dle požadavku / nastavení dotazu, tak až tehdy jsou nahrány instance entit, ale pouze tohoto relevantního výseku dat. Prostě čím méně dat, tím lépe.

Počítá se s tím, že uživatel aplikace může procházet sadu dat mající třeba desítky tisíc položek a uživatel nesmí u aplikace usnout, nebo rozkousat pracovní stůl.

Důležité je, že tento iterátor pro samotné konečné nahrání dat / instancí použije druhou metodu, kterou si vzápětí ukážeme.

Nahrání několika instancí – 2. možnost

Tato možnost původně vznikla jako vnitřní metod ORM, ale rád ji používám i v aplikacích.

Metoda fetch:

public static fetch(
        array $where_per_model = [],
        array|string|null $order_by = null,
        ?callable $item_key_generator = null,
        array|DataModel_PropertyFilter|null $load_filter = null
): static[]

Tato metoda již nepoužívá žádné iterátor, ale přímo nahrává data a skládá z nich instance entit.

Specifická je tím, že na vstupu očekává definice WHERE pro jednotlivé dílčí entity. To je její hlavní výhoda. Je možné říct: nahraj mi a poskládej instance přesně na základě těchto podmínek. A tak mít naprosto pod kontrolou co se bude dít. Ovšem stále platí, že tu otravnou rutinu provede ORM.

Praktické využití? Mnohé, např. v mezinárodních aplikacích je možné pro účel zobrazení tahat pouze data v určené mutaci a pouze danou množinu dat – opět další optimalizace.

Pochopitelně neschází ani možnost řazení na rovni SQL.

Dalším parametrem je možnost předat generátor (v praxi nejčastěji anonymní funkci) klíčů výsledného pole.

A i zde neschází možnost režimu filtru nahrání. Tedy i zde je možné dílčí entity nahrát pouze částečně – pouze některé informace / tedy vlastnosti (či datové sloupečky).

Nahrání ID záznamů

V některých situacích je potřeba vytáhnout pouze ID určitých záznamů (a ano, i zde platí, že ID se může skládat z více vlastností / sloupců ).

K tomu slouží metoda:

public static fetchIDs( array $where = [] ): DataModel_Fetch_IDs

Jak jste si všimli, tak tato metoda obdobně jako fetchInstances vrací instanci iterátoru. Metoda fetchIDs funguje vlastně identicky jako fetchInstances. (dokonce mají i společný základ).

Jediný ale základní rozdíl je ten, že iterátor vrací instance ID kontroleru, samozřejmě již nastaveného hodnotami ID záznamů.

A ano, je to ta instance ID kontroleru, kterou lze předat například metodě load, jak je uvedeno výše.

Nahrání surových dat

Před mnoha lety ve starších verzích PHP Jet a jeho ORM tato možnost nebyla. Ano, i já jsem měl takové ty dogmatické tendence a říkal jsem si: no když mám takové nahrávání instancí, tak přece nebudu pracovat s daty po staru! To by přece bylo fujky-fujky a nehezké …

Pche … Reálný svět mi to velice rychle vysvětlil – nic není lepší než reálná praxe.

Ano, je hezké a správné všude pracovat s instancemi. Už kvůli zapouzdření a tak dále. Ale zkuste udělat servisní skript (např. přepočty nějakých hodnost) na databázi kde jsou desítky a stovky tisíc záznamů a vytvářet desítky a stovky tisíc instancí objektů. To je takový … překladatelský oříšek. Jde to, ale žere to zdroje. A ne malé. A někdy je prostě efektivnější to nelámat věci přes koleno.

Záhy jsem si tedy uvědomil, že v reálné praxi sem tam prostě potřebujeme natáhnout data do nějakého běžného pole, prostě si jen vytáhnout hodnoty. V některých situacích je to technicky lepší řešení i když není dobré aby to byl hlavní prvek návrhu aplikace. Jak říkám: je to super pro nějaké servisní skripty.

Dobře, ale Jet má přece možnost operovat i přímo s databází, potažmo stále mám k dispozici vše v samotném PHP. Ano, tak tomu je. Je možné hrát si s SQL dotazy úplně po staru. A i to může mít své místo. Například pokud je nějaký systém dělaný na míru zákazníkovi, pár let už běží a určitě ještě dlouho poběží a nějaké vyměňování jednoho databázového systému za druhý je asi tak pravděpodobné jako že mi rozkvetou hrábě na terase. Tak ano, lze použít starý konvenční přístup zcela bez ORM. Jde jen a pouze o tom, zda dané řešení má logiku v dané situaci a v daném kontextu.

Je tu však ještě jedna možná situace či úhel pohledu.

Co když chcete zachovat onu přenositelnost systému na různé databázové systémy? Co když vytváříte například ne dílčí projekt na zakázku, ale naopak produkt na kterém bude X projektů postaveno? Nebo co když i v dané situaci prostě chcete zachovat konzistentní přístup a dále využívat některé výhody ORM?

V takovém případě vlastně stále potřebuji alespoň část systému ORM. Nadále potřebuji něco, co bude vytvářet dotazy v daném SQL dialektu pro konkrétní databázový systém, stále potřebuji definice typů datových polí a dokonce stále potřebuji i definice relací. A také i přetypování dat na správné PHP typy při načtení. Vlastně je potřeba vše kromě toho že jsou data “poskládána” do instancí nějakých tříd a “nalita” do jejich vlastností.

Proto má ORM v PHP Jet metody pro přímé načítání dat fungujících právě takto. Funguje prakticky vše, pouze to vrací surová data (ovśem již přetypovaná na adekvátní PHP typy, nebo instance tříd).

Těch metod je několik, ale liší se pouze tím v jaké formě vrací výsledná data, jejich funkce a parametry jsou stejné.

A díky pojmenovaným parametrům z PHP 8 se to používá například takto:

$article_data = Content_Article::dataFetchAll(
        select: [
                'id',
                'date_time',
                'article_localized.locale',
                'article_localized.title',
        ]
);

var_dump( $article_data );

Použití je velice jednoduché ačkoliv to za vás udělá dost práce:

  • Sestaví to SQL i s případnými JOIN (protože relace jsou známé)
  • Provede to dotaz
  • Vlastnost date_time je ve výsledném poli již jako instance Jet\Data_DateTime, locale je již instance Jet\Locale
  • S daty je možné rovnou pracovat

A co nějaké podmínky? Samozřejmě, není problém:

$article_data = Content_Article::dataFetchAll(
        select: [
                'id',
                'date_time',
                'article_localized.locale',
                'article_localized.title',
        ],
        where: [
                'article_localized.locale' => (new Locale('cs_CZ'))
        ]
);

A tak by šlo pokračovat. Lze samozřejmě doplnit GROUP BY, HAVING, řazení, ale samozřejmě i limit a offset.

Všechny principy jsou stále stejné pro celé ORM. Tedy i dotazy je možné dynamicky skládat – stále je to stejné pole. Pouze výsledkem jsou data v klasických vždy polích (ale nejen to – viz dále) a ne objekty.

Existuje tyto metody pro “tahání” dat:

  • dataFetchAll: Prosté nahrání všech dat.
  • dataFetchAssoc: První vlastnost bude považována za klíč pole výsledného seznamu dat.
  • dataFetchCol: Výsledkem bude prosté jednorozměrné pole tvořené první nahranou vlastností.
  • dataFetchPairs: Vrátí asociované pole, kde klíčem bude první určená vlastnost a hodnotou ta druhá.
  • dataFetchRow: Vrátí pouze jeden řádek.
  • dataFetchOne: Vrátí pouze jednu hodnotu (tedy ne pole)
Content_Article_Localized::updateData(
        data: [
                'title' => 'Nový titulek článku’,
                ‘text’ => ‘text článku’,
        ],
        where: [
                'article_id' => $id,
                'AND',
                'locale' => $locale
        ]
);

Prostě stále stejný princip a hlavně vůbec nic složitého 🙂

Závěr (pro zatím)

A to je pro dnešek vše. Před námi je hlavní sezóna dovolených, krásného počasí, vody, cestování … A já si dám do září od psaní článků pokoj. Tedy nikam nezmizím a brzy (v září) se vrátím (pokud si někde při sportu nerozbiju kokos definitivně … jako se mi to povedlo loni, naštěstí ne definitivně …)

Chtěl bych všem poděkovat. Za těch několik měsíců se stala spousta věcí, dostal jsem spoustu podnětů a nápadů pro další cestu a i když přes léto nebudu publikovat články, tak se práce určitě nezastaví.

Ale mohu říct, že nyní jsou mé plány jasnější, poznal jsem nové věci v nové oblasti pro mne do té doby neznámé a bylo to velice užitečně a vše zúročím v další fázi.

Tak “na viděnou” v září a užijte si léto!

Sdílet