sql >> Databasteknik >  >> RDS >> Database

Smutsiga hemligheter av CASE-uttrycket

CASE expression är en av mina favoritkonstruktioner i T-SQL. Det är ganska flexibelt och är ibland det enda sättet att styra i vilken ordning SQL Server ska utvärdera predikat.
Det missförstås dock ofta.

Vad är T-SQL CASE-uttrycket?

I T-SQL, CASE är ett uttryck som utvärderar ett eller flera möjliga uttryck och returnerar det första lämpliga uttrycket. Termen uttryck kan vara lite överbelastad här, men i grund och botten är det allt som kan utvärderas som ett enda skalärt värde, till exempel en variabel, en kolumn, en bokstavlig sträng eller till och med utdata från en inbyggd eller skalär funktion .

Det finns två former av CASE i T-SQL:

  • Enkelt CASE-uttryck – när du bara behöver utvärdera jämställdhet:

    FALL NÄR … [ELSE ] END

  • Sökade CASE-uttryck – när du behöver utvärdera mer komplexa uttryck, som olikhet, LIKE eller IS NOT NULL:

    CASE WHEN THEN … [ELSE ] END

Returuttrycket är alltid ett enskilt värde, och utdatatypen bestäms av datatypsprioritet.

Som sagt, uttrycket CASE missförstås ofta; här är några exempel:

CASE är ett uttryck, inte ett påstående

Förmodligen inte viktigt för de flesta, och kanske är detta bara min pedantiska sida, men många människor kallar det ett CASE uttalande – inklusive Microsoft, vars dokumentation använder påstående och uttryck ibland omväxlande. Jag tycker att detta är lätt irriterande (som rad/skiva och kolumn/fält ) och även om det mestadels är semantik, men det finns en viktig skillnad mellan ett uttryck och ett påstående:ett uttryck returnerar ett resultat. När folk tänker på CASE som ett påstående , leder det till experiment med kodförkortning så här:

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

Eller det här:

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

Denna typ av kontroll-av-flödeslogik kan vara möjlig med CASE påståenden på andra språk (som VBScript), men inte i Transact-SQL:s CASE uttryck . För att använda CASE inom samma frågelogik skulle du behöva använda ett CASE uttryck för varje utdatakolumn:

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 kortsluter inte alltid

Den officiella dokumentationen antydde en gång att hela uttrycket kommer att kortsluta, vilket betyder att det kommer att utvärdera uttrycket från vänster till höger och sluta utvärdera när det träffar en matchning:

CASE-satsen [sic!] utvärderar dess villkor sekventiellt och slutar med det första villkoret vars villkor är uppfyllt.

Detta är dock inte alltid sant. Och till dess kredit, i en mer aktuell version, fortsatte sidan med att försöka förklara ett scenario där detta inte är garanterat. Men det får bara en del av historien:

I vissa situationer utvärderas ett uttryck innan en CASE-sats [sic!] tar emot resultatet av uttrycket som dess input. Fel vid utvärdering av dessa uttryck är möjliga. Aggregerade uttryck som förekommer i WHEN-argument till en CASE-sats [sic!] utvärderas först, sedan till CASE-satsen [sic!]. Till exempel producerar följande fråga ett divideringsfel med noll när värdet för MAX-aggregatet produceras. Detta inträffar innan CASE-uttrycket utvärderas.

Dela med noll-exemplet är ganska lätt att återskapa, och jag visade det i det här svaret på dba.stackexchange.com:

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

Resultat:

Msg 8134, Level 16, State 1
Dela med noll fel påträffat.

Det finns triviala lösningar (som ELSE (SELECT MIN(1/0)) END ), men detta kommer som en riktig överraskning för många som inte har memorerat ovanstående meningar från Books Online. Jag blev först medveten om detta specifika scenario i en konversation på en privat e-postdistributionslista av Itzik Ben-Gan (@ItzikBenGan), som i sin tur först meddelades av Jaime Lafargue. Jag rapporterade felet i Connect #690017:CASE / COALESCE kommer inte alltid att utvärdera i textordning; det stängdes snabbt som "By Design." Paul White (blogg | @SQL_Kiwi) skickade därefter in Connect #691535:Aggregates Don't Follow the Semantics Of CASE, och det stängdes som "Fixed." Fixningen, i det här fallet, var förtydligande i Books Online-artikeln; nämligen utdraget som jag kopierade ovan.

Detta beteende kan ge efter sig i vissa andra, mindre uppenbara scenarier också. Till exempel, Connect #780132 :FREETEXT() respekterar inte utvärderingsordningen i CASE-satser (inga aggregat involverade) visar att, ja, CASE Utvärderingsordningen är inte heller garanterad från vänster till höger när du använder vissa fulltextfunktioner. På det föremålet kommenterade Paul White att han också observerade något liknande med den nya LAG() funktion introducerad i SQL Server 2012. Jag har ingen repro till hands, men jag tror på honom, och jag tror inte att vi har grävt fram alla kantfall där detta kan inträffa.

Så när aggregat eller icke-infödda tjänster som fulltextsökning är inblandade, gör inga antaganden om kortslutning i ett CASE uttryck.

RAND() kan utvärderas mer än en gång

Jag ser ofta folk skriva en enkel CASE uttryck, så här:

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

Det är viktigt att förstå att detta kommer att utföras som en sökt CASE uttryck, så här:

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

Anledningen till att det är viktigt att förstå att uttrycket som utvärderas kommer att utvärderas flera gånger, är för att det faktiskt kan utvärderas flera gånger. När detta är en variabel, en konstant eller en kolumnreferens är det osannolikt att detta är ett verkligt problem; men saker och ting kan förändras snabbt när det är en icke-deterministisk funktion. Tänk på att detta uttryck ger en SMALLINT mellan 1 och 3; fortsätt och kör det många gånger, så får du alltid ett av dessa tre värden:

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

Lägg nu detta i ett enkelt CASE uttryck och kör det ett dussin gånger – så småningom får du resultatet NULL :

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

Hur går det till? Tja, hela CASE uttryck utökas till ett sökt uttryck enligt följande:

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;

Det som i sin tur händer är att varje NÄR klausul utvärderar och anropar RAND() oberoende – och i varje fall kan det ge ett annat värde. Låt oss säga att vi skriver in uttrycket och vi kontrollerar den första WHEN klausul, och resultatet är 3; vi hoppar över den klausulen och går vidare. Det är tänkbart att de nästa två satserna båda returnerar 1 när RAND() utvärderas igen – i vilket fall inget av villkoren utvärderas till sant, så ANSÅ tar över.

Andra uttryck kan utvärderas mer än en gång

Det här problemet är inte begränsat till RAND() fungera. Föreställ dig samma stil av icke-determinism som kommer från dessa rörliga mål:

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

Dessa uttryck kan uppenbarligen ge ett annat värde om de utvärderas flera gånger. Och med ett sökt CASE uttryck, det kommer att finnas tillfällen då varje omvärdering råkar falla utanför sökningen som är specifik för den aktuella WHEN , och till slut trycker du på ANSE klausul. För att skydda dig från detta är ett alternativ att alltid hårdkoda din egen explicita ANSÅ; var bara försiktig med reservvärdet du väljer att returnera, eftersom detta kommer att få en skev effekt om du letar efter jämn fördelning. Ett annat alternativ är att bara ändra den sista NÄR klausul till ANSÅ , men detta kommer fortfarande att leda till ojämn fördelning. Det föredragna alternativet, enligt min mening, är att försöka tvinga SQL Server att utvärdera villkoret en gång (även om detta inte alltid är möjligt inom en enda fråga). Jämför till exempel dessa två resultat:

-- 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;

Distribution:

Värde Fråga A Fråga B Fråga C Fråga 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

Fördelning av värden med olika frågetekniker

I det här fallet förlitar jag mig på det faktum att SQL Server valde att utvärdera uttrycket i underfrågan och inte introducera det till det sökta CASE uttryck, men detta är bara för att visa att fördelningen kan tvingas till att bli jämnare. I verkligheten är det kanske inte alltid det här valet som optimeraren gör, så lär dig inte av det här lilla tricket. :-)

CHOOSE() påverkas också

Du kommer att observera att om du ersätter CHECKSUM(NEWID()) uttryck med RAND() uttryck får du helt andra resultat; framför allt kommer det senare bara att returnera ett värde. Detta beror på att RAND() , som GETDATE() och vissa andra inbyggda funktioner, behandlas särskilt som en körtidskonstant och utvärderas endast en gång per referens för hela raden. Observera att den fortfarande kan returnera NULL precis som den första frågan i föregående kodexempel.

Det här problemet är inte heller begränsat till CASE uttryck; du kan se liknande beteende med andra inbyggda funktioner som använder samma underliggande semantik. Till exempel, CHOOSE är bara syntaktisk socker för ett mer detaljerat sökt CASE uttryck, och detta kommer också att ge NULL ibland:

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

IIF() är en funktion som jag förväntade mig att falla i samma fälla, men den här funktionen är egentligen bara ett sökt CASE uttryck med endast två möjliga utfall och ingen ANSÅ – så det är svårt, utan att kapsla och introducera andra funktioner, att föreställa sig ett scenario där detta kan gå sönder oväntat. Medan det i det enkla fallet är en anständig förkortning för CASE , det är också svårt att göra något användbart med det om du behöver mer än två möjliga resultat. :-)

COALESCE() påverkas också

Slutligen bör vi undersöka den COALESCE kan ha liknande problem. Låt oss tänka på att dessa uttryck är likvärdiga:

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

I det här fallet @variable skulle utvärderas två gånger (liksom vilken funktion eller underfråga som helst, som beskrivs i detta Connect-objekt).

Jag kunde verkligen få några förbryllade blickar när jag tog upp följande exempel i en forumdiskussion nyligen. Låt oss säga att jag vill fylla i en tabell med en fördelning av värden från 1-5, men närhelst en 3:a påträffas vill jag använda -1 istället. Inte ett väldigt verkligt scenario, men lätt att konstruera och följa. Ett sätt att skriva detta uttryck är:

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

(På engelska, arbeta inifrån och ut:konvertera resultatet av uttrycket 1+RAND()*5 till en liten pinne; om resultatet av den konverteringen är 3, ställ in den på NULL; om resultatet av det är NULL , ställ in den på -1. Du kan skriva detta med ett mer utförligt CASE uttryck, men kortfattat verkar vara kung.)

Om du kör det ett gäng gånger bör du se ett värdeintervall från 1-5, samt -1. Du kommer att se några instanser av 3, och du kanske också har märkt att du ibland ser NULL , även om du kanske inte förväntar dig något av dessa resultat. Låt oss kontrollera distributionen:

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;

Resultat (dina resultat kommer säkert att variera, men den grundläggande trenden bör vara liknande):

TheNumber förekomster
NULL 1 654
-1 2 002
1 1 290
2 1 266
3 1 287
4 1 251
5 1 250

Distribution av TheNumber med COALESCE

Dela upp ett sökt CASE-uttryck

Kliar du dig i huvudet ännu? Hur fungerar värdena NULL och 3 dyker upp, och varför är distributionen för NULL och -1 betydligt högre? Tja, jag ska svara på det förra direkt och bjuda in hypoteser för det senare.

Uttrycket expanderar grovt till följande, logiskt, eftersom RAND() utvärderas två gånger i NULLIF , och multiplicera sedan det med två utvärderingar för varje gren av COALESCE fungera. Jag har ingen debugger till hands, så det här är inte nödvändigtvis *exakt* vad som görs inuti SQL Server, men det borde vara tillräckligt likvärdigt för att förklara poängen:

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

Så du kan se att att utvärderas flera gånger snabbt kan bli en Välj ditt eget äventyr™-bok, och hur både NULL och 3 är möjliga utfall som inte verkar möjliga när man granskar det ursprungliga påståendet. En intressant sidonotering:detta händer inte riktigt på samma sätt om du tar distributionsskriptet ovan och ersätter COALESCE med ISNULL . I så fall finns det ingen möjlighet för en NULL produktion; fördelningen är ungefär som följer:

TheNumber förekomster
-1 1 966
1 1 585
2 1 644
3 1 573
4 1 598
5 1 634

Distribution av TheNumber med ISNULL

Återigen, dina faktiska resultat kommer säkert att variera, men borde inte göra så mycket. Poängen är att vi fortfarande kan se att 3 faller mellan stolarna ganska ofta, men ISNULL eliminerar på magiskt sätt potentialen för NULL för att klara det hela.

Jag pratade om några av de andra skillnaderna mellan COALESCE och ISNULL i ett tips, med titeln "Bestämma mellan COALESCE och ISNULL i SQL Server." När jag skrev det var jag starkt för att använda COALESCE förutom i fallet där det första argumentet var en underfråga (igen, på grund av denna bugg "funktionsgap"). Nu är jag inte så säker på att jag känner lika starkt för det.

Enkla CASE-uttryck kan bli kapslade över länkade servrar

En av de få begränsningarna för CASE uttrycket är att det är begränsat till 10 bonivåer. I det här exemplet på dba.stackexchange.com visar Paul White (med hjälp av Plan Explorer) att ett enkelt uttryck som detta:

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

Utvidgas av tolken till den sökta formen:

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

Men kan faktiskt överföras över en länkad serveranslutning som följande, mycket mer utförliga fråga:

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

I den här situationen, även om den ursprungliga frågan bara hade en enda CASE uttryck med 10+ möjliga utfall, när det skickades till den länkade servern hade det 10+ kapslade CASE uttryck. Som sådan, som du kan förvänta dig, returnerade den ett fel:

Meddelande 8180, nivå 16, tillstånd 1
Uttalanden kunde inte förberedas.
Meddelande 125, nivå 15, tillstånd 4
Case-uttryck får endast kapslas till nivå 10.

I vissa fall kan du skriva om det som Paul föreslog, med ett uttryck som detta (förutsatt att kolumnnamn är en varchar-kolumn):

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

I vissa fall, endast SUBSTRING kan behövas för att ändra platsen där uttrycket utvärderas; i andra, endast CONVERT . Jag utförde inte uttömmande tester, men detta kan ha att göra med den länkade serverleverantören, alternativ som Collation Compatible och Use Remote Collation och versionen av SQL Server i vardera änden av röret.

Lång historia kort, det är viktigt att komma ihåg att ditt CASE uttryck kan skrivas om åt dig utan förvarning, och att alla lösningar du använder senare kan åsidosättas av optimeraren, även om det fungerar för dig nu.

CASE Expression Slutliga tankar och ytterligare resurser

Jag hoppas att jag har funderat lite på några av de mindre kända aspekterna av CASE uttryck och viss insikt i situationer där CASE – och några av funktionerna som använder samma underliggande logik – ger oväntade resultat. Några andra intressanta scenarier där den här typen av problem har dykt upp:

  • Stack Overflow :Hur når detta CASE-uttryck ELSE-satsen?
  • Stack Overflow:CRYPT_GEN_RANDOM() Konstiga effekter
  • Stack Overflow:CHOOSE() fungerar inte som avsett
  • Stackoverflow :CHECKSUM(NewId()) körs flera gånger per rad
  • Anslut #350485:Bugg med NEWID() och tabelluttryck

  1. Hämta meddelanden från Mailbox med PL/SQL Mail_Client API

  2. Hur man väljer alla kolumner och ett antal(*) i samma fråga

  3. Försöker distribuera Oracle-ADF-applikationen till Tomcat 7

  4. Tablix:Upprepa rubrikrader på varje sida som inte fungerar - Report Builder 3.0