Komplex példa - Validálás - 5. rész: Egyedi validációs szabályok

Attila | 2022. 08. 09. 09:23 | Olvasási idő: 5 perc

Címkék: #Érvényesítés (Validation) #Laravel #Laravel 9 #Mintaillesztés (Pattern matching) #Nézet (View) #Többnyelvűsítés (Localization) #Űrlap (Form)

A Laravel nagyon sok lehetőséget nyújt arra, hogy egyszerűen kezeljük a bemenő adatok ellenőrzését. Azonban bármikor szükségünk lehet arra, hogy saját, egyedi szabályokat definiáljunk ahhoz, hogy eldöntsük, mit fogadunk el érvényes adatnak.
custom_validation_rules

Bevezetés

Milyen egyedi szabályok képzelhetők el a komplex példánk kapcsán?

  1. Csak 18 éven felüli utast lehet létrehozni.
  2. Telefonszámnak egy speciális formátumát is ellenőrizhetjük szerver oldalon (is).
  3. ... bármi mást is kitalálhatnánk, a lényeg itt pont az, hogy egyedi szabályokat leszünk képesek megfogalmazni, aztán pedig implementálni.

Az első szabályt megmutatom, hogy hogyan kell megoldani egy korábbi módszer szerint, a 2. szabályt pedig megnézzük egy nagyon friss Laravel keretrendszer 9.18-as verziószám szerinti újítás alapján.


1. példa: csak 18 éven felüli utast lehet létrehozni

Azért először ezt mutatom meg, mert ha ezt átlátjuk, utána már sokkal könnyebben fogjuk tudni alkalmazni a 9.18-as újítást is. Kezdjük meg a munkát egy Rule (szabály) objektum létrehozásával:

php artisan make:rule LegalAgeRule

Az utasítás kiadásának hatására létre is jött egy új fájl az app / Rules mappában (a mappa is új amúgy): LegalAgeRule.php

Ha megnézzük ezt a fájlt, akkor láthatunk benne három metódust:

  1. A __construct konstruktor segítségével tudunk alapértékeket (függőségeket) definiálni vagy valamilyen middleware-t (lásd majd később) alkalmazni.
  2. A Rule osztály "magja": a passes metódus igaz (megfelelő) vagy hamis (elbukik a validáció) értékkel tér vissza, attól függően, hogy megfelel-e a beérkező adat a szabálynak. A metódus két paramétert kap meg: $attribute: ez a beérkező adat nevét szimbolizálja a $value pedig ennek az értékét.
  3. A message metódus tartalmazza a felhasználónak visszaküldött hibaüzenetet.

De térjünk is vissza a mi példánkra és próbáljuk ki az iménti dolgokat a gyakorlatban. Állítsunk be az osztályon belül egy változót, aminek majd a konstruktoron keresztül adunk kezdőértéket.

private $legalAge;

public function __construct($age)
{
  $this->legalAge = $age;
}

A passes metódussal fogjuk vizsgálni, hogy a bemenetként kapott születési dátum az régebben volt-e mint az imént beállított "legális életkor", tehát hogy a születési dátum alapján elmúlt-e mondjuk 18 éves az utas...

A dátumok kezelése általában problémás szokott lenni a programozásnál, mivel ahány nyelv és nemzet, annyiféleképpen használják és formázzák a dátumaikat. Ezért nagyon gyakori, hogy a keretrendszerek (mint a Laravel is) és a kiegészítő csomagjaik próbálnak segítséget nyújtani a dátumok kezelésében. A Carbon is egy ilyen csomag (részletes használatáról itt nézhetünk meg rengeteg példát). Importáljuk be ezt a csomagot a fájl tetején:

use Carbon\Carbon;

Majd jöhet az alkalmazása is:

public function passes($attribute, $value)
{
  $formattedValue = new Carbon($value);
  $legalAge = Carbon::now()->subYears($this->legalAge);
  return $formattedValue < $legalAge;
}

Kód értelmezése: a kapott értéket egy új Carbon objektumnak adjuk át. Utána a konstruktorban definiált legális életkornál megnézzük, hogy mi a referencia dátum (például, ha 18 év van beállítva neki és ma 2022. augusztus 10. van, akkor a referenciadátum az ebből a dátumból kivonva a 18 év lesz, tehát 2002. augusztus 10.), amit végül összehasonlítunk a felhasználótól kapott születési dátummal annak eldöntésére, hogy elmúlt-e a felhasználónk 18 éves.

Végül következhet az üzenet definiálása, amit akkor kap meg a felhasználó, ha elbukott a validáción (vagyis nincs még 18 éves):

public function message()
{
  return 'Az utasnak legalább ' . $this->legalAge . ' évesnek kell lennie!';
}

Itt egy sima szövegösszefűzéssel üzenjük meg a felhasználónak (leendő utasunknak), hogy még nem múlt el 18 éves, ezért nem utazhat velünk.

... és ennyi! Elvileg a szabály megfogalmazásával készen is vagyunk, már csak hozzá kell fűznünk az utas létrehozásának szerver oldali validációjához, ami pedig a PassengersController store() metódusában történik meg. Megjegyzés: az utasoknál már sok kliens oldali validációt csináltunk (views / passengers / create nézet), de innen a PassengerController store() metódusából még hiányzik is ez a szerver oldali validáció. Sebaj, rögtön pótoljuk ezt a hiányosságot és a kliens oldali validációnak megfelelően először definiáljuk a szerver oldali szabályokat itt:

$request->validate([
  'utasneve' => 'required|min:10|max:50',
  'age' => 'required|numeric|min:6|max:99',
  'email' => 'required|email|max:100',
  'phone' => 'required|max:11',
  'repulojarata' => 'required|integer|exists:flights,id',
]);

Definiáltuk is itt az alapvető szabályokat, magyarázni már talán nem is kell őket és most attól is eltekintek, hogy mindegyik szabályhoz saját hibaüzenetet adjak meg, hiszen azt mindenki meg tudja tenni az előző blogbejegyzésem alapján (ami viszont fontos, hogy a views / luggage / create nézetből másoljuk át a views / passengers / create nézetbe a szerver oldali hibák megjelenítését: @if ($errors->any()) résszel kezdődő elágazás). Megjegyzésként talán még újra annyit hozzáfűznék, hogy ha ezeket a szerver oldali szabályokat akarjuk tesztelni helyes működés szempontjából, akkor addig érdemes kivenni a kliens oldali ellenőrzéseket és ha meggyőződtünk a szerver oldali szabályok működéséről, akkor vissza lehet tenni a kliens oldali ellenőrzéseket is.

Ez eddig teljesen jó, de még nem használtuk fel azt a funkcionalitást, amin korábban dolgoztunk, az egyedien létrehozott legális korhatár szabály alkalmazását. A születési dátum mező viszont még hiányzik a folyamatunkból, nézzük meg, hogy mit kell csinálni hozzá:

  1. Szükség van egy új dátum típusú mezőre a passengers / create nézetben, ezzel kezdek rögtön a felsorolás után.
  2. A PassengersController store() metódusában lévő (imént bemutatott) validációs szabályt is bővíteni fogjuk, mielőtt hozzáadnánk a mezőt a Passenger::create() metódushoz paraméterként
  3. Mindehhez szükség van még egy migrációs fájlra (add_birthdate_to_passengers_table), ami a passengers adattáblában egy birthdate dátum típusú mezőt hoz létre (ezt most már nem mutatnám be részletesen, mivel már jónéhányszor csináltunk ilyet, de aki mégis bizonytalan lenne, az majd a bejegyzés végén lévő Github commit-ben meg tudja nézni, hogy hogyan kell csinálni).
  4. Végül adjuk hozzá a birthdate-et a Passenger Model osztály $fillable tömbjéhez is.

views / passengers / create.blade.php -ban helyezzük el a form-on belül. Megjegyzés: tudom, hogy van már egy életkor (age) mezőnk is, amelyek összefüggésben vannak olyan szempontból, hogy ha valaki beállítja, hogy 2000-ben született, de 50 éves kort ad meg magának, akkor az ellentmondáshoz vezet, de ilyeneket szoktak alkalmazni marketingkutatásoknál is, úgynevezett ellenőrző kérdésként.

<label for="birthdate">Születési dátum:</label>
<input type="date" name="birthdate" id="birthdate" class="form-control mt-2 mb-4" required>

A PassengersController store() metódusának releváns bővítése kiemelve a születési dátumos részekkel:

$request->validate([
  'utasneve' => 'required|min:10|max:50',
  'age' => 'required|numeric|min:6|max:99',
  'email' => 'required|email|max:100',
  'phone' => 'required|max:11',
  'repulojarata' => 'required|integer|exists:flights,id',
  'birthdate' => ['required', new LegalAgeRule(18)]
]);

Passenger::create([
  'name' => request('utasneve'),
  'flight_id' => request('repulojarata'),
  'age' => request('age'),
  'email' => request('email'),
  'phone' => request('phone'),
  'birthdate' => request('birthdate'),
]);		

Megjegyzés: Hol lehet ez az egyedi szabály létrehozás még hasznos? Például egy alkoholokkal kereskedő webshopnál rögtön az elején, amikor meg kell adni a születési dátumot, akkor csak akkor engedjük tovább a látogatót, ha ("kiszámoltuk" és) elmúlt már 18 éves. A konstruktoros értékadásnál egy sima paraméterátadással a 18-at módosíthatjuk 21-re is, ha mondjuk az USA-ban van a webshopunk célközönsége.

Természetesen importáljuk is be a fájl elején a LegalAgeRule-t, mert csak akkor fog működni. Majd jöhet egy tesztkitöltés:

És vissza is kapjuk a Mentés gomb megnyomása után hibaüzenetként az űrlap felett, hogy az utasnak legalább 18 évesnek kell lennie!

Kódújraszervezés (Refactoring)

A Laravel keretrendszer szereti, ha logikusan építjük fel az alkalmazásunkat és az építőkockákból utána mi magunk is hatékonyabban tudunk építkezni. Emiatt válasszuk szét a szerver oldalon a validációt és az eltárolási folyamatot, így a validáció utána újrafelhasználhatóvá fog válni, ha ugyanazokat a szabályokat akarjuk használni a store(...) és az update(...) metódusok esetében, ami valljuk meg, eléggé valószínű. Hozzunk létre egy PassengerStoreUpdateRequest-et:

php artisan make:request PassengerStoreUpdateRequest

Ennek az utasításnak a hatására az app / Http mappában létrejön a Requests mappa és benne az új fájlunkkal: PassengerStoreUpdateRequest.php. Ebbe fogjuk áthelyezni a validációs szabályainkat. Két alapértelmezett metódust tartalmaz, az authorize() a felhasználói engedélyekkel kapcsolatos, de mivel nekünk még nincsenek regisztrált / bejelentkezett felhasználóink, ezzel csak annyit foglalkozzunk, hogy a return után true-val térünk vissza, nem false értékkel, különben mindig 403-as HTTP hibakódot kapnánk (This action is unauthorized.). Viszont ott van a rules() metódus, amibe betesszük a validációs szabályainkat a PassengersController store() metódusából. Látható, hogy ez csak egy tömbbel tér vissza, úgyhogy ezt kell idemásolnunk majd (a LegalAgeRule importálásával együtt):

public function rules()
{
  return [
    'utasneve' => 'required|min:10|max:50',
    'age' => 'required|numeric|min:6|max:99',
    'email' => 'required|email|max:100',
    'phone' => 'required|max:11',
    'repulojarata' => 'required|integer|exists:flights,id',
    'birthdate' => ['required', new LegalAgeRule(18)]
  ];
}

Így viszont a PassengersController store() metódusából ugye kivettük a validációt, amit mégiscsak vissza kellene kötni valahogy. Így tehetjük meg:

public function store(PassengerStoreUpdateRequest $request)
{
  $validated = $request->validated();

  Passenger::create([ ...

Kiemeltem tehát a store() metódus paraméterét (amit természetesen a fájl elején importálni is kell!), valamint a 3. sorban látható validálást.

Tesztelés után láthatjuk, hogy a kódunk szerencsére ugyanúgy működik. A kódújraszervezésnek köszönhetően pedig hozzájutottunk egy olyan kódegységhez, ami könnyedén újrafelhasználható most már akár a PassengersController update() metódusában is.

Ez volt tehát a teljes példa, amit a Laravel keretrendszer 9.18-as verzióújításának köszönhetően rövidebben is meg tudtunk volna oldani, de ezt most a 2. példánkban fogjuk megtenni.


2. példa: Telefonszám speciális formátumának ellenőrzése (mintaillesztés)

A példát kétféleképpen is megoldjuk, utána mindenki eldöntheti, hogy melyik használatát tartja célravezetőbbnek, logikusabbnak, hasznosabbnak.

Egyszerűbb megoldás (de nem újrafelhasználható)

Bővítsük ki a PassengerStoreUpdateRequest osztály rules() metódusában a phone-ra vonatkozó szabályt:

'phone' => 'required|max:11|regex:/^\d{2}\/\d{3}\-\d{4}$/'

Itt az utolsó szabály a lényeges most, ami a mintaillesztésről szól (ez ugyanaz, amint a kliens oldalon a telefonszám bemeneti mező pattern attribútumában is meghatároztunk). Megjegyzés: előfordulhat a mintaillesztésnél, hogy a | karakter is fontos szerepet játszik a mintában, ilyenkor a bemeneti adathoz tartozó szabályok sorozatát inkább tömbként definiáljuk ('phone' => ['required', 'max:11', 'regex_szabály']), és ne pedig így szövegesen | karakterrel elválasztva. Ez a háttérben a PHP preg_match metódust használja, amivel rá is kanyarodok rögtön a következő alfejezetre...

Összetettebb megoldás (újrafelhasználható) 🚀 - Laravel 9.18 újítás

Hozzunk létre egy új szabályt (Rule) tartalmazó osztályt, ahogy korábban is tettük, de egy kis kiegészítéssel:

php artisan make:rule PhoneNumberFormatInvokableRule --invokable

Létre is jön az új fájl a már meglévő app / Rules mappánkban. Viszont az --invokable kapcsoló miatt ez már más szerkezettel jött létre. Nincsenek benne a __construct(), passes(), message() metódusok, mindössze egy __invoke() metódusunk van, azonban majd látni fogjuk, hogy ezzel "ki tudjuk váltani" az imént említett három másik metódust is.

  1. Maga az osztály a Rule helyett az InvokableRule interface-t fogja megvalósítani.
  2. Az __invoke() metódus paraméterként tartalmazza az $attribute és $value változókat, amik ugyanazok, mint a korábbi passes() metódus esetében, viszont ennek nem muszáj bool értékkel visszatérnie, csak akkor, ha elbukik a validációs szabályunk, akkor térjünk majd vissza false értékkel...
  3. ... de rögtön helyesbítem is magam, mert akkor se false értékkel térjünk vissza, hanem a $fail Closure paramétert felhasználva a korábbi message() metódus funkcionalitását vegyük át... és... ennyi! De ne rohanjunk előre, nézzük meg inkább a működését a gyakorlatban.

Amit az egyszerűbb megoldásnál említettem (preg_match), azt itt már expliciten mi fogjuk használni:

public function __invoke($attribute, $value, $fail)
{
  if( ! preg_match('/^\d{2}\/\d{3}\-\d{4}$/', $value)) {
    $fail('A telefonszám formátuma nem megfelelő.');
  }
}

Kódmagyarázat: egy feltételvizsgálatot tartalmaz a kód, ami azt vizsgálja, hogy a megadott minta illeszkedik-e a kapott telefonszám bemeneti adatra, ha igen, akkor nyilván nincsen baj, emiatt nekünk azzal kell foglalkoznunk, hogy mi van, ha nem passzol (ezért van a preg_match metódus előtt egy negálás), akkor futunk rá a hiba kiváltására és a hibaüzenet kiírására.

Majd a PassengerStoreUpdateRequest osztály rules() metódusában a phone-ra vonatkozó szabályt:

'phone' => ['required', 'max:11', new PhoneNumberFormatInvokableRule()],

Ezt az osztályt természetesen importáljuk is a fájl tetején.

Többnyelvűsítés

Természetesen itt is működik a többnyelvűsítés a hibaüzenetek megjelenítésénél. Nézzük is meg a példát: adjunk hozzá egy új kulcs-érték párost a lang / en / validation.php fájl visszatérési tömbjéhez:

'phone_format' => 'The phone number format is invalid.',

A PhoneNumberFormatInvokeableRule osztályban az __invoke() metódusban lévő feltételben lévő sort módosítsuk:

if( ! preg_match('/^\d{2}\/\d{3}\-\d{4}$/', $value)) {
  $fail('validation.phone_format')->translate();
}

A $fail Closure paramétere a nyelvi fájlnév (validation) után ponttal elválasztva a tömbben lévő kulcs neve (phone_format). Ezután teszteljük és próbáljuk ki, hogy a szerver oldali validáció a megfelelő (angol nyelvű) hibaüzenettel tér-e vissza, ha hibázunk a kitöltéskor a telefonszám formátum kapcsán.

Másik nyelv teszteléséhez hozzuk létre a lang / hu / validation.php fájlt és adjuk hozzá ugyanazt a kulcsot, de más (magyar) értékkel:

<?php

return [
  'phone_format' => 'A telefonszám formátuma nem megfelelő.',
];

Most elmentés után, ha azt szeretnénk, hogy a magyar hibaüzenetet írja ki, akkor a config / app.php fájlban a 'locale' kulcs értékét kell átírnunk 'hu'-ra (vagy majd vissza 'en'-re, attól függően, hogy melyiket akarjuk tesztelni), és így tudjuk tesztelgetni a többnyelvű hibaüzenet kiíratását.

A tesztelés sikeréről való megbizonyosodás után érdemes visszatenni a kliens oldali validációt, ami a telefonszám bemeneti mezőnél a pattern attribútum és értékének beállítása lesz.


Összefoglalás és továbblépés

Ebben a bejegyzésben áttekintettem az egyedi validációs szabályok alkalmazását, példákkal illusztrálva. A két bővebb példa az űrlapok leginkább problémás részeire fókuszált: egy dátum típusú mezőre és egy mintaillesztési probléma megoldására. A 2. példa összetett részében rávilágítottam egy olyan újításra is, ami a Laravel keretrendszer 9.18-as verziószámánál jelent meg. Ennek segítségével nagyon egyszerűen kezelhető az egyedi érvényesítési szabályok alkalmazása, akár többnyelvűsítéssel is.

A bejegyzéshez tartozó Github commit-ben minden részlet benne van, még az is, amit esetleg kódújraszervezéssel átstrukturáltam (ezeket nem töröltem, csak kommenteltem). Továbbá például a passengers / create nézetben a bemeneti mezők alapértékeinek (value) beállításai is megvannak, amelyekre itt már külön nem hívtam fel a figyelmet.

Továbblépésként azt gondolom, hogy érdemes lenne tesztelni a validációt automatikusan, úgyhogy a Laravel Dusk használata ismét előtérbe kerülhet.