Database
 sql >> Database >  >> RDS >> Database

Genera un set o una sequenza senza loop – parte 3

All'inizio di questa serie (Parte 1 | Parte 2) abbiamo parlato della generazione di una serie di numeri utilizzando varie tecniche. Sebbene interessante e utile in alcuni scenari, un'applicazione più pratica consiste nel generare una serie di date contigue; ad esempio, un rapporto che richiede di mostrare tutti i giorni di un mese, anche se alcuni giorni non hanno avuto transazioni.

In un post precedente ho detto che è facile ricavare una serie di giorni da una serie di numeri. Poiché abbiamo già stabilito diversi modi per derivare una serie di numeri, diamo un'occhiata a come appare il passaggio successivo. Iniziamo in modo molto semplice e facciamo finta di voler eseguire un rapporto per tre giorni, dal 1 gennaio al 3 gennaio, e includere una riga per ogni giorno. Il vecchio modo sarebbe creare una tabella #temp, creare un ciclo, avere una variabile che contenga il giorno corrente, inserire una riga all'interno del ciclo nella tabella #temp fino alla fine dell'intervallo, quindi utilizzare il # tabella temporanea per unire esterno ai nostri dati di origine. È più codice di quello che voglio presentare qui, non importa metterlo in produzione, mantenerlo e da cui i colleghi imparano.

Inizio semplice

Con una sequenza stabilita di numeri (indipendentemente dal metodo scelto), questo compito diventa molto più semplice. Per questo esempio posso sostituire i generatori di sequenze complesse con un'unione molto semplice, poiché ho bisogno solo di tre giorni. Farò in modo che questo set contenga quattro righe, in modo che sia anche facile dimostrare come tagliare esattamente le serie di cui hai bisogno.

Innanzitutto, abbiamo un paio di variabili per contenere l'inizio e la fine dell'intervallo che ci interessa:

DECLARE @s DATE = '2012-01-01', @e DATE = '2012-01-03';

Ora, se iniziamo con il semplice generatore di serie, potrebbe apparire così. Aggiungerò un ORDER BY anche qui, tanto per sicurezza, visto che non possiamo mai fare affidamento su ipotesi che facciamo sull'ordine.

;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

Per convertirlo in una serie di date, possiamo semplicemente applicare DATEADD() dalla data di inizio:

;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

Questo non è ancora del tutto corretto, dal momento che la nostra gamma inizia il 2° invece che il 1°. Quindi, per utilizzare la nostra data di inizio come base, dobbiamo convertire il nostro set da 1 a 0. Possiamo farlo sottraendo 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

Quasi lì! Dobbiamo solo limitare il risultato della nostra fonte di serie più ampia, cosa che possiamo fare alimentando il DATEDIFF , in giorni, tra l'inizio e la fine dell'intervallo, a un TOP operatore – e quindi aggiungendo 1 (poiché DATEDIFF essenzialmente segnala un intervallo aperto).

;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

Aggiunta di dati reali

Ora, per vedere come ci uniremmo a un'altra tabella per derivare un rapporto, possiamo semplicemente utilizzare la nostra nuova query e il join esterno rispetto ai dati di origine.

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

(Nota che non possiamo più dire COUNT(*) , poiché questo conterà il lato sinistro, che sarà sempre 1.)

Un altro modo per scriverlo sarebbe:

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

Questo dovrebbe rendere più facile immaginare come sostituiresti il ​​CTE principale con la generazione di una sequenza di date da qualsiasi fonte tu scelga. Analizzeremo quelli (con l'eccezione dell'approccio CTE ricorsivo, che serviva solo a distorcere i grafici), usando AdventureWorks2012, ma useremo il SalesOrderHeaderEnlarged tabella che ho creato da questo script di Jonathan Kehayias. Ho aggiunto un indice per aiutare con questa specifica query:

CREATE INDEX d_so ON Sales.SalesOrderHeaderEnlarged(OrderDate);

Tieni inoltre presente che sto scegliendo un intervallo di date arbitrario che so esiste nella tabella.

    Tabella dei numeri
    ;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;

    Piano (clicca per ingrandire):

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

    Piano (clicca per ingrandire):

    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;

    Piano (clicca per ingrandire):

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

    Piano (clicca per ingrandire):

    Ora, per un anno lungo, questo non lo taglierà, poiché produce solo 100 file. Per un anno avremmo bisogno di coprire 366 righe (per tenere conto dei potenziali anni bisestili), quindi sarebbe simile a questo:

    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;

    Piano (clicca per ingrandire):

    Tabella calendario

    Questa è una novità di cui non abbiamo parlato molto nei due post precedenti. Se stai utilizzando le serie di date per molte query, dovresti considerare di avere sia una tabella di numeri che una tabella di calendario. Lo stesso argomento vale su quanto spazio è veramente richiesto e quanto sarà veloce l'accesso quando la tabella viene interrogata frequentemente. Ad esempio, per memorizzare 30 anni di date, sono necessarie meno di 11.000 righe (il numero esatto dipende da quanti anni bisestili si coprono) e occupano solo 200 KB. Sì, avete letto bene:200 kilobyte . (E compresso, è solo 136 KB.)

    Per generare una tabella Calendar con 30 anni di dati, supponendo che tu sia già convinto che avere una tabella di Numbers sia una buona cosa, possiamo farlo:

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

    Ora per utilizzare quella tabella del calendario nella nostra query del rapporto sulle vendite, possiamo scrivere una query molto più semplice:

    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;

    Piano (clicca per ingrandire):

Prestazioni

Ho creato copie compresse e non compresse delle tabelle Numbers e Calendar e ho testato un intervallo di una settimana, un intervallo di un mese e un intervallo di un anno. Ho anche eseguito query con cache fredda e cache calda, ma ciò si è rivelato in gran parte irrilevante.


Durata, in millisecondi, per generare un intervallo di una settimana


Durata, in millisecondi, per generare un intervallo di un mese


Durata, in millisecondi, per generare un intervallo di un anno

Addendum

Paul White (blog | @SQL_Kiwi) ha sottolineato che puoi forzare la tabella di Numbers a produrre un piano molto più efficiente usando la seguente query:

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;

A questo punto non ho intenzione di rieseguire tutti i test delle prestazioni (esercizio per il lettore!), ma presumo che genererà tempi migliori o simili. Tuttavia, penso che una tabella Calendar sia una cosa utile da avere anche se non è strettamente necessario.

Conclusione

I risultati parlano da soli. Per la generazione di una serie di numeri, l'approccio della tabella dei numeri vince, ma solo marginalmente, anche a 1.000.000 di righe. E per una serie di date, all'estremità inferiore, non vedrai molta differenza tra le varie tecniche. Tuttavia, è abbastanza chiaro che man mano che il tuo intervallo di date diventa più ampio, in particolare quando hai a che fare con una tabella di origine di grandi dimensioni, la tabella Calendar dimostra davvero il suo valore, soprattutto dato il suo ingombro di memoria ridotto. Anche con il bizzarro sistema di misurazione canadese, 60 millisecondi sono molto meglio di circa 10 *secondi* quando sono stati utilizzati solo 200 KB su disco.

Spero che questa piccola serie vi sia piaciuta; è un argomento che intendo rivisitare da anni.

[ Parte 1 | Parte 2 | Parte 3]