sql >> Databáze >  >> RDS >> Database

Špinavá tajemství výrazu CASE

CASE výraz je jedním z mých oblíbených konstruktů v T-SQL. Je poměrně flexibilní a někdy je to jediný způsob, jak řídit pořadí, ve kterém bude SQL Server vyhodnocovat predikáty.
Často je to však špatně pochopeno.

Co je to T-SQL CASE Expression?

V T-SQL CASE je výraz, který vyhodnocuje jeden nebo více možných výrazů a vrací první vhodný výraz. Termín výraz je zde možná trochu přetížený, ale v zásadě jde o cokoli, co lze vyhodnotit jako jedinou skalární hodnotu, jako je proměnná, sloupec, řetězcový literál nebo dokonce výstup vestavěné nebo skalární funkce. .

V T-SQL existují dvě formy CASE:

  • Jednoduchý výraz CASE – když potřebujete pouze vyhodnotit rovnost:

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

  • Hledaný výraz typu CASE – když potřebujete vyhodnotit složitější výrazy, jako je nerovnost, LIKE nebo IS NOT NULL:

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

Návratový výraz je vždy jedna hodnota a výstupní datový typ je určen prioritou datového typu.

Jak jsem řekl, výraz CASE je často nepochopený; zde je několik příkladů:

CASE je výraz, nikoli příkaz

Pro většinu lidí to pravděpodobně není důležité a možná je to jen moje pedantská stránka, ale mnoho lidí tomu říká CASE prohlášení – včetně společnosti Microsoft, jejíž dokumentace používá prohlášení a výraz občas zaměnitelně. Považuji to za mírně otravné (jako řádek/záznam a sloupec/pole ) a i když jde většinou o sémantiku, existuje důležitý rozdíl mezi výrazem a příkazem:výraz vrací výsledek. Když lidé myslí na CASE jako prohlášení , vede to k experimentům se zkracováním kódu, jako je tento:

VYBERTE PŘÍPAD [stav] WHEN 'A' THEN StatusLabel ='Authorized', LastEvent =AuthorizedTime WHEN 'C' THEN StatusLabel ='Completed', LastEvent =CompletedTime ENDFROM dbo.some_table;

Nebo toto:

VYBERTE PŘÍPAD, KDYŽ @foo =1 THEN (VYBERTE foo, bar FROM dbo.fizzbuzz)ELSE (SELECT blat, mort FROM dbo.splunge)END;

Tento typ logiky řízení toku může být možný pomocí CASE výroky v jiných jazycích (jako VBScript), ale ne v CASE Transact-SQL výraz . Chcete-li použít CASE v rámci stejné logiky dotazu byste museli použít CASE výraz pro každý výstupní sloupec:

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

CASE nebude vždy zkratovat

Oficiální dokumentace kdysi naznačovala, že celý výraz se zkratuje, což znamená, že výraz vyhodnotí zleva doprava a přestane se vyhodnocovat, když narazí na shodu:

Příkaz CASE [sic!] vyhodnotí své podmínky postupně a zastaví se u první podmínky, jejíž podmínka je splněna.

To však není vždy pravda. A ke cti, že v aktuálnější verzi stránka pokračovala ve snaze vysvětlit jeden scénář, kde to není zaručeno. Ale dostane jen část příběhu:

V některých situacích je výraz vyhodnocen předtím, než příkaz CASE [sic!] obdrží výsledky výrazu jako svůj vstup. Při vyhodnocování těchto výrazů jsou možné chyby. Souhrnné výrazy, které se objevují v argumentech WHEN příkazu CASE [sic!], jsou nejprve vyhodnoceny a poté poskytnuty příkazu CASE [sic!]. Například následující dotaz vytvoří chybu dělení nulou při vytváření hodnoty agregace MAX. K tomu dochází před vyhodnocením výrazu CASE.

Příklad dělení nulou je docela snadné reprodukovat a ukázal jsem to v této odpovědi na dba.stackexchange.com:

DECLARE @i INT =1;VYBERTE VELKÝ PŘÍPAD, KDYŽ @i =1 THEN 1 ELSE MIN(1/0) END;

Výsledek:

Msg 8134, Level 16, State 1
Chyba dělení nulou.

Existují triviální zástupná řešení (například ELSE (SELECT MIN(1/0)) END ), ale to je skutečným překvapením pro mnohé, kteří si nezapamatovali výše uvedené věty z Books Online. Poprvé jsem byl upozorněn na tento konkrétní scénář v konverzaci na soukromém e-mailovém distribučním seznamu od Itzika Ben-Gana (@ItzikBenGan), kterého na oplátku původně upozornil Jaime Lafargue. Nahlásil jsem chybu v Connect #690017 :CASE / COALESCE nebude vždy vyhodnocovat v textovém pořadí; bylo rychle uzavřeno jako „By Design“. Paul White (blog | @SQL_Kiwi) následně zadal Connect #691535 :Aggregates Don't Follow the Semantics Of CASE a byl uzavřen jako „Opraveno“. Opravou v tomto případě bylo objasnění v článku Books Online; jmenovitě úryvek, který jsem zkopíroval výše.

Toto chování se může projevit i v některých jiných, méně zřejmých scénářích. Například Connect #780132 :FREETEXT() nerespektuje pořadí hodnocení v příkazech CASE (žádné agregace nejsou zapojeny), což ukazuje, že CASE ani při použití určitých fulltextových funkcí není zaručeno, že pořadí hodnocení bude zleva doprava. K této položce Paul White poznamenal, že také pozoroval něco podobného pomocí nového LAG() funkce zavedená v SQL Server 2012. Nemám po ruce repro, ale věřím mu a nemyslím si, že jsme odhalili všechny okrajové případy, kdy k tomu může dojít.

Pokud se tedy jedná o agregáty nebo nenativní služby, jako je fulltextové vyhledávání, nedělejte prosím žádné předpoklady o zkratu v CASE výraz.

RAND() lze vyhodnotit více než jednou

Často vidím lidi psát jednoduché CASE výraz, jako je tento:

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

Je důležité pochopit, že toto bude provedeno jako vyhledávané CASE výraz, jako je tento:

VYBERTE PŘÍPAD, KDYŽ @proměnná =1, POTOM 'foo', KDYŽ @proměnná =2 POTOM 'bar'END

Důvod, proč je důležité pochopit, že hodnocený výraz bude vyhodnocen vícekrát, je ten, že jej lze ve skutečnosti vyhodnotit vícekrát. Pokud se jedná o proměnnou, konstantu nebo odkaz na sloupec, je nepravděpodobné, že by to byl skutečný problém; věci se však mohou rychle změnit, pokud jde o nedeterministickou funkci. Zvažte, že tento výraz dává SMALLINT mezi 1 a 3; pokračujte a spusťte jej mnohokrát a vždy získáte jednu z těchto tří hodnot:

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

Nyní to vložte do jednoduchého CASE výraz a spusťte ho tucetkrát – nakonec dostanete výsledek NULL :

VYBRAT [výsledek] =PŘEVOD VELKÝCH PŘÍPADŮ (SMALLINT, 1+RAND()*3) WHEN 1 THEN 'one' WHEN 2 THEN 'dva' WHEN 3 THEN 'tři'END;

jak se to stane? No, celý CASE výraz se rozšíří na hledaný výraz následovně:

VYBERTE [výsledek] =CASE WHEN CONVERT(SMALLINT, 1+RAND()*3) =1 THEN 'jeden' WHEN CONVERT(SMALLINT, 1+RAND()*3) =2 THEN 'dva' WHEN CONVERT( SMALLINT, 1+RAND()*3) =3 THEN 'tři' ELSE NULL -- toto je vždy implicitně tamEND;

Na druhé straně se stane, že každý WHEN klauzule vyhodnotí a vyvolá RAND() nezávisle – a v každém případě může přinést jinou hodnotu. Řekněme, že zadáme výraz a zkontrolujeme první WHEN klauzule a výsledkem je 3; přeskočíme tuto klauzuli a jedeme dál. Je možné, že následující dvě klauzule vrátí obě 1, když RAND() se vyhodnotí znovu – v takovém případě není žádná z podmínek vyhodnocena jako pravdivá, takže ELSE přebírá.

Jiné výrazy mohou být vyhodnoceny více než jednou

Tento problém není omezen na RAND() funkce. Představte si stejný styl nedeterminismu vycházející z těchto pohyblivých cílů:

SELECT [crypt_gen] =1+ABS(CRYPT_GEN_RANDOM(10) % 20), [newid] =LEFT(NEWID(),2), [kontrolní součet] =ABS(CHECKSUM(NEWID())%3); 

Tyto výrazy mohou mít samozřejmě jinou hodnotu, pokud jsou vyhodnocovány vícekrát. A s vyhledávaným CASE výraz, budou chvíle, kdy každé přehodnocení náhodou vypadne z vyhledávání specifického pro aktuální WHEN a nakonec stiskněte ELSE doložka. Abyste se před tím ochránili, jednou z možností je vždy napevno zakódovat vlastní explicitní ELSE; jen si dejte pozor na nouzovou hodnotu, kterou se rozhodnete vrátit, protože to bude mít určitý zkreslený efekt, pokud hledáte rovnoměrné rozložení. Další možností je pouze změnit poslední WHEN klauzule na ELSE , ale to stále povede k nerovnoměrnému rozdělení. Preferovanou možností je podle mého názoru pokusit se a donutit SQL Server, aby vyhodnotil podmínku jednou (ačkoli to není vždy možné v rámci jednoho dotazu). Porovnejte například tyto dva výsledky:

-- Dotaz A:výraz odkazovaný přímo v 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; -- Dotaz B:další klauzule ELSE: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; -- Dotaz C:Final WHEN převeden na 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; -- Dotaz D:Vložení vyhodnocení NEWID() do poddotazu: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;

Distribuce:

Hodnota Dotaz A Dotaz B Dotaz C Dotaz D
NULL 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

Distribuce hodnot pomocí různých technik dotazů

V tomto případě spoléhám na to, že SQL Server se rozhodl výraz v poddotazu vyhodnotit a nezavést jej do hledaného CASE výraz, ale to má pouze demonstrovat, že distribuci lze vynutit, aby byla rovnoměrnější. Ve skutečnosti to nemusí být vždy volba optimalizátoru, takže se prosím nepoučte z tohoto malého triku. :-)

Ovlivněno je také CHOOSE()

Zjistíte, že pokud nahradíte CHECKSUM(NEWID()) výraz s RAND() výrazem, získáte úplně jiné výsledky; nejpozoruhodnější je, že druhý vždy vrátí pouze jednu hodnotu. Důvodem je RAND() , například GETDATE() a některé další vestavěné funkce se zvláštním způsobem zpracovává jako konstanta za běhu a vyhodnocuje se pouze jednou za referenci pro celou řadu. Všimněte si, že stále může vrátit NULL stejně jako první dotaz v předchozí ukázce kódu.

Tento problém se také neomezuje na CASE výraz; můžete vidět podobné chování s jinými vestavěnými funkcemi, které používají stejnou základní sémantiku. Například CHOOSE je pouze syntaktický cukr pro propracovanější hledaný CASE výraz a tím také vznikne NULL občas:

VYBRAT [vybrat] =VYBRAT(CONVERT(SMALLINT, 1+RAND()*3),'jeden','dva','tři');

IIF() je funkce, od které jsem očekával, že spadne do stejné pasti, ale tato funkce je ve skutečnosti jen hledaný CASE výraz s pouze dvěma možnými výsledky a bez ELSE – takže je těžké, bez vnořování a zavádění dalších funkcí, představit si scénář, kde se to může nečekaně zlomit. Zatímco v jednoduchém případě je to slušná zkratka pro CASE , je také těžké s tím udělat něco užitečného, ​​pokud potřebujete více než dva možné výsledky. :-)

CoALESCE() je také ovlivněno

Nakonec bychom měli prozkoumat to COALESCE může mít podobné problémy. Uvažujme, že tyto výrazy jsou ekvivalentní:

SELECT COALESCE(@proměnná, 'konstanta'); VYBERTE PŘÍPAD, KDYŽ @proměnná NENÍ NULL THEN @proměnná ELSE 'konstantní' END);

V tomto případě @variable by byl vyhodnocen dvakrát (stejně jako jakákoli funkce nebo dílčí dotaz, jak je popsáno v této položce Connect).

Byl jsem opravdu schopen získat nějaké zmatené pohledy, když jsem v nedávné diskusi na fóru uvedl následující příklad. Řekněme, že chci naplnit tabulku rozložením hodnot od 1 do 5, ale kdykoli narazí na 3, chci místo toho použít -1. Není to příliš reálný scénář, ale lze jej snadno sestavit a sledovat. Jeden způsob, jak napsat tento výraz, je:

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

(V angličtině, práce zevnitř:převeďte výsledek výrazu 1+RAND()*5 do malého; pokud je výsledek převodu 3, nastavte jej na NULL; pokud je výsledkem NULL , nastavte na -1. Můžete to napsat s podrobnějším CASE výraz, ale výstižný se zdá být král.)

Pokud to spustíte několikrát, měli byste vidět rozsah hodnot od 1 do 5 a také od -1. Uvidíte několik případů 3 a možná jste si také všimli, že občas vidíte NULL , i když byste možná nečekali ani jeden z těchto výsledků. Zkontrolujeme distribuci:

USE tempdb;GOCREATE TABLE dbo.dist(TheNumber SMALLINT);GOINSERT dbo.dist(TheNumber) SELECT COALESCE(NULLIF(CONVERT(SMALLINT,1+RAND()*5),3),-1);GO 10000SELECT TheNumber, výskyty =COUNT(*) FROM dbo.dist GROUP BY TheNumber ORDER BY TheNumber;GODROP TABLE dbo.dist;

Výsledky (vaše výsledky se budou jistě lišit, ale základní trend by měl být podobný):

Číslo výskyty
NULL 1 654
-1 2 002
1 1 290
2 1 266
3 1 287
4 1 251
5 1 250

Distribuce TheNumber pomocí COALESCE

Rozdělení hledaného výrazu CASE

Už se škrábeš na hlavě? Jak fungují hodnoty NULL a 3 se zobrazí a proč je distribuce pro NULL a -1 podstatně vyšší? No, odpovím přímo na první a vyzvem hypotézy pro druhé.

Výraz se zhruba expanduje na následující, logicky, protože RAND() je v NULLIF vyhodnoceno dvakrát a pak to vynásobte dvěma vyhodnoceními pro každou větev COALESCE funkce. Nemám po ruce ladicí program, takže to nemusí být nutně *přesně* to, co se dělá uvnitř SQL Serveru, ale mělo by to být dostatečně ekvivalentní, aby to vysvětlilo:

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

Můžete tedy vidět, že z vícenásobného hodnocení se může rychle stát kniha Choose Your Own Adventure™ a jak obě NULL a 3 jsou možné výsledky, které se při zkoumání původního prohlášení nezdají možné. Zajímavá vedlejší poznámka:nedojde k tomu úplně stejně, pokud použijete výše uvedený distribuční skript a nahradíte COALESCE s ISNULL . V takovém případě neexistuje možnost NULL výstup; distribuce je zhruba následující:

Číslo výskyty
-1 1 966
1 1 585
2 1 644
3 1 573
4 1 598
5 1 634

Distribuce TheNumber pomocí ISNULL

Vaše skutečné výsledky se opět budou jistě lišit, ale neměly by se příliš lišit. Jde o to, že stále můžeme vidět, že 3 propadá trhlinami poměrně často, ale ISNULL magicky eliminuje potenciál NULL aby to bylo celé.

Mluvil jsem o některých dalších rozdílech mezi COALESCE a ISNULL v tipu nazvaném "Rozhodování mezi COALESCE a ISNULL v SQL Server." Když jsem to psal, byl jsem silně pro použití COALESCE kromě případu, kdy prvním argumentem byl dílčí dotaz (opět kvůli této chybě "mezera funkcí"). Teď si nejsem tak jistý, jestli to cítím tak silně.

Jednoduché CASE výrazy se mohou vnořit do propojených serverů

Jedno z mála omezení CASE výraz je, že je omezen na 10 vnořených úrovní. V tomto příkladu na dba.stackexchange.com Paul White demonstruje (pomocí Plan Explorer), že jednoduchý výraz, jako je tento:

VYBRAT CASE název_sloupce WHEN '1' THEN 'a' WHEN '2' THEN 'b' WHEN '3' THEN 'c' ...ENDFROM ...

Rozbalí se analyzátorem na hledaný formulář:

VYBERTE PŘÍPAD, KDYŽ column_name ='1' THEN 'a' WHEN column_name ='2' THEN 'b' WHEN column_name ='3' THEN 'c' ...ENDFROM ...

Ve skutečnosti však může být přenášen přes propojené připojení serveru jako následující, mnohem podrobnější dotaz:

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 ENDFROM .. .

V této situaci, i když původní dotaz měl pouze jeden CASE výraz s 10+ možnými výsledky, když byl odeslán na propojený server, měl 10+ vnořených CASE výrazy. Jako takový, jak byste mohli očekávat, vrátil chybu:

Zpráva 8180, úroveň 16, stav 1
Prohlášení(a) nelze připravit.
Zpráva 125, úroveň 15, stav 4
Výrazy případu mohou být vnořeny pouze do úrovně 10.

V některých případech jej můžete přepsat, jak navrhl Paul, s výrazem jako je tento (za předpokladu column_name je sloupec varchar):

SELECT CASE CONVERT(VARCHAR(MAX), SUBSTRING(název_sloupce, 1, 255)) WHEN 'a' THEN '1' WHEN 'b' THEN '2' WHEN 'c' THEN '3' ...ENDFROM . ..

V některých případech pouze SUBSTRING může být požadováno změnit místo, kde je výraz vyhodnocen; v ostatních pouze CONVERT . Neprováděl jsem vyčerpávající testování, ale to může souviset s poskytovatelem propojeného serveru, možnostmi jako Collation Compatible a Use Remote Collation a verzí SQL Serveru na obou koncích kanálu.

Stručně řečeno, je důležité mít na paměti, že váš CASE výraz pro vás může být přepsán bez varování a že jakékoli alternativní řešení, které použijete, může být později přepsáno optimalizátorem, i když vám nyní funguje.

Závěrečné myšlenky a další zdroje CASE Expression

Doufám, že jsem dal k zamyšlení některé z méně známých aspektů CASE výraz a určitý vhled do situací, kdy CASE – a některé funkce, které používají stejnou základní logiku – vracejí neočekávané výsledky. Některé další zajímavé scénáře, kde se tento typ problému objevil:

  • Přetečení zásobníku:Jak tento výraz CASE dosáhne klauzule ELSE?
  • Přetečení zásobníku:CRYPT_GEN_RANDOM() Podivné efekty
  • Přetečení zásobníku:CHOOSE() nefunguje, jak bylo zamýšleno
  • Přetečení zásobníku:CHECKSUM(NewId()) se provede vícekrát na řádek
  • Connect #350485 :Chyba s NEWID() a tabulkovými výrazy

  1. Odečtěte roky od data v SQLite

  2. Úprava plánu agenta SQL Server (T-SQL)

  3. Hibernace> CLOB> Oracle :(

  4. Funkce s SQL dotazem nemá žádný cíl pro výsledná data