Komplex példa 3. rész - Adatkapcsolatok 2., kulcsok, gyárak

Attila | 2022. 03. 19. 09:46 | Olvasási idő: 6 perc

Címkék: #Adatbázis (Database) #Adatgyár (Factory) #Blade #Controller #CRUD #Eloquent #Laravel #Laravel 6 #Laravel 8 #Laravel 9 #MySQL #Nézet (View) #Tesztelés (Testing) #Tinker

Egy sokkal gyakrabban használt relációs adatbázis kapcsolatot fogunk áttekinteni, ez az "egy-több"-es vagy "több-egy"-es kapcsolat lesz, attól függ, honnan nézzük. A lényeg, hogy az egyik oldalon (táblában) egy, míg a másik oldalon (táblában) több szereplő (adatsor) vesz (vehet) részt a kapcsolatban. De példákon keresztül talán jobban megérthető ez: egy Facebook bejegyzésnek lehet több kommentje, egy felhasználónak lehet több bejegyzése, egy embernek lehet több autója, egy projektnek lehet több mérföldköve és még a végtelenségig lehetne sorolni. Mi azt fogjuk megnézni, amikor egy repülőjáratnak lehet több utasa. A kapcsolat megfordítása pedig így néz ki: egy utas csak egy repülőjárathoz tartozhat (adott pillanatban). A külső (idegen) kulcsokat fogjuk használni a kapcsolat létrehozásához. A kapcsolat teszteléséhez pedig példaadatokat fogunk létrehozni adatbázis gyárak segítségével. A bejegyzés végén ismét kijelölök egy(-két) gyakorló feladatot.
Egy-több-kulcs-gyár

Bevezető

Vágjunk is rögtön bele! A kulcsszavak, amit fogunk használni az Eloquent kapcsolat kialakításához: belongsTo (vagyis: valami tartozik valamihez) és a hasMany (vagyis: valaminek van több valamije). Vagy ha a bevezetőben elmondott példánkat szeretnénk ezekkel a kulcsszavakkal meghatározni: 1) egy utas kapcsolódik egy repülőjárathoz (adott pillanatban pont egyhez), 2) egy repülőjáratnak van több utasa (adott pillanatban, amikor aktív). Adjuk hozzá ezt az új metódust a Flight Model osztályunkhoz:

public function passengers() {
  return $this->hasMany(Passenger::class);
}

De ugye még a Passenger Model osztályunk nem létezik, úgyhogy hozzuk ezt létre a migrációs fájljával együtt:

php artisan make:model Passenger -m

Itt a Passenger Model fájlban pedig hozzuk létre a hasMany ellentettjét, ami a belongsTo utasítás lesz.

public function flight() {
        return $this->belongsTo(Flight::class);
}


Kapcsolat létrehozása külső (idegen) kulcson keresztül

Nyissuk meg a passenger migrációs fájlt. Az biztos, hogy minden utas egy repülőjárathoz tartozik, úgyhogy ezt erősítsük meg egy idegen kulcs hozzáadásával, betartva a névkonvenciókat. Mivel ez egy "create table" jellegű migrációs fájl, nyugodtan hozzá tudjuk adni még itt a legelején a két új mezőnket az up()-ban lévő create() metódushoz (id után, timestamps elé beszúrva):

$table->unsignedBigInteger('flight_id');
$table->string('name');

A down metódus a táblát fogja törölni, úgyhogy oda nem kell külön felsorolni ezeket az oszlopokat. Viszont maradjunk még az up()-on belüli create() metódusnál. Az előző blogbejegyzésemben említettem, hogy az unsignedBigInteger('flight_id') igazából nem hoz létre külső kulcsos kapcsolódást a másik táblához, csak előkészíti annak a helyes működését. Így még egy mezőt beszúrhatunk még ide a mezők definiálása után:

$table->foreign('flight_id')->references('id')->on('flights');

Ez az a sor, amivel valójában létre fog jönni a külső kulcsos kapcsolat a passengers és a flights adattáblák között. Végigolvasva a sort, kiszűrhetjük belőle, hogy ez egy idegen (külső kulcs) definiálása lesz, mégpedig a flight_id mezőre, utána következik az, hogy melyik "idegen mezőre" fog hivatkozni, ez az id lesz, de nem passengers (saját táblájára), hanem az on() metódusba beírt flights tábla id mezőjére fog hivatkozni. Itt a végén a fordított sorrend, ne zavarjon meg minket (előbb a hivatkozott oszlopnevet írtuk, majd csak utána a hivatkozott táblanevet). Migrálás után (php artisan migrate) ellenőrizhetjük is a MySQL Workbench-ben, hogy létrejött-e a külső kulcsos hivatkozás (tábla neve melletti csavarkulcsra kattinthatunk, utána alul pedig a Foreign Keys-re):

Kérdezhetnénk, hogy miért nem volt elég a sima mezőnév megadás, hiszen azon keresztül is létre tudtuk hozni az előző bejegyzésben az Eloquent kapcsolatot két tábla között? A kapcsolat tényleges létezésének okát több szempontból is meg tudnám indokolni: a relációs adatbázis működésének elve szerint, ha mi létrehozunk egy hivatkozást a másik táblára (idegen kulcson keresztül), akkor ő automatikusan létre fog hozni egy INDEX fát az oszlopra (MySQL Workbenchben ugyanott alul, csak az "Indexes"-t választva tudjuk ezt is ellenőrizni). Ha valaki nem tudná, hogy mi is ez, annak csak annyit mondanék röviden, hogy ez alapján az adatbáziskezelő sokkal gyorsabban fog tudni keresni, szűrni és rendezni az adatbázisban, tehát elsősorban teljesítményjavítás szempontjából ez mindenképpen hasznos, ha használunk külső kulcsot. A másik főbb okot pedig az iménti képen jobb oldalon láthatjuk, ami a "Foreign Key Options" részben van: itt tudjuk meghatározni azokat a kényszereket, hogy mi történjen akkor a hivatkozó (passengers) tábla soraival, ha a hivatkozott táblában (flights) mondjuk frissítésre (on update) vagy törlésre (on delete) kerül az a sor, amire a passengers hivatkozott. Egy beszédesebb példa: mi történjen azokkal az utasokkal, akik adott repülőjárathoz tartoznak akkor, amikor töröljük ezt a repülőjáratot. Megmaradjanak (tárolódjanak a táblában) ezek az utasok akik a törölt repülőjárathoz tartoznak vagy töröljük őket az utasok táblából is? (Ugyanígy igaz ez a frissítésre.)

Visszatérve a passengers migrációs fájlunkra, ott még két sorban adtuk meg a mező definícióját és a külső kulcs hivatkozást. Laravel 9-ben már lehetőségünk van arra, hogy mindezt egy sorban fogalmazzuk meg, akár úgy is, hogy az imént említett (on update és/vagy delete) kényszereket is belefűzzük. Hajtsunk végre egy adatbázis visszavonást:

php artisan migrate:rollback

Utána pedig módosítsuk a passengers migrációs fájlját, töröljük (vagy kommenteljük ki) a két említett sort és szúrjunk be helyette egyet:

$table->foreignId('flight_id')->constrained()->onUpdate('cascade')->onDelete('cascade');

A foreignId mező a külső kulcs mező lesz, ami után a constrained() metódusba nem is muszáj beírnom a hivatkozott tábla nevét, ha úgy, mint ahogy én is írtam, betartjátok a névkonvenciót, akkor a rendszer tudja, hogy a flights táblára hivatkozunk, annak is az elsődleges kulcs mezőjére, az id-ra. Hozzáfűztem még további kényszereket, de az utasítás amúgy végrehajtható lenne onUpdate és onDelete nélkül is, de ha már odaírtam őket, akkor meg is magyarázom: onUpdate és onDelete esetén is a cascade (vagy "továbbgyűrűzés") opciót választottam. Ami a példámban azt fogja jelenteni, hogy ha mondjuk törlünk majd egy repülőjáratot, aminek vannak utasai, akkor az utasok is törlődni fognak az utasok táblából, tehát a törlés továbbgyűrűzik... és ugyanez igaz lenne, ha a repülőjáratnak módosítanám az id (azonosítóját), akkor utána az továbbgyűrűzne a passengers táblába is és a hozzákapcsolt utasoknál is frissülne a flight_id mező értéke.

Megjegyzés: az onUpdate és onDelete megszorításoknak a cascade mellett további értékek is beállíthatók: 1) restrict: ekkor addig nem engedi törölni/frissíteni a repülőjáratot, amíg van neki legalább egy darab utasa. 2) set null: ha töröljük a repülőjáratot vagy frissítjük az azonosítóját (id), akkor a hozzá tartozó utasok flight_id mezőjének helyére null érték fog bekerülni. 3) no action: ha töröljük a repülőjáratot vagy frissítjük az azonosítóját, akkor a hivatkozó utasok táblában nem fog történni semmi a kapcsolódó utasok flight_id mezőjével, tehát kvázi "rosszak" lesznek, hiszen olyan mező értékre fognak hivatkozni (flights.id), ami már nem létezik vagy megváltozott.

Nekünk most ez a cascade, továbbgyűrűzés teljesen jó, úgyhogy migráljuk újra a passengers migrációs fájlját:

php artisan migrate

Frissíthetjük a Workbench-ben a passengers tábla tulajdonságának lekérdezését és már láthatjuk is, hogy az idegen kulcs újra létrejött, méghozzá helyes táblára és mezőre hivatkozik, plusz az On Update és On Delete mezők is megkapták a Cascade módosítót.


Adatbázis gyárak - ismerkedés, gyakorlás

Most nagyon jó lenne, ha lennének a passengers táblában soraink, amikkel tudnánk tesztelni az alkalmazás működését. Ebben ugye korábban segített nekünk a Tinker és manuálisan új objektumokat hoztunk létre, amelyeket elmentettünk és ez végrehajtotta az adatbázistáblába a mentést. De milyen jó lenne, ha tudnánk ezt egy kicsit automatizálni, vagy legalábbis a tömeges feltöltést megkönnyíteni. Ezt fogjuk majd csinálni, de előbb ismerkedjünk meg az adatbázis gyárak működésével a felhasználók létrehozásán keresztül (azért ezen keresztül, mert ehhez alapértelmezetten létezik az alkalmazás projektünk már ilyen gyár). A Tinker-re most is szükségünk lesz, úgyhogy indítsuk el:

php artisan tinker

Mellette azért nézzük a projekt kódunkat is, mivel azt mondtam, hogy egy létező adatbázis gyárat fogunk használni, ezért angolosok előnyben, már kereshetik is ezt a projektünkben. database (adatbázis) könyvtárban van egy factory (gyár) könyvtár, amin belül van a UserFactory.php fájlunk és benne az osztályunk, amit fel fogunk használni felhasználók tömeges létrehozására a példa kedvéért. Itt látható a UserFactory osztály definition() metódusa, amelyben igazából csak egy gyűjtemény (tömb) van visszatérési értékként:

return [
  'name' => $this->faker->name(),
  'email' => $this->faker->unique()->safeEmail(),
  'email_verified_at' => now(),
  'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
  'remember_token' => Str::random(10),
];

Azt mondom, hogy ez a felhasználó gyár segít nekünk tömeges tesztadatokat előállítani az adatbázisban, méghozzá úgy, hogy "betartja" a mezők értékeire itt megfogalmazott szabályokat. Ehhez használja a faker-t, ami korábban egy külső csomagként használt a Laravel, de most már teljesen beépült a keretrendszer magjába (megjegyzés: mivel a Laravel nyílt forráskódú, ezért végig tudjuk járni például a metódushívások útját, ha a VSCode-ban rákattintunk a "faker"-re és megnyomjuk az F12-t). A faker-t tudjuk használni tehát tesztadatok generálására, itt a fenti felhasználós példában nevet és e-mail címet fog generálni nekünk a rendszer, de ezer más dolgot is lehet még generálni, itt van a korábbi Github projekt oldala. (Most már archív projekt, de ugyanígy működik most is, tehát az itteni példakódok felhasználhatók. A projekt fejlesztője amúgy azért hagyta abba a fejlesztést, mert már nagyon nagyra nőtt a csomagjának a mérete és a srác aggódott a klímaváltozás miatt, mivel még azok is letöltötték a projektjét, akik csak 1-1 metódust használtak belőle, így kiszámolta, hogy a projektje méretét felszorozta a letöltések számával és kiszámolta az "ökolábnyomát", ami elszörnyedt, ezért abbahagyta a projekt fejlesztését.) Mi viszont továbbra is tudjuk használni a Laravel projektjeink adatbázis gyáraiban. A nevet, e-mail címet tisztáztuk, az e_mail_verified_at mező értékét egy akkori időbélyeggel fogja ellátni, amikor meghívásra kerül a gyár működése, a password mező a "password" hash-elt változatát fogja tartalmazni, míg a "remember_token" egy véletlenszerűen generált 10 karakter hosszú szöveg lesz. De próbáljuk is ezt ki a Tinker-ben:

User::factory()->make();

Eredményül megkapjuk a User Model osztály egy példányát, a fenti szabályrendszer szerint generált mező értékekkel, de ez csak egy darab, én pedig azt ígértem, hogy bármennyit egyszerűen le tudunk gyártani... szóval nézzük meg, ehhez mit kell tennünk:

User::factory()->count(3)->make();

Mindössze annyit csináltam, hogy belefűztem a ->count(3) metódushívást és most már nem 1 hanem 3 felhasználó készült el. Kitalálhatjuk, hogy ha a 3 helyére 100-at írnék, akkor 100 felhasználó készülne el, mindössze egyetlen utasítás kiadásával! Ellenőrizzük le az adatbázisban, hogy létrejött-e ez a 1+3 = 4 darab adatsor a users táblában...

... de azt látjuk, hogy a users táblánk üres, úgyhogy itt valami nem teljesen jó még. Adjuk ki a Tinkerben ezt az utasítást:

User::factory()->create();

Szemfülesek észrevehetik, hogy most is visszakaptunk egy példa felhasználót a példa adataival, de most kaptunk még hozzá három mezőt: updated_at, created_at, id. Ezek pedig onnan lehetnek ismerősek, hogy az adattábláinkba ezek azok a mezők, amiket alapból mindig be akar szúrni a Laravel, így én élnék a feltételezéssel, hogy most már beszúrásra is került 1 sor az adattáblánkba... és valóban! Ott is van az adatsor. Ha pedig kiadjuk a "count"-os utasítást, mondjuk 100 felhasználóval és a "make" helyett "create"-et írunk bele, akkor létre fog jönni 100 újabb sor a users adattáblánkba.

User::factory()->count(100)->create();

A users táblánk ezután már 101 sort fog tartalmazni.


Adatbázis gyárak - saját gyárunk létrehozása és működtetése

Ennyi ismerkedés után, már képesek is vagyunk arra, hogy a saját gyárunkat elkészítsük. Repülőjáratom van már 3 darab, így az nekem most elég, viszont utasom még nincs egy sem, ezért a Passenger Model osztályhoz fogok készíteni egy gyárat.

php artisan make:factory PassengerFactory --model=Passenger

A biztonság kedvéért odaírtam az utasítás végére, hogy melyik Model osztályhoz tartozik, de ez egyáltalán nem lett volna kötelező. A létrejövő új fájlunkban (database/factory/PassengerFactory.php) az osztályunk már tartalmazza is a szükséges dolgokat, legfőképpen a definition() metódust. (Puskázás miatt, akár nyitva is tarthatjuk a UserFactory.php fájlt, mert nekünk is majd biztosan kell egy name mező, hiszen az utasnak is van neve, másoljuk is ezt át a visszatérési tömbbe). Az id, created_at, updated_at mezőkkel nem kell foglalkoznunk, azok automatikusan generálódnak majd. Van viszont nekünk itt egy különleges mezőnk, ami a külső kulcs flight_id mező értékei lesznek. Tesztelünk, ugyebár, és emiatt a külső kulcs mező értékeit úgy kellene meghatározni, hogy azok valóban csak olyan értékeket vehessenek fel, amilyen id-k vannak a flights táblában. (Tehát magyarul, csak olyan utasokat generáljunk teszteléshez, akik létező repülőjáratokhoz tudok rendelni, jelenleg nálam ez az 1-3-ig tartó értékkészlet a valós, így ne akarjak 80-as flight_id-val generálni utast, mivel nincs is 80-as repülőjárat.) Azt csak megjegyzésként teszem hozzá, hogy természetesen a fent linkelt Github oldalon található példák szerint könnyedén tudnánk az utasokhoz telefonszámot, e-mail címet, országot, várost, munkahelyet stb. generálni. Most viszont nekünk a külső kulcs az ami leginkább szükségeltetik.

return [
  'name' => $this->faker->name(),
  'flight_id' => Flight::inRandomOrder()->first()->id,
];

Látható, hogy itt is tudtuk használni az Eloquent osztályunkat, lekértük véletlenszerű sorrendben (inRandomOrder) a meglévő repülőjáratainkat, azok közül kiválasztottuk az elsőt (first), majd annak az id-ját helyettesítjük be értékként a létrejövő utas flight_id mezőjébe. Ennek teszteléséhez indítsuk újra a Tinker-t, hiszen a kódunkat módosítottuk. Újraindítás után adjuk ki ezt az utasítást:

Passenger::factory()->make();

Igen, ez így jó is lesz, úgyhogy a make-et lecserélhetjük create-re és belefűzhetjük azt is, hogy hány darab utast szeretnénk létrehozni, mondjuk legyen 50 utasunk első körben.

Passenger::factory()->count(50)->create();

Így be is került az 50 utas az adattáblánkba.

Ha esetleg "be akarjuk égetni", hogy a létrejövő utasok mind az 1-es repülőjárathoz tartozzanak, akkor azt is megtehetjük:

Passenger::factory()->count(4)->create(['flight_id' => 1]);

Itt a létrejövő 4 utas mindegyikét fixen az 1-es repülőjárathoz rendeltem hozzá.

Le is tudjuk kérdezni az Eloquent Model segítségével, hogy adott járatokon mely utasok utaznak. Például az első repülőjárat utasai ők:

Flight::first()->passengers;

De így túl sok felesleges információt is látok, mi van, ha én csak az utasok neveit szeretném látni?

Flight::first()->passengers->pluck('name');

A pluck() metódus van a segítségünkre abban, hogy a visszakapott passengers gyűjteményt úgy alakítsuk át, hogy minden objektumból csak adott mezőt veszek ki (name) és ezeket foglalom bele egy gyűjteménybe.

A nevek természetesen biztosan mások, mint nálatok, hiszen mindenkinél más tesztadatokat generált le a rendszer.

Visszafelé is működik az Eloquent lekérdezés: ha például a 25-ös számú utas járatát szeretném lekérdezni, akkor azt így tehetem meg:

Passenger::where('id', 25)->first()->flight;

Vagy egy kicsit másképp, még egyszerűbben: ha csak a 25-ös számú utas járatának a számát akarom lekérni, akkor megtehetem így is:

Passenger::find(25)->flight->number;


Komplex alkalmazásunk bővítése

Az itt megszerzett tudásunkat használjuk fel arra, hogy bővítjük az alkalmazásunkat és a repülőjáratok részletezésénél az utaslistát is felsoroljuk.

Kezdjük a FlightsController show metódusával, itt a visszatérési értéket (return utasítást) kell megváltoztatni és a korábbit megjegyzésbe tenni, vagy törölni:

return view('flights.show', [
  'flight' => $flight,
  'passengers' => $flight->passengers->pluck('name')
]);

Látható, hogy az utasok változóba betettük azt a gyűjteményt, amit korábban ki is próbáltunk a Tinker segítségével (visszaadta a repülőjárathoz tartozó utasok neveit). Ezután következhet a show nézet módosítása, a kapitány kiíratása után szúrjuk be a következőt:

A Blade-es @forelse direktívát használtam, ami végigmegy a kapott gyűjteményen, de ha az a gyűjtemény üres, akkor az @empty ágára fut rá, ahol kiírtam, hogy nincs utasa a repülőjáratnak.

Ennyi! Nagyon könnyen beépítettük ezt, az egyik repülőjáratom részletezése pedig itt látható, alul az utaslistával:

Vegyük észre, hogy mindehhez minimálisan kellett kódolnunk, nagyon sok mindenben segített nekünk a Laravel keretrendszer. Nincs viszont itt még vége! Ha valaki nem kapott semmilyen hibát, akkor a következő szekciót át lehet ugrani és a blogbejegyzés alján lévő gyakorlást meg lehet csinálni.

A blogbejegyzéshez tartozó git commit itt található.


Korábbi tapasztalataim szerint létező tipikus hibák

... amelyekbe szerencsére itt a Laravel 9-nél már nem futottam bele, de hátha valakinek hasznos egy kis felsorolás a lehetséges hibákról és megoldásukról, még a régebbi verziójú Laravel-ekhez:

Lehetséges hiba 1.: adatbázis migrálás során kaphatunk ilyen üzenetet: Illuminate\Database\QueryException  : SQLSTATE[42000]: Syntax error or access violation: 1071 Specified key was too long; max key length is 1000 bytes (SQL: alter table `users` add unique `users_email_unique`(`email`))

Megoldása Laravel 6-ban: Nyissuk meg az AppServiceProvider.php fájlt és a boot metódus magjához adjuk hozzá ezt:

Builder::defaultStringLength(191);

A fájl tetején pedig importáljunk a következőképpen:

use Illuminate\Database\Schema\Builder;

Ha ez megvan, utána már mennie kell a migrálásnak.

Megoldása Laravel 8-ban, nagyon hasonló, mint a 6-osnál volt, csak mást kell beszúrni az AppServiceProvider boot metódusába:

Schema::defaultStringLength(191);

És az import a fájl tetején:

use Illuminate\Support\Facades\Schema;

Lehetséges hiba 2.: ha 1062-es Duplicate entry hibát kapunk migráláskor.

Megoldása: nyissuk meg a config/database.php fájlt, keressük ki a 'connections' gyűjteménynél a 'mysql' részt és az 'engine' értékét null-ról írjuk át 'InnoDB'-re. Utána pedig hajtsuk végre a következő utasítást:

php artisan config:clear

Utána újra végezzük el a migrálást és már mennie kell.


Gyakorlás

Az eddig tanultak alapján készítsünk el egy légitársaság (airline) Model osztályt a hozzá tartozó migrációs fájllal együtt. Kössük össze külső (idegen kulccsal) a repülőjáratokkal úgy, hogy egy repülőgép társaságnak bármennyi repülőjárata lehet, és egy repülőjárat egy repülőgép társasághoz tartozik. Példa adatokkal (gyárak segítségével) töltsük fel az adattáblákat és jelenítsük meg az eredményt a weboldalunkon.

Kis segítség és tipp az induláshoz, a Model osztály létrehozásakor nem csak migrációs fájlt és Controller-t adhatunk hozzá már a legelején, hanem Factory-t is:

php artisan make:model Airline -mf

Aki már profinak érzi magát, megpróbálkozhat a repülőjárat gyár beüzemelésével...