Riguardo la progettazione di un ORM
Se lavorate con PHP, probabilmente avete usato strumenti come Eloquent o Doctrine fino alla nausea. Sono fantastici, ci salvano la vita ogni giorno. Ma vi siete mai chiesti cosa succede esattamente "sotto il cofano" quando scrivete User::find(1)?
Spesso usiamo questi framework come scatole nere, fidandoci ciecamente. Ma per noi sviluppatori, capire come funzionano le cose è fondamentale (e anche divertente). In questa serie di articoli, voglio portarvi con me nel dietro le quinte di Concrete, un ORM che ho scritto da zero.
Cerchiamo di analizzare insieme il perché di certe scelte, i compromessi che ho dovuto accettare e come i concetti che leggiamo nei libri (SOLID, Design Patterns) si applicano (o si ignorano!) nel mondo reale.
Il problema
Il primo muro contro cui andiamo a sbattere quando scriviamo un'app è sempre lui: l'Impedance Mismatch. È quel termine tecnico che indica la differenza di paradigma tra i due mondi: il mondo degli oggetti (fatto di classi, ereditarietà e proprietà) e il mondo dei dati (fatto di tabelle, righe e vincoli). Infatti da una parte abbiamo i nostri oggetti PHP, dall'altra le tabelle relazionali nel database. Parlano due lingue diverse e l'ORM dovrà svolgere il ruolo di traduttore simultaneo.
Per questo progetto, mi sono dato tre obiettivi semplici:
- Non sposare nessun database: Oggi uso MySQL, domani voglio passare a SQLite per i test senza riscrivere mezza riga di codice.
- Active Record: Gli oggetti devono essere "intelligenti". Ad esempio un'istanza della classe
Userdeve sapere come salvare se stessa o eliminarsi dal database, senza dover passare per manager esterni ogni volta. - Ma soprattutto: Deve essere facile! Se devo leggere la documentazione per fare una query semplice, ho fallito.
L'architettura
Per non legarmi mani e piedi a un singolo database, andiamo a creare dei livelli di separazione. Questi sono delle astrazioni che in qualche modo isolano il nostro codice da dettagli specifici del database. Immaginatela così:
- Livello Connessione: E' il contatto fisico con il DB.
- Livello Driver: E' il "traduttore" che sa come parla quello specifico database. Quindi, idealmente, esisteranno più driver, uno per ogni database.
- Livello ORM: E' l'interfaccia che vediamo e che usiamo nel nostro codice.
Il livello Driver
Per il livello driver (il "traduttore"), useremo il Pattern Strategy. In parole povere, questo pattern ci permette di definire una famiglia di algoritmi, incapsularli e renderli intercambiabili. Nel nostro caso, gli "algoritmi" sono i diversi modi di interagire con i database (MySQL, SQLite, ecc.), permettendo al resto dell'applicazione di ignorare quale tecnologia stia effettivamente girando sotto il cofano.
In effetti ogni database ha le proprie caratteristiche: MySQL usa i backticks ` per i nomi delle colonne, PostgreSQL usa le virgolette doppie, e le funzioni SQL variano leggermente.
In pratica, andremo a definire un contratto, un'interfaccia DriverInterface.
Attenzione però: qui è dove molti ORM amatoriali cadono nella trappola della SQL Injection. Se il nostro driver si limitasse a restituire una stringa SQL con i valori concatenati dentro (es. "... WHERE id = " . $id), basterebbe che un utente malintenzionato inserisse ' OR '1'='1 per bucare il nostro database.
Per questo motivo, il nostro Driver non deve restituire solo l'SQL, ma deve separare la struttura della query dai dati. Useremo i Prepared Statements (o Parameter Binding). Il driver genererà una stringa SQL con dei "segnaposto" (come ? o :value), e restituirà i valori reali separatamente. Sarà poi PDO a unirli in modo sicuro.
interface DriverInterface
{
public function connect(string $host, string $db, string $user, string $pass): \PDO;
/**
* Compila una Select e ritorna una struttura contenente SQL e Bindings.
* @return array{0: string, 1: array} Esempio: ['SELECT * FROM users WHERE id = ?', [1]]
*/
public function compileSelect(string $table, array $columns, array $wheres): array;
public function compileUpdate(string $table, array $values, array $wheres): array;
}
Usando questo approccio, se domani voglio aggiungere il supporto per SQL Server, creo semplicemente un SqlServerDriver. Lui saprà che per i limiti e gli offset deve usare una sintassi diversa rispetto a MySQL, ma alla fine restituirà sempre un array pulito: [SQL string, Parameters array]. Il core dell'ORM rimarrà al sicuro e all'oscuro di tutto.
Il livello Connessione
Per gestire la connessione (Database.php), useremo un Singleton (o quasi), accessibile staticamente.
class Database
{
private static ?\PDO $pdo = null;
public static function init(DriverInterface $driver, /* ... */)
{
// ... inizializzazione ...
}
public static function getConnection(): \PDO
{
return self::$pdo;
}
}
Questa implmenetazione potrebbe far storcere il naso ai puristi dell'architettura. Sui libri infatti c'è scritto che lo stato statico globale è il male assoluto. Rende i test più difficili e nasconde le dipendenze. Ma allora perché lo stiamo facendo?
In realtà stiamo implementando un pattern chiamato Facade. Immaginate la Facade come la reception di un grande hotel: voi (il codice client) parlate solo con il receptionist (la classe statica) per avere la chiave della camera, senza dovervi preoccupare di come funziona il sistema elettrico, idraulico o di prenotazione dell'albergo che sta dietro.
Tecnicamente, in un ORM Active Record (come quello che stiamo costruendo), vogliamo poter scrivere $user->save(). Se non avessimo un accesso statico al DB, dovremmo inizializzare ogni singolo modello passandogli la connessione: new User($dbConnection). Un incubo da scrivere ogni volta.
Sotto a tutto questo, useremo PDO perché fa già metà del lavoro sporco di astrazione per noi. Ci offre un'interfaccia unificata per preparare ed eseguire le query, indipendentemente dal driver sottostante. Reinventare la ruota qui sarebbe stato inutile.
Il livello ORM
Dopo aver stabilito l'architettura di connessione e quella di traduzione, ci concentriamo ora sul cuore della libreria. Parliamo quindi dei due concetti chiave per utilizzare l'ORM nei nostri progetti, ovvero:
- Il Query Builder, responsabile della costruzione programmatica delle query SQL.
- L'Active Record, che mappa le righe del database in oggetti.
Il Query Builder
L'obiettivo del Query Builder è fornire un'astrazione orientata agli oggetti per la sintassi SQL. Invece di manipolare stringhe, lo sviluppatore manipola oggetti. Questo significa che, anzichè scrivere $builder->query('select * from users where name like %foo%'), possiamo idealmente scrivere $builder->select('users')->where('name', 'like', '%foo%').
Questo design pattern si chiama Fluent Interface (interfaccia fluida) e permette di scrivere codice più leggibile e comprensibile. Ora lo vedremo, ma viene solitamente implementato facendo in modo che ogni metodo ritorni l'oggetto stesso, permettendo di concatenare i metodi.
Analizziamo l'implementazione di un metodo (where) del query builder:
public function where(string $column, string $operator, $value): self
{
$this->wheres[] = ['type' => 'basic', 'column' => $column, 'operator' => $operator, 'value' => $value];
return $this; // Ritorno l'istanza stessa per permettere il chaining
}
Ogni metodo muta lo stato interno dell'oggetto e come si può vedere, restituisce $this. Questo permette di scrivere:
$builder->where('age', '>', 18)->orderBy('name')->take(5);
Dal punto di vista della State Machine, l'oggetto
Builderaccumula transizioni di stato fino a quando non viene invocato un metodo terminale (comeget()ofirst()), che innesca la compilazione e l'esecuzione.
L'uso dei Traits
Quando su progetta una classe molto complessa, come può essere un Builder, ci si imbatte spesso in un problema: il numero di righe e la complessità della classe stessa. E' facile infatti raggiungere un punto in cui non si riesce più a capire quale metodo fa cosa e quali sono le dipendenze tra i vari metodi. Infatti un builder SQL deve gestire SELECT, UPDATE, DELETE, JOIN, WHERE, ORDER BY, GROUP BY, ecc. e mettere tutto in un'unica classe porterebbe a creare una scatola nera immensa e ingestibile.
In questi casi il PHP ci viene in aiuto con i Traits, che anzichè farci creare una gerarchia complessa di ereditarietà (come vorrebbe la programmazione ad oggetti), permette di comporre le classi in orizzontale aggiungendo semplicemente delle funzionalità.
class Builder
{
use Filterable; // Logica WHERE
use Orderable; // Logica ORDER BY
use Joinable; // Logica JOIN
// ...
}
Questo approccio rispetta il principio di Segregazione delle Interfacce (la "I" di SOLID) a livello di implementazione. Ogni trait incapsula una responsabilità specifica, rendendo il codice più modulare e testabile. Se volessimo aggiungere il supporto per le clausole HAVING, creeremmo un trait Havingable (non è il nome migliore, lo so) senza dover "inquinare" la logica della classe esistente.
Active Record
Il pattern Active Record (descritto da Martin Fowler in Patterns of Enterprise Application Architecture) è un approccio in cui un oggetto "wrappra" una riga di una tabella del database, incapsulando l'accesso ai dati e aggiungendo logica di dominio su quei dati. Così facendo, il modello diventa un vero e proprio "oggetto di dominio" capace di eseguire in autonomia operazioni sulle proprie proprietà, come ad esempio la validazione, la gestione degli eventi, la logica di business, ecc.
Idratazione degli Oggetti
L'idratazione è il processo mediante il quale l'ORM trasforma i risultati "grezzi" di una query SQL (solitamente array associativi restituiti dal driver del database) in oggetti ricchi di significato. È una delle fasi più delicate e computazionalmente costose perché non si limita a un semplice passaggio di valori, ma comporta diverse operazioni:
- Mapping dei tipi: Converte le stringhe restituite dal database nei tipi corretti (es. stringhe in oggetti
DateTime, interi o booleani). - Assegnazione delle proprietà: Mappa le colonne della tabella (es.
created_at) sulle proprietà corrispondenti della classe (es.$createdAt), spesso gestendo la visibilitàprotectedoprivatetramite Reflection. - Gestione dello stato: L'oggetto viene marcato come "esistente" (non più dirty), permettendo all'ORM di sapere se un successivo
save()debba generare unaINSERTo unaUPDATE.
Senza questo passaggio, lavoreremmo con semplici array; grazie all'idratazione, ogni riga del database diventa un'entità capace di esporre metodi e logica di business.
// Ciclo tutte le righe provenienti dal database e creo oggetti in $results
while ($row = $stmt->fetch()) {
$obj = new $this->modelClass();
$obj->hydrate($row);
$results[] = $obj;
}
Nel metodo hydrate dovremo popolare un array $attributes con i dati correnti provenienti dal database, inoltre terremo una seconda copia di questi dati in $original per poterli confrontare successivamente.
Ok, detto così sembra uno spreco di memoria, ma in realtà è un'ottimizzazione che riduce di parecchio il carico di lavoro del database.
Dirty Checking e Ottimizzazione delle Query
Un errore che ho visto analizzando alcuni ORM amatoriali è eseguire l'UPDATE di tutte le colonne ogni volta che si salva un oggetto, anche se è cambiato solo un campo.
Immaginate una tabella con 50 colonne e indici pesanti. Fare un update completo quando è cambiata solo l'email è uno spreco di risorse I/O e computazionali per il database.
Per risolvere questo, implementiamo il Dirty Checking. Quando viene chiamato il metodo save() del record, l'ORM confronta lo stato attuale ($attributes) con lo stato iniziale ($original) e genera una query UPDATE che modifica solo le colonne effettivamente cambiate.
public function getDirty(): array
{
$dirty = [];
foreach ($this->attributes as $key => $value) {
// Verifica se la chiave esiste nell'originale e se il valore è diverso
if (!array_key_exists($key, $this->original) || $this->original[$key] !== $value) {
$dirty[$key] = $value;
}
}
return $dirty;
}
Se $dirty è vuoto, il metodo save() ritorna immediatamente senza eseguire alcuna query SQL. Se non è vuoto, genera una query UPDATE che modifica solo le colonne effettivamente cambiate. Questo riduce il carico di parsing del database e, in alcuni engine, riduce il locking delle righe o la frammentazione degli indici.
Gestione delle Chiavi Primarie
Un sistema robusto non può assumere che la Primary Key (PK) di una tabella sia sempre un singolo intero auto-incrementante. In contesti reali, specialmente in database legacy o architetture enterprise, ci si scontra spesso con chiavi naturali o chiavi composite (composte da più colonne).
Un ORM, anche se basilare, deve essere flessibile: deve permettere di definire quale sia la colonna (o le colonne) che identificano univocamente il record e gestire la ricerca in modo polimorfico. Se passiamo un singolo valore, l'ORM interrogherà la colonna primaria standard; se passiamo un array, dovrà essere in grado di costruire dinamicamente una clausola WHERE multi-colonna.
Nel nostro Model potremmo definire qualcosa di simile a:
public const PRIMARY_KEY = 'id'; // Default, ma sovrascrivibile
Nel metodo find($id), quindi, eseguiremo un controllo di tipo (Type Inspection) sull'input. Se $id è un array, il sistema dedurrà che si tratta di una ricerca su chiave composita e adatterà dinamicamente la clausola WHERE.
if (is_array($id)) {
foreach ($pks as $pk) {
$query->where(static::col($pk), '=', $id[$pk]);
}
}
Conclusioni
Abbiamo appena grattato la superficie di cosa significhi costruire un ORM da zero. Ci sono decine di altri aspetti che non abbiamo toccato: le relazioni (HasOne, HasMany), l'Eager Loading per evitare il problema N+1, la gestione delle transazioni e gli eventi del ciclo di vita dei modelli.
Vi invito a dare un'occhiata al repository di Concrete su GitHub, lì potrete trovare il codice completo dell'ORM che abbiamo provato a progettare in questo articolo e la documentazione completa.
Il codice è open source e ogni contributo, che sia una Pull Request per un nuovo driver, una correzione di bug o anche solo un feedback sulla documentazione, è assolutamente benvenuto. Costruire strumenti da zero è il modo migliore per imparare, ma migliorarli insieme è ciò che ci fa progredire.