Da mysql_* a PDO: riscrivere l’accesso al database senza riscrivere l’applicazione

Come sostituire le funzioni mysql_*, rimosse in PHP 7.0, con PDO in modo graduale: uno strato di accesso unico, la connessione configurata bene, le corrispondenze tra funzioni, le query parametrizzate e le differenze da verificare prima del rilascio.

Copertina con il logo php∞, la categoria Framework e codice custom, il titolo Da mysql_* a PDO e il numero 39

Nelle applicazioni PHP nate tra il 2000 e il 2012 l’accesso al database è quasi sempre scritto con le funzioni mysql_*: mysql_connect() in un file di configurazione, poi centinaia di mysql_query() sparse tra pagine, classi e script notturni. Queste funzioni non esistono più da PHP 7.0. Finché il server resta su PHP 5 il problema resta invisibile. Diventa il primo ostacolo appena si prova a migrare, e spesso il motivo per cui un’applicazione è rimasta ferma su una versione fuori supporto.

La buona notizia è che per sostituirle non serve riscrivere l’applicazione. In questo articolo descriviamo il metodo che usiamo per passare a PDO in modo graduale: uno strato di accesso unico, una connessione configurata bene, la corrispondenza tra le vecchie funzioni e i metodi di PDO, le query parametrizzate dove servono di più e le differenze di comportamento da verificare prima del rilascio.

Perché mysql_* è sparito

L’estensione mysql è stata deprecata in PHP 5.5 e rimossa in PHP 7.0. Il manuale di PHP indica come alternative MySQLi e PDO_MySQL. L’estensione non supportava le query preparate e da anni riceveva solo manutenzione, mentre le funzioni più recenti di MySQL arrivavano soltanto in mysqli e PDO.

Per chi gestisce l’applicazione il problema più serio è un altro. Con mysql_* l’unico modo per inserire un valore in una query era concatenarlo alla stringa SQL, dopo averlo protetto con mysql_real_escape_string(). Basta una variabile dimenticata, un valore numerico non racchiuso tra apici o un campo usato in un ORDER BY per aprire una SQL injection. Nel codice legacy queste dimenticanze sono frequenti, e la migrazione è l’occasione giusta per chiuderle.

mysqli o PDO?

Le alternative sono due, ed entrambe sono mantenute e adatte alla produzione. MySQLi ha anche un’interfaccia procedurale, con funzioni come mysqli_query() molto simili alle vecchie: la conversione sembra quasi una sostituzione di testo. In realtà l’ordine degli argomenti cambia, la connessione va passata a ogni chiamata e le query parametrizzate richiedono comunque di ripensare il codice.

PDO offre un’interfaccia unica per database diversi, segnaposto con nome come :cliente, la gestione degli errori tramite eccezioni e un oggetto risultato che si può scorrere direttamente con foreach. Per un’applicazione che deve vivere ancora molti anni lo consideriamo la scelta più solida, ed è quella che descriviamo qui. Il metodo, comunque, è lo stesso anche con mysqli.

La strategia: uno strato di accesso unico

L’errore più comune è sostituire le chiamate una per una, file per file, ciascuna con il proprio new PDO() o con il proprio modo di gestire gli errori. Il risultato è un’applicazione con dieci stili diversi di accesso ai dati. Conviene invece concentrare tutto in un unico punto, e procedere in tre passaggi:

  1. creare uno strato di accesso, un file o una classe con la connessione PDO e poche funzioni come db_query(), db_row() e db_insert_id();
  2. sostituire le chiamate mysql_* con le funzioni dello strato, mantenendo per ora le query così come sono: è un lavoro meccanico, che si verifica facilmente;
  3. parametrizzare le query una alla volta, partendo da quelle che ricevono dati dall’esterno: form, URL, cookie, file importati.

I primi due passaggi rendono l’applicazione compatibile con PHP 7 e 8. Il terzo la rende più sicura e può proseguire anche dopo il passaggio di versione. Lo strato unico ha un vantaggio in più: un solo punto dove registrare gli errori SQL, misurare le query lente e, un domani, cambiare libreria.

Diagramma in tre fasi della migrazione da mysql a PDO. Prima: pagine, classi e script chiamano direttamente mysql_query e mysql_real_escape_string, con query concatenate, e funzionano solo fino a PHP 5.6. Fase uno e due: tutto il codice chiama poche funzioni di uno strato di accesso unico, db_query, db_row e db_insert_id, che usa PDO con eccezioni e charset esplicito; le query sono ancora concatenate ma l’applicazione gira su PHP 8. Fase tre: le query che ricevono dati esterni passano ai segnaposto, a partire da login, ricerca e form pubblici
Prima la compatibilità, poi la sicurezza delle query, una alla volta.

La connessione, configurata una volta sola

Il cuore dello strato è la connessione. Queste sono le opzioni che impostiamo sempre in modo esplicito, anche quando coincidono con i valori predefiniti della versione in uso:

<?php
// db.php: unico punto di accesso al database

function db()
{
    static $pdo = null;
    if ($pdo === null) {
        $pdo = new PDO(
            // Usa lo stesso charset con cui l’applicazione si collegava prima
            'mysql:host=localhost;dbname=gestionale;charset=utf8mb4',
            'utente',
            'password',
            array(
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // errori come eccezioni
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,      // righe come array associativi
                PDO::ATTR_EMULATE_PREPARES   => false,                 // query preparate dal server
            )
        );
    }
    return $pdo;
}

// Esegue una query, con o senza parametri, e restituisce il risultato
function db_query($sql, array $parametri = array())
{
    $stmt = db()->prepare($sql);
    $stmt->execute($parametri);
    return $stmt;
}

// Prima riga del risultato, oppure false se non ci sono righe
function db_row($sql, array $parametri = array())
{
    return db_query($sql, $parametri)->fetch();
}

function db_insert_id()
{
    return db()->lastInsertId();
}

Il charset nella DSN merita attenzione. Molte applicazioni scritte con mysql_* non lo impostavano, e si collegavano con il charset predefinito del server, spesso latin1, anche quando le pagine erano in UTF-8. Se nel database ci sono dati salvati in questo modo, passare di colpo a utf8mb4 fa comparire caratteri strani al posto delle lettere accentate. Prima di scegliere, verifica le impostazioni attuali con SHOW VARIABLES LIKE 'character_set%' e controlla un campione di dati con accenti: la conversione delle tabelle è un lavoro a sé, da pianificare separatamente.

Dalle funzioni mysql_* ai metodi di PDO

Per quasi ogni funzione dell’estensione esiste un equivalente diretto. La tabella riassume le corrispondenze che servono nella maggior parte delle applicazioni:

PrimaCon PDONota
mysql_connect() e mysql_select_db()new PDO($dsn, $utente, $password)il database si indica nella DSN
mysql_query()prepare() ed execute(), oppure query()restituisce un PDOStatement
mysql_fetch_assoc()fetch(PDO::FETCH_ASSOC)restituisce false a fine risultato
mysql_fetch_row() e mysql_fetch_object()fetch(PDO::FETCH_NUM) e fetch(PDO::FETCH_OBJ)
mysql_result($r, 0)fetchColumn()prima colonna della riga successiva
mysql_num_rows()count($stmt->fetchAll()) o una query COUNT(*)rowCount() non è affidabile per le SELECT
mysql_affected_rows()rowCount()per INSERT, UPDATE e DELETE
mysql_insert_id()lastInsertId()restituisce una stringa
mysql_real_escape_string()segnaposto ? o :nomequote() solo dove non si può fare altrimenti
mysql_error()PDOExceptioncon ERRMODE_EXCEPTION
Fonte: manuale di PHP, pagine di PDO e di PDOStatement.

Il caso di mysql_num_rows() è il più delicato. Il manuale di PDOStatement::rowCount() avverte che per le SELECT il comportamento dipende dal driver e non va considerato affidabile. Se il codice conta le righe per poi leggerle, conviene leggerle tutte con fetchAll() e contare l’array; se serve solo il numero, una query SELECT COUNT(*) è più efficiente.

Query parametrizzate, dove serve di più

Una query parametrizzata invia al database il testo SQL e i valori separatamente. Il valore non viene mai interpretato come codice SQL, qualunque carattere contenga, e la protezione non dipende dalla memoria di chi scrive il codice. Il passaggio, per ogni query, è di questo tipo:

<?php
// Prima: valore concatenato, protetto a mano
$id  = mysql_real_escape_string($_GET['cliente']);
$res = mysql_query("SELECT * FROM ordini WHERE cliente_id = '$id'");
while ($riga = mysql_fetch_assoc($res)) {
    // ...
}

// Dopo: segnaposto, il valore viaggia separato dalla query
$stmt = db_query('SELECT * FROM ordini WHERE cliente_id = ?', array($_GET['cliente']));
foreach ($stmt as $riga) {
    // ...
}

// LIKE: i caratteri jolly vanno nel valore, non nella query
$stmt = db_query('SELECT * FROM prodotti WHERE nome LIKE ?', array('%' . $cerca . '%'));

// IN: un segnaposto per ogni valore (se l’elenco è vuoto, salta la query)
$segnaposto = implode(',', array_fill(0, count($ids), '?'));
$stmt = db_query("SELECT * FROM prodotti WHERE id IN ($segnaposto)", array_values($ids));

// LIMIT: valori interi dichiarati come tali
$stmt = db()->prepare('SELECT * FROM news ORDER BY data DESC LIMIT ?');
$stmt->bindValue(1, (int) $quante, PDO::PARAM_INT);
$stmt->execute();

I segnaposto sostituiscono solo valori. Nomi di tabelle e colonne, direzioni di ordinamento come ASC e DESC e parole chiave SQL non si possono passare come parametri: vanno scelti da un elenco di valori ammessi scritto nel codice, mai copiati dalla richiesta. È proprio il caso dei parametri ordina= nelle tabelle dei pannelli di amministrazione, una delle fonti di SQL injection più diffuse nel codice legacy.

Confronto tra query concatenata e query parametrizzata con lo stesso valore malevolo ricevuto dal form, la stringa apice OR apice 1 apice uguale apice 1. Nella query concatenata il valore diventa parte del testo SQL, la condizione WHERE diventa sempre vera e la query restituisce tutte le righe della tabella. Nella query parametrizzata il testo SQL con il punto interrogativo e il valore arrivano al database separati, il valore viene confrontato come semplice testo e la query non restituisce nessuna riga
Con i segnaposto il valore resta un dato, qualunque cosa contenga.

Le differenze di comportamento da verificare

Anche con una corrispondenza precisa tra funzioni, alcune differenze emergono solo durante i test. Sono queste quelle che incontriamo più spesso:

  • errori: mysql_query() restituiva false e il codice spesso non controllava. Con le eccezioni una query sbagliata interrompe la pagina, e molte query già rotte da anni vengono finalmente alla luce. Il gestore degli errori deve registrarle senza mostrare i dettagli all’utente;
  • tipi dei valori: con le preparazioni native, e da PHP 8.1 anche con quelle emulate, interi e decimali arrivano come numeri e non più come stringhe. I confronti con === e le serializzazioni JSON possono cambiare risultato;
  • segnaposto ripetuti: senza emulazione lo stesso segnaposto con nome non si può usare due volte nella stessa query. Servono nomi diversi, come :da e :a;
  • connessione implicita: le funzioni mysql_* usavano in automatico l’ultima connessione aperta. Il codice che apriva due connessioni verso database diversi va reso esplicito.

Il passaggio a PDO si somma alle altre incompatibilità della migrazione, che descriviamo nell’articolo sulle funzioni rimosse in PHP 8. Per trovare in anticipo tutte le chiamate da convertire, e le query che concatenano variabili, sono utili gli strumenti di analisi statica.

Test e rilascio graduale

Lo strato unico permette di separare il lavoro in rilasci piccoli. Le funzioni dello strato funzionano anche su PHP 5.6, con PDO già disponibile, quindi la sostituzione delle chiamate può andare in produzione prima del cambio di versione, sulla piattaforma attuale. Così, quando arriva il momento di passare a PHP 8, il database è già un problema risolto.

  • registra nello strato ogni query fallita, con il file di provenienza, e controlla il log ogni giorno nelle prime settimane;
  • prepara un elenco dei percorsi critici, come login, ricerca, ordine, fatturazione ed esportazioni, e provali su una copia recente del database;
  • confronta il risultato di report ed esportazioni prima e dopo la modifica: totali, numero di righe, caratteri accentati;
  • tieni pronto il rollback del codice, che con lo strato unico è un singolo file o poco più.

Quando l’applicazione è grande e la produzione gira su un PHP fuori supporto, questo lavoro richiede settimane o mesi. Nel frattempo i server vanno protetti: è il caso tipico in cui affianchiamo alla migrazione il backport delle patch di sicurezza. Se vuoi una stima sulla tua applicazione, scrivici indicando versione di PHP e dimensioni del codice.

In sintesi

Le funzioni mysql_*, deprecate in PHP 5.5 e rimosse in PHP 7.0, sono il primo ostacolo di quasi ogni migrazione di codice legacy. Per sostituirle basta concentrare l’accesso ai dati in uno strato unico basato su PDO, con eccezioni, charset esplicito e preparazioni native, sostituire le chiamate in modo meccanico e poi parametrizzare le query partendo da quelle che ricevono dati dall’esterno. Le corrispondenze tra funzioni sono quasi tutte dirette, con l’eccezione di mysql_num_rows(); le differenze da verificare riguardano errori, tipi dei valori, charset e connessioni implicite. Lavorando per passaggi piccoli, la parte sul database può andare in produzione ancora prima del cambio di versione di PHP.

IL PROSSIMO PASSO

Mettiamo
in sicurezza.

Indicaci versione di PHP, sistema operativo e cosa ti preoccupa. Ti risponderemo con una valutazione dell’esposizione e una proposta di intervento.

Richiedi supporto

Ti porta al modulo di richiesta, in quattro passi.

PHP LEGACY · MANAGED SERVER S.R.L.info@phplegacy.it +39 02 5656 9681

Lunedì – venerdì · 09:30 – 19:30

Urgenza in corso? Reperibilità H24
+39 340 985 4556 ↗