sql >> Base de Datos >  >> RDS >> Database

Secretos sucios de la expresión CASE

El CASE expresión es una de mis construcciones favoritas en T-SQL. Es bastante flexible y, a veces, es la única forma de controlar el orden en que SQL Server evaluará los predicados.
Sin embargo, a menudo se malinterpreta.

¿Qué es la expresión CASE de T-SQL?

En T-SQL, CASE es una expresión que evalúa una o más expresiones posibles y devuelve la primera expresión adecuada. El término expresión puede estar un poco sobrecargado aquí, pero básicamente es cualquier cosa que se pueda evaluar como un único valor escalar, como una variable, una columna, un literal de cadena o incluso la salida de una función escalar o integrada. .

Hay dos formas de CASE en T-SQL:

  • Expresión CASE simple – cuando solo necesitas evaluar la igualdad:

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

  • Expresión CASE buscada – cuando necesite evaluar expresiones más complejas, como desigualdad, LIKE o IS NOT NULL:

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

La expresión de retorno es siempre un valor único y el tipo de datos de salida está determinado por la precedencia del tipo de datos.

Como dije, la expresión CASE a menudo se malinterpreta; aquí hay algunos ejemplos:

CASE es una expresión, no una declaración

Probablemente no sea importante para la mayoría de las personas, y quizás este sea solo mi lado pedante, pero mucha gente lo llama un CASE declaración – incluido Microsoft, cuya documentación utiliza statement y expresión indistintamente a veces. Encuentro esto levemente molesto (como row/record y columna/campo ) y, aunque es principalmente semántica, hay una distinción importante entre una expresión y una declaración:una expresión devuelve un resultado. Cuando la gente piensa en CASE como una declaración , conduce a experimentos de acortamiento de código como este:

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

O esto:

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

Este tipo de lógica de control de flujo puede ser posible con CASE declaraciones en otros lenguajes (como VBScript), pero no en CASE de Transact-SQL expresión . Para usar CASE dentro de la misma lógica de consulta, tendría que usar un CASE expresión para cada columna de salida:

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 no siempre provocará un cortocircuito

La documentación oficial dio a entender una vez que toda la expresión se cortocircuitará, lo que significa que evaluará la expresión de izquierda a derecha y dejará de evaluar cuando coincida:

La instrucción CASE [¡sic!] evalúa sus condiciones secuencialmente y se detiene con la primera condición cuya condición se cumple.

Sin embargo, esto no siempre es cierto. Y para su crédito, en una versión más actual, la página trató de explicar un escenario en el que esto no está garantizado. Pero solo obtiene una parte de la historia:

En algunas situaciones, una expresión se evalúa antes de que una instrucción CASE [¡sic!] reciba los resultados de la expresión como entrada. Los errores en la evaluación de estas expresiones son posibles. Las expresiones agregadas que aparecen en los argumentos CUANDO de una sentencia CASE [¡sic!] se evalúan primero y luego se proporcionan a la sentencia CASE [¡sic!]. Por ejemplo, la siguiente consulta produce un error de división por cero al producir el valor del agregado MAX. Esto ocurre antes de evaluar la expresión CASE.

El ejemplo de dividir por cero es bastante fácil de reproducir, y lo demostré en esta respuesta en dba.stackexchange.com:

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

Resultado:

Mensaje 8134, nivel 16, estado 1
Se encontró un error de división por cero.

Hay soluciones triviales (como ELSE (SELECT MIN(1/0)) END ), pero esto es una verdadera sorpresa para muchos que no han memorizado las oraciones anteriores de Books Online. Me enteré por primera vez de este escenario específico en una conversación en una lista de distribución de correo electrónico privada por parte de Itzik Ben-Gan (@ItzikBenGan), quien a su vez fue notificado inicialmente por Jaime Lafargue. Informé el error en Connect #690017:CASE / COALESCE no siempre se evaluará en orden textual; se cerró rápidamente como "Por diseño". Paul White (blog | @SQL_Kiwi) posteriormente presentó Connect #691535:Los agregados no siguen la semántica de CASE, y se cerró como "Fijo". La solución, en este caso, fue la aclaración en el artículo de Books Online; es decir, el fragmento que copié arriba.

Este comportamiento también puede darse en otros escenarios menos obvios. Por ejemplo, Connect #780132 :FREETEXT() no respeta el orden de evaluación en declaraciones CASE (sin agregados involucrados) muestra que, bueno, CASE tampoco se garantiza que el orden de evaluación sea de izquierda a derecha cuando se usan ciertas funciones de texto completo. En ese artículo, Paul White comentó que también observó algo similar usando el nuevo LAG() función introducida en SQL Server 2012. No tengo una reproducción a mano, pero le creo, y no creo que hayamos descubierto todos los casos extremos en los que esto puede ocurrir.

Por lo tanto, cuando se trate de servicios agregados o no nativos como la búsqueda de texto completo, no haga suposiciones sobre cortocircuitos en un CASE. expresión.

RAND() se puede evaluar más de una vez

A menudo veo gente escribiendo un simple CASE expresión, así:

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

Es importante entender que esto se ejecutará como un buscado CASE expresión, así:

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

La razón por la que es importante comprender que la expresión que se evalúa se evaluará varias veces es porque en realidad se puede evaluar varias veces. Cuando se trata de una variable, una constante o una referencia de columna, es poco probable que sea un problema real; sin embargo, las cosas pueden cambiar rápidamente cuando se trata de una función no determinista. Considere que esta expresión produce un SMALLINT entre 1 y 3; siga adelante y ejecútelo muchas veces, y siempre obtendrá uno de esos tres valores:

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

Ahora, pon esto en un CASE simple y ejecútelo una docena de veces; finalmente obtendrá un resultado de NULL :

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

¿Como sucedió esto? Bueno, todo el CASE expresión se expande a una expresión buscada, de la siguiente manera:

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;

A su vez, lo que sucede es que cada WHEN cláusula evalúa e invoca RAND() independientemente, y en cada caso podría arrojar un valor diferente. Digamos que ingresamos la expresión y verificamos el primer WHEN cláusula, y el resultado es 3; nos saltamos esa cláusula y seguimos adelante. Es concebible que las siguientes dos cláusulas devuelvan 1 cuando RAND() se evalúa de nuevo, en cuyo caso ninguna de las condiciones se evalúa como verdadera, por lo que ELSE se hace cargo.

Otras expresiones se pueden evaluar más de una vez

Este problema no se limita a RAND() función. Imagine el mismo estilo de no determinismo proveniente de estos objetivos en movimiento:

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

Estas expresiones obviamente pueden arrojar un valor diferente si se evalúan varias veces. Y con un CASE buscado expresión, habrá ocasiones en las que cada reevaluación quede fuera de la búsqueda específica del WHEN actual , y finalmente presione ELSE cláusula. Para protegerse de esto, una opción es codificar siempre su propio ELSE explícito; solo tenga cuidado con el valor de respaldo que elige devolver, porque esto tendrá un efecto sesgado si está buscando una distribución uniforme. Otra opción es simplemente cambiar el último WHEN cláusula a ELSE , pero esto aún conducirá a una distribución desigual. La opción preferida, en mi opinión, es intentar forzar a SQL Server para que evalúe la condición una vez (aunque esto no siempre es posible dentro de una sola consulta). Por ejemplo, compare estos dos resultados:

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

Distribución:

Valor Consulta A Consulta B Consulta C Consulta D
NULO 2572
0 2923 2900 2928 2949
1 1946 1959 1927 2896
2 1,295 3877 3881 2891

Distribución de valores con diferentes técnicas de consulta

En este caso, confío en el hecho de que SQL Server eligió evaluar la expresión en la subconsulta y no introducirla en el CASE buscado. expresión, pero esto es simplemente para demostrar que la distribución puede ser coaccionada para que sea más uniforme. En realidad, esta puede no ser siempre la elección que hace el optimizador, así que no aprenda de este pequeño truco. :-)

CHOOSE() también se ve afectado

Observará que si reemplaza CHECKSUM(NEWID()) expresión con RAND() expresión, obtendrá resultados completamente diferentes; más notablemente, este último solo devolverá un valor. Esto se debe a que RAND() , como GETDATE() y algunas otras funciones integradas, recibe un tratamiento especial como una constante de tiempo de ejecución y solo se evalúa una vez por referencia para toda la fila. Tenga en cuenta que aún puede devolver NULL al igual que la primera consulta en el ejemplo de código anterior.

Este problema tampoco se limita al CASE expresión; puede ver un comportamiento similar con otras funciones integradas que usan la misma semántica subyacente. Por ejemplo, CHOOSE es simplemente azúcar sintáctico para un CASE buscado más elaborado expresión, y esto también producirá NULL ocasionalmente:

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

IIF() es una función que esperaba caer en esta misma trampa, pero esta función es realmente solo un CASE buscado expresión con solo dos resultados posibles y sin ELSE – por lo que es difícil, sin anidar e introducir otras funciones, imaginar un escenario en el que esto pueda romperse inesperadamente. Mientras que en el caso simple es una abreviatura decente para CASE , también es difícil hacer algo útil si necesita más de dos resultados posibles. :-)

COALESCE() también se ve afectado

Finalmente, debemos examinar que COALESCE puede tener problemas similares. Consideremos que estas expresiones son equivalentes:

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

En este caso, @variable se evaluaría dos veces (al igual que cualquier función o subconsulta, como se describe en este elemento de Conexión).

Realmente pude obtener algunas miradas desconcertadas cuando mencioné el siguiente ejemplo en una discusión reciente en el foro. Digamos que quiero llenar una tabla con una distribución de valores del 1 al 5, pero cada vez que se encuentra un 3, quiero usar -1 en su lugar. No es un escenario muy real, pero es fácil de construir y seguir. Una forma de escribir esta expresión es:

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

(En inglés, trabajando de adentro hacia afuera:convierta el resultado de la expresión 1+RAND()*5 a un minúsculo; si el resultado de esa conversión es 3, configúrelo en NULL; si el resultado de eso es NULL , configúrelo en -1. Podrías escribir esto con un CASE más detallado expresión, pero concisa parece ser el rey.)

Si lo ejecuta varias veces, debería ver un rango de valores de 1 a 5, así como -1. Verá algunas instancias de 3, y es posible que también haya notado que ocasionalmente ve NULL , aunque es posible que no espere ninguno de esos resultados. Comprobemos la distribución:

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;

Resultados (sus resultados ciertamente variarán, pero la tendencia básica debería ser similar):

El Número ocurrencias
NULO 1654
-1 2002
1 1290
2 1266
3 1287
4 1251
5 1250

Distribución de TheNumber usando COALESCE

Desglosar una expresión CASE buscada

¿Ya te rascas la cabeza? ¿Cómo los valores NULL y 3 aparecen, y por qué es la distribución para NULL y -1 sustancialmente mayor? Bueno, responderé a lo primero directamente e invito a formular hipótesis para lo segundo.

La expresión se expande aproximadamente a lo siguiente, lógicamente, ya que RAND() se evalúa dos veces dentro de NULLIF , y luego multiplique eso por dos evaluaciones para cada rama de COALESCE función. No tengo un depurador a mano, así que esto no es necesariamente *exactamente* lo que se hace dentro de SQL Server, pero debería ser lo suficientemente equivalente para explicar el punto:

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

Entonces puede ver que ser evaluado varias veces puede convertirse rápidamente en un libro de Choose Your Own Adventure™, y cómo tanto NULL y 3 son posibles resultados que no parecen posibles al examinar la declaración original. Una nota al margen interesante:esto no sucede exactamente igual si toma el script de distribución anterior y reemplaza COALESCE con ISNULL . En ese caso, no hay posibilidad de un NULL producción; la distribución es más o menos la siguiente:

El Número ocurrencias
-1 1966
1 1585
2 1644
3 1573
4 1598
5 1634

Distribución de TheNumber usando ISNULL

Una vez más, sus resultados reales sin duda variarán, pero no deberían variar mucho. El punto es que todavía podemos ver que 3 se pasa por alto con bastante frecuencia, pero ISNULL elimina mágicamente el potencial de NULL para llegar hasta el final.

Hablé sobre algunas de las otras diferencias entre COALESCE y ISNULL en una sugerencia, titulada "Decidir entre COALESCE e ISNULL en SQL Server". Cuando escribí eso, estaba muy a favor de usar COALESCE excepto en el caso en que el primer argumento fuera una subconsulta (de nuevo, debido a este error "brecha de características"). Ahora no estoy tan seguro de sentirme tan fuerte al respecto.

Las expresiones CASE simples pueden anidarse en servidores vinculados

Una de las pocas limitaciones del CASE expresión es que está restringida a 10 niveles de nido. En este ejemplo de dba.stackexchange.com, Paul White demuestra (usando Plan Explorer) que una expresión simple como esta:

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

El analizador lo expande a la forma buscada:

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

Pero en realidad se puede transmitir a través de una conexión de servidor vinculado como la siguiente consulta mucho más detallada:

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

En esta situación, aunque la consulta original solo tenía un único CASE expresión con más de 10 resultados posibles, cuando se envió al servidor vinculado, tenía más de 10 anidados CASE expresiones Como tal, como era de esperar, devolvió un error:

Mensaje 8180, Nivel 16, Estado 1
No se pudieron preparar declaraciones.
Mensaje 125, Nivel 15, Estado 4
Las expresiones de casos solo se pueden anidar en el nivel 10.

En algunos casos, puede reescribirlo como sugirió Paul, con una expresión como esta (suponiendo que column_name es una columna varchar):

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

En algunos casos, solo la SUBSTRING puede ser necesario modificar la ubicación donde se evalúa la expresión; en otros, solo el CONVERT . No realicé pruebas exhaustivas, pero esto puede tener que ver con el proveedor del servidor vinculado, opciones como Collation Compatible y Use Remote Collation, y la versión de SQL Server en cada extremo de la tubería.

Para resumir, es importante recordar que su CASE expresión se puede volver a escribir para usted sin previo aviso, y que cualquier solución alternativa que use puede ser anulada más tarde por el optimizador, incluso si funciona para usted ahora.

Reflexiones finales de CASE Expression y recursos adicionales

Espero haber dado algo de qué pensar sobre algunos de los aspectos menos conocidos del CASE expresión, y algunas ideas sobre situaciones en las que CASE – y algunas de las funciones que usan la misma lógica subyacente – devuelven resultados inesperados. Algunos otros escenarios interesantes donde ha surgido este tipo de problema:

  • Desbordamiento de pila:¿Cómo llega esta expresión CASE a la cláusula ELSE?
  • Desbordamiento de pila:efectos extraños de CRYPT_GEN_RANDOM()
  • Desbordamiento de pila:CHOOSE() no funciona según lo previsto
  • Desbordamiento de pila:CHECKSUM(NewId()) se ejecuta varias veces por fila
  • Conexión n.º 350485:error con NEWID() y expresiones de tabla