Database
 sql >> Database >  >> RDS >> Database

Sporchi segreti dell'espressione CASE

Il CASE expression è uno dei miei costrutti preferiti in T-SQL. È abbastanza flessibile e talvolta è l'unico modo per controllare l'ordine in cui SQL Server valuterà i predicati.
Tuttavia, è spesso frainteso.

Cos'è l'espressione CASE T-SQL?

In T-SQL, CASE è un'espressione che valuta una o più espressioni possibili e restituisce la prima espressione appropriata. Il termine espressione potrebbe essere un po' sovraccaricato qui, ma fondamentalmente è qualsiasi cosa che può essere valutata come un singolo valore scalare, come una variabile, una colonna, una stringa letterale o anche l'output di una funzione integrata o scalare .

Esistono due forme di CASE in T-SQL:

  • Espressione CASE semplice – quando devi solo valutare l'uguaglianza:

    CASE <input> WHEN <eval> THEN <return> … [ELSE <return>] END

  • Espressione CASE cercata – quando devi valutare espressioni più complesse, come disuguaglianza, LIKE o IS NOT NULL:

    CASE WHEN <input_bool> THEN <return> … [ELSE <return>] END

L'espressione restituita è sempre un valore singolo e il tipo di dati di output è determinato dalla precedenza del tipo di dati.

Come ho detto, l'espressione CASE è spesso fraintesa; ecco alcuni esempi:

CASE è un'espressione, non un'affermazione

Probabilmente non è importante per la maggior parte delle persone, e forse questo è solo il mio lato pedante, ma molte persone lo chiamano un CASE dichiarazione – inclusa Microsoft, la cui documentazione utilizza dichiarazione e espressione a volte in modo intercambiabile. Trovo questo leggermente fastidioso (come riga/record e colonna/campo ) e, sebbene sia principalmente semantica, ma esiste un'importante distinzione tra un'espressione e un'istruzione:un'espressione restituisce un risultato. Quando la gente pensa a CASE come dichiarazione , porta a esperimenti di accorciamento del codice come questo:

SELECT CASE [status]
    WHEN 'A' THEN
        StatusLabel      = 'Authorized',
        LastEvent        = AuthorizedTime
    WHEN 'C' THEN
        StatusLabel      = 'Completed',
        LastEvent        = CompletedTime
    END
FROM dbo.some_table;

O questo:

SELECT CASE WHEN @foo = 1 THEN
    (SELECT foo, bar FROM dbo.fizzbuzz)
ELSE
    (SELECT blat, mort FROM dbo.splunge)
END;

Questo tipo di logica di controllo del flusso può essere possibile con CASE dichiarazioni in altri linguaggi (come VBScript), ma non in CASE di Transact-SQL espressione . Per utilizzare CASE all'interno della stessa logica di query, dovresti usare un CASE espressione per ogni colonna di output:

SELECT 
  StatusLabel = CASE [status]
      WHEN 'A' THEN 'Authorized' 
      WHEN 'C' THEN 'Completed' END,
  LastEvent = CASE [status]
      WHEN 'A' THEN AuthorizedTime
      WHEN 'C' THEN CompletedTime END
FROM dbo.some_table;

CASE non va sempre in cortocircuito

La documentazione ufficiale una volta implicava che l'intera espressione andasse in cortocircuito, il che significa che valuterà l'espressione da sinistra a destra e smetterà di valutare quando raggiunge una corrispondenza:

L'istruzione CASE [sic!] valuta le sue condizioni in sequenza e si ferma con la prima condizione la cui condizione è soddisfatta.

Tuttavia, questo non è sempre vero. E a suo merito, in una versione più attuale, la pagina ha cercato di spiegare uno scenario in cui ciò non è garantito. Ma entra solo in parte nella storia:

In alcune situazioni, un'espressione viene valutata prima che un'istruzione CASE [sic!] riceva i risultati dell'espressione come input. Sono possibili errori nella valutazione di queste espressioni. Le espressioni aggregate che appaiono negli argomenti WHEN di un'istruzione CASE [sic!] vengono prima valutate, quindi fornite all'istruzione CASE [sic!]. Ad esempio, la query seguente produce un errore di divisione per zero durante la produzione del valore dell'aggregazione MAX. Ciò si verifica prima di valutare l'espressione CASE.

L'esempio di divisione per zero è abbastanza facile da riprodurre e l'ho dimostrato in questa risposta su dba.stackexchange.com:

DECLARE @i INT = 1;
SELECT CASE WHEN @i = 1 THEN 1 ELSE MIN(1/0) END;

Risultato:

Msg 8134, livello 16, stato 1
Si è verificato un errore di divisione per zero.

Esistono soluzioni alternative banali (come ELSE (SELECT MIN(1/0)) END ), ma questa è una vera sorpresa per molti che non hanno memorizzato le frasi di cui sopra da Books Online. Sono stato informato per la prima volta di questo scenario specifico in una conversazione su una lista di distribuzione di posta elettronica privata da Itzik Ben-Gan (@ItzikBenGan), che a sua volta è stato inizialmente informato da Jaime Lafargue. Ho segnalato il bug in Connect #690017:CASE / COALESCE non sempre valuta in ordine testuale; è stato rapidamente chiuso come "By Design". Paul White (blog | @SQL_Kiwi) ha successivamente depositato Connect #691535:Gli aggregati non seguono la semantica di CASE ed è stato chiuso come "Risolto". La correzione, in questo caso, è stata un chiarimento nell'articolo della documentazione in linea; vale a dire, lo snippet che ho copiato sopra.

Questo comportamento può manifestarsi anche in altri scenari meno ovvi. Ad esempio, Connect #780132 :FREETEXT() non rispetta l'ordine di valutazione nelle istruzioni CASE (nessun aggregato coinvolto) mostra che, beh, CASE Non è garantito che l'ordine di valutazione sia da sinistra a destra quando si utilizzano determinate funzioni full-text. Su quell'elemento, Paul White ha commentato di aver anche osservato qualcosa di simile usando il nuovo LAG() funzione introdotta in SQL Server 2012. Non ho una riproduzione a portata di mano, ma gli credo e non credo che abbiamo portato alla luce tutti i casi limite in cui ciò potrebbe verificarsi.

Pertanto, quando sono coinvolti servizi aggregati o non nativi come la ricerca di testo completo, non fare supposizioni sul cortocircuito in un CASE espressione.

RAND() può essere valutato più di una volta

Vedo spesso persone che scrivono un semplice CASE espressione, come questa:

SELECT CASE @variable 
  WHEN 1 THEN 'foo'
  WHEN 2 THEN 'bar'
END

È importante capire che questo verrà eseguito come cercato CASE espressione, come questa:

SELECT CASE  
  WHEN @variable = 1 THEN 'foo'
  WHEN @variable = 2 THEN 'bar'
END

Il motivo per cui è importante capire che l'espressione valutata verrà valutata più volte è perché può essere effettivamente valutata più volte. Quando si tratta di una variabile, di una costante o di un riferimento di colonna, è improbabile che si tratti di un problema reale; tuttavia, le cose possono cambiare rapidamente quando si tratta di una funzione non deterministica. Considera che questa espressione produce un SMALLINT tra 1 e 3; vai avanti ed eseguilo molte volte e otterrai sempre uno di questi tre valori:

SELECT CONVERT(SMALLINT, 1+RAND()*3);

Ora, inseriscilo in un semplice CASE expression ed eseguilo una dozzina di volte:alla fine otterrai un risultato di NULL :

SELECT [result] = CASE CONVERT(SMALLINT, 1+RAND()*3)
  WHEN 1 THEN 'one'
  WHEN 2 THEN 'two'
  WHEN 3 THEN 'three'
END;

Come succede? Bene, l'intero CASE l'espressione viene espansa in un'espressione cercata, come segue:

SELECT [result] = CASE 
  WHEN CONVERT(SMALLINT, 1+RAND()*3) = 1 THEN 'one'
  WHEN CONVERT(SMALLINT, 1+RAND()*3) = 2 THEN 'two'
  WHEN CONVERT(SMALLINT, 1+RAND()*3) = 3 THEN 'three'
  ELSE NULL -- this is always implicitly there
END;

A sua volta, ciò che accade è che ogni WHEN la clausola valuta e invoca RAND() indipendentemente – e in ogni caso potrebbe produrre un valore diverso. Diciamo che inseriamo l'espressione e controlliamo il primo WHEN clausola, e il risultato è 3; saltiamo quella clausola e andiamo avanti. È ipotizzabile che le due clausole successive restituiscano entrambe 1 quando RAND() viene valutato di nuovo, nel qual caso nessuna delle condizioni viene valutata come vera, quindi ELSE prende il sopravvento.

Altre espressioni possono essere valutate più di una volta

Questo problema non è limitato al RAND() funzione. Immagina lo stesso stile di non determinismo proveniente da questi bersagli mobili:

SELECT 
  [crypt_gen]   = 1+ABS(CRYPT_GEN_RANDOM(10) % 20),
  [newid]       = LEFT(NEWID(),2),
  [checksum]    = ABS(CHECKSUM(NEWID())%3);

Queste espressioni possono ovviamente produrre un valore diverso se valutate più volte. E con un CASE cercato espressione, ci saranno momenti in cui ogni rivalutazione esce dalla ricerca specifica per l'attuale WHEN e infine premi ELSE clausola. Per proteggerti da questo, un'opzione è quella di codificare sempre il tuo ELSE esplicito; fai solo attenzione al valore di fallback che scegli di restituire, perché questo avrà un effetto di distorsione se stai cercando una distribuzione uniforme. Un'altra opzione è semplicemente cambiare l'ultimo WHEN clausola su ELSE , ma ciò comporterà comunque una distribuzione non uniforme. L'opzione preferita, secondo me, è provare a costringere SQL Server a valutare la condizione una volta (sebbene ciò non sia sempre possibile all'interno di una singola query). Ad esempio, confronta questi due risultati:

-- Query A: expression referenced directly in CASE; no ELSE:
SELECT x, COUNT(*) FROM
(
  SELECT x = CASE ABS(CHECKSUM(NEWID())%3) 
  WHEN 0 THEN '0' WHEN 1 THEN '1' WHEN 2 THEN '2' END 
  FROM sys.all_columns
) AS y GROUP BY x;
 
-- Query B: additional ELSE clause:
SELECT x, COUNT(*) FROM
(
  SELECT x = CASE ABS(CHECKSUM(NEWID())%3) 
  WHEN 0 THEN '0' WHEN 1 THEN '1' WHEN 2 THEN '2' ELSE '2' END 
  FROM sys.all_columns
) AS y GROUP BY x;
 
-- Query C: Final WHEN converted to ELSE:
SELECT x, COUNT(*) FROM
(
  SELECT x = CASE ABS(CHECKSUM(NEWID())%3) 
  WHEN 0 THEN '0' WHEN 1 THEN '1' ELSE '2' END 
  FROM sys.all_columns
) AS y GROUP BY x;
 
-- Query D: Push evaluation of NEWID() to subquery:
SELECT x, COUNT(*) FROM
(
  SELECT x = CASE x WHEN 0 THEN '0' WHEN 1 THEN '1' WHEN 2 THEN '2' END 
  FROM 
  (
    SELECT x = ABS(CHECKSUM(NEWID())%3) FROM sys.all_columns
  ) AS x
) AS y GROUP BY x;

Distribuzione:

Valore Richiesta A Interrogazione B interrogazione C Richiesta D
NULLO 2.572
0 2.923 2.900 2.928 2.949
1 1.946 1.959 1.927 2.896
2 1.295 3.877 3.881 2.891

Distribuzione di valori con diverse tecniche di query

In questo caso, mi sto basando sul fatto che SQL Server ha scelto di valutare l'espressione nella sottoquery e di non introdurla nel CASE cercato espressione, ma questo serve semplicemente a dimostrare che la distribuzione può essere costretta ad essere più uniforme. In realtà, questa potrebbe non essere sempre la scelta che fa l'ottimizzatore, quindi per favore non imparare da questo piccolo trucco. :-)

Anche CHOOSE() è interessato

Osserverai che se sostituisci CHECKSUM(NEWID()) espressione con RAND() espressione, otterrai risultati completamente diversi; in particolare, quest'ultimo restituirà sempre e solo un valore. Questo perché RAND() , come GETDATE() e alcune altre funzioni integrate, riceve un trattamento speciale come costante di runtime e viene valutata solo una volta per riferimento per l'intera riga. Nota che può comunque restituire NULL proprio come la prima query nell'esempio di codice precedente.

Anche questo problema non è limitato al CASE espressione; puoi vedere un comportamento simile con altre funzioni integrate che utilizzano la stessa semantica sottostante. Ad esempio, CHOOSE è semplicemente zucchero sintattico per un CASE più elaborato espressione, e questo produrrà anche NULL occasionalmente:

SELECT [choose] = CHOOSE(CONVERT(SMALLINT, 1+RAND()*3),'one','two','three');

IIF() è una funzione in cui mi aspettavo di cadere nella stessa trappola, ma questa funzione è in realtà solo un CASE cercato espressione con solo due possibili risultati e nessun ELSE – quindi è difficile, senza annidare e introdurre altre funzioni, immaginare uno scenario in cui questo possa interrompersi inaspettatamente. Mentre nel caso semplice è un'abbreviazione decente per CASE , è anche difficile fare qualcosa di utile con esso se hai bisogno di più di due possibili risultati. :-)

Anche COALESCE() è interessato

Infine, dovremmo esaminare quel COALESCE può avere problemi simili. Consideriamo che queste espressioni sono equivalenti:

SELECT COALESCE(@variable, 'constant');
 
SELECT CASE WHEN @variable IS NOT NULL THEN @variable ELSE 'constant' END);

In questo caso, @variable verrebbe valutato due volte (come qualsiasi funzione o sottoquery, come descritto in questo elemento Connect).

Sono stato davvero in grado di ottenere alcuni sguardi perplessi quando ho portato il seguente esempio in una recente discussione sul forum. Diciamo che voglio popolare una tabella con una distribuzione di valori da 1 a 5, ma ogni volta che si incontra un 3, voglio invece usare -1. Non uno scenario molto reale, ma facile da costruire e seguire. Un modo per scrivere questa espressione è:

SELECT COALESCE(NULLIF(CONVERT(SMALLINT,1+RAND()*5),3),-1);

(In inglese, lavorando dall'interno:converti il ​​risultato dell'espressione 1+RAND()*5 a una piccola fetta; se il risultato di tale conversione è 3, impostalo su NULL; se il risultato è NULL , impostalo su -1. Potresti scriverlo con un CASE più dettagliato espressione, ma conciso sembra essere il re.)

Se lo esegui un sacco di volte, dovresti vedere un intervallo di valori compreso tra 1-5 e -1. Vedrai alcune istanze di 3 e potresti anche aver notato che occasionalmente vedi NULL , anche se potresti non aspettarti nessuno di questi risultati. Controlliamo la distribuzione:

USE tempdb;
GO
CREATE TABLE dbo.dist(TheNumber SMALLINT);
GO
INSERT dbo.dist(TheNumber) SELECT COALESCE(NULLIF(CONVERT(SMALLINT,1+RAND()*5),3),-1);
GO 10000
SELECT TheNumber, occurences = COUNT(*) FROM dbo.dist 
  GROUP BY TheNumber ORDER BY TheNumber;
GO
DROP TABLE dbo.dist;

Risultati (i tuoi risultati varieranno sicuramente, ma la tendenza di base dovrebbe essere simile):

Il numero occorrenze
NULL 1.654
-1 2.002
1 1.290
2 1.266
3 1.287
4 1.251
5 1.250

Distribuzione di TheNumber utilizzando COALESCE

Scomposizione di un'espressione CASE cercata

Ti stai già grattando la testa? Come funzionano i valori NULL e 3 vengono visualizzati, e perché la distribuzione per NULL e -1 sostanzialmente superiore? Bene, risponderò direttamente alla prima e inviterò ipotesi per la seconda.

L'espressione si espande approssimativamente a quanto segue, logicamente, poiché RAND() viene valutato due volte all'interno di NULLIF , quindi moltiplicalo per due valutazioni per ogni ramo di COALESCE funzione. Non ho un debugger a portata di mano, quindi questo non è necessariamente *esattamente* ciò che viene fatto all'interno di SQL Server, ma dovrebbe essere abbastanza equivalente per spiegare il punto:

SELECT 
  CASE WHEN 
      CASE WHEN CONVERT(SMALLINT,1+RAND()*5) = 3 THEN NULL 
      ELSE CONVERT(SMALLINT,1+RAND()*5) 
      END 
    IS NOT NULL 
    THEN
      CASE WHEN CONVERT(SMALLINT,1+RAND()*5) = 3 THEN NULL 
      ELSE CONVERT(SMALLINT,1+RAND()*5) 
      END
    ELSE -1
  END
END

Quindi puoi vedere che essere valutato più volte può diventare rapidamente un libro Scegli la tua avventura™ e come entrambi NULL e 3 sono possibili esiti che non sembrano possibili quando si esamina l'affermazione originale. Una nota a margine interessante:questo non accade esattamente se prendi lo script di distribuzione sopra e sostituisci COALESCE con ISNULL . In tal caso, non è possibile un NULL produzione; la distribuzione è approssimativamente la seguente:

Il numero occorrenze
-1 1.966
1 1.585
2 1.644
3 1.573
4 1.598
5 1.634

Distribuzione di TheNumber utilizzando ISNULL

Ancora una volta, i tuoi risultati effettivi varieranno sicuramente, ma non dovrebbero variare di molto. Il punto è che possiamo ancora vedere che 3 cade abbastanza spesso, ma ISNULL elimina magicamente il potenziale per NULL per arrivare fino in fondo.

Ho parlato di alcune delle altre differenze tra COALESCE e ISNULL in un suggerimento, intitolato "Decidere tra COALESCE e ISNULL in SQL Server". Quando l'ho scritto, ero decisamente favorevole all'utilizzo di COALESCE tranne nel caso in cui il primo argomento fosse una sottoquery (di nuovo, a causa di questo bug "gap di funzionalità"). Ora non sono così sicuro di esserne così forte.

Semplici espressioni CASE possono essere nidificate su server collegati

Una delle poche limitazioni del CASE l'espressione è limitata a 10 livelli di nido. In questo esempio su dba.stackexchange.com, Paul White dimostra (usando Plan Explorer) che un'espressione semplice come questa:

SELECT CASE column_name
  WHEN '1' THEN 'a' 
  WHEN '2' THEN 'b'
  WHEN '3' THEN 'c'
  ...
END
FROM ...

Viene espanso dal parser al modulo cercato:

SELECT CASE 
  WHEN column_name = '1' THEN 'a' 
  WHEN column_name = '2' THEN 'b'
  WHEN column_name = '3' THEN 'c'
  ...
END
FROM ...

Ma può effettivamente essere trasmesso su una connessione al server collegata come la seguente query molto più dettagliata:

SELECT 
  CASE WHEN column_name = '1' THEN 'a' ELSE
    CASE WHEN column_name = '2' THEN 'b' ELSE
      CASE WHEN column_name = '3' THEN 'c' ELSE 
      ... 
      ELSE NULL
      END
    END
  END
FROM ...

In questa situazione, anche se la query originale aveva un solo CASE espressione con 10+ risultati possibili, quando inviata al server collegato aveva 10+ nidificati CASE espressioni. Pertanto, come ci si potrebbe aspettare, ha restituito un errore:

Msg 8180, livello 16, stato 1
Impossibile preparare le dichiarazioni.
Msg 125, livello 15, stato 4
Le espressioni dei casi possono essere nidificate solo al livello 10.

In alcuni casi, puoi riscriverlo come suggerito da Paul, con un'espressione come questa (assumendo column_name è una colonna varchar):

SELECT CASE CONVERT(VARCHAR(MAX), SUBSTRING(column_name, 1, 255))
  WHEN 'a' THEN '1'
  WHEN 'b' THEN '2'
  WHEN 'c' THEN '3'
  ...
END
FROM ...

In alcuni casi, solo il SUBSTRING potrebbe essere richiesto di modificare la posizione in cui viene valutata l'espressione; in altri, solo il CONVERT . Non ho eseguito test esaurienti, ma ciò potrebbe avere a che fare con il provider del server collegato, opzioni come Collation Compatible e Use Remote Collation e la versione di SQL Server alle due estremità della pipe.

Per farla breve, è importante ricordare che il tuo CASE l'espressione può essere riscritta per te senza preavviso e che qualsiasi soluzione alternativa utilizzata potrebbe essere successivamente annullata dall'ottimizzatore, anche se ora funziona per te.

Espressione del CASO Considerazioni finali e risorse aggiuntive

Spero di aver dato qualche spunto di riflessione su alcuni degli aspetti meno noti del CASE espressione e alcune informazioni sulle situazioni in cui CASE – e alcune delle funzioni che utilizzano la stessa logica sottostante – restituiscono risultati imprevisti. Alcuni altri scenari interessanti in cui è emerso questo tipo di problema:

  • Stack Overflow:in che modo questa espressione CASE raggiunge la clausola ELSE?
  • Overflow dello stack:CRYPT_GEN_RANDOM() strani effetti
  • Overflow dello stack:CHOOSE() non funziona come previsto
  • Overflow dello stack:CHECKSUM(NewId()) viene eseguito più volte per riga
  • Connetti #350485 :Bug con NEWID() ed espressioni di tabella