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

CASE-udtrykkets beskidte hemmeligheder

CASE udtryk er en af ​​mine yndlingskonstruktioner i T-SQL. Det er ret fleksibelt og er nogle gange den eneste måde at kontrollere den rækkefølge, som SQL Server vil evaluere prædikater i.
Det bliver dog ofte misforstået.

Hvad er T-SQL CASE-udtrykket?

I T-SQL, CASE er et udtryk, der evaluerer et eller flere mulige udtryk og returnerer det første passende udtryk. Udtrykket udtryk kan være lidt overbelastet her, men grundlæggende er det alt, der kan evalueres som en enkelt skalarværdi, såsom en variabel, en kolonne, en streng-literal eller endda output fra en indbygget eller skalar funktion .

Der er to former for CASE i T-SQL:

  • Simpelt CASE-udtryk – når du kun skal evaluere lighed:

    CASE NÅR … [ELSE ] END

  • Søgt CASE-udtryk – når du har brug for at evaluere mere komplekse udtryk, såsom ulighed, LIKE eller IS NOT NULL:

    CASE WHEN THEN … [ELSE ] END

Returudtrykket er altid en enkelt værdi, og outputdatatypen bestemmes af datatypeforrang.

Som sagt bliver CASE-udtrykket ofte misforstået; her er nogle eksempler:

CASE er et udtryk, ikke et udsagn

Sandsynligvis ikke vigtigt for de fleste mennesker, og måske er dette bare min pedantiske side, men mange mennesker kalder det en CASE erklæring – inklusive Microsoft, hvis dokumentation bruger erklæring og udtryk i flæng til tider. Jeg synes, det er en smule irriterende (som række/optag og kolonne/felt ) og selvom det for det meste er semantik, men der er en vigtig skelnen mellem et udtryk og et udsagn:et udtryk returnerer et resultat. Når folk tænker på CASE som en erklæring , fører det til eksperimenter med kodeforkortning som denne:

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

Eller denne:

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

Denne type kontrol-af-flow-logik kan være mulig med CASE erklæringer på andre sprog (som VBScript), men ikke i Transact-SQL's CASE udtryk . For at bruge CASE inden for den samme forespørgselslogik skal du bruge en CASE udtryk for hver outputkolonne:

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 kortslutter ikke altid

Den officielle dokumentation antydede engang, at hele udtrykket vil kortslutte, hvilket betyder, at det vil evaluere udtrykket fra venstre mod højre og stoppe med at evaluere, når det rammer et match:

CASE-sætningen [sic!] evaluerer dens betingelser sekventielt og stopper med den første betingelse, hvis betingelse er opfyldt.

Dette er dog ikke altid sandt. Og til sin kredit, i en mere aktuel version, fortsatte siden med at forsøge at forklare et scenarie, hvor dette ikke er garanteret. Men det får kun en del af historien:

I nogle situationer evalueres et udtryk, før en CASE-sætning [sic!] modtager resultaterne af udtrykket som input. Fejl ved evaluering af disse udtryk er mulige. Aggregerede udtryk, der optræder i WHEN-argumenter til en CASE-sætning [sic!] evalueres først, derefter leveres til CASE-sætningen [sic!]. For eksempel producerer følgende forespørgsel en divider med nul-fejl, når værdien af ​​MAX-aggregatet produceres. Dette sker før evaluering af CASE-udtrykket.

Divider med nul-eksemplet er ret nemt at gengive, og jeg demonstrerede det i dette svar 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
Divider med nul fejl fundet.

Der er trivielle løsninger (såsom ELSE (SELECT MIN(1/0)) END ), men dette kommer som en virkelig overraskelse for mange, der ikke har lært ovenstående sætninger udenad fra Books Online. Jeg blev først gjort opmærksom på dette specifikke scenarie i en samtale på en privat e-mail distributionsliste af Itzik Ben-Gan (@ItzikBenGan), som til gengæld oprindeligt blev underrettet af Jaime Lafargue. Jeg rapporterede fejlen i Connect #690017:CASE / COALESCE vil ikke altid evaluere i tekstmæssig rækkefølge; det blev hurtigt lukket som "By Design." Paul White (blog | @SQL_Kiwi) indgav efterfølgende Connect #691535 :Aggregates Don't Follow the Semantics Of CASE, og det blev lukket som "Fixed." Rettelsen, i dette tilfælde, var en afklaring i Books Online-artiklen; nemlig uddraget, jeg kopierede ovenfor.

Denne adfærd kan også give sig selv i nogle andre, mindre indlysende scenarier. For eksempel, Connect #780132 :FREETEXT() overholder ikke evalueringsrækkefølgen i CASE-udsagn (ingen aggregater involveret) viser, at, ja, CASE Evalueringsrækkefølgen er heller ikke garanteret fra venstre mod højre ved brug af visse fuldtekstfunktioner. På det punkt kommenterede Paul White, at han også observerede noget lignende ved hjælp af den nye LAG() funktion introduceret i SQL Server 2012. Jeg har ikke en repro handy, men jeg tror på ham, og jeg tror ikke, vi har afdækket alle de kanttilfælde, hvor dette kan forekomme.

Så når aggregater eller ikke-indfødte tjenester som f.eks. fuldtekstsøgning er involveret, skal du ikke gøre nogen antagelser om kortslutning i en CASE udtryk.

RAND() kan evalueres mere end én gang

Jeg ser ofte folk skrive en simpel CASE udtryk som dette:

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

Det er vigtigt at forstå, at dette vil blive udført som en søgt CASE udtryk som dette:

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

Grunden til, at det er vigtigt at forstå, at det udtryk, der evalueres, vil blive evalueret flere gange, er fordi det faktisk kan evalueres flere gange. Når dette er en variabel, en konstant eller en kolonnereference, er det usandsynligt, at dette er et reelt problem; men tingene kan ændre sig hurtigt, når det er en ikke-deterministisk funktion. Overvej, at dette udtryk giver en SMALLINT mellem 1 og 3; gå videre og kør det mange gange, og du vil altid få en af ​​disse tre værdier:

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

Indsæt nu dette i en simpel CASE udtryk, og kør det et dusin gange – til sidst vil du få et resultat af NULL :

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

Hvordan sker det? Nå, hele CASE udtryk udvides til et søgt udtryk som følger:

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;

Til gengæld er det, der sker, at hver HVORNÅR klausul evaluerer og påkalder RAND() uafhængigt – og i hvert enkelt tilfælde kan det give en anden værdi. Lad os sige, at vi indtaster udtrykket, og vi tjekker den første HVORNÅR klausul, og resultatet er 3; vi springer den klausul over og går videre. Det er tænkeligt, at de næste to sætninger begge vil returnere 1, når RAND() evalueres igen – i hvilket tilfælde ingen af ​​betingelserne vurderes til sande, så ELSE tager over.

Andre udtryk kan evalueres mere end én gang

Dette problem er ikke begrænset til RAND() fungere. Forestil dig den samme stil af ikke-determinisme, der kommer fra disse bevægelige mål:

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

Disse udtryk kan naturligvis give en anden værdi, hvis de evalueres flere gange. Og med en søgt CASE udtryk, vil der være tidspunkter, hvor hver re-evaluering falder ud af søgningen, der er specifik for den aktuelle HVORNÅR , og til sidst ramte ELSE klausul. For at beskytte dig selv mod dette, er en mulighed altid at hårdkode din egen eksplicitte ELSE; Bare vær forsigtig med den reserveværdi, du vælger at returnere, for dette vil have en skæv effekt, hvis du leder efter jævn fordeling. En anden mulighed er blot at ændre den sidste HVORNÅR klausul til ELSE , men dette vil stadig føre til ujævn fordeling. Den foretrukne mulighed er efter min mening at prøve at tvinge SQL Server til at evaluere tilstanden én gang (selvom dette ikke altid er muligt inden for en enkelt forespørgsel). Sammenlign f.eks. disse to resultater:

-- 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ærdi Forespørgsel A Forespørgsel B Forespørgsel C Forespørgsel 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

Fordeling af værdier med forskellige forespørgselsteknikker

I dette tilfælde stoler jeg på, at SQL Server valgte at evaluere udtrykket i underforespørgslen og ikke introducere det til den søgte CASE udtryk, men dette er blot for at demonstrere, at fordelingen kan tvinges til at være mere jævn. I virkeligheden er dette måske ikke altid det valg, optimizeren træffer, så vær sød ikke at lære af dette lille trick. :-)

CHOOSE() er også påvirket

Du vil bemærke, at hvis du erstatter CHECKSUM(NEWID()) udtryk med RAND() udtryk, får du helt andre resultater; mest bemærkelsesværdigt vil sidstnævnte kun nogensinde returnere én værdi. Dette er fordi RAND() , som GETDATE() og nogle andre indbyggede funktioner, får særlig behandling som en køretidskonstant og evalueres kun én gang pr. reference for hele rækken. Bemærk, at den stadig kan returnere NULL ligesom den første forespørgsel i det foregående kodeeksempel.

Dette problem er heller ikke begrænset til CASE udtryk; du kan se lignende adfærd med andre indbyggede funktioner, der bruger den samme underliggende semantik. For eksempel VÆLG er blot syntaktisk sukker for en mere omfattende søgt CASE udtryk, og dette vil også give NULL lejlighedsvis:

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

IIF() er en funktion, som jeg forventede ville falde i den samme fælde, men denne funktion er egentlig bare en søgt CASE udtryk med kun to mulige udfald og ingen ELSE – så det er svært, uden at bygge ind og introducere andre funktioner, at forestille sig et scenarie, hvor dette kan gå i stykker uventet. Mens det i det simple tilfælde er en anstændig forkortelse for CASE , er det også svært at gøre noget nyttigt med det, hvis du har brug for mere end to mulige udfald. :-)

COALESCE() er også påvirket

Til sidst bør vi undersøge den COALESCE kan have lignende problemer. Lad os overveje, at disse udtryk er ækvivalente:

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

I dette tilfælde @variable vil blive evalueret to gange (som enhver funktion eller underforespørgsel, som beskrevet i dette Connect-element).

Jeg var virkelig i stand til at få nogle forvirrede blikke, da jeg bragte følgende eksempel op i en nylig forumdiskussion. Lad os sige, at jeg vil udfylde en tabel med en fordeling af værdier fra 1-5, men hver gang en 3 støder på, vil jeg bruge -1 i stedet for. Ikke et scenarie i den virkelige verden, men let at konstruere og følge. En måde at skrive dette udtryk på er:

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

(På engelsk, arbejde indefra og ud:konverter resultatet af udtrykket 1+RAND()*5 til en smallint; hvis resultatet af denne konvertering er 3, skal du indstille det til NULL; hvis resultatet af det er NULL , indstil den til -1. Du kunne skrive dette med en mere udførlig CASE udtryk, men kortfattet synes at være konge.)

Hvis du kører det en masse gange, bør du se en række værdier fra 1-5 samt -1. Du vil se nogle forekomster af 3, og du har måske også bemærket, at du af og til ser NULL , selvom du måske ikke forventer nogen af ​​disse resultater. Lad os tjekke fordelingen:

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;

Resultater (dine resultater vil helt sikkert variere, men den grundlæggende tendens bør være ens):

TheNumber forekomster
NULL 1.654
-1 2.002
1 1.290
2 1.266
3 1.287
4 1.251
5 1.250

Distribution af TheNumber ved hjælp af COALESCE

Nedbrydning af et søgt CASE-udtryk

Kløer du dig i hovedet endnu? Hvordan fungerer værdierne NULL og 3 vises, og hvorfor er fordelingen for NULL og -1 væsentligt højere? Nå, jeg vil besvare førstnævnte direkte og indbyde hypoteser til sidstnævnte.

Udtrykket udvides groft til følgende, logisk, da RAND() evalueres to gange inde i NULLIF , og gange det derefter med to evalueringer for hver gren af ​​COALESCE fungere. Jeg har ikke en debugger ved hånden, så dette er ikke nødvendigvis *præcis*, hvad der gøres inde i SQL Server, men det burde være tilsvarende nok til at forklare pointen:

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, at det at blive evalueret flere gange hurtigt kan blive en Vælg dit eget eventyr™-bog, og hvordan både NULL og 3 er mulige udfald, der ikke synes mulige, når man undersøger den oprindelige erklæring. En interessant sidebemærkning:dette sker ikke helt det samme, hvis du tager ovenstående distributionsscript og erstatter COALESCE med ISNULL . I så fald er der ingen mulighed for en NULL produktion; fordelingen er nogenlunde som følger:

TheNumber forekomster
-1 1.966
1 1.585
2 1.644
3 1.573
4 1.598
5 1.634

Distribution af TheNumber ved hjælp af ISNULL

Igen, dine faktiske resultater vil helt sikkert variere, men burde ikke være meget. Pointen er, at vi stadig kan se, at 3 falder gennem sprækkerne ret ofte, men ISNULL på magisk vis eliminerer potentialet for NULL for at klare det hele vejen igennem.

Jeg talte om nogle af de andre forskelle mellem COALESCE og ISNULL i et tip med titlen "Beslutning mellem COALESCE og ISNULL i SQL Server." Da jeg skrev det, gik jeg stærkt ind for at bruge COALESCE undtagen i det tilfælde, hvor det første argument var en underforespørgsel (igen, på grund af denne fejl "funktionsgab"). Nu er jeg ikke så sikker på, at jeg føler så stærkt over det.

Simple CASE-udtryk kan blive indlejret over sammenkædede servere

En af de få begrænsninger af CASE udtrykket er, at det er begrænset til 10 redeniveauer. I dette eksempel på dba.stackexchange.com demonstrerer Paul White (ved hjælp af Plan Explorer), at et simpelt udtryk som dette:

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

Bliver udvidet af parseren til den søgte form:

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

Men kan faktisk overføres via en forbundet serverforbindelse som følgende, meget mere detaljerede forespørgsel:

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 denne situation, selvom den oprindelige forespørgsel kun havde en enkelt CASE udtryk med 10+ mulige udfald, når det blev sendt til den linkede server, havde det 10+ indlejret CASE udtryk. Som sådan, som du kunne forvente, returnerede den en fejl:

Meddelelse 8180, niveau 16, tilstand 1
Erklæring(er) kunne ikke udarbejdes.
Besked 125, niveau 15, tilstand 4
Kasusudtryk må kun indlejres til niveau 10.

I nogle tilfælde kan du omskrive det, som Paul foreslog, med et udtryk som dette (forudsat kolonnenavn er en varchar-kolonne):

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 nogle tilfælde er det kun SUBSTRING kan være påkrævet for at ændre det sted, hvor udtrykket evalueres; i andre, kun CONVERT . Jeg udførte ikke udtømmende test, men dette kan have at gøre med den linkede serverudbyder, muligheder som Collation Compatible og Use Remote Collation og versionen af ​​SQL Server i hver ende af røret.

Lang historie kort, det er vigtigt at huske, at din CASE udtryk kan omskrives for dig uden varsel, og at enhver løsning, du bruger, senere kan blive tilsidesat af optimeringsværktøjet, selvom det virker for dig nu.

CASE-udtryk endelige tanker og yderligere ressourcer

Jeg håber, at jeg har givet stof til eftertanke om nogle af de mindre kendte aspekter af CASE udtryk og en vis indsigt i situationer, hvor CASE – og nogle af de funktioner, der bruger den samme underliggende logik – giver uventede resultater. Nogle andre interessante scenarier, hvor denne type problemer er dukket op:

  • Stack Overflow:Hvordan når dette CASE-udtryk ELSE-sætningen?
  • Stakoverløb:CRYPT_GEN_RANDOM() mærkelige effekter
  • Stakoverløb:CHOOSE() virker ikke efter hensigten
  • Stakoverløb:CHECKSUM(NewId()) udføres flere gange pr. række
  • Forbind #350485:Fejl med NEWID() og tabeludtryk

  1. Vigtigheden af ​​varchar-længde i MySQL-tabel

  2. Generisk Ruby-løsning til SQLite3 LIKE eller PostgreSQL ILIKE?

  3. Hvad er Oracle-ækvivalenten til SQL Servers IsNull()-funktion?

  4. Opgaveliste