Komplex példa 2. rész - Adatkapcsolatok 1.

Attila | 2022. 03. 17. 14:47 | Olvasási idő: 5 perc

Címkék: #Adatbázis (Database) #Blade #CRUD #Eloquent #Laravel #Laravel 9 #MySQL #Nézet (View) #Query Builder #Tinker

Folytatjuk a komplex példánk feldolgozását és ezen keresztül végigvesszük a Laravel Eloquent ORM adatkapcsolatait. A példák kapcsán mindig rávilágítok majd arra is, hogy a háttérben, vagyis az adatbázisban mi történik igazából. A legegyszerűbb adatkapcsolattal fogjuk kezdeni és haladunk majd előre a bonyolultabbak felé.
Laravel_1-1_kapcsolat

Ahogy említettem, Eloquent kapcsolatokról fogok írni, de fontos észben tartani, hogy ez a háttérben adattáblák és azok sorainak kapcsolatait takarják. Akiknek van adatbázis tudása, azoknak bizonyos kapcsolattípusok ismerősek lesznek, de fontosnak tartom, hogy végighaladjunk minden egyes kapcsolattípuson úgy, hogy egyből gyakorlati példát is nézünk rá és beépítjük a komplex alkalmazásunkba.


1-1-es (egy-az-egyes) kapcsolat

A legegyszerűbb adatkapcsolat két Model osztály és ezáltal két adattábla között, az 1-1 kapcsolat. Ami annyit tesz, hogy mindkét oldalról 1-1 szereplő fog részt venni a kapcsolatban. Ha a megértést segítendő valós életbeli példát szeretnék mondani, akkor biztos azzal kezdeném, hogy minden egyes (14+) embernek van egy darab személyazonosító igazolványa és fordítva is igaz ez, mert egy ilyen igazolvány egy személyhez tartozik. Vagy egy másik példa (hogy ha hazánkat nézzük), akkor egy férjnek egy felesége lehet egy házasságban, és fordítva. Ha ezt a terminológiát átültetjük a mi komplex feladatunkra, akkor azt mondhatnánk, hogy minden egyes repülőgép járatnak van egy konkrét kapitánya és adott pillanatban egy pilóta maximum egy repülőjáraton lehet kapitány (a feladat helyes értelmezése miatt éljünk azzal a feltételezéssel, hogy az adott kapitány más járaton csak sima pilóta lehet).

Hozzunk létre egy kapitányokat eltároló Model osztályt, a migrációs fájljával együtt:

php artisan make:model Captain -m

De még mielőtt hozzányúlnánk a "kapitány dolgaihoz", előtte bővítsük ki a Flight Model osztályunkat egy metódussal:

public function captain()
{
  return $this->hasOne(Captain::class);
}

Ezzel a metódussal fogjuk majd lekérni az adott repülőjárathoz (Flight osztály példányához) tartozó kapitányt (Captain osztály példányát). Ahhoz, hogy ezt meg tudjuk tenni, az adatbázisban meg kell lennie a captains táblának, ami a migrációs fájljával fog létrejönni. Adjuk hozzá a kapitány nevét és egy idegen kulcs (illetve egy annak megfelelő típusú és nevű!) mezőt az [időbélyeg]_create_captains_table.php osztályához (az id után a timestamps elé szúrjuk be ezt a két sort a create() metóduson belül:

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

Kiegészítés: itt még nem hozzuk létre a kapcsolatot a migrációs fájl segítségével, csak egy olyan mezőt hozunk létre, ami típusában és nevében alkalmas lesz arra, hogy idegen kulcsként tudjon működni majd a jövőben, de maga az idegen kulcs hivatkozás itt még nem jön létre.

Egy kicsit ismertetném, hogy mi is az az idegen kulcs: ez gyakorlatilag egy olyan mező, amin keresztül kapcsolatot tudunk teremteni egy másik adattábla (jelen esetben: 1-1) adott sorával annak id mezőjén keresztül.

Ahogy azt már tudjuk, a Laravel-ben vannak névkonvenciók, szabályok, amelyeket itt is érdemes betartani ahhoz, hogy a rendszer úgy működjön, ahogy azt mi elvárjuk. Kezdjük az idegen kulcsnak szánt mező típusával: unsignedBigInteger, ez azt jelenti, hogy előjel nélküli (tehát nem negatív) egész számokat lesz képes eltárolni ez a mező az adattáblájában. Miért fontos ez? Azért, mert az adatkapcsolathoz szükséges mező típusának meg kell egyeznie annak a mezőnek a típusával, amire hivatkozik majd. Amikor pedig a migrációs fájlokban kiadjuk ezt az utasítást: $table->id(); akkor ez gyakorlatilag azt jelenti, hogy létre fog jönni egy id nevű mező, aminek a típusa unsignedBigInteger (azon túl persze, hogy ez egy elsődleges kulcs lesz, ami egyértelműen azonosít minden egyes sort az adattáblájában). Tehát a hivatkozó (captains tábla flight_id) mező és a hivatkozott (flights tábla id) mező típusainak meg kell egyeznie, ez elvárás... ha nem így lenne, hibát adna a Laravel tényleges idegen kulcs létrehozása esetén. A másik névkonvenció a hivatkozó flight_id mező neve: itt az aláhúzás előtti részben kisbetűvel kell beírni a hivatkozott Model nevét (vagy ha úgy tetszik, az adattábla nevét angolul egyes számban): flight, az aláhúzás után pedig azt, hogy annak melyik mezőjére hivatkozunk (id). Ez utóbbi olyan konvenció, amelyet bár felülírhatnánk önkényesen, de ha csoportban dolgozunk, akkor miért is akarnánk "becsapni" a társunkat azzal, hogy mi a Laravel-től eltérő névkonvenciót alkalmazunk az idegen kulcs mezőnév megadására...? Ne tegyünk logikátlan dolgokat, mert az csak bonyodalmakat szülne.

Mentés után mehet a migrálás:

php artisan migrate

Migrálás után ellenőrizhetjük a captains táblánk szerkezetét: MySQL Workbench segítségével az adatbázisunkban a tábla neve fölé tudjuk vinni az egeret és válasszuk a sor jobb szélén a csavarkulcs ikont, ekkor mutatni fogja a mezőket és láthatjuk, hogy létrejött a flight_id BIGINT típusú mező (amik ki vannak pipálva: NOT NULL, nem lehet ilyen érték nélkül beszúrni sort a táblába, Unsigned, vagyis előjel nélküli módosítókkal).

Utána alul ki tudjuk választani a Foreign Keys (Idegen Kulcsok) lapfület és láthatjuk, hogy nincs még idegen kulcs definiálva a captains táblához, ahogy azt vártuk is:

Teszteljük az alkalmazásunkat: először adatfeltöltésre lesz szükségünk, de szerencsére a járatok táblában vannak már adataink, így most csak kapitány(oka)t kell feltöltenünk. Ehhez a már a korábban alkalmazott Tinker lesz a segítségünkre, indítás után már írhatjuk is a kódsorokat:

php artisan tinker

$firstCaptain = new App\Models\Captain;
$firstCaptain->name = 'Attila';
$firstCaptain->flight_id = 2;
$firstCaptain->save();

Nálam három sor van a flights adattáblában, 1, 2, 3 id azonosítókkal. Létrehoztam az Attila nevű kapitányt, akit a flight_id-n keresztül hozzárendeltem a 2-es azonosítójú repülőjárathoz. Ha ezután kiadom a következő utasítást:

$secFlight = App\Models\Flight::where('id', 2)->first();

Akkor megkapom a 2-es adatsort, vagyis a hozzá tartozó Flight Model osztály objektumát. Utána pedig az Eloquent kapcsolat (Flight Model osztály captain() metódusa segítségével) lekérhetem a hozzá kapcsolódó kapitányt:

$secFlight->captain;

Még csak nem is metódusként hívom, hanem mezőként és működni fog, az eredménye alább látszódik. A "mögötte meghúzódó" SQL utasítás pedig ez: SELECT * FROM captains WHERE flight_id = 2;

De folytathatjuk ezt a példát és lekérhetjük a repülőgépjárat objektumán keresztül a hozzá tartozó kapitány nevét is:

$secFlight->captain->name;

Ilyen egyszerű, és visszakapjuk, hogy Attila. A háttérben lefutó SQL utasítás: SELECT name FROM captains WHERE flight_id = 2;


Működne visszafelé is? Lekérhetnénk "simán" (módosítások nélkül) a kapitány repülőjáratát is?

Az Eloquent kapcsolatok, mint ahogy a relációs adatbázis kapcsolatok is többoldalúak, többszereplősek (leggyakrabban kétszereplősek). Emiatt a Captain Model osztályunkban is hasonlóan hozzuk létre a kapcsolatért felelős metódust:

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

Így most már nem csak a repülőjárat kapitányát, hanem a kapitány repülőjáratát is le tudjuk kérni. Mivel ez egy programkódbeli módosítás, indítsuk újra a Tinker-t, hiszen az a programkódunk korábbi aktuális állapotával kommunikál, ez pedig egy új változtatás. Egyetlen kapitányom van, annak akarom lekérni a repülőjáratát:

App\Models\Captain::first()->flight;

De hibát kapok erre, mivel a rendszer hiányolja a flights tábla captain_id mezőjét, majd ki is írja, hogy milyen SQL utasítást szeretne futtatni, amire hibát kapott vissza. A hiány jelzése teljesen jogos, mivel tényleg nincsen a flights táblában captain_id mező... vegyük észre, hogy a Laravel be szeretné tartani a névkonvenciót és egyből azt a mezőnevet keresi az SQL utasításban, ami utal a másik táblára és annak id mezőjére. Hogyan oldjuk ezt meg? Ne, ne akarjunk beletúrni a flights "create table"-szerű migrációs fájljába és rollback-elni vissza addig a pontig a változtatásokat, majd újra migrálni az adatbázisban, mert ebben az esetben elveszítjük a flights tábla (és az addig vissza "rollback-elt" táblák) adatait... Hozzunk létre inkább egy új migrációs fájlt, ami csak ezt a mezőbővítést tartalmazza: (Megjegyzés: a migration fájl létrehozásánál be tudjuk állítani a --table kapcsolót, hogy melyik táblához szeretnénk a bővítést csinálni, de miért lenne erre szükség, ha már a névkonvenció betartásával a Laravel ki tudja "találni", hogy melyik táblához akarunk hozzányúlni...?)

php artisan make:migration add_captain_id_to_flights_table

Az így létrejövő migrációs fájl egyből tartalmazza a (nem create!) table metódusának első paraméterében a 'flights' táblanevet. Az up metódusban lévő table metódusba ezt írjuk:

$table->unsignedBigInteger('captain_id')->after('arrived_at');

Az after itt csak azt jelenti, hogy melyik mező után szeretnénk beszúrni az új captain_id mezőnket. Enélkül az utolsó helyre kerülne be.

A down metódusban lévő table metódusba pedig ezt írjuk:

$table->dropColumn('captain_id');

Így a migrálás (és az esetleges későbbi rollback) is megfelelően tud működni ezen fájl esetén. Futtassuk is a migrálást:

php artisan migrate

Ellenőrzésként rápillanthatunk, hogy valóban hozzáadta-e a megfelelő helyre a Laravel a flights tábla új mezőjét és szerencsére nálam igen.

Ha most le szeretnénk kérni a 2-es id-jú repülőjárat kapitányát, még mindig null-t kapnánk eredményül, mivel az adattáblában a captain_id mezőben mindhárom soromban 0 szerepel, ilyen azonosítójú (id-val) rendelkező kapitány pedig nincsen.

Megjegyzés még az adatbázis részhez: az 1-1-es kapcsolatok bár elég ritkák a valós életbeli problémák megoldása során. Miért? Mert ilyenkor a "kapcsolat társ" elemei egy-az-egyben beépíthetők a kapcsolódó táblába. De adatbázis szempontból az az előnye, hogy megismerkedjünk a kapcsolat létrehozásával magával (idegen kulcson keresztül) és így a kapcsolatban lévő táblák közül bármelyikbe elhelyezhetjük a másik táblára vonatkozó idegen kulcsot. Nincs megkötés arra vonatkozólag, hogy melyikben kell szerepelnie, de nyilván, ahogy láttuk is, akkor annak megfelelően kell kialakítanunk a kódjainkat, hogy melyik "oldalon" szerepel az idegen kulcs.

Tipp: talán az Eloquent ORM utasítások mögött megbúvó SQL utasításokat nehezebb átlátni, de ehhez van egy segítségünk a Tinker-ben:

DB::enableQueryLog();
Flight::latest()->get();
DB::getQueryLog();

Ha kiadjuk az első utasítást, akkor azzal engedélyezzük, hogy onnantól kezdve naplózza a rendszer az adatbázis lekérdezéseket. Majd a második utasításban (és utána még bármennyi utasításban) végezhetünk adatbázissal kapcsolatos műveleteket, de itt most csak egyet csinálunk. Ez az egy kilistázza a flights adattáblában lévő 3 repülőjáratomat. Majd végül lekérjük a naplózás tartalmát, ami tartalmazza az itteni egyetlen adatbázis lekérés SQL utasítását, a lefutás idejét millisecundum-ban, illetve ha lett volna valamilyen adatkötés, akkor még azt is (például csak az aktív repülőjáratokat kértük volna le, akkor az a "bindings" részbe kerülne be, próbáljuk majd ki!):

Ha több Eloquent utasítást végeztünk volna el a napló lekérdezése előtt, akkor ez a gyűjtemény több elemet tartalmazna.


Utóbbi probléma megoldása esetén mit tudna a Query Builder?

Ebben a blogbejegyzésben említettem, hogy a Laravel-ben kétféle módon tudunk az adatbázisból adatokat lekérdezni, az egyik az Eloquent, a másik a Query Builder. Az előbb láthattuk, hogy az Eloquent milyen dolgokat követel meg (új külső kulcsos mező és annak értékbeállítása az adattáblában, új metódus a Flight Model osztályban stb.), vagyis igényel a helyes működéshez. Ezzel szemben a Query Builder-rel akkor is működne az összekapcsolás, ha nem hoztuk volna létre a zárójelben imént felsorolt elemeket. De hogyan? Hát nagyjából így (próbáljuk ki Tinker-ben):

DB::table('flights')
  ->join('captains', 'flights.id', '=', 'captains.flight_id')
  ->select('number', 'flights.name AS flight', 'captains.name AS captain')
  ->first();

Ez ugye sokkal "SQL-szerűbb", és itt két tábla összekapcsolása simán megy a join metódushívással, amiben megadom, hogy minek mivel kell egyeznie, majd a select-ben kiválaszthatom, hogy melyik mezők értékeire lesz szükségem és már vissza is kapok egy objektumot, aminek le tudom kérni a number, flight vagy akár a captain nevű mezőjét a továbbiakban.


Megjegyzés: a fenti kimásolható szövegben csak a jobb átláthatóság miatt írtam több sorba a lekérdezést, de igazából mindez írható lenne egy sorba is, ahogy a képen látszódik.

Mivel itt most nekem csak 1 kapitányom van a táblában és az 1 repülőjárathoz van rendelve, ezért csak ezt az 1 eredményt kaphattam. Ha ugyanezt a fenti utasítást a végén nem "first"-tel, hanem "get"-tel kértem volna le, akkor egy gyűjteményt kapnék, benne egyetlen objektummal.

Ha több sor szerepelne a captains táblában (több kapitányunk lenne) és össze lennének kötve egy-egy másik repülőjárattal, akkor egy where() metódushívást is "bele kellene fűzni" az utasításba, mondjuk ha az 1-es kapitány járatára vagyunk kíváncsiak, akkor: ->where('captains.id', 1)

"Adatbázisos szemmel" én úgy gondolom, hogy ezt a Query Builder-t könnyebb átlátni, de ha mégis bizonytalanok lennénk, akkor az utolsó metódus hozzáfűzése lehet ez: ->toSql() és akkor a tényleges SQL utasítást is megkapjuk hozzá.

Mindez persze nem fog meggátolni minket abban, hogy az Eloquent további kapcsolattípusait megismerjük majd...


Jelenítsük meg a kapcsolat lekérdezésének eredményét

A Tinker segítségével ugye programozottan megszólítjuk a Laravel alkalmazásunkat, úgyhogy miért ne tehetnénk meg ezt akár úgy, hogy a meglévő Controller-ünkben lekérjük a kapitányt, majd a nevét megjelenítenénk a nézeteinkben?

A FlightsController index() metódusával kezdem. Ez az, ami kilistázza az összes járatot, de csak a járatok számát jeleníti meg a nézetben. Nagyon könnyen meg tudjuk tenni, hogy a lekért gyűjteményben lévő Flight objektumok magukba foglalják a hozzájuk kapcsolódó Captain objektumokat. A megoldást tesztelhetjük Tinker-ben is, ha még bizonytalanok lennénk, de a kiindulási utasításom a már a FlightsController index metódusában lévő utasítás volt:

Flight::latest()->with('Captain')->get();

Ez működik, úgyhogy bővíthetjük is a with() metódushívással az index()-ben lévő adatátadásunkat. Majd következhet a resources/views/flights/index.blade.php -ben a bővítés. Szúrjuk be ezt a járat számának kiírása után:

Így csak annál a jártnál kerül kiírásra a kapitány neve, ahol ténylegesen beállítottam kapitányt a kapcsolaton keresztül. Eredménye:

A FlightsController show($id) metódusában lévő Eloquent utasítást is az előzőhöz hasonlóan bővíthetem így:

$flight = Flight::with('Captain')->findOrFail($id);

Míg a show.blade.php-ba ugyanazt a Blade-es @if utasításhármast kell beszúrni, amit az imént is mutattam. Így az eredmény ilyen lehet a 2-es id-jú repülőjáratom esetén:

A változások git commit-ja itt található.


Gyakorlás

Gyakorlásképpen mindenki megcsinálhatja, hogy létrehoz még néhány repülőjáratot és kapitányt a Tinker-ben az Eloquent Model-ek segítségével. Beállít néhány kapcsolatot. Majd létrehoz egy új menüpontot az oldalon "Kapitányok" névvel, hasonlóan a "Repülőjáratok" mintájára, és beállítja ehhez az útvonalakat, CaptainsController-t és metódusait, valamint a hozzájuk kapcsolódó nézet fájlokat. Mindez könnyedén megvalósítható, ha valaki alkalmazza az eddig megszerzett tudásunkat.

Jó munkát!