sql >> Database >  >> RDS >> Database

Vuile geheimen van de CASE-expressie

De CASE expressie is een van mijn favoriete constructies in T-SQL. Het is vrij flexibel en is soms de enige manier om de volgorde te bepalen waarin SQL Server predikaten evalueert.
Het wordt echter vaak verkeerd begrepen.

Wat is de T-SQL CASE-expressie?

In T-SQL, CASE is een expressie die een of meer mogelijke expressies evalueert en de eerste geschikte expressie retourneert. De term expressie is hier misschien een beetje overbelast, maar in feite is het alles dat kan worden geëvalueerd als een enkele, scalaire waarde, zoals een variabele, een kolom, een letterlijke tekenreeks, of zelfs de uitvoer van een ingebouwde of scalaire functie .

Er zijn twee vormen van CASE in T-SQL:

  • Eenvoudige CASE-uitdrukking – wanneer u alleen gelijkheid hoeft te evalueren:

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

  • Gezochte CASE-expressie – wanneer u complexere uitdrukkingen moet evalueren, zoals ongelijkheid, LIKE of IS NOT NULL:

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

De retourexpressie is altijd een enkele waarde en het uitvoergegevenstype wordt bepaald door de prioriteit van het gegevenstype.

Zoals ik al zei, wordt de uitdrukking CASE vaak verkeerd begrepen; hier zijn enkele voorbeelden:

CASE is een uitdrukking, geen statement

Waarschijnlijk niet belangrijk voor de meeste mensen, en misschien is dit gewoon mijn pedante kant, maar veel mensen noemen het een CASE verklaring – inclusief Microsoft, wiens documentatie gebruik maakt van statement en expressie af en toe uitwisselbaar. Ik vind dit een beetje vervelend (zoals row/record en kolom/veld ) en hoewel het voornamelijk semantiek is, is er een belangrijk onderscheid tussen een uitdrukking en een instructie:een uitdrukking retourneert een resultaat. Als mensen denken aan CASE als een statement , leidt dit tot experimenten met het inkorten van code als volgt:

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

Of dit:

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

Dit type control-of-flow-logica is mogelijk met CASE uitspraken in andere talen (zoals VBScript), maar niet in Transact-SQL's CASE uitdrukking . Om CASE te gebruiken binnen dezelfde query-logica zou u een CASE . moeten gebruiken uitdrukking voor elke uitvoerkolom:

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 zal niet altijd kortsluiten

De officiële documentatie suggereerde ooit dat de hele uitdrukking zal kortsluiten, wat betekent dat het de uitdrukking van links naar rechts zal evalueren en stopt met evalueren wanneer er een overeenkomst wordt gevonden:

De CASE-instructie [sic!] evalueert de voorwaarden opeenvolgend en stopt met de eerste voorwaarde waarvan aan de voorwaarde is voldaan.

Dit is echter niet altijd waar. En het strekt tot eer, in een recentere versie, probeerde de pagina een scenario uit te leggen waarin dit niet gegarandeerd is. Maar het krijgt maar een deel van het verhaal:

In sommige situaties wordt een uitdrukking geëvalueerd voordat een CASE-instructie [sic!] de resultaten van de uitdrukking als invoer ontvangt. Fouten bij het evalueren van deze uitdrukkingen zijn mogelijk. Geaggregeerde expressies die voorkomen in WHEN-argumenten voor een CASE-instructie [sic!] worden eerst geëvalueerd en vervolgens geleverd aan de CASE-instructie [sic!]. De volgende query produceert bijvoorbeeld een fout door delen door nul bij het produceren van de waarde van het MAX-aggregaat. Dit gebeurt voordat de CASE-expressie wordt geëvalueerd.

Het voorbeeld van delen door nul is vrij eenvoudig te reproduceren en ik demonstreerde het in dit antwoord op dba.stackexchange.com:

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

Resultaat:

Msg 8134, Level 16, State 1
Delen door nul opgetreden fout.

Er zijn triviale oplossingen (zoals ELSE (SELECT MIN(1/0)) END ), maar dit komt als een echte verrassing voor velen die de bovenstaande zinnen van Books Online niet hebben onthouden. Ik werd voor het eerst op de hoogte gebracht van dit specifieke scenario in een gesprek op een privé-e-maildistributielijst door Itzik Ben-Gan (@ItzikBenGan), die op zijn beurt aanvankelijk werd geïnformeerd door Jaime Lafargue. Ik heb de bug gemeld in Connect #690017:CASE / COALESCE wordt niet altijd in tekstuele volgorde geëvalueerd; het werd snel gesloten als "By Design." Paul White (blog | @SQL_Kiwi) heeft vervolgens Connect #691535 ingediend:Aggregaten volgen de semantiek van CASE niet, en het werd gesloten als "Vast". De oplossing was in dit geval verduidelijking in het Books Online-artikel; namelijk het fragment dat ik hierboven heb gekopieerd.

Dit gedrag kan zich ook in sommige andere, minder voor de hand liggende scenario's voordoen. Connect #780132 :FREETEXT() houdt zich bijvoorbeeld niet aan de volgorde van evaluatie in CASE-statements (er zijn geen aggregaten bij betrokken) laat zien dat, nou ja, CASE De evaluatievolgorde is ook niet gegarandeerd van links naar rechts bij het gebruik van bepaalde full-text-functies. Over dat item merkte Paul White op dat hij ook iets soortgelijks had waargenomen met behulp van de nieuwe LAG() functie geïntroduceerd in SQL Server 2012. Ik heb geen repro bij de hand, maar ik geloof hem wel, en ik denk niet dat we alle randgevallen hebben ontdekt waarin dit kan voorkomen.

Dus, als het om aggregaten of niet-native services zoals Full-Text Search gaat, maak dan geen aannames over kortsluiting in een CASE uitdrukking.

RAND() kan meer dan één keer worden geëvalueerd

Ik zie vaak mensen een eenvoudige . schrijven CASE uitdrukking, zoals deze:

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

Het is belangrijk om te begrijpen dat dit zal worden uitgevoerd als een gezocht CASE uitdrukking, zoals deze:

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

De reden waarom het belangrijk is om te begrijpen dat de uitdrukking die wordt geëvalueerd, meerdere keren zal worden geëvalueerd, is omdat deze daadwerkelijk kan worden geëvalueerd meerdere keren. Als dit een variabele, of een constante, of een kolomverwijzing is, is dit waarschijnlijk geen echt probleem; dingen kunnen echter snel veranderen als het een niet-deterministische functie is. Bedenk dat deze uitdrukking een SMALLINT . oplevert tussen 1 en 3; ga je gang en voer het vele malen uit, en je krijgt altijd een van die drie waarden:

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

Zet dit nu in een eenvoudige CASE expressie, en voer het een tiental keer uit - uiteindelijk krijg je een resultaat van NULL :

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

Hoe gebeurde dit? Welnu, de hele CASE uitdrukking wordt als volgt uitgebreid tot een gezochte uitdrukking:

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;

Wat er op zijn beurt gebeurt, is dat elke WHEN clausule evalueert en roept RAND() . op onafhankelijk van elkaar – en in elk geval kan het een andere waarde opleveren. Laten we zeggen dat we de uitdrukking invoeren, en we controleren de eerste WHEN clausule, en het resultaat is 3; we slaan die clausule over en gaan verder. Het is denkbaar dat de volgende twee clausules beide een 1 retourneren wanneer RAND() wordt opnieuw geëvalueerd - in welk geval geen van de voorwaarden wordt geëvalueerd als waar, dus de ELSE neemt het over.

Andere uitdrukkingen kunnen meer dan één keer worden geëvalueerd

Dit probleem is niet beperkt tot de RAND() functie. Stel je voor dat dezelfde stijl van niet-determinisme voortkomt uit deze bewegende doelen:

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

Deze uitdrukkingen kunnen uiteraard een andere waarde opleveren als ze meerdere keren worden geëvalueerd. En met een gezochte CASE uitdrukking, zullen er momenten zijn dat elke herevaluatie uit de zoekopdracht valt die specifiek is voor de huidige WHEN , en druk uiteindelijk op de ELSE clausule. Om jezelf hiertegen te beschermen, is een optie om altijd je eigen expliciete ELSE . te coderen; wees voorzichtig met de fallback-waarde die u wilt retourneren, omdat dit een scheef effect heeft als u op zoek bent naar een gelijkmatige verdeling. Een andere optie is om gewoon de laatste WHEN . te veranderen clausule naar ELSE , maar dit zal nog steeds leiden tot ongelijke verdeling. De voorkeursoptie is naar mijn mening om te proberen SQL Server te dwingen de voorwaarde één keer te evalueren (hoewel dit niet altijd mogelijk is binnen een enkele query). Vergelijk bijvoorbeeld deze twee resultaten:

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

Distributie:

Waarde Query A Query B Query C Query D
NULL 2.572
0 2.923 2.900 2.928 2.949
1 1946 1.959 1.927 2.896
2 1.295 3.877 3.881 2.891

Distributie van waarden met verschillende zoektechnieken

In dit geval vertrouw ik op het feit dat SQL Server ervoor heeft gekozen om de expressie in de subquery te evalueren en niet in te voeren in de gezochte CASE expressie, maar dit is alleen maar om aan te tonen dat de verdeling kan worden gedwongen om gelijkmatiger te zijn. In werkelijkheid is dit misschien niet altijd de keuze die de optimizer maakt, dus leer alsjeblieft niet van deze kleine truc. :-)

CHOOSE() wordt ook beïnvloed

U zult merken dat als u de CHECKSUM(NEWID()) . vervangt uitdrukking met de RAND() expressie, je krijgt heel andere resultaten; met name de laatste zal slechts één waarde retourneren. Dit komt omdat RAND() , zoals GETDATE() en enkele andere ingebouwde functies, krijgt een speciale behandeling als een runtime-constante en wordt slechts eenmaal per referentie geëvalueerd voor de hele rij. Merk op dat het nog steeds NULL kan retourneren net als de eerste query in het voorgaande codevoorbeeld.

Dit probleem is ook niet beperkt tot de CASE uitdrukking; u kunt vergelijkbaar gedrag zien met andere ingebouwde functies die dezelfde onderliggende semantiek gebruiken. Bijvoorbeeld CHOOSE is slechts syntactische suiker voor een uitgebreider gezocht CASE expressie, en dit levert ook NULL . op af en toe:

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

IIF() is een functie waarvan ik verwachtte dat hij in dezelfde val zou trappen, maar deze functie is eigenlijk gewoon een gezochte CASE uitdrukking met slechts twee mogelijke uitkomsten, en geen ELSE – dus het is moeilijk om, zonder nesten en het introduceren van andere functies, een scenario voor te stellen waarin dit onverwachts kan breken. Terwijl het in het eenvoudige geval een behoorlijke afkorting is voor CASE , is het ook moeilijk om er iets nuttigs mee te doen als je meer dan twee mogelijke uitkomsten nodig hebt. :-)

COALESCE() wordt ook beïnvloed

Ten slotte moeten we onderzoeken dat COALESCE soortgelijke problemen kunnen hebben. Laten we aannemen dat deze uitdrukkingen equivalent zijn:

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

In dit geval @variable zou twee keer worden geëvalueerd (zoals elke functie of subquery, zoals beschreven in dit Connect-item).

Ik kon echt wat verbaasde blikken krijgen toen ik het volgende voorbeeld naar voren bracht in een recente forumdiscussie. Laten we zeggen dat ik een tabel wil vullen met een verdeling van waarden van 1-5, maar wanneer ik een 3 tegenkom, wil ik in plaats daarvan -1 gebruiken. Geen erg realistisch scenario, maar eenvoudig te construeren en te volgen. Een manier om deze uitdrukking te schrijven is:

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

(In het Engels, van binnen naar buiten werken:converteer het resultaat van de uitdrukking 1+RAND()*5 tot een kleintje; als het resultaat van die conversie 3 is, stel het dan in op NULL; als het resultaat daarvan NULL is , zet deze op -1. Je zou dit kunnen schrijven met een meer uitgebreide CASE uitdrukking, maar beknopt lijkt de koning te zijn.)

Als je dat een aantal keren uitvoert, zou je een bereik van waarden van 1-5 en -1 moeten zien. U zult enkele gevallen van 3 zien, en het is u misschien ook opgevallen dat u af en toe NULL . ziet , hoewel u geen van beide resultaten zou verwachten. Laten we eens kijken naar de distributie:

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;

Resultaten (uw resultaten zullen zeker variëren, maar de basistrend zou vergelijkbaar moeten zijn):

TheNumber voorvallen
NULL 1.654
-1 2.002
1 1290
2 1.266
3 1.287
4 1.251
5 1250

Distributie van TheNumber met COALESCE

Een gezochte CASE-uitdrukking opsplitsen

Ben je je hoofd al aan het krabben? Hoe werken de waarden NULL en 3 verschijnen, en waarom is de distributie voor NULL en -1 aanzienlijk hoger? Welnu, ik zal de eerste rechtstreeks beantwoorden en hypothesen voor de laatste uitnodigen.

De uitdrukking wordt ruwweg uitgebreid tot het volgende, logisch, aangezien RAND() wordt tweemaal geëvalueerd binnen NULLIF en vermenigvuldig dat vervolgens met twee evaluaties voor elke tak van de COALESCE functie. Ik heb geen debugger bij de hand, dus dit is niet noodzakelijk *exact* wat er in SQL Server wordt gedaan, maar het zou equivalent genoeg moeten zijn om het punt uit te leggen:

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

U kunt dus zien dat meerdere keren evalueren snel een Choose Your Own Adventure™-boek kan worden, en hoe beide NULL en 3 zijn mogelijke uitkomsten die niet mogelijk lijken bij het onderzoeken van de oorspronkelijke verklaring. Een interessante kanttekening:dit gebeurt niet helemaal hetzelfde als je het bovenstaande distributiescript neemt en COALESCE vervangt met ISNULL . In dat geval is er geen mogelijkheid voor een NULL uitgang; de verdeling is ongeveer als volgt:

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

Distributie van TheNumber met ISNULL

Nogmaals, uw werkelijke resultaten zullen zeker variëren, maar niet veel. Het punt is dat we nog steeds kunnen zien dat 3 vrij vaak door de kieren valt, maar ISNULL elimineert op magische wijze het potentieel voor NULL om er helemaal doorheen te komen.

Ik heb het gehad over enkele van de andere verschillen tussen COALESCE en ISNULL in een tip, getiteld "Beslissen tussen COALESCE en ISNULL in SQL Server." Toen ik dat schreef, was ik een groot voorstander van het gebruik van COALESCE behalve in het geval dat het eerste argument een subquery was (opnieuw vanwege deze bug "functiehiaat"). Nu weet ik niet zo zeker of ik daar zo sterk over denk.

Eenvoudige CASE-expressies kunnen genest worden over gekoppelde servers

Een van de weinige beperkingen van de CASE expressie is die beperkt is tot 10 nestniveaus. In dit voorbeeld op dba.stackexchange.com demonstreert Paul White (met behulp van Plan Explorer) dat een eenvoudige uitdrukking als deze:

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

Wordt door de parser uitgebreid naar het gezochte formulier:

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

Maar kan in feite worden verzonden via een gekoppelde serververbinding als de volgende, veel uitgebreidere vraag:

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 deze situatie, ook al had de oorspronkelijke zoekopdracht maar één CASE expressie met 10+ mogelijke uitkomsten, wanneer verzonden naar de gekoppelde server, had deze 10+ geneste CASE uitdrukkingen. Als zodanig heeft het, zoals je zou verwachten, een fout geretourneerd:

Msg 8180, Level 16, State 1
Statement(en) konden niet worden voorbereid.
Msg 125, Level 15, State 4
Case-expressies mogen alleen worden genest op level 10.

In sommige gevallen kun je het herschrijven zoals Paul suggereerde, met een uitdrukking als deze (ervan uitgaande dat column_name is een varchar-kolom):

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 sommige gevallen is alleen de SUBSTRING kan nodig zijn om de locatie te wijzigen waar de uitdrukking wordt geëvalueerd; in andere, alleen de CONVERT . Ik heb geen uitgebreide tests uitgevoerd, maar dit kan te maken hebben met de provider van de gekoppelde server, opties zoals Collation Compatible en Use Remote Collation, en de versie van SQL Server aan beide uiteinden van de pijp.

Om een ​​lang verhaal kort te maken, het is belangrijk om te onthouden dat uw CASE expressie voor u kan worden herschreven zonder waarschuwing, en dat elke tijdelijke oplossing die u gebruikt later kan worden overruled door de optimizer, zelfs als het nu voor u werkt.

CASE Expression Laatste gedachten en aanvullende bronnen

Ik hoop dat ik wat stof tot nadenken heb gegeven over enkele van de minder bekende aspecten van de CASE uitdrukking, en enig inzicht in situaties waarin CASE – en sommige functies die dezelfde onderliggende logica gebruiken – leveren onverwachte resultaten op. Enkele andere interessante scenario's waarin dit type probleem zich voordoet:

  • Stack Overflow:Hoe bereikt deze CASE-expressie de ELSE-clausule?
  • Stack Overflow:CRYPT_GEN_RANDOM() Vreemde effecten
  • Stack Overflow:CHOOSE() Werkt niet zoals bedoeld
  • Stack Overflow:CHECKSUM(NewId()) wordt meerdere keren per rij uitgevoerd
  • Verbind #350485 :Bug met NEWID() en Table Expressions

  1. SQL Server-systeemdatabases - Systeemdatabases herstellen

  2. Hoe u SQL Server-databases van het ene exemplaar naar het andere kopieert

  3. Een Amazon Aurora-cluster maken

  4. SQL-BEPERKINGEN