Database
 sql >> Base de Dados >  >> RDS >> Database

Bugs, armadilhas e práticas recomendadas do T-SQL – dinamizando e não dinamizando


Este artigo é a quinta parte de uma série sobre bugs, armadilhas e práticas recomendadas do T-SQL. Anteriormente, abordei determinismo, subconsultas, junções e janelas. Este mês, abordo pivotar e não pivotar. Obrigado Erland Sommarskog, Aaron Bertrand, Alejandro Mesa, Umachandar Jayachandran (UC), Fabiano Neves Amorim, Milos Radivojevic, Simon Sabin, Adam Machanic, Thomas Grohser, Chan Ming Man e Paul White por compartilhar suas sugestões!

Em meus exemplos, usarei um banco de dados de exemplo chamado TSQLV5. Você pode encontrar o script que cria e preenche esse banco de dados aqui e seu diagrama ER aqui.

Agrupamento implícito com PIVOT


Quando as pessoas desejam dinamizar dados usando T-SQL, elas usam uma solução padrão com uma consulta agrupada e expressões CASE ou o operador de tabela PIVOT proprietário. O principal benefício do operador PIVOT é que ele tende a resultar em código mais curto. No entanto, esse operador possui algumas deficiências, entre elas uma armadilha de design inerente que pode resultar em bugs em seu código. Aqui descreverei a armadilha, o bug em potencial e uma prática recomendada que evita o bug. Também descreverei uma sugestão para aprimorar a sintaxe do operador PIVOT de uma maneira que ajude a evitar o bug.

Quando você dinamiza dados, há três etapas envolvidas na solução, com três elementos associados:
  1. Grupo baseado em um elemento de agrupamento/em linhas
  2. Spread baseado em um elemento spread/on cols
  3. Agregar com base em um elemento de agregação/dados

Segue a sintaxe do operador PIVOT:
SELECT FROM  PIVOT( () FOR  IN() ) AS ;

O design do operador PIVOT exige que você especifique explicitamente os elementos de agregação e difusão, mas permite que o SQL Server descubra implicitamente o elemento de agrupamento por eliminação. Quaisquer colunas que apareçam na tabela de origem fornecida como entrada para o operador PIVOT, elas se tornarão implicitamente o elemento de agrupamento.

Suponha, por exemplo, que você queira consultar a tabela Sales.Orders no banco de dados de amostra TSQLV5. Você deseja retornar IDs de remetente em linhas, anos de envio em colunas e a contagem de pedidos por remetente e ano como agregado.

Muitas pessoas têm dificuldade em descobrir a sintaxe do operador PIVOT, e isso geralmente resulta no agrupamento dos dados por elementos indesejados. Como exemplo de nossa tarefa, suponha que você não perceba que o elemento de agrupamento é determinado implicitamente e você apresenta a seguinte consulta:
SELECT shipperid, [2017], [2018], [2019]FROM Sales.Orders CROSS APPLY( VALUES(YEAR(shippeddate)) ) AS D(shippedyear) PIVOT( COUNT(shippeddate) FOR shippingyear IN([2017] , [2018], [2019]) ) AS P;

Há apenas três remetentes presentes nos dados, com IDs de remetente 1, 2 e 3. Portanto, você espera ver apenas três linhas no resultado. No entanto, a saída real da consulta mostra muito mais linhas:
envio 2017 2018 2019--------------- ----------- ----------- ---------- -3 1 0 01 1 0 02 1 0 01 1 0 02 1 0 02 1 0 02 1 0 03 1 0 02 1 0 03 1 0 0...3 0 1 03 0 1 03 0 1 01 0 1 03 0 1 01 0 1 03 0 1 03 0 1 03 0 1 01 0 1 0...3 0 0 11 0 0 12 0 0 11 0 0 12 0 0 11 0 0 13 0 0 13 0 0 12 0 1 0...(830 linhas afetadas)

O que aconteceu?

Você pode encontrar uma pista que o ajudará a descobrir o bug no código observando o plano de consulta mostrado na Figura 1.

Figura 1:planejar a consulta dinâmica com agrupamento implícito

Não deixe que o uso do operador CROSS APPLY com a cláusula VALUES na consulta o confunda. Isso é feito simplesmente para calcular a coluna de resultado ano de envio com base na coluna data de envio de origem e é tratado pelo primeiro operador de computação escalar no plano.

A tabela de entrada para o operador PIVOT contém todas as colunas da tabela Sales.Orders, mais a coluna de resultado shippingyear. Conforme mencionado, o SQL Server determina o elemento de agrupamento implicitamente por eliminação com base no que você não especificou como os elementos de agregação (data de envio) e distribuição (ano de envio). Talvez você esperasse intuitivamente que a coluna shipperid fosse a coluna de agrupamento porque ela aparece na lista SELECT, mas como você pode ver no plano, na prática você tem uma lista de colunas muito maior, incluindo orderid, que é a coluna de chave primária em a tabela de origem. Isso significa que, em vez de obter uma linha por remetente, você obtém uma linha por pedido. Como na lista SELECT você especificou apenas as colunas shipperid, [2017], [2018] e [2019], você não vê o resto, o que aumenta a confusão. Mas o resto participou do agrupamento implícito.

O que poderia ser ótimo é se a sintaxe do operador PIVOT suportasse uma cláusula onde você pudesse indicar explicitamente o elemento grouping/on rows. Algo assim:
SELECT FROM  PIVOT( () FOR  IN() ON ROWS  ) AS ;

Com base nessa sintaxe, você usaria o seguinte código para lidar com nossa tarefa:
SELECT shipperid, [2017], [2018], [2019]FROM Sales.Orders CROSS APPLY( VALUES(YEAR(shippeddate)) ) AS D(shippedyear) PIVOT( COUNT(shippeddate) FOR shippingyear IN([2017] , [2018], [2019]) ON ROWS shipperid ) AS P;

Você pode encontrar um item de feedback com uma sugestão para melhorar a sintaxe do operador PIVOT aqui. Para tornar esta melhoria uma mudança ininterrupta, esta cláusula pode ser tornada opcional, sendo o padrão o comportamento existente. Existem outras sugestões para melhorar a sintaxe do operador PIVOT, tornando-o mais dinâmico e suportando vários agregados.

Enquanto isso, há uma prática recomendada que pode ajudá-lo a evitar o bug. Use uma expressão de tabela, como uma CTE ou uma tabela derivada, na qual você projeta apenas os três elementos necessários para se envolver na operação de pivô e, em seguida, use a expressão de tabela como entrada para o operador PIVOT. Dessa forma, você controla totalmente o elemento de agrupamento. Aqui está a sintaxe geral seguindo esta prática recomendada:
WITH  AS( SELECT , ,  FROM )SELECT FROM  PIVOT( () FOR  IN( ) ) AS ;

Aplicado à nossa tarefa, você usa o seguinte código:
WITH C AS( SELECT shipperid, YEAR(shippeddate) AS shippingyear, shippingdate FROM Sales.Orders)SELECT shipperid, [2017], [2018], [2019]FROM C PIVOT( COUNT(shippeddate) FOR shippingyear IN([ 2017], [2018], [2019]) ) AS P;

Desta vez, você obtém apenas três linhas de resultados conforme o esperado:
envio 2017 2018 2019--------------- ----------- ----------- ---------- -3 51 125 731 36 130 792 56 143 116

Outra opção é usar a solução padrão antiga e clássica para dinamizar usando uma consulta agrupada e expressões CASE, assim:
SELECIONE shipperiid, COUNT(CASE WHEN WHEN shippingyear =2017 THEN 1 END) AS [2017], COUNT(CASE WHEN WHEN WHEN shippingyear =2018 THEN 1 END) AS [2018], COUNT(CASE WHEN WHEN ENVIADOANO =2019 THEN 1 END) AS [2019]FROM Sales.Orders CROSS APPLY( VALUES(YEAR(shippeddate))) AS D(shippedyear)WHERE shippingdate NÃO NULLGROUP BY shipperid;

Com essa sintaxe, todas as três etapas de pivô e seus elementos associados devem estar explícitos no código. No entanto, quando você tem um grande número de valores de espalhamento, essa sintaxe tende a ser detalhada. Nesses casos, as pessoas geralmente preferem usar o operador PIVOT.

Remoção implícita de NULLs com UNPIVOT


O próximo item deste artigo é mais uma armadilha do que um bug. Tem a ver com o operador proprietário T-SQL UNPIVOT, que permite que você desvire dados de um estado de colunas para um estado de linhas.

Usarei uma tabela chamada CustOrders como meus dados de amostra. Use o seguinte código para criar, preencher e consultar esta tabela para mostrar seu conteúdo:
DROP TABLE SE EXISTE dbo.CustOrders;GO WITH C AS(SELECT custid, YEAR(orderdate) AS orderyearyear, val FROM Sales.OrderValues)SELECT custid, [2017], [2018], [2019]INTO dbo.CustOrdersFROM C PIVOT( SOMA(val) PARA pedidoanoano IN([2017], [2018], [2019]) ) AS P; SELECT * FROM dbo.CustOrders;

Este código gera a seguinte saída:
custid 2017 2018 2019----------- ---------- ---------- ----------1 NULL 2022,50 2250,502 88,80 799,75 514.403 403.20 5960.78 660.004 1379.00 6406.90 5604.755 4324.40 13849.02 6754.166 NULL 1079.80 2160.007 9986.20 7817.88 730.008 982.00 3026.85 224.009 4074.28 11208.36 6680.6110 1832.80 7630.25 11338.5611 479.40 3179.50 2431.0012 NULL 238.00 1576.8013 100.80 NULL NULL14 1674.22 6516.40 4158.2615 2169.00 1128.00 513.7516 NULL 787.60 931.5017 533.60 420.00 2809.6118 268.80 487.00 860.1019 950.00 4514,35 9296,6920 15568,07 48096,27 41210,65...

Esta tabela contém os valores totais do pedido por cliente e ano. NULLs representam casos em que um cliente não teve nenhuma atividade de pedido no ano de destino.

Suponha que você queira desdinamizar os dados da tabela CustOrders, retornando uma linha por cliente e ano, com uma coluna de resultado chamada val contendo o valor total do pedido para o cliente e ano atuais. Qualquer tarefa não dinâmica geralmente envolve três elementos:
  1. Os nomes das colunas de origem existentes que você está desarticulando:[2017], [2018], [2019] no nosso caso
  2. Um nome que você atribui à coluna de destino que conterá os nomes das colunas de origem:orderyear em nosso caso
  3. Um nome que você atribui à coluna de destino que conterá os valores da coluna de origem:val em nosso caso

Se você decidir usar o operador UNPIVOT para lidar com a tarefa não dinâmica, primeiro descubra os três elementos acima e, em seguida, use a seguinte sintaxe:
SELECT , , FROM  UNPIVOT(  FOR  IN() ) AS ;

Aplicado à nossa tarefa, você usa a seguinte consulta:
SELECT custid, orderyear, valFROM dbo.CustOrders UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) ) AS U;

Essa consulta gera a seguinte saída:
pedido personalizadoValor do ano----------- ---------- ----------1 2018 2022.501 2019 2250.502 2017 88.802 2018 799.752 2019 514.403 2017 403.203 2018 5960.783 2019 660.004 2017 1379.004 2018 6406.904 2019 5604.755 2017 4324.405 2018 13849.025 2019 6754.166 2018 1079.806 2019 2160.007 2017 9986.207 2018 7817.887 2019 730.00.007 ... 
Observando os dados de origem e o resultado da consulta, você percebe o que está faltando?

O design do operador UNPIVOT envolve uma eliminação implícita de linhas de resultado que possuem um NULL na coluna de valores – val em nosso caso. Observando o plano de execução para esta consulta mostrado na Figura 2, você pode ver o operador Filter removendo as linhas com os NULLs na coluna val (Expr1007 no plano).

Figura 2:plano para consulta unpivot com remoção implícita de NULLs

Às vezes, esse comportamento é desejável e, nesse caso, você não precisa fazer nada de especial. O problema é que às vezes você quer manter as linhas com os NULLs. A armadilha é quando você deseja manter os NULLs e nem percebe que o operador UNPIVOT foi projetado para removê-los.

O que poderia ser ótimo é se o operador UNPIVOT tivesse uma cláusula opcional que permitisse especificar se você deseja remover ou manter NULLs, sendo o primeiro o padrão para compatibilidade com versões anteriores. Aqui está um exemplo de como essa sintaxe pode se parecer:
SELECT , , FROM  UNPIVOT(  FOR  IN() [REMOVE NULLS | KEEP NULLS]) AS ; 
Se você quisesse manter NULLs, com base nessa sintaxe, você usaria a seguinte consulta:
SELECT custid, orderyear, valFROM dbo.CustOrders UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) KEEP NULLS ) AS U;

Você pode encontrar um item de feedback com uma sugestão para melhorar a sintaxe do operador UNPIVOT dessa maneira aqui.

Enquanto isso, se você quiser manter as linhas com os NULLs, você precisa encontrar uma solução alternativa. Se você insistir em usar o operador UNPIVOT, precisará aplicar duas etapas. Na primeira etapa, você define uma expressão de tabela com base em uma consulta que usa a função ISNULL ou COALESCE para substituir NULLs em todas as colunas não dinâmicas por um valor que normalmente não pode aparecer nos dados, por exemplo, -1 no nosso caso. Na segunda etapa, você usa a função NULLIF na consulta externa na coluna de valores para substituir o -1 de volta por um NULL. Aqui está o código completo da solução:
WITH C AS( SELECT custid, ISNULL([2017], -1.0) AS [2017], ISNULL([2018], -1.0) AS [2018], ISNULL([2019], -1.0) AS [2019 ] FROM dbo.CustOrders)SELECT custid, orderyear, NULLIF(val, -1.0) AS valFROM C UNPIVOT( val FOR orderyear IN([2017], [2018], [2019]) ) AS U;

Aqui está a saída desta consulta mostrando que as linhas com NULLs na coluna val são preservadas:
pedido personalizadoval do ano----------- ---------- ----------1 2017 NULL1 2018 2022.501 2019 2250.502 2017 88.802 2018 799.752 2019 514.403 2017 403.203 2018 5960.783 2019 660.004 2017 1379.004 2018 6406.904 2019 5604.755 2017 4324.405 2018 13849.025 2019 6754.166 2017 NULL6 2018 1079.806 2019 2160.007 2017 9986.2078 78 78 78777777777777777. 
Essa abordagem é complicada, especialmente quando você tem um grande número de colunas para desarticular.

Uma solução alternativa usa uma combinação do operador APPLY e da cláusula VALUES. Você constrói uma linha para cada coluna não dinâmica, com uma coluna representando a coluna de nomes de destino (orderyear em nosso caso) e outra representando a coluna de valores de destino (val em nosso caso). Você fornece o ano constante para a coluna de nomes e a coluna de origem correlacionada relevante para a coluna de valores. Aqui está o código completo da solução:
SELECT custid, orderyear, valFROM dbo.CustOrders CROSS APPLY ( VALUES(2017, [2017]), (2018, [2018]), (2019, [2019])) AS A(orderyear, val); 
O legal aqui é que, a menos que você esteja interessado na remoção das linhas com os NULLs na coluna val, você não precisa fazer nada de especial. Não há nenhuma etapa implícita aqui que remova as linhas com o NULLS. Além disso, como o alias da coluna val é criado como parte da cláusula FROM, é acessível à cláusula WHERE. Então, se você estiver interessado na remoção dos NULLs, você pode ser explícito sobre isso na cláusula WHERE interagindo diretamente com o alias da coluna de valores, assim:
SELECT custid, orderyear, valFROM dbo.CustOrders CROSS APPLY ( VALUES(2017, [2017]), (2018, [2018]), (2019, [2019]) ) AS A(orderyear, val)WHERE val IS NÃO NULO;

O ponto é que essa sintaxe permite controlar se você deseja manter ou remover NULLs. É mais flexível do que o operador UNPIVOT de outra maneira, permitindo que você lide com várias medidas não dinâmicas, como val e qty. Meu foco neste artigo, porém, foi a armadilha envolvendo NULLs, então não entrei nesse aspecto.

Conclusão


O design dos operadores PIVOT e UNPIVOT às vezes leva a bugs e armadilhas em seu código. A sintaxe do operador PIVOT não permite indicar explicitamente o elemento de agrupamento. Se você não perceber isso, poderá acabar com elementos de agrupamento indesejados. Como prática recomendada, é recomendável que você use uma expressão de tabela como entrada para o operador PIVOT e, por isso, controle explicitamente qual é o elemento de agrupamento.

A sintaxe do operador UNPIVOT não permite controlar se deve remover ou manter linhas com NULLs na coluna de valores de resultado. Como solução alternativa, você pode usar uma solução complicada com as funções ISNULL e NULLIF ou uma solução baseada no operador APPLY e na cláusula VALUES.

Mencionei também dois itens de feedback com sugestões para melhorar os operadores PIVOT e UNPIVOT com opções mais explícitas para controlar o comportamento do operador e seus elementos.