Tidigare i den här serien (Del 1 | Del 2) pratade vi om att generera en serie tal med olika tekniker. Även om det är intressant och användbart i vissa scenarier, är en mer praktisk tillämpning att generera en serie sammanhängande datum; till exempel en rapport som kräver att alla dagar i en månad visas, även om vissa dagar inte hade några transaktioner.
I ett tidigare inlägg nämnde jag att det är lätt att härleda en serie dagar från en serie siffror. Eftersom vi redan har etablerat flera sätt att härleda en serie tal, låt oss titta på hur nästa steg ser ut. Låt oss börja väldigt enkelt och låtsas att vi vill köra en rapport i tre dagar, från 1 januari till 3 januari, och inkludera en rad för varje dag. Det gammalmodiga sättet skulle vara att skapa en #temp-tabell, skapa en loop, ha en variabel som håller den aktuella dagen, infoga en rad i #temp-tabellen i slingan till slutet av intervallet och sedan använda # temp tabell till yttre koppling till vår källdata. Det är mer kod än jag ens vill presentera här, strunt i att sätta i produktion, underhålla och låta kollegor lära sig av.
Börjar enkelt
Med en etablerad nummersekvens (oavsett vilken metod du väljer) blir denna uppgift mycket enklare. För det här exemplet kan jag ersätta komplexa sekvensgeneratorer med en mycket enkel union, eftersom jag bara behöver tre dagar. Jag ska göra det här setet innehållande fyra rader, så att det också är enkelt att demonstrera hur man skär av till exakt den serie du behöver.
Först har vi ett par variabler som håller början och slutet av intervallet vi är intresserade av:
DECLARE @s DATE = '2012-01-01', @e DATE = '2012-01-03';
Om vi nu börjar med bara den enkla seriegeneratorn kan det se ut så här. Jag ska lägga till en ORDER BY
här också, bara för säkerhets skull, eftersom vi aldrig kan lita på antaganden vi gör om ordning.
;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
För att konvertera det till en serie datum kan vi helt enkelt använda DATEADD()
från startdatumet:
;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
Detta är fortfarande inte helt rätt, eftersom vårt sortiment börjar på 2:an istället för 1:an. Så för att använda vårt startdatum som bas måste vi konvertera vårt set från 1-baserat till 0-baserat. Vi kan göra det genom att subtrahera 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
Nästan där! Vi behöver bara begränsa resultatet från vår större seriekälla, vilket vi kan göra genom att mata in DATEDIFF
, i dagar, mellan början och slutet av intervallet, till en TOP
operator – och sedan lägga till 1 (sedan DATEDIFF
rapporterar i huvudsak ett öppet intervall).
;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
Lägga till riktig data
För att nu se hur vi skulle sammanfoga mot en annan tabell för att härleda en rapport, kan vi bara använda den nya frågan och den yttre sammanfogningen mot källdata.
;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;
(Observera att vi inte längre kan säga COUNT(*)
, eftersom detta kommer att räkna vänster sida, som alltid kommer att vara 1.)
Ett annat sätt att skriva detta är:
;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;
Detta borde göra det lättare att föreställa sig hur du skulle ersätta den ledande CTE med generering av en datumsekvens från vilken källa du väljer. Vi kommer att gå igenom dessa (med undantag för den rekursiva CTE-metoden, som endast tjänade till att skeva grafer), med AdventureWorks2012, men vi kommer att använda SalesOrderHeaderEnlarged
tabell som jag skapade från detta manus av Jonathan Kehayias. Jag lade till ett index som hjälp med den här specifika frågan:
CREATE INDEX d_so ON Sales.SalesOrderHeaderEnlarged(OrderDate);
Observera också att jag väljer ett godtyckligt datumintervall som jag vet finns i tabellen.
Siffertabell
;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;
Planera (klicka för att förstora):
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;
Planera (klicka för att förstora):
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;
Planera (klicka för att förstora):
Stackade CTE
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;
Planera (klicka för att förstora):
Nu, under ett års lång räckvidd, kommer detta inte att klippa det, eftersom det bara producerar 100 rader. Under ett år skulle vi behöva täcka 366 rader (för att ta hänsyn till potentiella skottår), så det skulle se ut så här:
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;
Planera (klicka för att förstora):
Kalendertabell
Det här är en ny som vi inte pratade så mycket om i de två tidigare inläggen. Om du använder datumserier för många frågor bör du överväga att ha både en Numbers-tabell och en Kalender-tabell. Samma argument gäller hur mycket utrymme som verkligen krävs och hur snabb åtkomst kommer att vara när tabellen frågas ofta. Till exempel, för att lagra 30 år av datum, kräver det mindre än 11 000 rader (exakt antal beror på hur många skottår du spänner över), och tar bara upp 200 KB. Ja, du läste rätt:200 kilobyte . (Och komprimerad är den bara 136 KB.)
För att generera en kalendertabell med 30 års data, förutsatt att du redan har varit övertygad om att det är bra att ha en Numbers-tabell, kan vi göra detta:
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);
För att nu använda den kalendertabellen i vår försäljningsrapportfråga kan vi skriva en mycket enklare fråga:
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;
Planera (klicka för att förstora):
Prestanda
Jag skapade både komprimerade och okomprimerade kopior av tabellerna Numbers och Calendar, och testade ett intervall på en vecka, ett intervall på en månad och ett intervall på ett år. Jag körde också frågor med kall cache och varm cache, men det visade sig vara i stort sett oviktigt.
Längd, i millisekunder, för att generera ett veckolångt intervall
Längd, i millisekunder, för att generera ett månadslångt intervall
Längd, i millisekunder, för att generera ett år långt intervall
Tillägg
Paul White (blogg | @SQL_Kiwi) påpekade att du kan tvinga Numbers-tabellen att skapa en mycket effektivare plan med hjälp av följande fråga:
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;
Vid det här laget tänker jag inte köra om alla prestationstest (övning för läsaren!), men jag kommer att anta att det kommer att generera bättre eller liknande timings. Ändå tycker jag att en kalendertabell är en användbar sak att ha även om det inte är absolut nödvändigt.
Slutsats
Resultaten talar för sig själva. För att generera en serie siffror vinner taltabellmetoden, men bara marginellt – även vid 1 000 000 rader. Och för en serie datum, i den nedre delen, kommer du inte att se mycket skillnad mellan de olika teknikerna. Det är dock helt klart att när ditt datumintervall blir större, särskilt när du har att göra med en stor källtabell, visar kalendertabellen verkligen sitt värde – särskilt med tanke på dess låga minnesfotavtryck. Även med Kanadas galna metriska system är 60 millisekunder mycket bättre än cirka 10 *sekunder* när det bara ådrog sig 200 KB på disken.
Jag hoppas att du har gillat den här lilla serien; det är ett ämne som jag har tänkt att återkomma till i evigheter.
[ Del 1 | Del 2 | Del 3 ]