
Validation Framework | Episodio 2
alberto • May 17, 2022
validationEccoci al secondo appuntamento. Nessuno ci credeva, nemmeno io. Ma nonostante tutto, siamo ancora qua :)
In questa seconda puntata approfondiremo quelle regole di validazione che si appoggiano al database per validare la bontà dei dati inseriti dai nostri utenti.
È opportuno conoscerle bene per poterle utilizzare al meglio, per evitare che qualche malintenzionato riesca a fare entrare schifezze nel nostro database.
Le regole sono principalmente due, ma offrono un bel set di strumenti per configurarle a nostro piacere.
Exists
Ufficialmente la regola verifica che un determinato input utente esista nel nostro database e in caso di non esistenza la validazione ritornerà esito negativo.
Il caso d'uso tipico di questa regola è la validazione dei componenti select
che inviano al server l'id di un particolare modello (spesso in relazione BelongsTo con quello principale). Prima di inserire l'id a database, è buona norma verificare che questo id realmente esista nella tabella correlata.
Nella sua forma canonica, la regola si compone di due elementi:
- il nome della tabella
- il nome della colonna da validare
Una regola di questo tipo: esists:cities,name
verifica che la seguente query ritorni almeno un risultato: SELECT count(*) FROM cities WHERE name = $parametro
.
Prima di sporcarci le mani, essendo un test che richiede la presenza di dati a database, abbiamo bisogno di lanciare setuppare il nostro test.
Creiamo quindi una semplice tabella:
public function up()
{
Schema::create('cities', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('country');
$table->timestamps();
});
}
e inseriamo qualche dato di test durante il nostro unit test:
public function setUp(): void
{
parent::setUp();
City::factory()->create([
'name' => 'Milan',
'country' => 'Italy'
]);
City::factory()->create([
'name' => 'Turin',
'country' => 'Italy'
]);
City::factory()->create([
'name' => 'Rome',
'country' => 'Italy'
]);
City::factory()->create([
'name' => 'Neaples',
'country' => 'Italy'
]);
City::factory()->create([
'name' => 'New York',
'country' => 'USA'
]);
City::factory()->create([
'name' => 'Nice',
'country' => 'France'
]);
}
Siamo quindi pronti per un primo test, verificando che Milan esista mentre Venice no:
public function test_exists_column()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => 'exists:cities,name'
]);
$this->assertFalse($validator->fails());
$validator = Validator::make([
'city' => 'Venice'
], [
'city' => 'exists:cities,name'
]);
$this->assertTrue($validator->fails());
}
Laravel ci mette a disposizione altre opzioni:
- possiamo prefissare il nome della tabella con una connessione database qualora non volessimo utilizzare quella di default (per esempio
exists:connection.cities,name
) - possiamo utilizzare il riferimento ad un modello invece che ripetere il nome della classe
Facciamo un ulteriore test per l'ultimo punto della lista:
public function test_exists_model()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => 'exists:\App\Models\City,name'
]);
$this->assertFalse($validator->fails());
$validator = Validator::make([
'city' => 'Venice'
], [
'city' => 'exists:\App\Models\City,name'
]);
$this->assertTrue($validator->fails());
}
Stesso esito del primo test: ottimo, non siamo costretti a ripetere il nome della tabella.
Personalizzazioni ulteriori su Exists
Ma non finisce qua. Dato che le personalizzazioni su una regola del genere possono essere infinite, e che tutto non puó essere mappato all'interno di una stringa di configurazione, Laravel permette anche di utilizzare una sintassi piú programmatica, utilizzando il metodo Rule::exists
.
Grazie a questa classe e alla potenza delle closure possiamo modificare la query che viene eseguita per effettuare questo controllo. Qualora volessi assicurarmi, per esempio, che la città da validare sia fisicamente locata in Italia, potrei farlo in questo modo:
public function test_exists_closure()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => Rule::exists(City::class, 'name')->where('country', 'Italy')
]);
$this->assertFalse($validator->fails());
$validator = Validator::make([
'city' => 'Nice'
], [
'city' => Rule::exists(City::class, 'name')->where('country', 'Italy')
]);
$this->assertTrue($validator->fails());
}
In questo caso, nonostante Nizza esiste a database, la validazione ritornerà un esito negativo perchè la country è differente da Italia.
Nota bene: l'oggetto ritornato da Rule::exists non è un vero query builder, quindi non possiamo invocare tutti i metodi che conosciamo. Per approfondimenti ecco il link alle api Laravel.
Unique
Unique rappresenta la controparte di Exists, ovvero è una regola di validazione che si occupa di verificare che un determinato valore non esista già a database e in caso positivo, bloccare la validazione.
Il classico caso d'uso per questa regola di validazione è l'indirizzo email degli utenti registrati in una piattaforma. Non consentendo due utenti con lo stesso indirizzo, applicare con cura questa regola è fondamentale per non avere un database compromesso.
La struttura della regola è simile a quanto visto per exists con una caratteristica in piú. Creiamo quindi un test speculare a quello visto per exists:
public function test_unique_column()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => 'unique:cities,name'
]);
$this->assertTrue($validator->fails());
$validator = Validator::make([
'city' => 'Venice'
], [
'city' => 'unique:cities,name'
]);
$this->assertFalse($validator->fails());
}
public function test_unique_model()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => 'unique:\App\Models\City,name'
]);
$this->assertTrue($validator->fails());
$validator = Validator::make([
'city' => 'Venice'
], [
'city' => 'unique:\App\Models\City,name'
]);
$this->assertFalse($validator->fails());
}
public function test_unique_closure()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => Rule::unique(City::class, 'name')->where('country', 'Italy')
]);
$this->assertTrue($validator->fails());
$validator = Validator::make([
'city' => 'Nice'
], [
'city' => Rule::unique(City::class, 'name')->where('country', 'Italy')
]);
$this->assertFalse($validator->fails());
}
Possibilità di ignorare un record
Lo scenario tipico descritto in precedenza, quello dell'indirizzo email che deve essere unico, presenta un particolare complicazione nel caso di update di un particolare modello. Questo perchè l'indirizzo email, esistendo a database, risulterebbe di fatto già utilizzato e la validazione darebbe quindi esito negativo, nonostante l'utente sia legittimato a inviare i dati al backend.
Per risolvere questa problematica, il Validation Framework di Laravel presenta la possibilità di ignorare un particolare id (e solamente lui) quando viene effettuata una verifica di univocità. Grazie all'oggetto Unique abbiamo la possibilità di invocare il metodo ignore
in questo modo:
public function test_unique_ignore()
{
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => Rule::unique(City::class, 'name')
]);
$this->assertTrue($validator->fails());
$milanId = City::firstWhere('name', 'Milan')->id;
$validator = Validator::make([
'city' => 'Milan'
], [
'city' => Rule::unique(City::class, 'name')->ignore($milanId)
]);
$this->assertFalse($validator->fails());
$validator = Validator::make([
'city' => 'Nice'
], [
'city' => Rule::unique(City::class, 'name')->ignore($milanId)
]);
$this->assertTrue($validator->fails());
}
Il primo test verifica che il validatore torna esito negativo. Il secondo test, una volta recuperato l'id del modello "Milan" (id che avremmo a disposizione in caso di update), lo passa al metodo ignore
che rende il validatore attento ad ignorare quella determinata riga in fase di check. Il terzo test verifica che, qualora l'id passato fosse di un modello diverso da quello da validare, il validatore darebbe esito negativo.
Un'idea per il riciclo del codice
Essendo Exists
e Unique
delle classi, nessuno vi vieta di estenderle creando una vostra versione da riutilizzare piú volte all'interno dello stesso progetto senza dover ripetere le clausule where
.
Per esempio si potrebbe creare una classe di questo tipo:
class ExistsForCurrentTenantWithRole extends Exists
{
public function __construct($table, $column = 'NULL', $role)
{
parent::__construct($table, $column);
$this->wheres[] = [
'column' => 'tenant_id',
'value' => TenantManager::currentTenantId()
];
$this->wheres[] = [
'column' => 'role_id',
'value' => Role::firstWhere('name', $role)->id
];
}
}
In particolare questa regola appende alle condizioni di where un attributo tenant_id
ottenuto da una Facade e un secondo attributo role_id
ottenuto facendo una query su un parametro extra passato al costruttore.
Saluti
Sebbene siano state introdotte solamente due nuove regole, l'argomento trattato è molto importante. Per migliorare la sicurezza dei nostri applicativi non bisogna dare mai nulla per scontato (se metto una tendina sono sicuro che arrivi sicuramente uno dei valori delle option) e implementare un sistema di validazione efficace è il primo passo per assicurarsi che non arrivino valori sballati a database.
Le regole analizzate Exists e Unique sono fondamentali per questo scopo. Devono essere studiate in profondità e utilizzate senza nessuna parsimonia.
PS nel solito gist trovate gli unit test di oggi.