Este artigo é a terceira parte de uma série sobre bugs, armadilhas e melhores práticas do T-SQL. Anteriormente eu cobri determinismo e subconsultas. Desta vez, concentro-me nas junções. Alguns dos bugs e práticas recomendadas que abordo aqui são resultado de uma pesquisa que fiz entre colegas MVPs. 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 oferecerem seus insights!
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.
Neste artigo, concentro-me em quatro erros comuns clássicos:COUNT(*) em junções externas, agregações de mergulho duplo, contradição ON-WHERE e contradição de junção OUTER-INNER. Todos esses bugs estão relacionados aos fundamentos de consulta T-SQL e são fáceis de evitar se você seguir as práticas recomendadas simples.
COUNT(*) em junções externas
Nosso primeiro bug tem a ver com contagens incorretas relatadas para grupos vazios como resultado do uso de uma junção externa e do agregado COUNT(*). Considere a seguinte consulta computando o número de pedidos e o frete total por cliente:
USE TSQLV5; -- http://tsql.solidq.com/SampleDatabases/TSQLV5.zip SELECT custid, COUNT(*) AS numorders, SUM(freight) AS totalfreight FROM Sales.Orders GROUP BY custid ORDER BY custid;
Essa consulta gera a seguinte saída (abreviada):
números personalizados frete total ------- ---------- ------------- 1 6 225,58 2 4 97,42 3 7 268,52 4 13 471,95 5 18 1559,52 ... 21 7 232,75 23 5 637,94 ... 56 10 862,74 58 6 277,96 ... 87 15 822,48 88 9 194,71 89 14 1353,06 90 7 88,41 91 7 175,74 (89 linhas afetadas)
Existem atualmente 91 clientes presentes na tabela Clientes, dos quais 89 efetuaram encomendas; portanto, a saída dessa consulta mostra 89 grupos de clientes e sua contagem correta de pedidos e agregados totais de frete. Os clientes com IDs 22 e 57 estão presentes na tabela Clientes, mas não fizeram nenhum pedido e, portanto, não aparecem no resultado.
Suponha que você seja solicitado a incluir clientes que não tenham pedidos relacionados no resultado da consulta. A coisa natural a fazer nesse caso é realizar uma junção externa esquerda entre Clientes e Pedidos para preservar clientes sem pedidos. No entanto, um bug típico ao converter a solução existente para uma que aplica a junção é deixar o cálculo da contagem de pedidos como COUNT(*), conforme mostrado na consulta a seguir (chame-a de Consulta 1):
SELECT C.custid, COUNT(*) AS numorders, SUM(O.freight) AS totalfreight FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON C.custid =O.custid GROUP BY C.custid ORDEM POR C.custid;
Essa consulta gera a seguinte saída:
números personalizados frete total ------- ---------- ------------- 1 6 225,58 2 4 97,42 3 7 268,52 4 13 471,95 5 18 1559,52 ... 21 7 232,75 22 1 NULO 23 5 637,94 ... 56 10 862,74 57 1 NULO 58 6 277,96 ... 87 15 822,48 88 9 194,71 89 14 1357,06 90 7 918,41 linhas afetadas) pré>
Observe que os clientes 22 e 57 desta vez aparecem no resultado, mas sua contagem de pedidos mostra 1 em vez de 0 porque COUNT(*) conta linhas e não pedidos. O frete total é informado corretamente porque SUM(freight) ignora entradas NULL.
O plano para esta consulta é mostrado na Figura 1.
Figura 1:plano para a consulta 1
Neste plano, Expr1002 representa a contagem de linhas por grupo, que, como resultado da junção externa, é inicialmente definida como NULL para clientes sem pedidos correspondentes. O operador Compute Scalar logo abaixo do nó raiz SELECT converte o NULL em 1. Esse é o resultado da contagem de linhas em oposição às ordens de contagem.
Para corrigir esse bug, você deseja aplicar o agregado COUNT a um elemento do lado não preservado da junção externa e deseja certificar-se de usar uma coluna não NULLable como entrada. A coluna de chave primária seria uma boa escolha. Aqui está a consulta da solução (chame-a de Consulta 2) com o bug corrigido:
SELECT C.custid, COUNT(O.orderid) AS numorders, SUM(O.freight) AS totalfreight FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON C.custid =O.custid GROUP BY C .custid ORDEM POR C.custid;
Aqui está a saída desta consulta:
números personalizados frete total ------- ---------- ------------- 1 6 225,58 2 4 97,42 3 7 268,52 4 13 471,95 5 18 1559,52 ... 21 7 232,75 22 0 NULL 23 5 637,94 ... 56 10 862,74 57 0 NULL 58 6 277,96 ... 87 15 822,48 88 9 194,71 89 14 1357,06 90 7 918,41 pré>
Observe que desta vez os clientes 22 e 57 mostram a contagem correta de zero.
O plano para esta consulta é mostrado na Figura 2.
Figura 2:plano para consulta 2
Você também pode ver a mudança no plano, onde um NULL representando a contagem de um cliente sem pedidos correspondentes é convertido em 0 e não em 1 desta vez.
Ao usar junções, tenha cuidado ao aplicar o agregado COUNT(*). Ao usar associações externas, geralmente é um bug. A prática recomendada é aplicar o agregado COUNT a uma coluna não NULLable do lado muitos da junção um-para-muitos. A coluna de chave primária é uma boa opção para essa finalidade, pois não permite NULLs. Isso pode ser uma boa prática mesmo ao usar associações internas, pois você nunca sabe se posteriormente precisará alterar uma associação interna para uma externa devido a uma alteração nos requisitos.
Agregados de imersão dupla
Nosso segundo bug também envolve a mistura de junções e agregações, desta vez levando em consideração os valores de origem várias vezes. Considere a seguinte consulta como um exemplo:
SELECT C.custid, COUNT(O.orderid) AS numorders, SUM(O.freight) AS totalfreight, CAST(SUM(OD.qty * OD.unitprice * (1 - OD.discount)) AS NUMERIC(12 , 2)) AS totalval FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON C.custid =O.custid LEFT OUTER JOIN Sales.OrderDetails AS OD ON O.orderid =OD.orderid GROUP BY C.custid ORDER POR C.custid;
Essa consulta une Clientes, Pedidos e Detalhes do pedido, agrupa as linhas por cliente e deve calcular agregações como contagem de pedidos, frete total e valor total por cliente. Essa consulta gera a seguinte saída:
números personalizados totalfrete totalval ------- ---------- ------------- --------- 1 12 419,60 4273,00 2 10 306,59 1402.95 3 17 667,29 7023.98 4 30 1447.14 13390.65 5 52 4835.18 24927.58 ... 87 37 2611.93 15648.70 88 19 5466 6068 2610 40417.3.3.3.73.710 31163.113.16111313131111313.
Você consegue identificar o bug aqui?
Os cabeçalhos dos pedidos são armazenados na tabela Orders e suas respectivas linhas de pedidos são armazenadas na tabela OrderDetails. Ao unir cabeçalhos de pedidos com suas respectivas linhas de pedidos, o cabeçalho é repetido no resultado da união por linha. Como resultado, a agregação COUNT(O.orderid) reflete incorretamente a contagem de linhas de pedido e não a contagem de pedidos. Da mesma forma, o SUM(O.freight) incorretamente leva em consideração o frete várias vezes por pedido — tanto quanto o número de linhas de pedido dentro do pedido. O único cálculo agregado correto nesta consulta é aquele usado para calcular o valor total, pois é aplicado aos atributos das linhas de pedido:SUM(OD.qty * OD.unitprice * (1 – OD.discount).
Para obter a contagem correta de pedidos, basta usar um agregado de contagem distinto:COUNT(DISTINCT O.orderid). Você pode pensar que a mesma correção pode ser aplicada ao cálculo do frete total, mas isso apenas introduziria um novo bug. Aqui está nossa consulta com agregados distintos aplicados às medidas do cabeçalho do pedido:
SELECT C.custid, COUNT(DISTINCT O.orderid) AS numorders, SUM(DISTINCT O.freight) AS totalfreight, CAST(SUM(OD.qty * OD.unitprice * (1 - OD.discount)) AS NUMERIC (12, 2)) AS totalval FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON C.custid =O.custid LEFT OUTER JOIN Sales.OrderDetails AS OD ON O.orderid =OD.orderid GROUP BY C. custid ORDEM POR C.custid;
Essa consulta gera a seguinte saída:
números personalizados totalfrete totalval ------- ---------- ------------- --------- 1 6 225,58 4273,00 2 4 97.42 1402.95 3 7 268.52 7023.98 4 13 448.23 13390.65 ***** 5 18 1559.52 24927.58 ... 87 15 822.48 15648.70 88 9 194.71 6068.20 89 14 1353.06 27363.61 90 7 87.66 3161.35 ***** 91 7 175.74 3531.95
As contagens de pedidos agora estão corretas, mas os valores totais de frete não estão. Você consegue identificar o novo bug?
O novo bug é mais evasivo porque se manifesta apenas quando o mesmo cliente tem pelo menos um caso em que vários pedidos têm exatamente os mesmos valores de frete. Nesse caso, agora você está considerando o frete apenas uma vez por cliente, e não uma vez por pedido, como deveria.
Use a seguinte consulta (requer SQL Server 2017 ou superior) para identificar valores de frete não distintos para o mesmo cliente:
WITH C AS ( SELECT custid, frete, STRING_AGG(CAST(orderid AS VARCHAR(MAX)), ', ') WITHIN GROUP(ORDER BY orderid) AS orders FROM Sales.Orders GROUP BY custid, frete HAVING COUNT(* )> 1 ) SELECT custid, STRING_AGG(CONCAT('(freight:', frete, ', orders:', orders, ')'), ', ') como duplicatas FROM C GROUP BY custid;
Essa consulta gera a seguinte saída:
duplicatas personalizadas ------- -------------------------------------- - 4 (frete:23,72, pedidos:10743, 10953) 90 (frete:0,75, pedidos:10615, 11005)
Com essas descobertas, você percebe que a consulta com o bug relatou valores totais de frete incorretos para os clientes 4 e 90. A consulta relatou valores totais de frete corretos para o restante dos clientes, pois seus valores de frete eram únicos.
Para corrigir o bug, você precisa separar o cálculo de agregações de pedidos e de linhas de pedido para diferentes etapas usando expressões de tabela, assim:
WITH O AS ( SELECT custid, COUNT(orderid) AS numorders, SUM(freight) AS totalfreight FROM Sales.Orders GROUP BY custid ), OD AS ( SELECT O.custid, CAST(SUM(OD.qty * OD. unitprice * (1 - OD.discount)) AS NUMERIC(12, 2)) AS totalval FROM Sales.Orders AS O INNER JOIN Sales.OrderDetails AS OD ON O.orderid =OD.orderid GROUP BY O.custid ) SELECT C. custid, O.numorders, O.totalfreight, OD.totalval FROM Sales.Customers AS C LEFT OUTER JOIN O ON C.custid =O.custid LEFT OUTER JOIN OD ON C.custid =OD.custid ENCOMENDAR POR C.custid;
Essa consulta gera a seguinte saída:
números personalizados totalfrete totalval ------- ---------- ------------- --------- 1 6 225,58 4273,00 2 4 97.42 1402.95 3 7 268.52 7023.98 4 13 471.95 13390.65 ***** 5 18 1559.52 24927.58 ... 87 15 822.48 15648.70 88 9 194.71 6068.20 89 14 1353.06 27363.61 90 7 88.41 3161.35 ***** 91 7 175.74 3531.95
Observe que os valores totais de frete para os clientes 4 e 90 agora são maiores. Esses são os números corretos.
A melhor prática aqui é estar atento ao juntar e agregar dados. Você deseja estar alerta para esses casos ao unir várias tabelas e aplicar agregações a medidas de uma tabela que não seja uma tabela de borda ou folha nas junções. Nesse caso, você geralmente precisa aplicar os cálculos agregados nas expressões de tabela e, em seguida, unir as expressões de tabela.
Portanto, o bug de agregados de imersão dupla foi corrigido. No entanto, há potencialmente outro bug nessa consulta. Você consegue identificar? Fornecerei os detalhes sobre um bug em potencial como o quarto caso que abordarei mais tarde em “contradição de junção OUTER-INNER”.
contradição ON-WHERE
Nosso terceiro bug é resultado de confundir os papéis que as cláusulas ON e WHERE deveriam desempenhar. Como exemplo, suponha que você tenha recebido uma tarefa para combinar clientes e pedidos que eles fizeram desde 12 de fevereiro de 2019, mas também incluir na saída os clientes que não fizeram pedidos desde então. Você tenta resolver a tarefa usando a seguinte consulta (chame-a de Consulta 3):
SELECT C.custid, C.companyname, O.orderid, O.orderdate FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON O.custid =C.custid WHERE O.orderdate>='20190212';
Ao usar uma junção interna, ON e WHERE desempenham as mesmas funções de filtragem e, portanto, não importa como você organiza os predicados entre essas cláusulas. No entanto, ao usar uma junção externa como no nosso caso, essas cláusulas têm significados diferentes.
A cláusula ON desempenha um papel de correspondência, o que significa que todas as linhas do lado preservado da junção (Clientes em nosso caso) serão retornadas. As que possuem correspondências baseadas no predicado ON são conectadas às suas correspondências e, como resultado, repetidas por correspondência. Os que não possuem correspondências são retornados com NULLs como espaços reservados nos atributos do lado não preservado.
Por outro lado, a cláusula WHERE desempenha um papel de filtragem mais simples - sempre. Isso significa que as linhas para as quais o predicado de filtragem avalia como true são retornadas e todo o restante é descartado. Como resultado, algumas das linhas do lado preservado da junção podem ser removidas completamente.
Lembre-se de que os atributos do lado não preservado da junção externa (Orders em nosso caso) são marcados como NULLs para linhas externas (não correspondências). Sempre que você aplica um filtro envolvendo um elemento do lado não preservado da junção, o predicado do filtro é avaliado como desconhecido para todas as linhas externas, resultando em sua remoção. Isso está de acordo com a lógica de predicado de três valores que o SQL segue. Efetivamente, a junção se torna uma junção interna como resultado. A única exceção a essa regra é quando você procura especificamente um NULL em um elemento do lado não preservado para identificar não correspondências (elemento IS NULL).
Nossa consulta com bugs gera a seguinte saída:
custid nome da empresa orderid orderdate ------- --------------- -------- ---------- 1 Cliente NRZBB 11011 2019-04-09 1 Cliente NRZBB 10952 2019-03-16 2 Cliente MLTDN 10926 2019-03-04 4 Cliente HFBZG 11016 2019-04-10 4 Cliente HFBZG 10953 2019-03-16 4 Cliente HFBZG 10920 2019-04-10 03 5 Cliente HGVLZ 10924 2019-03-04 6 Cliente XHXJV 11058 2019-04-29 6 Cliente XHXJV 10956 2019-03-17 8 Cliente QUHWH 10970 2019-03-24 ... 20 Cliente 6THHDP 10979 2019-03-03 Cliente THHDP 10968 2019-03-23 20 Cliente THHDP 10895 2019-02-18 24 Cliente CYZTN 11050 2019-04-27 24 Cliente CYZTN 11001 2019-04-06 24 Cliente CYZTN 10993 2019-04-01 ... (195 linhas afetado)
A saída desejada deve ter 213 linhas, incluindo 195 linhas representando pedidos feitos desde 12 de fevereiro de 2019 e 18 linhas adicionais representando clientes que não fizeram pedidos desde então. Como você pode ver, a saída real não inclui os clientes que não fizeram pedidos desde a data especificada.
O plano para esta consulta é mostrado na Figura 3.
Figura 3:planejar a consulta 3
Observe que o otimizador detectou a contradição e converteu internamente a junção externa em uma junção interna. Isso é bom de ver, mas ao mesmo tempo é uma indicação clara de que há um bug na consulta.
Já vi casos em que as pessoas tentaram corrigir o bug adicionando o predicado OR O.orderid IS NULL à cláusula WHERE, assim:
SELECT C.custid, C.companyname, O.orderid, O.orderdate FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON O.custid =C.custid WHERE O.orderdate>='20190212' OR O.orderid IS NULL;
O único predicado correspondente é aquele que compara os IDs do cliente dos dois lados. Portanto, a própria junção retorna clientes que fizeram pedidos em geral, juntamente com seus pedidos correspondentes, bem como clientes que não fizeram pedidos, com NULLs em seus atributos de pedido. Em seguida, os predicados de filtragem filtram clientes que fizeram pedidos desde a data especificada, bem como clientes que não fizeram pedidos (clientes 22 e 57). A consulta está faltando clientes que fizeram alguns pedidos, mas não desde a data especificada!
Essa consulta gera a seguinte saída:
custid nome da empresa orderid orderdate ------- --------------- -------- ---------- 1 Cliente NRZBB 11011 2019-04-09 1 Cliente NRZBB 10952 2019-03-16 2 Cliente MLTDN 10926 2019-03-04 4 Cliente HFBZG 11016 2019-04-10 4 Cliente HFBZG 10953 2019-03-16 4 Cliente HFBZG 10920 2019-04-10 03 5 Cliente HGVLZ 10924 2019-03-04 6 Cliente XHXJV 11058 2019-04-29 6 Cliente XHXJV 10956 2019-03-17 8 Cliente QUHWH 10970 2019-03-24 ... 20 Cliente 6THHDP 10979 2019-03-03 Cliente THHDP 10968 23-03-2019 20 Cliente THHDP 10895 18-02-2019 22 Cliente DTDMN NULO NULO 24 Cliente CYZTN 11050 27-04-2019 24 Cliente CYZTN 11001 2019-04-06 24-01 Cliente CYZTN 10993 2019-04-06. .. (197 linhas afetadas)
Para corrigir o bug corretamente, você precisa que o predicado que compara os IDs do cliente dos dois lados e o da data do pedido sejam considerados predicados correspondentes. Para conseguir isso, ambos precisam ser especificados na cláusula ON, assim (chame esta Consulta 4):
SELECT C.custid, C.companyname, O.orderid, O.orderdate FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON O.custid =C.custid AND O.orderdate>='20190212';
Essa consulta gera a seguinte saída:
custid nome da empresa orderid orderdate ------- --------------- -------- ---------- 1 Cliente NRZBB 11011 2019-04-09 1 Cliente NRZBB 10952 2019-03-16 2 Cliente MLTDN 10926 2019-03-04 3 Cliente KBUDE NULL NULL 4 Cliente HFBZG 11016 2019-04-10 4 Cliente HFBZG 10953 2019-03-16 4 Cliente HFBZG 10920 2019-03-03 5 Cliente HGVLZ 10924 2019-03-04 6 Cliente XHXJV 11058 2019-04-29 6 Cliente XHXJV 10956 2019-03-17 7 Cliente QXVLA NULL NULL 8 Cliente QUHWH 10970 2019-03-17 ... 20 Cliente THHDP 10979 2019-03-26 20 Cliente THHDP 10968 2019-03-23 20 Cliente THHDP 10895 2019-02-18 21 Cliente KIDPX NULL NULL 22 Cliente DTDMN NULL 23 Cliente WVFAF NULL NULL 24 Cliente CYZTN 110- CYZTN 110 27 24 Cliente CYZTN 11001 2019-04-06 24 Cliente CYZTN 10993 01-04-2019 ... (213 linhas afetadas)
O plano para esta consulta é mostrado na Figura 4.
Figura 4:planejar a consulta 4
Como você pode ver, o otimizador tratou a junção como uma junção externa desta vez.
Esta é uma consulta muito simples que usei para fins de ilustração. Com consultas muito mais elaboradas e complexas, mesmo desenvolvedores experientes podem ter dificuldade em descobrir se um predicado pertence à cláusula ON ou à cláusula WHERE. O que facilita as coisas para mim é simplesmente me perguntar se o predicado é um predicado correspondente ou um predicado de filtragem. Se for o primeiro, pertence à cláusula ON; se for o último, pertence à cláusula WHERE.
contradição de junção OUTER-INNER
Nosso quarto e último bug é de certa forma uma variação do terceiro bug. Isso geralmente acontece em consultas de várias associações em que você mistura tipos de associação. Como exemplo, suponha que você precise unir as tabelas Customers, Orders, OrderDetails, Products e Suppliers para identificar pares cliente-fornecedor que tiveram atividade conjunta. Você escreve a seguinte consulta (chame-a de Consulta 5):
SELECT DISTINCT C.custid, C.companyname AS cliente, S.supplierid, S.companyname AS fornecedor FROM Sales.Customers AS C INNER JOIN Sales.Orders AS O ON O.custid =C.custid INNER JOIN Sales.OrderDetails AS OD ON OD.orderid =O.orderid INNER JOIN Produção.Produtos AS P ON P.productid =OD.productid INNER JOIN Produção.Fornecedores AS S ON S.supplierid =P.supplierid;
Essa consulta gera a seguinte saída com 1.236 linhas:
cliente fornecedorid fornecedor custid ------- --------------- ----------- ---------- ----- 1 Cliente NRZBB 1 Fornecedor SWRXU 1 Cliente NRZBB 3 Fornecedor STUAZ 1 Cliente NRZBB 7 Fornecedor GQRCV ... 21 Cliente KIDPX 24 Fornecedor JNNES 21 Cliente KIDPX 25 Fornecedor ERVYZ 21 Cliente KIDPX 28 Fornecedor OAVQT 23 Cliente WVFAF 3 Fornecedor STUAZ 23 Cliente WVFAF 7 Fornecedor GQRCV 23 Cliente WVFAF 8 Fornecedor BWGYE ... 56 Cliente QNIVZ 26 Fornecedor ZWZDM 56 Cliente QNIVZ 28 Fornecedor OAVQT 56 Cliente QNIVZ 29 Fornecedor OGLRK 58 Cliente AHXHT 1 Fornecedor SWRXU 58 Cliente AHXHT 5 Fornecedor EQPNC 58 Cliente AHXHT 6 Fornecedor QWUSF ... (1236 linhas afetadas)
O plano para esta consulta é mostrado na Figura 5.
Figura 5:planejar a consulta 5
Todas as associações no plano são processadas como associações internas, como seria de esperar.
Você também pode observar no plano que o otimizador aplicou a otimização de ordenação de junção. Com junções internas, o otimizador sabe que pode reorganizar a ordem física das junções da maneira que desejar, preservando o significado da consulta original, portanto, tem muita flexibilidade. Aqui, sua otimização baseada em custo resultou no pedido:join(Customers, join(Orders, join(join(Suppliers, Products), OrderDetails))).
Suponha que você receba um requisito para alterar a consulta de forma que inclua clientes que não fizeram pedidos. Lembre-se de que atualmente temos dois desses clientes (com IDs 22 e 57), portanto, o resultado desejado deve ter 1.238 linhas. Um bug comum nesse caso é alterar a junção interna entre Clientes e Pedidos para uma junção externa esquerda, mas deixar todo o resto das junções como internas, assim:
SELECT DISTINCT C.custid, C.companyname AS cliente, S.supplierid, S.companyname AS fornecedor FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON O.custid =C.custid INNER JOIN Vendas. OrderDetails AS OD ON OD.orderid =O.orderid INNER JOIN Production.Products AS P ON P.productid =OD.productid INNER JOIN Production.Suppliers AS S ON S.supplierid =P.supplierid;
Quando uma junção externa esquerda é subsequentemente seguida por junções externas internas ou direitas, e o predicado de junção compara algo do lado não preservado da junção externa esquerda com algum outro elemento, o resultado do predicado é o valor lógico desconhecido e a junção externa original linhas são descartadas. A junção externa esquerda efetivamente se torna uma junção interna.
Como resultado, essa consulta gera a mesma saída da Consulta 5, retornando apenas 1.236 linhas. Também aqui o otimizador detecta a contradição e converte a junção externa em uma junção interna, gerando o mesmo plano mostrado anteriormente na Figura 5.
Uma tentativa comum de corrigir o bug é fazer com que todas as junções sejam junções externas à esquerda, assim:
SELECT DISTINCT C.custid, C.companyname AS cliente, S.supplierid, S.companyname AS fornecedor FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O ON O.custid =C.custid LEFT OUTER JOIN Vendas .OrderDetails AS OD ON OD.orderid =O.orderid LEFT OUTER JOIN Produção.Produtos AS P ON P.productid =OD.productid LEFT OUTER JOIN Produção.Fornecedores AS S ON S.supplierid =P.supplierid;
Essa consulta gera a seguinte saída, que inclui os clientes 22 e 57:
cliente fornecedorid fornecedor custid ------- --------------- ----------- ---------- ----- 1 Cliente NRZBB 1 Fornecedor SWRXU 1 Cliente NRZBB 3 Fornecedor STUAZ 1 Cliente NRZBB 7 Fornecedor GQRCV ... 21 Cliente KIDPX 24 Fornecedor JNNES 21 Cliente KIDPX 25 Fornecedor ERVYZ 21 Cliente KIDPX 28 Fornecedor OAVQT 22 Cliente DTDMN NULL NULL 23 Cliente WVFAF 3 Fornecedor STUAZ 23 Cliente WVFAF 7 Fornecedor GQRCV 23 Cliente WVFAF 8 Fornecedor BWGYE ... 56 Cliente QNIVZ 26 Fornecedor ZWZDM 56 Cliente QNIVZ 28 Fornecedor OAVQT 56 Cliente QNIVZ 29 Fornecedor OGLRK 57 Cliente WVAXS NULL NULL 58 Cliente AHXHT 1 Fornecedor SWRXU 58 Cliente AHXHT 5 Fornecedor EQPNC 58 Cliente AHXHT 6 Fornecedor QWUSF ... (1238 linhas affe cted)
No entanto, existem dois problemas com esta solução. Suponha que, além de Customers, você possa ter linhas em outra tabela na consulta sem linhas correspondentes em uma tabela subsequente e que, nesse caso, você não queira manter essas linhas externas. Por exemplo, e se em seu ambiente fosse permitido criar um cabeçalho para um pedido e, posteriormente, preenchê-lo com linhas de pedido. Suponha que, nesse caso, a consulta não deva retornar esses cabeçalhos de pedidos vazios. Ainda assim, a consulta deve retornar clientes sem pedidos. Como a junção entre Orders e OrderDetails é uma junção externa esquerda, essa consulta retornará esses pedidos vazios, mesmo que não devesse.
Outro problema é que, ao usar junções externas, você impõe mais restrições ao otimizador em termos de rearranjos que ele pode explorar como parte de sua otimização de ordenação de junção. O otimizador pode reorganizar a junção A LEFT OUTER JOIN B para B RIGHT OUTER JOIN A, mas esse é praticamente o único rearranjo que é permitido explorar. Com associações internas, o otimizador também pode reordenar tabelas além de apenas inverter os lados, por exemplo, ele pode reordenar join(join(join(join(A, B), C), D), E)))) para join(A, join(B, join(join(E, D), C))) conforme mostrado anteriormente na Figura 5.
Se você pensar bem, o que você realmente quer é fazer a junção à esquerda dos Clientes com o resultado das junções internas entre o resto das tabelas. Obviamente, você pode conseguir isso com expressões de tabela. No entanto, o T-SQL oferece suporte a outro truque. O que realmente determina a ordem lógica de junção não é exatamente a ordem das tabelas na cláusula FROM, mas sim a ordem das cláusulas ON. No entanto, para que a consulta seja válida, cada cláusula ON deve aparecer logo abaixo das duas unidades que ela está unindo. Então, para considerar a junção entre Clientes e o resto como último, basta mover a cláusula ON que conecta Clientes e o resto para aparecer por último, assim:
SELECT DISTINCT C.custid, C.companyname AS cliente, S.supplierid, S.companyname AS fornecedor FROM Sales.Customers AS C LEFT OUTER JOIN Sales.Orders AS O -- mover daqui ------- ---------------- INNER JOIN Sales.OrderDetails AS OD -- ON OD.orderid =O.orderid -- INNER JOIN Production.Products AS P -- ON P.productid =OD .productid -- INNER JOIN Production.Suppliers AS S -- ON S.supplierid =P.supplierid -- ON O.custid =C.custid; -- <-- aqui --
Agora a ordem lógica de junção é:leftjoin(Clientes, join(join(join(Orders, OrderDetails), Products), Suppliers)). Desta vez, você manterá os clientes que não fizeram pedidos, mas não manterá os cabeçalhos dos pedidos que não possuem linhas de pedidos correspondentes. Além disso, você permite ao otimizador total flexibilidade de pedidos de junção nas junções internas entre Pedidos, Detalhes do Pedido, Produtos e Fornecedores.
A única desvantagem dessa sintaxe é a legibilidade. A boa notícia é que isso pode ser facilmente corrigido usando parênteses, assim (chame esta Consulta 6):
SELECT DISTINCT C.custid, C.companyname AS cliente, S.supplierid, S.companyname AS fornecedor FROM Sales.Customers AS C LEFT OUTER JOIN ( Sales.Orders AS O INNER JOIN Sales.OrderDetails AS OD ON OD.orderid =O.orderid INNER JOIN Production.Products AS P ON P.productid =OD.productid INNER JOIN Production.Suppliers AS S ON S.supplierid =P.supplierid ) ON O.custid =C.custid;
Não confunda o uso de parênteses aqui com uma tabela derivada. Esta não é uma tabela derivada, mas apenas uma maneira de separar alguns dos operadores de tabela em sua própria unidade, para maior clareza. A linguagem realmente não precisa desses parênteses, mas eles são altamente recomendados para facilitar a leitura.
O plano para esta consulta é mostrado na Figura 6.
Figura 6:planejar a consulta 6
Observe que desta vez a junção entre Clientes e o restante é processada como uma junção externa e que o otimizador aplicou a otimização de ordenação de junção.
Conclusão
Neste artigo, abordei quatro bugs clássicos relacionados a junções. Ao usar junções externas, calcular o agregado COUNT(*) normalmente resulta em um bug. A prática recomendada é aplicar a agregação a uma coluna não NULLable do lado não preservado da junção.
Ao unir várias tabelas e envolver cálculos agregados, se você aplicar os agregados a uma tabela não folha nas junções, geralmente é um bug que resulta em agregados de imersão dupla. The best practice is then to apply the aggregates within table expressions and joining the table expressions.
It’s common to confuse the meanings of the ON and WHERE clauses. With inner joins, they’re both filters, so it doesn’t really matter how you organize your predicates within these clauses. However, with outer joins the ON clause serves a matching role whereas the WHERE clause serves a filtering role. Understanding this helps you figure out how to organize your predicates within these clauses.
In multi-join queries, a left outer join that is subsequently followed by an inner join, or a right outer join, where you compare an element from the nonpreserved side of the join with others (other than the IS NULL test), the outer rows of the left outer join are discarded. To avoid this bug, you want to apply the left outer join last, and this can be achieved by shifting the ON clause that connects the preserved side of this join with the rest to appear last. Use parentheses for clarity even though they are not required.