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:
IF @custno IS NOT NULL
SELECT ... FROM customers WHERE custno = @custno
ELSE IF @natregno IS NOT NULL
SELECT ... FROM customers WHERE natregno = @natregno
ELSE IF @custname IS NOT NULL
SELECT ...
FROM customers
WHERE custname LIKE @custname + '%'
ORDER BY custname
ELSE
RAISERROR('No search condition given!', 16, 1)
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.
IF @custno IS NOT NULL
INSERT @cust (custno) VALUES (@custno)
ELSE IF @natregno IS NOT NULL
INSERT @cust (custno) SELECT custno FROM customers WHERE natregno = @natregno
ELSE IF @custname IS NOT NULL
INSERT @cust (custno)
SELECT TOP (200) custno
FROM customers
WHERE custname LIKE @custname + '%'
ORDER BY custname
ELSE
RAISERROR('No search condition given!', 16, 1)
SELECT ...
FROM @cust c
JOIN customers cst ON cst.custno = c.custno
JOIN ...
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
SELECT TOP 200 ...
FROM customers
WHERE (custno = @custno AND @custno IS NOT NULL) OR
(natregno = @natregno AND @natregno IS NOT NULL) OR
(custname LIKE @custname + '%' AND @custname IS NOT NULL)
ORDER BY custname
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:
CREATE PROCEDURE search_orders
@orderid int = NULL,
@fromdate datetime = NULL,
@employeetbl intlist_tbltype READONLY AS
SELECT o.OrderID, o.OrderDate, od.UnitPrice, od.Quantity,
p.ProductName, p.UnitsInStock, p.UnitsOnOrder, o.EmployeeID
FROM Orders o
JOIN [Order Details] od ON o.OrderID = od.OrderID
JOIN Customers c ON o.CustomerID = c.CustomerID
JOIN Products p ON p.ProductID = od.ProductID
WHERE ???
ORDER BY o.OrderID
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
SELECT o.OrderID
, o.OrderDate
, od.UnitPrice
, od.Quantity
...
FROM Orders o
JOIN [Order Details] od ON o.OrderID = od.OrderID
...
WHERE ( o.OrderID = @orderid OR @orderid IS NULL )
AND (o.OrderDate >= @fromdate OR @fromdate IS NULL )
...
AND ( c.CompanyName LIKE @custname + '%' OR @custname IS NULL )
AND ( c.City = @city OR @city IS NULL)
...
AND (p.ProductName LIKE @prodname + '%' OR @prodname IS NULL)
ORDER BY o.OrderID
OPTION (RECOMPILE)
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:
OPTION (RECOMPILE)
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:
EXEC search_orders_3 @orderid = 11000
SQL Server ottimizzerร la clausola WHERE
in questo modo:
WHERE (o.OrderID = 11000 OR 11000 IS NULL)
AND (o.OrderDate >= NULL OR NULL IS NULL)
AND (o.OrderDate <= NULL OR NULL IS NULL)
...
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:
o.orderID = coalesce(@orderid, o.OrderID)
o.orderID = isnull(@orderid, o.OrderID)
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
c.Region = coalesce(@region, c.Region)
diventa
NULL=NULL
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
coalesce(c.Region, '') = coalesce(@region, c.Region, '')
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:
AND (o.EmployeeID IN (SELECT number FROM intlist_to_tbl(@employeestr)) OR
@employeestr IS NULL)
AND (o.EmployeeID IN (SELECT val FROM @employeetbl) OR @hasemptbl = 0)
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:
DECLARE @rowc int,
@emp1 int,
@emp2 int,
@emp3 int,
@emp4 int,
@emptbl bit = 0
IF @employeestr IS NOT NULL
BEGIN
INSERT @employeetbl (rowno, employeeid)
SELECT row_number() OVER(ORDER BY (SELECT 1)), number
FROM intlist_to_tbl(@employeestr)
SELECT @rowc = @@rowcount
IF @rowc BETWEEN 1 AND 4
BEGIN
SELECT @emp1 = employeeid FROM @employeetbl WHERE rowno = 1
SELECT @emp2 = employeeid FROM @employeetbl WHERE rowno = 2
SELECT @emp3 = employeeid FROM @employeetbl WHERE rowno = 3
SELECT @emp4 = employeeid FROM @employeetbl WHERE rowno = 4
END
ELSE IF @rowc > 4
SELECT @emptbl = 1
END
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
AND (o.EmployeeID IN (@emp1, @emp2, @emp3, @emp4) OR @emp1 IS NULL)
AND (o.EmployeeID IN (SELECT employeeid FROM @employeetbl) OR @hasemptbl = 0)
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:
ORDER BY CASE @sortcol
WHEN 'OrderID'
THEN o.OrderID
WHEN 'EmployeeID'
THEN o.EmployeeID
WHEN 'ProductID'
THEN od.ProductID
END
, CASE @sortcol
WHEN 'CustomerName'
THEN c.CompanyName
WHEN 'ProductName'
THEN p.ProductName
END
, CASE @sortcol
WHEN 'OrderDate'
THEN o.OrderDate
END
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.
ORDER BY CASE WHEN @isdesc = 1 THEN
CASE @sortcol WHEN 'OrderID' THEN o.OrderID
WHEN 'EmployeeID' THEN o.EmployeeID
WHEN 'ProductID' THEN od.ProductID
END DESC,
CASE WHEN @isdesc = 0 THEN
CASE @sortcol WHEN 'OrderID' THEN o.OrderID
WHEN 'EmployeeID' THEN o.EmployeeID
WHEN 'ProductID' THEN od.ProductID
END ASC
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:
AND (@suppl_country IS NULL OR EXISTS (SELECT *
FROM Suppliers s
WHERE s.SupplierID = p.SupplierID
AND s.Country = @suppl_country))
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:
FROM (SELECT o.OrderID, o.OrderDate, od.UnitPrice, od.Quantity,
od.CustomerID, od.ProductID
FROM Orders o
JOIN [Order Details] od ON o.OrderID = od.OrderID
WHERE @ishistoric = 0
UNION ALL
SELECT o.OrderID, o.OrderDate, od.UnitPrice, od.Quantity,
od.CustomerID, od.ProductID
FROM HistoricOrders o
JOIN HistoricOrderDetails od ON o.OrderID = od.OrderID
WHERE @ishistoric = 1) AS u
JOIN Customers c ON o.CustomerID = u.CustomerID
JOIN Products p ON p.ProductID = u.ProductID
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.
DECLARE @sql nvarchar(MAX);
DECLARE paramlist nvarchar(4000);
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.
SELECT @sql =
'SELECT o.OrderID, o.OrderDate, od.UnitPrice, od.Quantity,
c.CustomerID, c.CompanyName, c.Address, c.City,
c.Region, c.PostalCode, c.Country, c.Phone,
p.ProductID, p.ProductName, p.UnitsInStock,
p.UnitsOnOrder, o.EmployeeID
FROM dbo.Orders o
JOIN dbo.[Order Details] od ON o.OrderID = od.OrderID
JOIN dbo.Customers c ON o.CustomerID = c.CustomerID
JOIN dbo.Products p ON p.ProductID = od.ProductID
WHERE 1 = 1'
Di seguito controlliamo ogni parametro in ingresso, se questo รจ non nullo aggiungere il filtro per tale parametro alla query string.
...
IF @country IS NOT NULL
SELECT @sql += ' AND c.Country = @country'
IF @employeestr IS NOT NULL
SELECT @sql += ' AND o.EmployeeID IN' +
' (SELECT number FROM intlist_to_tbl(@employeestr))'
IF @prodid IS NOT NULL
SELECT @sql += ' AND od.ProductID = @prodid' +
' AND p.ProductID = @prodid'
...
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:
IF @debug = 1
PRINT @sql
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:
IF @debug = 1
SELECT @sql FOR XML PATH(''), TYPE
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
:
SELECT @paramlist = '@orderid int,
@fromdate datetime,
@todate datetime,
@minprice money,
@maxprice money,
@custid nchar(5),
@custname nvarchar(40),
@city nvarchar(15),
@region nvarchar(15),
@country nvarchar(15),
@prodid int,
@prodname nvarchar(40),
@employeestr varchar(MAX),
@employeetbl intlist_tbltype READONLY'
Esecuzione della query:
EXEC sp_executesql @sql, @paramlist,
@orderid, @fromdate, @todate, @minprice,
@maxprice, @custid, @custname, @city, @region,
@country, @prodid, @prodname, @employeestr, @employeetbl
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:
CREATE PROCEDURE sp @paramlist AS
@sql
go
EXEC sp orderid, @fromdate, @todate, @minprice...
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:
@sql += ' ORDER BY ' + @sortcol
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:
SELECT @sql += ' ORDER BY ' +
CASE @sortcol WHEN 'OrderID' THEN 'o.OrderID'
WHEN 'EmplyoeeID' THEN 'o.EmployeeID'
ELSE 'o.OrderID'
END + CASE @isdesc WHEN 0 THEN ' ASC' ELSE ' DESC' END
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:
FROM dbo.' + CASE @ishistoric
WHEN 0 THEN 'Orders'
WHEN 1 THEN 'HistoricOrders'
END + ' o
JOIN dbo.' + CASE @ishistoric
WHEN 0 THEN '[Order Details]'
WHEN 1 THEN 'HistoricOrderDetails'
END + ' od
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.