Database
 sql >> Base de données >  >> RDS >> Database

Les sales secrets de l'expression CASE

Le CASE expression est l'une de mes constructions préférées dans T-SQL. C'est assez flexible, et c'est parfois le seul moyen de contrôler l'ordre dans lequel SQL Server évaluera les prédicats.
Cependant, c'est souvent mal compris.

Qu'est-ce que l'expression T-SQL CASE ?

Dans T-SQL, CASE est une expression qui évalue une ou plusieurs expressions possibles et renvoie la première expression appropriée. Le terme expression peut être un peu surchargé ici, mais fondamentalement, il s'agit de tout ce qui peut être évalué comme une valeur scalaire unique, telle qu'une variable, une colonne, un littéral de chaîne ou même la sortie d'une fonction intégrée ou scalaire. .

Il existe deux formes de CASE dans T-SQL :

  • Expression CASE simple – lorsque vous avez seulement besoin d'évaluer l'égalité :

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

  • Expression CASE recherchée – lorsque vous devez évaluer des expressions plus complexes, telles que l'inégalité, LIKE ou IS NOT NULL :

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

L'expression de retour est toujours une valeur unique et le type de données de sortie est déterminé par la priorité des types de données.

Comme je l'ai dit, l'expression CASE est souvent mal comprise; voici quelques exemples :

CASE est une expression, pas une instruction

Probablement pas important pour la plupart des gens, et c'est peut-être juste mon côté pédant, mais beaucoup de gens appellent ça un CASE déclaration – y compris Microsoft, dont la documentation utilise statement et expression parfois de façon interchangeable. Je trouve cela légèrement ennuyeux (comme row/record et colonne/champ ) et, bien qu'il s'agisse principalement de sémantique, il existe une distinction importante entre une expression et une instruction :une expression renvoie un résultat. Quand les gens pensent à CASE sous forme de déclaration , cela conduit à des expériences de raccourcissement de code comme ceci :

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

Ou ceci :

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

Ce type de logique de contrôle de flux peut être possible avec CASE déclarations dans d'autres langages (comme VBScript), mais pas dans le CASE de Transact-SQL expression . Pour utiliser CASE dans la même logique de requête, vous devrez utiliser un CASE expression pour chaque colonne de sortie :

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 ne court-circuitera pas toujours

La documentation officielle impliquait autrefois que l'expression entière serait court-circuitée, ce qui signifie qu'elle évaluerait l'expression de gauche à droite et cesserait d'évaluer lorsqu'elle trouverait une correspondance :

L'instruction CASE [sic!] évalue ses conditions séquentiellement et s'arrête à la première condition dont la condition est satisfaite.

Cependant, ce n'est pas toujours vrai. Et à son crédit, dans une version plus actuelle, la page a tenté d'expliquer un scénario où cela n'est pas garanti. Mais cela n'apporte qu'une partie de l'histoire :

Dans certaines situations, une expression est évaluée avant qu'une instruction CASE [sic!] reçoive les résultats de l'expression en entrée. Des erreurs d'évaluation de ces expressions sont possibles. Les expressions agrégées qui apparaissent dans les arguments WHEN d'une instruction CASE [sic!] sont d'abord évaluées, puis fournies à l'instruction CASE [sic!]. Par exemple, la requête suivante produit une erreur de division par zéro lors de la production de la valeur de l'agrégat MAX. Cela se produit avant l'évaluation de l'expression CASE.

L'exemple de division par zéro est assez facile à reproduire, et je l'ai démontré dans cette réponse sur dba.stackexchange.com :

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

Résultat :

Msg 8134, Niveau 16, État 1
Division par zéro erreur rencontrée.

Il existe des solutions de contournement triviales (telles que ELSE (SELECT MIN(1/0)) END ), mais cela surprend beaucoup ceux qui n'ont pas mémorisé les phrases ci-dessus de Books Online. J'ai été informé pour la première fois de ce scénario spécifique dans une conversation sur une liste de distribution de courrier électronique privée par Itzik Ben-Gan (@ItzikBenGan), qui à son tour a été initialement informé par Jaime Lafargue. J'ai signalé le bug dans Connect #690017 :CASE / COALESCE ne sera pas toujours évalué dans l'ordre textuel; il a été rapidement fermé sous le nom de "By Design". Paul White (blog | @SQL_Kiwi) a ensuite déposé Connect #691535 :les agrégats ne suivent pas la sémantique de CASE, et il a été fermé comme "fixe". Le correctif, dans ce cas, était une clarification dans l'article de Books Online; à savoir, l'extrait que j'ai copié ci-dessus.

Ce comportement peut également se produire dans d'autres scénarios moins évidents. Par exemple, Connect #780132 :FREETEXT() n'honore pas l'ordre d'évaluation dans les instructions CASE (aucun agrégat n'est impliqué) montre que, eh bien, CASE l'ordre d'évaluation n'est pas non plus garanti de gauche à droite lors de l'utilisation de certaines fonctions de texte intégral. Sur cet élément, Paul White a commenté qu'il avait également observé quelque chose de similaire en utilisant le nouveau LAG() fonction introduite dans SQL Server 2012. Je n'ai pas de reproduction à portée de main, mais je le crois, et je ne pense pas que nous ayons découvert tous les cas extrêmes où cela peut se produire.

Ainsi, lorsque des agrégats ou des services non natifs tels que la recherche en texte intégral sont impliqués, veuillez ne pas faire d'hypothèses sur le court-circuit dans un CASE expression.

RAND() peut être évalué plusieurs fois

Je vois souvent des gens écrire un simple CASE expression, comme ceci :

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

Il est important de comprendre que cela sera exécuté comme une recherche CASE expression, comme ceci :

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

La raison pour laquelle il est important de comprendre que l'expression en cours d'évaluation sera évaluée plusieurs fois, c'est parce qu'elle peut en fait être évaluée plusieurs fois. Lorsqu'il s'agit d'une variable, d'une constante ou d'une référence de colonne, il est peu probable que ce soit un réel problème ; cependant, les choses peuvent changer rapidement lorsqu'il s'agit d'une fonction non déterministe. Considérez que cette expression donne un SMALLINT entre 1 et 3 ; allez-y et lancez-le plusieurs fois, et vous obtiendrez toujours l'une de ces trois valeurs :

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

Maintenant, mettez cela dans un simple CASE expression, et exécutez-la une douzaine de fois - vous obtiendrez éventuellement un résultat de NULL :

SELECT [résultat] =CASE CONVERT(SMALLINT, 1+RAND()*3) QUAND 1 ALORS 'un' QUAND 2 ALORS 'deux' QUAND 3 ALORS 'trois'END ;

Comment cela peut-il arriver? Eh bien, tout le CASE expression est étendue à une expression recherchée, comme suit :

SELECT [result] =CASE WHEN CONVERT(SMALLINT, 1+RAND()*3) =1 THEN 'un' WHEN CONVERT(SMALLINT, 1+RAND()*3) =2 THEN 'two' WHEN CONVERT( SMALLINT, 1+RAND()*3) =3 THEN 'three' ELSE NULL -- c'est toujours implicitement làEND;

À son tour, ce qui se passe est que chaque WHEN la clause évalue et invoque RAND() indépendamment - et dans chaque cas, cela pourrait donner une valeur différente. Disons que nous entrons dans l'expression et que nous vérifions le premier WHEN clause, et le résultat est 3 ; nous sautons cette clause et passons à autre chose. Il est concevable que les deux clauses suivantes retournent toutes les deux 1 lorsque RAND() est à nouveau évaluée - auquel cas aucune des conditions n'est évaluée comme vraie, donc le ELSE prend le relais.

D'autres expressions peuvent être évaluées plus d'une fois

Ce problème n'est pas limité au RAND() une fonction. Imaginez le même style de non-déterminisme venant de ces cibles mouvantes :

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

Ces expressions peuvent évidemment donner une valeur différente si elles sont évaluées plusieurs fois. Et avec un CASE recherché expression, il y aura des moments où chaque réévaluation tombera en dehors de la recherche spécifique au WHEN actuel , et finalement appuyez sur ELSE clause. Pour vous protéger de cela, une option consiste à toujours coder en dur votre propre ELSE explicite; faites juste attention à la valeur de repli que vous choisissez de renvoyer, car cela aura un effet de biais si vous recherchez une distribution uniforme. Une autre option est de simplement changer le dernier WHEN clause à ELSE , mais cela conduira toujours à une répartition inégale. L'option préférée, à mon avis, est d'essayer de contraindre SQL Server à évaluer la condition une fois (bien que ce ne soit pas toujours possible dans une seule requête). Par exemple, comparez ces deux résultats :

-- Requête A :expression référencée directement dans 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 ) COMME y GROUPER PAR x ; -- Requête B :clause ELSE supplémentaire : 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; -- Requête C :finale WHEN convertie en 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; -- Requête D :pousser l'évaluation de NEWID() vers la sous-requête :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;

Diffusion :

Valeur Requête A Requête B Requête C Requête 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

Répartition des valeurs avec différentes techniques de requête

Dans ce cas, je m'appuie sur le fait que SQL Server a choisi d'évaluer l'expression dans la sous-requête et de ne pas l'introduire dans le CASE recherché expression, mais c'est simplement pour démontrer que la distribution peut être forcée à être plus égale. En réalité, ce n'est pas toujours le choix que fait l'optimiseur, alors s'il vous plaît, n'apprenez pas de cette petite astuce. :-)

CHOOSE() est également affecté

Vous remarquerez que si vous remplacez le CHECKSUM(NEWID()) expression avec le RAND() expression, vous obtiendrez des résultats entièrement différents ; plus particulièrement, ce dernier ne renverra qu'une seule valeur. C'est parce que RAND() , comme GETDATE() et certaines autres fonctions intégrées, reçoit un traitement spécial en tant que constante d'exécution et n'est évaluée qu'une seule fois par référence pour toute la rangée. Notez qu'il peut toujours retourner NULL tout comme la première requête dans l'exemple de code précédent.

Ce problème ne se limite pas non plus au CASE expression; vous pouvez voir un comportement similaire avec d'autres fonctions intégrées qui utilisent la même sémantique sous-jacente. Par exemple, CHOOSE est simplement du sucre syntaxique pour un CASE recherché plus élaboré expression, et cela donnera également NULL occasionnellement :

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

IIF() est une fonction que je m'attendais à tomber dans ce même piège, mais cette fonction n'est en réalité qu'un CASE recherché expression avec seulement deux résultats possibles, et aucun ELSE – il est donc difficile, sans imbriquer et introduire d'autres fonctions, d'envisager un scénario où cela peut se briser de manière inattendue. Alors que dans le cas simple, c'est un raccourci décent pour CASE , il est également difficile d'en faire quoi que ce soit d'utile si vous avez besoin de plus de deux résultats possibles. :-)

COALESCE() est également affecté

Enfin, nous devrions examiner que COALESCE peut avoir des problèmes similaires. Considérons que ces expressions sont équivalentes :

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

Dans ce cas, @variable serait évalué deux fois (comme le serait n'importe quelle fonction ou sous-requête, comme décrit dans cet élément Connect).

J'ai vraiment pu avoir des regards perplexes lorsque j'ai présenté l'exemple suivant dans une discussion récente sur le forum. Disons que je veux remplir une table avec une distribution de valeurs de 1 à 5, mais chaque fois qu'un 3 est rencontré, je veux utiliser -1 à la place. Pas un scénario très réel, mais facile à construire et à suivre. Une façon d'écrire cette expression est :

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

(En anglais, travailler de l'intérieur vers l'extérieur :convertir le résultat de l'expression 1+RAND()*5 à un petit entier ; si le résultat de cette conversion est 3, définissez-le sur NULL; si le résultat est NULL , réglez-le sur -1. Vous pouvez écrire ceci avec un CASE plus verbeux expression, mais concis semble être roi.)

Si vous exécutez cela plusieurs fois, vous devriez voir une plage de valeurs de 1 à 5, ainsi que -1. Vous verrez quelques instances de 3, et vous avez peut-être aussi remarqué que vous voyez occasionnellement NULL , bien que vous ne vous attendiez peut-être à aucun de ces résultats. Vérifions la distribution :

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, occurrences =COUNT(*) FROM dbo.dist GROUP BY TheNumber ORDER BY TheNumber;GODROP TABLE dbo.dist;

Résultats (vos résultats varieront certainement, mais la tendance de base devrait être similaire) :

LeNombre occurrences
NULL 1 654
-1 2 002
1 1 290
2 1 266
3 1 287
4 1 251
5 1 250

Distribution de TheNumber à l'aide de COALESCE

Décomposer une expression CASE recherchée

Vous vous grattez encore la tête ? Comment les valeurs NULL et 3 apparaissent, et pourquoi la distribution est-elle pour NULL et -1 sensiblement plus élevé? Eh bien, je répondrai directement au premier et inviterai des hypothèses pour le second.

L'expression se développe grossièrement comme suit, logiquement, puisque RAND() est évalué deux fois dans NULLIF , puis multipliez cela par deux évaluations pour chaque branche du COALESCE une fonction. Je n'ai pas de débogueur à portée de main, donc ce n'est pas nécessairement *exactement* ce qui se fait dans SQL Server, mais cela devrait être suffisamment équivalent pour expliquer le point :

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

Ainsi, vous pouvez voir qu'être évalué plusieurs fois peut rapidement devenir un livre Choisissez votre propre aventure™, et comment les deux NULL et 3 sont des résultats possibles qui ne semblent pas possibles lors de l'examen de la déclaration d'origine. Une remarque intéressante :cela ne se produit pas tout à fait de la même façon si vous prenez le script de distribution ci-dessus et remplacez COALESCE avec ISNULL . Dans ce cas, il n'y a pas de possibilité pour un NULL production; la répartition est à peu près la suivante :

LeNombre occurrences
-1 1 966
1 1 585
2 1 644
3 1 573
4 1 598
5 1 634

Distribution de TheNumber en utilisant ISNULL

Encore une fois, vos résultats réels varieront certainement, mais ne devraient pas beaucoup. Le fait est que nous pouvons toujours voir que 3 passe assez souvent entre les mailles du filet, mais ISNULL élimine comme par magie le potentiel de NULL pour aller jusqu'au bout.

J'ai parlé de certaines des autres différences entre COALESCE et ISNULL dans un conseil, intitulé "Décider entre COALESCE et ISNULL dans SQL Server". Quand j'ai écrit cela, j'étais fortement en faveur de l'utilisation de COALESCE sauf dans le cas où le premier argument était une sous-requête (encore une fois, à cause de ce bug "lacune de fonctionnalité"). Maintenant, je ne suis pas sûr d'avoir autant de sentiments à ce sujet.

Des expressions CASE simples peuvent être imbriquées sur des serveurs liés

Une des rares limitations du CASE L'expression est limitée à 10 niveaux d'imbrication. Dans cet exemple sur dba.stackexchange.com, Paul White démontre (à l'aide de Plan Explorer) qu'une expression simple comme celle-ci :

SELECT CASE nom_colonne QUAND '1' PUIS 'a' QUAND '2' PUIS 'b' QUAND '3' PUIS 'c' ...ENDFROM ...

Est étendu par l'analyseur au formulaire recherché :

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

Mais peut en fait être transmis via une connexion à un serveur lié sous la forme de la requête suivante, beaucoup plus détaillée :

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

Dans cette situation, même si la requête d'origine n'avait qu'un seul CASE expression avec plus de 10 résultats possibles, lorsqu'elle était envoyée au serveur lié, elle avait plus de 10 imbriquées CASE expressions. En tant que tel, comme vous vous en doutez, il a renvoyé une erreur :

Msg 8180, Niveau 16, État 1
Les instructions n'ont pas pu être préparées.
Msg 125, Niveau 15, État 4
Les expressions de casse ne peuvent être imbriquées qu'au niveau 10.

Dans certains cas, vous pouvez le réécrire comme Paul l'a suggéré, avec une expression comme celle-ci (en supposant que column_name est une colonne varchar) :

SELECT CASE CONVERT(VARCHAR(MAX), SUBSTRING(column_name, 1, 255)) QUAND 'a' ALORS '1' QUAND 'b' ALORS '2' QUAND 'c' ALORS '3' ...ENDFROM . ..

Dans certains cas, seul le SUBSTRING peut être nécessaire pour modifier l'emplacement où l'expression est évaluée ; dans d'autres, seul le CONVERT . Je n'ai pas effectué de tests exhaustifs, mais cela peut être lié au fournisseur de serveur lié, à des options telles que Collation Compatible et Use Remote Collation, et à la version de SQL Server à chaque extrémité du tuyau.

Pour faire court, il est important de se rappeler que votre CASE expression peut être réécrite pour vous sans avertissement, et que toute solution de contournement que vous utilisez peut être annulée ultérieurement par l'optimiseur, même si cela fonctionne pour vous maintenant.

Réflexions finales sur CASE Expression et ressources supplémentaires

J'espère avoir donné matière à réflexion sur certains des aspects les moins connus du CASE expression, et un aperçu des situations où CASE – et certaines des fonctions qui utilisent la même logique sous-jacente – renvoient des résultats inattendus. Quelques autres scénarios intéressants où ce type de problème a surgi :

  • Stack Overflow :comment cette expression CASE atteint-elle la clause ELSE ?
  • Débordement de pile :CRYPT_GEN_RANDOM() Effets étranges
  • Stack Overflow :CHOOSE() ne fonctionne pas comme prévu
  • Stack Overflow :CHECKSUM(NewId()) s'exécute plusieurs fois par ligne
  • Connect #350485 :Bug avec NEWID() et les expressions de table