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

Gerar um conjunto ou sequência sem loops – parte 3


Anteriormente nesta série (Parte 1 | Parte 2), falamos sobre a geração de uma série de números usando várias técnicas. Embora interessante e útil em alguns cenários, uma aplicação mais prática é gerar uma série de datas contíguas; por exemplo, um relatório que exija a exibição de todos os dias de um mês, mesmo que alguns dias não tenham transações.

Em um post anterior, mencionei que é fácil derivar uma série de dias de uma série de números. Como já estabelecemos várias maneiras de derivar uma série de números, vamos ver como fica a próxima etapa. Vamos começar de forma bem simples e fingir que queremos gerar um relatório por três dias, de 1º de janeiro a 3 de janeiro, e incluir uma linha para cada dia. A maneira antiga seria criar uma tabela #temp, criar um loop, ter uma variável que contenha o dia atual, dentro do loop inserir uma linha na tabela #temp até o final do intervalo e depois usar o # temp table para junção externa aos nossos dados de origem. Isso é mais código do que eu quero apresentar aqui, não importa colocar em produção, manter e fazer com que os colegas aprendam.

Começando simples


Com uma sequência de números estabelecida (independentemente do método escolhido), essa tarefa fica muito mais fácil. Para este exemplo, posso substituir geradores de sequência complexos por uma união muito simples, pois preciso apenas de três dias. Vou fazer com que este conjunto contenha quatro linhas, para que também seja fácil demonstrar como cortar exatamente a série que você precisa.

Primeiro, temos algumas variáveis ​​para manter o início e o fim do intervalo em que estamos interessados:
DECLARE @s DATE = '2012-01-01', @e DATE = '2012-01-03';

Agora, se começarmos apenas com o gerador de série simples, pode ficar assim. Vou adicionar um ORDER BY aqui também, apenas por segurança, já que nunca podemos confiar em suposições que fazemos sobre ordem.
;WITH n(n) AS (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4)
SELECT n FROM n ORDER BY n;
 
-- result:
 
n
----
1
2
3
4

Para converter isso em uma série de datas, podemos simplesmente aplicar DATEADD() a partir da data de início:
;WITH n(n) AS (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4)
SELECT DATEADD(DAY, n, @s) FROM n ORDER BY n;
 
-- result:
 
----
2012-01-02
2012-01-03
2012-01-04
2012-01-05

Isso ainda não está certo, já que nosso range começa no 2º ao invés do 1º. Portanto, para usar nossa data de início como base, precisamos converter nosso conjunto de base 1 para base 0. Podemos fazer isso subtraindo 1:
;WITH n(n) AS (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4)
SELECT DATEADD(DAY, n-1, @s) FROM n ORDER BY n;
 
-- result:
 
----
2012-01-01
2012-01-02
2012-01-03
2012-01-04

Quase lá! Só precisamos limitar o resultado de nossa fonte de série maior, o que podemos fazer alimentando o DATEDIFF , em dias, entre o início e o fim do intervalo, para um TOP operador – e, em seguida, adicionando 1 (já que DATEDIFF essencialmente relata um intervalo aberto).
;WITH n(n) AS (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4)
SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) DATEADD(DAY, n-1, @s) FROM n ORDER BY n;
 
-- result:
 
----
2012-01-01
2012-01-02
2012-01-03

Adicionando dados reais


Agora, para ver como uniríamos em outra tabela para derivar um relatório, podemos simplesmente usar nossa nova consulta e junção externa nos dados de origem.
;WITH n(n) AS 
(
  SELECT 1 UNION ALL SELECT 2 UNION ALL 
  SELECT 3 UNION ALL SELECT 4
),
d(OrderDate) AS
(
  SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) DATEADD(DAY, n-1, @s) 
  FROM n ORDER BY n
)
SELECT 
  d.OrderDate,
  OrderCount = COUNT(o.SalesOrderID)
FROM d
LEFT OUTER JOIN Sales.SalesOrderHeader AS o
ON o.OrderDate >= d.OrderDate
AND o.OrderDate < DATEADD(DAY, 1, d.OrderDate)
GROUP BY d.OrderDate
ORDER BY d.OrderDate;

(Observe que não podemos mais dizer COUNT(*) , pois isso contará o lado esquerdo, que sempre será 1.)

Outra maneira de escrever isso seria:
;WITH d(OrderDate) AS
(
  SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) DATEADD(DAY, n-1, @s) 
  FROM 
  (
    SELECT 1 UNION ALL SELECT 2 UNION ALL 
    SELECT 3 UNION ALL SELECT 4
  ) AS n(n) ORDER BY n
)
SELECT 
  d.OrderDate,
  OrderCount = COUNT(o.SalesOrderID)
FROM d
LEFT OUTER JOIN Sales.SalesOrderHeader AS o
ON o.OrderDate >= d.OrderDate
AND o.OrderDate < DATEADD(DAY, 1, d.OrderDate)
GROUP BY d.OrderDate
ORDER BY d.OrderDate;

Isso deve facilitar a visualização de como você substituiria o CTE principal pela geração de uma sequência de datas de qualquer fonte que você escolher. Examinaremos isso (com exceção da abordagem CTE recursiva, que serviu apenas para distorcer os gráficos), usando o AdventureWorks2012, mas usaremos o SalesOrderHeaderEnlarged tabela que criei a partir deste script de Jonathan Kehayias. Eu adicionei um índice para ajudar com esta consulta específica:
CREATE INDEX d_so ON Sales.SalesOrderHeaderEnlarged(OrderDate);

Observe também que estou escolhendo um intervalo de datas arbitrário que sei que existe na tabela.
    Tabela de números
    ;WITH d(OrderDate) AS
    (
      SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) DATEADD(DAY, n-1, @s) 
      FROM dbo.Numbers ORDER BY n
    )
    SELECT 
      d.OrderDate,
      OrderCount = COUNT(s.SalesOrderID)
    FROM d
    LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS s
    ON s.OrderDate >= @s AND s.OrderDate <= @e
    AND CONVERT(DATE, s.OrderDate) = d.OrderDate
    WHERE d.OrderDate >= @s AND d.OrderDate <= @e
    GROUP BY d.OrderDate
    ORDER BY d.OrderDate;

    Plano (clique para ampliar):


    spt_values
    DECLARE @s DATE = '2006-10-23', @e DATE = '2006-10-29';
     
    ;WITH d(OrderDate) AS
    (
      SELECT DATEADD(DAY, n-1, @s) 
      FROM (SELECT TOP (DATEDIFF(DAY, @s, @e) + 1)
       ROW_NUMBER() OVER (ORDER BY Number) FROM master..spt_values) AS x(n)
    )
    SELECT 
      d.OrderDate,
      OrderCount = COUNT(s.SalesOrderID)
    FROM d
    LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS s
    ON s.OrderDate >= @s AND s.OrderDate <= @e
    AND CONVERT(DATE, s.OrderDate) = d.OrderDate
    WHERE d.OrderDate >= @s AND d.OrderDate <= @e
    GROUP BY d.OrderDate
    ORDER BY d.OrderDate;

    Plano (clique para ampliar):


    sys.all_objects
    DECLARE @s DATE = '2006-10-23', @e DATE = '2006-10-29';
     
    ;WITH d(OrderDate) AS
    (
      SELECT DATEADD(DAY, n-1, @s) 
      FROM (SELECT TOP (DATEDIFF(DAY, @s, @e) + 1)
       ROW_NUMBER() OVER (ORDER BY [object_id]) FROM sys.all_objects) AS x(n)
    )
    SELECT 
      d.OrderDate,
      OrderCount = COUNT(s.SalesOrderID)
    FROM d
    LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS s
    ON s.OrderDate >= @s AND s.OrderDate <= @e
    AND CONVERT(DATE, s.OrderDate) = d.OrderDate
    WHERE d.OrderDate >= @s AND d.OrderDate <= @e
    GROUP BY d.OrderDate
    ORDER BY d.OrderDate;

    Plano (clique para ampliar):


    CTEs empilhados
    DECLARE @s DATE = '2006-10-23', @e DATE = '2006-10-29';
     
    ;WITH e1(n) AS 
    (
        SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
        SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
        SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
    ),
    e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b),
    d(OrderDate) AS
    (
      SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) 
        d = DATEADD(DAY, ROW_NUMBER() OVER (ORDER BY n)-1, @s) 
      FROM e2
    )
    SELECT 
      d.OrderDate, 
      OrderCount = COUNT(s.SalesOrderID)
    FROM d LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS s
    ON s.OrderDate >= @s AND s.OrderDate <= @e
    AND d.OrderDate = CONVERT(DATE, s.OrderDate)
    WHERE d.OrderDate >= @s AND d.OrderDate <= @e
    GROUP BY d.OrderDate
    ORDER BY d.OrderDate;

    Plano (clique para ampliar):



    Agora, por um período de um ano, isso não será suficiente, pois produz apenas 100 linhas. Por um ano, precisaríamos cobrir 366 linhas (para considerar possíveis anos bissextos), então ficaria assim:
    DECLARE @s DATE = '2006-10-23', @e DATE = '2007-10-22';
     
    ;WITH e1(n) AS 
    (
        SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
        SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
        SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
    ),
    e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b),
    e3(n) AS (SELECT 1 FROM e2 CROSS JOIN (SELECT TOP (37) n FROM e2) AS b),
    d(OrderDate) AS
    (
      SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) 
        d = DATEADD(DAY, ROW_NUMBER() OVER (ORDER BY N)-1, @s) 
      FROM e3
    )
    SELECT 
      d.OrderDate, 
      OrderCount = COUNT(s.SalesOrderID)
    FROM d LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS s
    ON s.OrderDate >= @s AND s.OrderDate <= @e
    AND d.OrderDate = CONVERT(DATE, s.OrderDate)
    WHERE d.OrderDate >= @s AND d.OrderDate <= @e
    GROUP BY d.OrderDate
    ORDER BY d.OrderDate;

    Plano (clique para ampliar):


    Tabela de calendário

    Este é um novo que não falamos muito nos dois posts anteriores. Se você estiver usando séries de datas para muitas consultas, considere ter uma tabela Numbers e uma tabela Calendar. O mesmo argumento vale sobre quanto espaço é realmente necessário e quão rápido será o acesso quando a tabela for consultada com frequência. Por exemplo, para armazenar 30 anos de datas, são necessárias menos de 11.000 linhas (o número exato depende de quantos anos bissextos você abrange) e ocupa apenas 200 KB. Sim, você leu certo:200 kilobytes . (E compactado, tem apenas 136 KB.)

    Para gerar uma tabela Calendar com 30 anos de dados, supondo que você já esteja convencido de que ter uma tabela Numbers é uma coisa boa, podemos fazer o seguinte:
    DECLARE @s DATE = '2005-07-01'; -- earliest year in SalesOrderHeader
    DECLARE @e DATE = DATEADD(DAY, -1, DATEADD(YEAR, 30, @s));
     
    SELECT TOP (DATEDIFF(DAY, @s, @e) + 1) 
     d = CONVERT(DATE, DATEADD(DAY, n-1, @s))
     INTO dbo.Calendar
     FROM dbo.Numbers ORDER BY n;
     
    CREATE UNIQUE CLUSTERED INDEX d ON dbo.Calendar(d);

    Agora, para usar essa tabela Calendar em nossa consulta de relatório de vendas, podemos escrever uma consulta muito mais simples:
    DECLARE @s DATE = '2006-10-23', @e DATE = '2006-10-29';
     
    SELECT
      OrderDate = c.d, 
      OrderCount = COUNT(s.SalesOrderID)
    FROM dbo.Calendar AS c
    LEFT OUTER JOIN Sales.SalesOrderHeaderEnlarged AS s
    ON s.OrderDate >= @s AND s.OrderDate <= @e
    AND c.d = CONVERT(DATE, s.OrderDate)
    WHERE c.d >= @s AND c.d <= @e
    GROUP BY c.d
    ORDER BY c.d;

    Plano (clique para ampliar):

Desempenho


Criei cópias compactadas e não compactadas das tabelas Numbers e Calendar e testei um intervalo de uma semana, um intervalo de um mês e um intervalo de um ano. Também executei consultas com cache frio e cache quente, mas isso acabou sendo em grande parte inconsequente.


Duração, em milissegundos, para gerar um intervalo de uma semana


Duração, em milissegundos, para gerar um intervalo de um mês


Duração, em milissegundos, para gerar um intervalo de um ano

Adendo


Paul White (blog | @SQL_Kiwi) apontou que você pode forçar a tabela Numbers a produzir um plano muito mais eficiente usando a seguinte consulta:
SELECT
  OrderDate = DATEADD(DAY, n, 0),
  OrderCount = COUNT(s.SalesOrderID)
FROM dbo.Numbers AS n
LEFT OUTER JOIN Sales.SalesOrderHeader AS s 
ON s.OrderDate >= CONVERT(DATETIME, @s)
  AND s.OrderDate < DATEADD(DAY, 1, CONVERT(DATETIME, @e))
  AND DATEDIFF(DAY, 0, OrderDate) = n
WHERE
  n.n >= DATEDIFF(DAY, 0, @s)
  AND n.n <= DATEDIFF(DAY, 0, @e)
GROUP BY n
ORDER BY n;

Neste ponto, não vou executar novamente todos os testes de desempenho (exercício para o leitor!), mas assumirei que gerará tempos melhores ou semelhantes. Ainda assim, acho que uma tabela de calendário é uma coisa útil, mesmo que não seja estritamente necessária.

Conclusão


Os resultados falam por si. Para gerar uma série de números, a abordagem da tabela Numbers vence, mas apenas marginalmente – mesmo em 1.000.000 de linhas. E para uma série de datas, na extremidade inferior, você não verá muita diferença entre as várias técnicas. No entanto, fica bem claro que, à medida que seu intervalo de datas aumenta, principalmente quando você está lidando com uma grande tabela de origem, a tabela Calendar realmente demonstra seu valor – especialmente devido ao baixo consumo de memória. Mesmo com o sistema métrico maluco do Canadá, 60 milissegundos é muito melhor do que cerca de 10 *segundos* quando incorreram apenas 200 KB em disco.

Espero que tenham gostado desta pequena série; é um tema que venho querendo revisitar há séculos.

[ Parte 1 | Parte 2 | Parte 3]