Il contenuto di questo articolo è una traduzione dell’articolo di Erland Sommarskog trovabile qui.
1.Introduzione
E’ molto comune dover scrivere query dove la ricerca viene eseguita secondo un diverso numero di criteri diversi a seconda dei parametri in ingresso. Quando bisogna implementare tali funzionalità è necessario porre attenzione a due cose principalmente:
- Produrre i risultati corretti
- Buone performance
Il punto principale è che non c’è un singolo piano di esecuzione che è buono per tutti i criteri di ricerca, anzi tendenzialmente vorrei che il query plan sia differente in base all’input dell’utente. Ci sono due modi per ottenere questo: il primo è scrivere una query statica e aggiungere il campo OPTION (RECOMPILE) che obbliga l’SQL Server a compilare la query ogni volta che viene lanciata; il secondo è creare costruire la query dinamicamente in base all’input dell’utente.
2.Query statiche
2.1 Usare gli IF
Assumiamo per esempio di avere un campo di ricerca di un utente in cui questo può cercare secondo varie condizioni di ricerca, oppure secondo condizioni mutuamente esclusive (posso cercare per nome utente o per id o per codice). Per questo esempio molto semplice, obbligare il server a ricompilare ogni volta può risultare pesante, sopratutto se le ricerche sono frequenti. la costruzione dinamica della query allo stesso modo rischia di essere troppo complicata. La soluzione migliore e leggera è usare delle clausole IF nel seguente modo:
Se volessi ritornare dei dati a partire da altre tabelle è consigliabile non ripetere il JOIN all’interno di ogni IF
, ma piuttosto creare una tabella temporanea ed inserire i risultati della query per poi eseguire un solo join in un secondo momento sulla tabella temporanea.
2.2 Usare gli OR
Come alternativa alla proliferazione di clausole IF
, possiamo inserire tutto in una stessa query utilizzando gli OR
in AND
con AND @var IS NOT NULL
2.3 Caso complesso
Analizziamo ora un caso più complesso, cioè il caso di una proccedura con molti termini di ricerca.
Assumiamo di avere una classica tabella Orders
, Customers
e Products
.
Una prima implementazione di questa SP è la seguente:
I parametri in ingresso di questa SP sono tutte le modalità di ricerca che può fare l’utente, dall’@orderid
, al @fromdate
fino al @minprice
e così via.
La search_orders
così fatta non è complicatissima: ogni condizione può essere implementata usando una singolo comando >
, <
o LIKE
. In applicazioni reali spesso esistono vincoli più complessi:
- L’utente può scegliere l’ordinamento di output
- In base ai parametri in ingresso devo poter accedere a tabelle e colonne differenti.
- Gli utenti possono anche poter scegliere gli operatori di paragone, come per esempio
@country = 'Germany'
o@country != 'Germany'
.
2.4 OPTION (RECOMPILE)
Scrivere query statiche può risultare comodo per questi motivi
- Finchè le condizioni di ricerca sono moderatamente complesse, il codice ottenuto è compatto e facile da mantenere;
- visto che la query è compilata ogni volta, ottengo un query plan ottimizzato esattamente per le condizioni di ricerca in input;
- Non devo preoccuparmi sui permessi: funziona esattamente coma una SP, l’utente può non avere permessi di modifica di tabelle.
Anche se bisogna porre attenzione alle seguenti problematiche:
- Quando i requisiti crescono in complessità, la complessità della query cresce in maniera non lineare;
- Se la chiamata viene eseguita numerose volte, la continua ricompilazione della SP può sovraccaricare il server.
La tecnica base è la seguente
L’effetto di tutti i @x IS NULL
è che se un parametro in ingresso è NULL, allora la sua condizione in AND fornisce sempre TRUE, conseguentemente non viene applicato nessun filtro.
Quindi gli unici filtri che hanno effetto sono quelli che hanno parametro in ingresso non nullo.
Vi è una grande differenza di performance con o senza l’ultima linea:
Senza questa istruzione infatti, SQL Server produce un piano di esecuzione che verrà messo un cache e riusato all’occorrenza, conseguentemente tale piano deve funzionare con tutti i possibili parametri in ingresso, quindi generalmente lento.
Se invece aggiungiamo l’OPTION (RECOMPILE)
SQL Server esegue una ricompilazione della query ad ogni chiamata in base ai parametri in ingresso: per esempio chiamando la procedura nel seguente modo:
SQL Server ottimizzeràla clausola WHERE
in questo modo:
Successivamente SQL Server rimuoverà tutte le clausole NULL IS NULL dalla query ottenendo una clausola WHERE ottimizzata per la richiesta.
2.5 Il problema del COALESCE
Invece di utilizzare l’OR
come indicato sopra, alcune persone preferiscono usare il comando COALESCE
o ISNULL
nel seguente modo:
COALESCE()
è una funzione che prende una lista di valori come parametro e fornisce il primo valore non nullo, oppure NULL
se tutti i parametri in ingresso sono NULL
.
Conseguentemente, se @orderid
è NULL
, allora il filtro sarà o.OrderID = o.OrderID
che è analogo a scrivere 1=1
.
Il codice così scritto è più compatto e ancora più chiaro ma porta ad un problema di non poco conto: se la colonna in questione contiene un valore NULL
e il parametro in ingresso è NULL
allora il comando
diventa
Ma in SQL NULL non è uguale a NULL. NULL indica “valore sconosciuto” e il suo paragone con NULL non porta ne a true ne a false, ma a unknown. Conseguentemente nessuna riga viene ritornata. Per evitare questa cosa è possibile scrivere
ma è assolutamente poco intuitivo. La morale della favola è evitare di usare il COALESCE per eseguire questi controlli.
2.6 Parametri in ingresso stringhe separate da virgola
Aggiungiamo due nuovi parametri in ingresso che non avevamo trattato prima per semplicità che hanno lo stesso scopo: fornire solo gli ordini per dei specifici employees.
@employeestr
: stringa separata da virgola con indicati gli IDs degli employees@employeetbl
: parametro table valued (vedi documentazione)
Per aggiungere questi filtri nella clausola WHERE
il modo più semplice è il seguente:
dove intlist_to_tbl
è una funzione che trasforma una lista separata da virgola in una tabella e @hasemptbl
è una variabile locale BIT
che indica se la variabile @employeetbl
ha record o meno.
Vi è una differenza fondamentale tra questi parametri e i normali parametri scalari: mentre per i parametri scalari l’ottimizzatore conosce esattamente la loro esistenza e il loro valore, per questi parametri così non è: delle variabili di tabella conosce il numero di righe, mentre della stringa separata da virgole non conosce assolutamente nulla.
Un modo per risolvere questo problema è l’idea che spesso l’utente vuole cercare fino a due o tre valori alla volta. Quindi, se ci sono fino a 4 elementi nella lista, la procedura utilizza il comando IN, altrimenti utilizza una variabile di tabella.
La procudura avrà quindi il seguente codice all’inizio:
Questo metodo, se @employeestr
è valorizzato, esegue l’unpack della lista e la inserisce in una variabile di tabella con le righe numerate. Se ci sono 4 o meno righe allora popoliamo le variabili da @emp1
a @emp4
altrimenti settiamo la variabile booleana @hasemptbl
a 1.
Le ultime due righe della clausola WHERE saranno quindi
Usando questo metodo la query risulta estremamente più veloce, sopratutto nel caso in cui vengano forniti meno di 4 IDs.
2.8 Scelta dell’ordinamento
Se vogliamo dare all’utente la possibilità di scegliere l’ordinamento, questo può essere facilmente eseguito con query SQL statiche. Qeusto è il pattern base da utilizzare:
Ho un parametro in ingresso che mi dice la colonna per cui ordinare e poi utilizzo il CASE per selezionare tale variabile.
La cosa importante da osservare è che tutti i rami del CASE devo avere lo stesso tipo di dato. In questo caso ho tre rami in quanto voglio eseguire tre ordinamenti di tipo diverso: int
, string
e datetime
.
Mescolando invece i tipi di dato, per le regole di precedenza di SQL server, otterrei errori di conversione.
Se utilizziamo l’opzione OPTION (RECOMPILE)
il comkpilatore è in grado di ridurre l’ORDER BY
ad una singola colonna in base al parametro in ingresso.
Se inoltre voglio gestire l’ordinamento ascendente e discendente, devo duplicare ogni cosa.
Usare questo metodo porta alla crescita della procedura in maniera non lineare. Conseguentemente usare l’SQL statica va bene solo se consideriamo un solo possibile ordinamento e se tutte i tipi di ordinamento possibili hanno lo stesso tipo. In caso contrario conviene procede in maniera diversa con una SQL dinamica o gestendo l’ordinamento lato client.
2.9 Tabelle opzionali
Talvolta voglio poter accedere ad una determinata tabella solo ad una determinata condizione.
Aggiungiamo un nuovo parametro @suppl_country
alla nostra procedura che indica che la nostra procedura fornirà informazioni solo sui prodotti il cui fornitore viene da un determinato stato.
Il modo migliore per implementare questo, invece che andare in JOIN sulla tabella Suppliers
possiamo utilizzare la clausola EXISTS in questo modo:
Se non trovo la @suppl_country l’EXISTS mi fornirà 0 e quindi il risultato verrà filtrato, altrimenti procederà. In questo modo filtro i risultati solo per lo stato che ho inserito in ingresso, inoltre usando questo trucco è possibile verificare che l’SQL Server è capace di evitare di accedere a Suppliers
anche senza l’OPTION(RECOMPILE)
2.10 Tabelle alternative
Un ulteriore scenario potrebbe essere quello di dover leggere da tabelle differenti in base ad un determinato parametro.
Per esempio, assumiamo che esista un parametro @ishistoric
che, se impostato a 1, va in join con la tabella HistoricOrders
, altrimenti da HistoricOrderDetails
.
Il modo più corretto per eseguire questa cosa è il seguente:
Ho rimosso il JOIN
esplicito tra Orders
e OrderDetails
con una tabella derivata che è un UNION ALL tra due query di JOIN in cui entrmabe hanno una clausola WHERE sulla variabile booleana in ingresso.
Usando OPTION(RECOMPILE)
solo un insieme di tabelle ordini verrà acceduto a run-time.
E’ facile notare come la query ha delle ripetizioni, la lista in SELECT
e le condizioni di JOIN
. Più le tabelle alternative aumentano, più conviene utilizzare una costruzione SQL dinamica rispetto a questa statica.
3. Costruzione dinamica della query
A differenza delle query statiche, le query dinamiche sono uno strumento molto potente ma rischiano di creare codice non mantenibile e difficile da comprendere. E’ necessario conseguentemente utilizzarle con cautela. I vantaggi principali delle SQL dinamiche sono:
- Sono più flessibili: la complessità della query cresce linearmente con la complessità dei requisiti
- I query plan sono messi in cache dalla stringa di query, quindi i criteri di ricerca crescenti non causeranno inutili ricompilazioni Gli svantaggi invece:
- Poca disciplina nella scritture del codice porta questo ad essere difficile da mantenere
- Introducono un ulteriore livello di difficoltà dall’inizio
- Il test è molto più difficile
3.1 Gestire i parametri ingresso
Ci sono due modi per gestire i parametri in ingresso inviati dall’utente:
- Concatenarli alla query string (
' AND col = ' + convert(varchar, @value)'
) - Usare query parametriche usando i parametri in ingresso di
sp_executesql
(' AND col = @value'
)
Nel 99.9% dei casi è estremamente consigliato usare query parametriche con sp_executesql
3.2 Costruzione della query
Dichiariamo la variabile @sql
che conterrà la mia query string.
Di seguito dichiariamo il nucleo della SQL dinamica che deve essere la query (funzionante) da lanciare quanto non viene passato alcun parametro in ingresso.
Per le tabelle usare sempre la notazione schema.nome_tabella
, per ragioni di performance.
La condizione WHERE 1 = 1
alla fine permette di appende tutte le altre condizini come AND qualcosa
senza incorrere in errori di sintassi.
Di seguito controlliamo ogni parametro in ingresso, se questo è non nullo aggiungere il filtro per tale parametro alla query string.
Sottolineo che i parametri che sono presenti in più tabelle (ottenuti da clausole di JOIN), come per esempio @prodid
è consigliabile aggiungere la condizione su entrmabe le tabelle in quanto aiutano notevolemente l’ottimizzatore nel caso in cui non esistano vincoli di foreing key.
3.3 PRINT di debug
Una volta costruita la query, prima di lanciarla con il sp_executesql
scriviamo:
Questo permette, se un parametro in ingresso è impostato a 1, di printare la query generata. Non dimenticare mai di aggiungere questo parametro quando lavoro con query generate dinamicamente. Uno degli svanataggi principali delle query dinamiche è che la concatenazione può portare ad errori di sintassi difficili da rilevare: trovare l’errore guardando il codice che genera la SQL dinamica è difficile, ma una volta vista la query generata è invece molto più semplice. Se la query supera i 4000 caratteri verrà troncata dal print, per risolvere invece di usare la funzione print scriviamo:
che non ha limiti di caratteri.
3.4 Eseguire la query
Per eseguire la query ho bisogno di due parti distinte, il set up della lista dei parametri e l’esecuzione effettiva della query.
Assegnazione della variabile @paramlist
:
Esecuzione della query:
Di seguito analizziamo la procedura sp_executesql
: questa ha due parametri in ingresso, il primo parametro è la stringa SQL, la seconda è la lista dei parametri impiegati come stringa separata da virgole. Gli altri parametri sono i parametri della variabile @sql
.
Per capire questa procedura basti pensare come la sp_executesql
crei una procedura virtuale con il seguente scheletro:
la lanci e poi la elimini.
3.5 Compilazione e caching
Abbiamo detto che la sp_executesql
definisce una procedura senza nome e la esegue direttamente. Questa procedura non è salvata sul database, ma è salvato il suo query plan. Quindi la prossima volta che un utente chiama tale stored procedura con esattamente lo stesso insieme di parametri in ingresso, SQL Server riutilizzerà tale piano di esecuzione trovato in cache
3.6 Scelta dell’ordinamento
Sembra che questa cosa possa essere eseguita semplicemente scrivendo:
Il client però così scrivendo dovrà inviare 'CustomerID, OrderID DESC'
come parametro in ingresso, che viola la regola principale per cui il client non deve conoscere nulla della query che sta chiamando.
Inoltre porta a svariati problemi di coerenza col model, SQL injection e così via.
Il modo migliore è utilizzare un CASE che mappi il valore di inpput con una colonna ordinabile nel seguente modo:
Rispetto alla query statica questa procedura è molto più semplice: non dobbiamo creare più CASE in base al tipo di dato da ordnare, ma possiamo fare tutto in una singola query
3.7 Tabelle alternative
Andare in join su tabelle diverse è molto più semplice con SQL dinamiche, seguendo l’esempio sopra possiamo scrivere:
Anche in questo caso è assolutamente sconsigliato farci passare il nome della tabella come parametro: devo essere libero di rinominare il backend senza dover fare modifice al frontend, inoltre questo non deve sapere nulla della struttura del database.
4. Conclusioni
Abbiamo visto che vi sono due modi per implementare condizioni di ricerca dinamiche: SQL statiche o dinamiche. Le soluzioni con le SQL statiche devono (quasi) sempre includere OPTION(RECOMPILE) tranne nei casi mlto semplici, sono più semplici da implementare se i requisiti sono moderatamente complessi. Le query dinamiche invece sono più difficili da usare ma hanno potenzialità molto maggiori.