Il problema: SQL non e uno standard unico
Sulla carta, SQL e uno standard. Nella pratica, ogni database parla il suo dialetto. MySQL usa i backtick per quotare gli identificatori, PostgreSQL le virgolette doppie, SQLite le accetta entrambe ma preferisce le virgolette. MySQL ha AUTO_INCREMENT, PostgreSQL usa SERIAL, SQLite si affida a INTEGER PRIMARY KEY AUTOINCREMENT. I booleani sono TINYINT(1) in MySQL, BOOLEAN nativo in PostgreSQL, e INTEGER in SQLite. Persino TRUNCATE TABLE non e universale — SQLite non lo supporta e serve un DELETE FROM.
Se scrivi SQL a mano, gestisci queste differenze con condizionali sparsi nel codice. Se usi un ORM maturo come Doctrine, il database abstraction layer se ne occupa. In Soft PHP MVC, il sistema Grammar risolve il problema con un pattern classico del design orientato agli oggetti: il Template Method.
AbstractSchemaGrammar: il contratto
AbstractSchemaGrammar e una classe astratta con oltre 27 metodi astratti. Ogni metodo rappresenta un'operazione SQL che varia tra i dialetti: autoIncrementPrimary(), booleanType(), quoteIdentifier(), enumType(), intervalExpression(), e cosi via. Le classi concrete — MysqlSchemaGrammar, MariadbSchemaGrammar, PgsqlSchemaGrammar, SqliteSchemaGrammar — implementano ogni metodo con la sintassi specifica del loro database.
Il vantaggio di questo approccio e che il codice che usa la Grammar non sa e non gli interessa quale database sta sotto. Chiama $grammar->booleanType('is_active') e riceve la stringa SQL corretta: `is_active` TINYINT(1) su MySQL, "is_active" BOOLEAN su PostgreSQL, "is_active" INTEGER su SQLite. Zero condizionali, zero switch-case nel codice applicativo.
Le differenze concrete tra i dialetti
Vediamo le divergenze piu significative che le Grammar gestiscono:
Auto-increment e chiavi primarie. MySQL: `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY. PostgreSQL: "id" SERIAL PRIMARY KEY (SERIAL e uno pseudo-tipo che crea una sequence). SQLite: "id" INTEGER PRIMARY KEY AUTOINCREMENT (la parola chiave AUTOINCREMENT e opzionale in SQLite, ma la includiamo per coerenza). MariaDB eredita la sintassi MySQL con AUTO_INCREMENT.
Quoting degli identificatori. Sembra un dettaglio cosmetico, ma usare il quoting sbagliato causa errori di parsing. MySQL/MariaDB: backtick `nome`. PostgreSQL/SQLite: virgolette doppie "nome". Il metodo quoteIdentifier() centralizza questa scelta, e quoteColumn() e quoteTable() lo riusano.
Tipi booleani. MySQL e MariaDB non hanno un tipo booleano nativo — usano TINYINT(1) dove 0 e false e 1 e true. PostgreSQL ha BOOLEAN nativo. SQLite salva tutto come INTEGER. Il type casting nel Model si occupa di riconvertire questi valori in bool PHP, ma a livello di schema la Grammar deve generare il tipo DDL corretto.
Tipi testo. MySQL e MariaDB distinguono tra TEXT, MEDIUMTEXT e LONGTEXT (dimensioni massime diverse). PostgreSQL e SQLite hanno solo TEXT senza limiti pratici. La Grammar mappa mediumTextType() e longTextType() al tipo appropriato per ogni driver.
ENUM. MySQL ha un tipo ENUM nativo: ENUM('draft', 'published', 'archived'). SQLite non lo supporta — la Grammar lo emula con un CHECK constraint: TEXT CHECK ("status" IN ('draft', 'published', 'archived')). PostgreSQL ha un proprio sistema di tipi ENUM personalizzati, ma per semplicita la Grammar usa lo stesso approccio CHECK.
Espressioni temporali. L'aritmetica sulle date e radicalmente diversa. MySQL: `created_at` + INTERVAL 30 DAY. SQLite: datetime("created_at", '+30 DAY') — una funzione, non un operatore. PostgreSQL supporta la sintassi INTERVAL ma con una grammatica leggermente diversa. Il metodo intervalExpression() astrae queste differenze.
MariaDB: non e solo un alias di MySQL
MariadbSchemaGrammar estende MysqlSchemaGrammar sovrascrivendo solo cio che differisce. La differenza piu rilevante e la collation di default: MySQL usa utf8mb4_general_ci, MariaDB usa utf8mb4_unicode_ci. La differenza sembra sottile, ma unicode_ci gestisce correttamente l'ordinamento di caratteri accentati e casi speciali Unicode, mentre general_ci e piu veloce ma meno accurata. Per un sito in italiano con accenti ovunque, la scelta conta.
In futuro, se MariaDB introducesse divergenze piu sostanziali nella sintassi DDL, la classe derivata permette di gestirle senza toccare la Grammar MySQL. Il pattern Open/Closed in pratica: estendi, non modificare.
La Factory: scegliere la Grammar giusta
Il DatabaseDriverFactory seleziona il driver corretto in base alla configurazione DB_DRIVER. Ogni driver porta con se la sua Grammar. Quando il sistema di migrazioni ha bisogno di generare SQL, chiede la Grammar al driver corrente — non c'e mai un punto nel codice dove si scrive if ($driver === 'mysql') per decidere come quotare una colonna.
Il Factory pattern qui si combina con il Template Method: il Factory sceglie la classe concreta, e il Template Method garantisce che ogni classe concreta implementi tutte le operazioni necessarie. Se aggiungi un nuovo database (CockroachDB, ad esempio), crei una nuova Grammar, implementi i metodi astratti, e il resto del framework funziona senza modifiche.
Dal Query Builder al SQL finale
La Grammar non e usata solo dalle migrazioni. Il Query Builder ha builder specializzati per ogni driver — MySQLBuilder, PostgresBuilder, SqliteBuilder, MariadbBuilder — che generano query DML (SELECT, INSERT, UPDATE, DELETE) nella sintassi corretta. Il metodo toSql() restituisce la query generata, utile per debug e per verificare che il builder produca esattamente cio che ti aspetti.
Il vantaggio pratico e concreto: posso sviluppare su SQLite in locale (nessun server da avviare, database in un file), eseguire i test con lo stesso SQLite, e deployare su MySQL o MariaDB in produzione. Le migrazioni generano il DDL corretto per l'ambiente, il query builder genera il DML corretto, e il type casting nel Model normalizza i tipi di ritorno. Zero differenze di comportamento tra ambienti.
Limiti e workaround
Ci sono operazioni che non tutti i driver supportano. SQLite non puo fare ALTER COLUMN — per cambiare il tipo di una colonna serve ricreare la tabella intera. La Grammar restituisce una stringa vuota per modifyColumnTypeSql() su SQLite, e il sistema di migrazioni gestisce il caso con un workaround o un warning. SQLite non supporta nemmeno DROP FOREIGN KEY, le AFTER clause per posizionare le colonne, o gli indici FULLTEXT.
Questi limiti sono documentati nei metodi stessi: supportsUnsigned(), supportsFullTextIndex(), supportsInlineIndex() restituiscono booleani che il sistema di migrazioni consulta prima di generare clausole potenzialmente non supportate. E un approccio conservativo: meglio un warning in sviluppo che un errore SQL in produzione.