Komplex példa 4. rész - Több-több adatkapcsolat

Attila | 2022. 03. 25. 23:18 | Olvasási idő: 4 perc

Címkék: #Adatbázis (Database) #Adatgyár (Factory) #CRUD #Eloquent #Laravel #Laravel 9 #Tinker

Ebben a bejegyzésben áttekintjük a több-többes adatbáziskapcsolatot. Példaként gondoljunk csak a diákokra és a tantárgyakra. Egy diák több tantárgyat is felvehet egy félévben, míg egy tantárgyhoz több diák is tartozhat egy félévben... ez tehát egy több-többes kapcsolat tipikus esete. Mi meg fogjuk vizsgálni Laravel-es környezetben ezt a kapcsolattípust úgy, hogy folytatjuk a komplex példánk bővítését, miközben új dolgokat tanulunk meg közben. Légitársaságok és a telephelyként szolgáló városok között fogunk ilyenfajta kapcsolatot teremteni, majd meg is jelenítjük őket a weboldalunkon. Ehhez szükségünk lesz majd egy kapcsolótáblára, amihez a kulcsfüggvényünk a belongsToMany lesz.
laravel-many-to-many-relationship

A városokhoz tartozó City Model létrehozását így végezzük el:

php artisan make:model City -mf

Egyből a migrációs fájlját és a gyárát is létrehozzuk. A migrációs fájlban adjunk hozzá az ott meglévőkhöz egy új oszlopot (name), és ugye szeretnénk összekapcsolni az Airline és City Model osztályokat, de hogyan is kellene ezt? Ha a cities táblában hoznánk létre egy airline_id idegen kulcsot, akkor minden város csak egy légitársasághoz tartozhatna, ami ugye nem túl valószerű. Ugyanez fordított esetben: ha az airlines táblában hoznánk létre egy city_id oszlopot, akkor az azt jelentené, hogy csak egy városban működne, illetve tevékenykedne a légitársaság, ami ugye megintcsak nem reális... akkor tehát adódik a kérdés, hogy hol és hogyan is valósítsuk meg ezt a kapcsolatot? Egy kapcsolótáblát fogunk ehhez használni. Adatbázis szempontjából ez az az új tábla, amely az összekapcsolni kívánt táblákra hivatkozó külső kulcsokat fog tartalmazni és így valósítható meg a több-többes kapcsolat.

Ezt az új kapcsolótáblát definiáljuk ugyanebben a City migrációs fájlban és annak up() metódusában, természetesen úgy, hogy betartjuk az ilyenkor szokásos névkonvenciókat:

  • A tábla neve a kapcsolatban lévő másik két táblából adódjon össze egy aláhúzással elválasztva: airline_city
  • Mindkét tábla neve legyen egyes számban.
  • A táblák sorrendje legyen ABC sorrendben (airline előrébb van mint a city).

Nézzük meg, hogy mire kell figyelni a létrehozás magjában:

  • Az id és timestamps mezők megmaradhatnak, hiszen a kapcsolatok létrehozásának idejéről és módosulásáról ezek szolgálhatnak majd információval a későbbiekben.
  • Biztosan szükség lesz két külső kulcs mezőre: airline_id és city_id. Ezek ugye a nevükből is látható táblákra fognak hivatkozni. Ezekhez pedig a külső kulcs hivatkozást is hozzuk létre úgy, ahogy például az előző gyakorló bejegyzésemben tettem meg.
  • Viszont ez még így nem lesz elég, mert meg kell adni egy olyan feltételt is, hogy az adott értékekkel rendelkező airline_id és city_id csak egyszer fordulhasson elő. A példa szerint: a 1-es azonosítójú Malév céget csak egyszer lehessen összekötni (a még nem létező azonosítójú) Budapest várossal.

Így néz ki tehát a City migrációs fájljának up() metódusa:

public function up()
{
  Schema::create('cities', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
  });

  Schema::create('airline_city', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('airline_id');
    $table->unsignedBigInteger('city_id');
    $table->timestamps();
    $table->foreign('airline_id')->references('id')->on('airlines')->onDelete('cascade')->onUpdate('cascade');
    $table->foreign('city_id')->references('id')->on('cities')->onDelete('cascade')->onUpdate('cascade');

    $table->unique(['airline_id', 'city_id']);
  });
}

A migrációs fájl down() metódusában fordított sorrendben "dobjuk el" (töröljük) a táblákat, mint ahogy az up()-ban létrehoztuk őket:

Schema::dropIfExists('airline_city');
Schema::dropIfExists('cities');

Végrehajthatjuk a migrálást:

php artisan migrate

Vegyük észre, hogy csak egy PHP fájl került migrálásra, de abban ugye két táblát is definiáltunk, amelyek létre is jöttek az adatbázisunkban.

Bővítsük az Airline Model osztályunkat:

public function cities()
{
  return $this->belongsToMany(City::class);
}

Bővítsük a City Model osztályunkat:

protected $fillable = ['name'];

public function airlines()
{
  return $this->belongsToMany(Airline::class);
}

Itt nem csak a kapcsolatot biztosító metódust adtam hozzá, hanem a tömeges adatfeltöltést engedélyező $fillable mező értékét is beállítottam. Úgyhogy most folytathatjuk is a CityFactory osztályunkkal, állítsuk be a következőt a definition gyűjteményben:

return [
  'name' => $this->faker->city()
];

Tinker megnyitása után hozzunk is létre néhány várost példaként, mondjuk 5 darabot:

City::factory()->count(5)->create();


Kapcsolatok hozzáadása, lekérdezése, törlése

Megjegyzés: az itt kiadott utasítások eredményét mindig ellenőrizhetjük mondjuk a MySQL Workbench-ben, ha mindig lekérjük az airline_city tábla aktuális sorait.

Nálam van tehát most 12 légitársaság (mivel a repülőjáratok gyárát úgy állítottam be az előző bejegyzésben, hogy minden új repülőjárat létrejöttekor jöjjön létre hozzá egy új légitársaság is) és van 5 városom, amiket az imént hoztam létre. Ezeket kellene összerendelni az airline_city kapcsolótábla segítségével. De hogyan tudunk az Eloquent segítségével új sorokat (kapcsolatokat) hozzáadni a kapcsolótáblánkhoz? Ehhez a Model osztályokban definiált kapcsolat metódusokat (airlines(), cities()) és az attach() metódust fogjuk alkalmazni.

City::find(5)->airlines()->attach(1)
City::find(4)->airlines()->attach([1,2,3])
City::find(5)->airlines()->attach(1)
$airlines = App\Models\Airline::findMany([7,8,9,10])
City::first()->airlines()->attach($airlines)

Az első sor végrehajtásával az 5-ös városhoz hozzárendelem az 1-es légitársaságot. A második sor megmutatja azt, hogy több elemet is tudok egyszerre összekapcsolni, itt például a 4-es városhoz hozzárendelem az 1, 2 és 3 azonosítójú légitársaságot. Végül a harmadik sorban újra megpróbálom az 5-ös városhoz hozzárendelni újra az 1-es légitársaságot, de szerencsére ez nem működik (integritási hiba), hiszen ilyen kapcsolatunk már volt és a rendszer jogosan mondja nekünk azt, hogy ilyet már újra nem hozhatunk (ne is hozzunk) létre. A negyedik sorban rászűrök a 7,8,9,10 légitársaságokra, majd az ötödik sorban hozzárendelem azokat az 1-es városhoz. A kapcsolatok hozzáadása tehát működik. Igény szerint próbálgathatjuk és "bármennyi" egyéb kapcsolatot hozzáadhatunk, jelen esetben ugye maximálisan annyi kapcsolatot tudunk létrehozni, amennyi a [városok száma * légitársaságok száma] = nálam 5 * 12 = 60 darab kapcsolat, jelen esetben és akkor minden városban lesz telephelyen minden légitársaságnak a feladat leírása és értelmezése szerint.

Nézzük meg a lekérdezést, amire ugye "kétirányból" van lehetőségünk.

City::first()->airlines
Airline::first()->cities

Ezek a korábban már megismert módon működnek. Itt például lekértük az 1-es városhoz tartozó légitársaságokat, és meg is kaptuk a 7,8,9,10-es légitársaságokat eredményül. A második sorban az 1-es légitársasághoz tartozó városokat kérdeztem le és eredményül megkaptam a 4,5-ös városokat.

Kapcsolat törléséhez a detach() metódust tudjuk segítségül hívni, ami nagyon hasonlóan működik, mint az imént bemutatott attach, csak fordítva (ez nem létrehozza a kapcsolatokat a kapcsolótáblában, hanem törli őket):

City::first()->airlines()->detach([8,10])
Airline::first()->cities()->detach(4)

Az első sorban az 1-es város légitársaságai közül töröltem a 8-ashoz és a 10-es való kapcsolódást. A második sorban pedig az 1-es légitársaságnál töröltem a kapcsolódást a 4-es városhoz.

Szinkronizálás: sync, syncWithoutDeatching (Fejes Dávid jelzése nyomán) és a toggle segédmetódusok

A kapcsolatokat szinkronizálni is tudjuk. De mit is jelent ez? (Megjegyzés: szükség esetén ürítsük ki az airline_city kapcsolótáblánkat, hogy azokat az eredményeket láthassuk, amiket én is itt jelzek.) Az itteni szekció címében látható segédmetódusokat fogjuk használni. Próbáljuk ki a következő utasításokat:

Airline::find(8)->cities()->sync([1,3,7])

Az utasítás az 8-as légitársasághoz hozzárendelni az 1., 3. és 7. városokat. Ebben eddig semmi meglepő nincs, ezt is vártuk tőle. Eredményben láthatjuk, hogy az attach gyűjteménybe bekerült az 1,3,7 számsor. Viszont ha most ezekután kiadjuk ezt az utasítást:

Airline::find(8)->cities()->sync([2,6,11])


Akkor itt láthatjuk, hogy a 2, 6, 11-es városok hozzáfűzésre kerültek a 8-as légitársasághoz (létrejöttek a kapcsolatok), viszont megszüntette a kapcsolatot az 1, 3, 7-es városokkal. Ezt nem biztos, hogy így akartuk, inkább lehet, hogy mi csak hozzáfűzni szerettünk volna, leválasztás nélkül, ekkor használható ez a parancs:

Airline::find(8)->cities()->syncWithoutDetaching([1,3,7])

Ennek hatására visszakerül a kapcsolatok közé a 8-as légitársasághoz az 1, 3, 7-es városok. Ez ugye azért is hasznos nekünk, mert így elkerülhetjük azt a "hibaüzenetet", amit akkor kaptunk, amikor már egy létező kapcsolatot szerettünk volna újra felvenni. Itt most ezzel nem lesz probléma, a rendszer egyszerűen csak tudomásul veszi, hogy ennek a kapcsolatnak léteznie kell és nem adódik ebből hiba.

Eljátszadozhatunk ezekkel a mi komplex alkalmazásunkban, de mindig figyeljük, hogy milyen eredményt jelez vissza számunkra a tinker, vagy milyen adatok is kerültek be a kapcsolótáblába. Ha már ezeket (sync, syncWithoutDetaching) megnéztük, akkor esetleg a toggle metódust is kipróbálhatjuk, amely megnézi, hogy az újonnan hozzáadandó kapcsolat az már létezett-e, ha létezett, akkor törli, ha nem létezett még korábban, akkor pedig most létrehozza:

Airline::find(8)->cities()->toggle([2,6,9])

Itt láthatjuk, hogy a 2, 6 leválasztásra kerültek a kapcsolatok közül, míg a 9-es új városként hozzáadódott a 8-as repülőjárat telephelyeihez.


Kapcsolatok megjelenítése a weboldalon

Jelen állapotban még nem látszódnak a légitársaságok és a városok a weboldalunkon. Hozzuk létre a hozzájuk kapcsolódó (1) útvonalakat, (2) Controller-eket és metódusokat, (3) nézeteket. Kezdjük az új útvonalakkal a routes/web.php-ban...

Route::get('/airlines/{airline}',[App\Http\Controllers\AirlinesController::class, 'show']);
Route::get('/airlines',[App\Http\Controllers\AirlinesController::class, 'index']);

Route::get('/cities/{city}',[App\Http\Controllers\CitiesController::class, 'show']);
Route::get('/cities',[App\Http\Controllers\CitiesController::class, 'index']);

Itt most már mindkét esetben egyértelműen törekszem arra, hogy a megfelelő Controller kezelje le az útvonal kérések kiszolgálását, aztán pedig majd a nézeteket is egy csoportba fogom szervezni, de előtte hozzuk létre a két itt látható Controller osztályt:

php artisan make:controller AirlinesController
php artisan make:controller CitiesController

Mindkettőhöz jó alap lehet a FlightsController osztályunk. Kezdjük az AirlinesController-rel:

public function show($id)
{
  $airline = Airline::findOrFail($id);
  return view('airlines.show', [
    'airline' => $airline,
    'cities' => $airline->cities->pluck('name')
  ]);
}

public function index()
{
  return view('airlines.index', [
    'airlines' => Airline::orderBy('name')->get()
  ]);
}

Folytassuk a CitiesController-rel:

public function show($id)
{
  $city = City::findOrFail($id);
  return view('cities.show', [
    'city' => $city,
    'airlines' => $city->airlines->pluck('name')
  ]);
}

public function index()
{
  return view('cities.index', [
    'cities' => City::orderBy('name')->get()
  ]);
}

Ezután következhet a nézet mappák létrehozása a resources/views mappában: airlines és cities névvel. Mindkét új mappába pedig létrehozom az index.blade.php és a show.blade.php nézeteket. Kezdjük az airlines/index.blade.php -val, amelyhez jó alapot biztosít a flights/index.blade.php

Ezt lemásolva, folytathatjuk a cities/index.blade.php -val:

De hogy ezeket ki tudjuk próbálni, a layout.blade.php fájlunkban a menünket bővíteni kell két új menüponttal:

Az eredmény szerencsére tökéletesen megfelelő:

A másik esetében is:

A részletező (show) oldalakra pedig tegyük be az adott légitársaság és város kapcsolatait is. A resources/views/flights mappában lévő show.blade.php megint jó példa lesz a kiindulásra, ehhez hasonlóan készítsük el az airlines/show.blade.php fájl tartalmát:

És a cities/show.blade.php tartalmát:

Az eredmény mindkét esetben megfelelően fog látszódni az oldalakon.

És a "társnézete":

A blogbejegyzésben elvégzett módosítások ezen a Github linken érhetők el.

A mostani blogbejegyzésben megtanultuk, hogy milyen az a több-többes adatbáziskapcsolat és megnéztük, hogy hogyan lehet kialakítani ezt a szerkezetet a Laravel-ben Model osztályok, migrációs fájlok, Controller-ek és nézetek segítségével.