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

Generar un conjunto o secuencia sin loops – parte 3

Anteriormente en esta serie (Parte 1 | Parte 2) hablamos sobre generar una serie de números usando varias técnicas. Si bien es interesante y útil en algunos escenarios, una aplicación más práctica es generar una serie de fechas contiguas; por ejemplo, un informe que requiere mostrar todos los días de un mes, incluso si algunos días no tuvieron transacciones.

En una publicación anterior mencioné que es fácil derivar una serie de días a partir de una serie de números. Como ya hemos establecido múltiples formas de derivar una serie de números, veamos cómo se ve el siguiente paso. Comencemos de manera muy simple y supongamos que queremos ejecutar un informe durante tres días, desde el 1 de enero hasta el 3 de enero, e incluir una fila para cada día. La forma antigua sería crear una tabla #temp, crear un ciclo, tener una variable que contenga el día actual, dentro del ciclo insertar una fila en la tabla #temp hasta el final del rango y luego usar # tabla temporal para unirse externamente a nuestros datos de origen. Eso es más código del que quiero presentar aquí, no importa ponerlo en producción, mantenerlo y hacer que los colegas aprendan.

Empezando simple

Con una secuencia de números establecida (independientemente del método que elijas), esta tarea se vuelve mucho más fácil. Para este ejemplo puedo reemplazar generadores de secuencias complejas con una unión muy simple, ya que solo necesito tres días. Voy a hacer que este conjunto contenga cuatro filas, de modo que también sea fácil demostrar cómo cortar exactamente la serie que necesita.

Primero, tenemos un par de variables para contener el inicio y el final del rango que nos interesa:

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

Ahora, si comenzamos solo con el generador de series simple, puede verse así. Voy a agregar un ORDER BY aquí también, solo para estar seguros, ya que nunca podemos confiar en las suposiciones que hacemos sobre el orden.

;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 convertir eso en una serie de fechas, simplemente podemos aplicar DATEADD() desde la fecha de inicio:

;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

Esto todavía no es del todo correcto, ya que nuestro rango comienza el 2 en lugar del 1. Entonces, para usar nuestra fecha de inicio como base, necesitamos convertir nuestro conjunto de 1 a 0. Podemos hacer eso restando 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

¡Casi ahí! Solo necesitamos limitar el resultado de nuestra fuente de serie más grande, lo que podemos hacer alimentando el DATEDIFF , en días, entre el inicio y el final del intervalo, hasta un TOP operador – y luego agregar 1 (desde DATEDIFF esencialmente informa un rango abierto).

;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

Agregar datos reales

Ahora, para ver cómo nos uniríamos con otra tabla para generar un informe, podemos usar esa nueva consulta y combinación externa con los datos de origen.

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

(Tenga en cuenta que ya no podemos decir COUNT(*) , ya que esto contará el lado izquierdo, que siempre será 1.)

Otra forma de escribir esto sería:

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

Esto debería facilitar la visualización de cómo reemplazaría el CTE principal con la generación de una secuencia de fechas de cualquier fuente que elija. Los revisaremos (con la excepción del enfoque CTE recursivo, que solo sirvió para sesgar gráficos), usando AdventureWorks2012, pero usaremos el SalesOrderHeaderEnlarged tabla que creé a partir de este guión de Jonathan Kehayias. Agregué un índice para ayudar con esta consulta específica:

CREATE INDEX d_so ON Sales.SalesOrderHeaderEnlarged(OrderDate);

También tenga en cuenta que estoy eligiendo un intervalo de fechas arbitrario que sé que existe en la tabla.

    Tabla 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 (haga clic para ampliar):

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

    Plano (haga clic para ampliar):

    sys.todos_los_objetos
    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 (haga clic para ampliar):

    CTE apilados
    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 (haga clic para ampliar):

    Ahora, para un rango de un año, esto no es suficiente, ya que solo produce 100 filas. Durante un año, necesitaríamos cubrir 366 filas (para tener en cuenta los posibles años bisiestos), por lo que se vería así:

    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 (haga clic para ampliar):

    Tabla de calendario

    Este es uno nuevo del que no hablamos mucho en las dos publicaciones anteriores. Si está utilizando series de fechas para muchas consultas, debería considerar tener una tabla de Números y una tabla de Calendario. El mismo argumento vale sobre cuánto espacio se necesita realmente y qué tan rápido será el acceso cuando la tabla se consulte con frecuencia. Por ejemplo, para almacenar 30 años de fechas, requiere menos de 11,000 filas (el número exacto depende de cuántos años bisiestos abarque) y ocupa solo 200 KB. Sí, has leído bien:200 kilobytes . (Y comprimido, solo tiene 136 KB).

    Para generar una tabla de Calendario con 30 años de datos, suponiendo que ya esté convencido de que tener una tabla de Números es algo bueno, podemos hacer esto:

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

    Ahora, para usar esa tabla de Calendario en nuestra consulta de informe de ventas, podemos escribir una consulta mucho más simple:

    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 (haga clic para ampliar):

Rendimiento

Creé copias tanto comprimidas como no comprimidas de las tablas Numbers y Calendar, y probé un rango de una semana, un rango de un mes y un rango de un año. También ejecuté consultas con caché en frío y caché en caliente, pero resultó ser en gran medida intrascendente.


Duración, en milisegundos, para generar un rango de una semana


Duración, en milisegundos, para generar un rango de un mes


Duración, en milisegundos, para generar un rango de un año

Anexo

Paul White (blog | @SQL_Kiwi) señaló que puede forzar la tabla Numbers para producir un plan mucho más eficiente usando la siguiente 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;

En este punto, no voy a volver a ejecutar todas las pruebas de rendimiento (¡ejercicio para el lector!), pero supondré que generará tiempos mejores o similares. Aún así, creo que una tabla de calendario es algo útil incluso si no es estrictamente necesario.

Conclusión

los resultados hablan por si mismos. Para generar una serie de números, el enfoque de la tabla de números gana, pero solo marginalmente, incluso con 1.000.000 de filas. Y para una serie de fechas, en el extremo inferior, no verá mucha diferencia entre las diversas técnicas. Sin embargo, está bastante claro que a medida que aumenta el intervalo de fechas, especialmente cuando se trata de una tabla de origen grande, la tabla Calendario realmente demuestra su valor, especialmente debido a su bajo consumo de memoria. Incluso con el extravagante sistema métrico de Canadá, 60 milisegundos es mucho mejor que unos 10 *segundos* cuando solo ocupaba 200 KB en el disco.

Espero que hayas disfrutado de esta pequeña serie; es un tema que he tenido la intención de volver a visitar durante mucho tiempo.

[ Parte 1 | Parte 2 | Parte 3 ]