По-рано в тази серия (Част 1 | Част 2) говорихме за генериране на серия от числа с помощта на различни техники. Макар и интересно и полезно в някои сценарии, по-практично приложение е генерирането на поредица от последователни дати; например отчет, който изисква показване на всички дни от месеца, дори ако в някои дни няма транзакции.
В предишна публикация споменах, че е лесно да се извлече поредица от дни от поредица от числа. Тъй като вече сме установили множество начини за извеждане на поредица от числа, нека да разгледаме как изглежда следващата стъпка. Нека започнем много просто и да се преструваме, че искаме да изготвим отчет за три дни, от 1 януари до 3 януари, и да включим ред за всеки ден. Старомодният начин би бил да създадете таблица #temp, да създадете цикъл, да имате променлива, която държи текущия ден, в рамките на цикъла да вмъкнете ред в таблицата #temp до края на диапазона и след това да използвате # временна таблица към външно присъединяване към нашите изходни данни. Това е повече код, отколкото дори искам да представя тук, без значение да се въвежда в производство, поддръжка и да се накарат колегите да се учат от.
Започваме просто
С установена последователност от числа (независимо от избрания от вас метод) тази задача става много по-лесна. За този пример мога да заменя сложни генератори на последователности с много прост съюз, тъй като ми трябват само три дни. Ще направя този комплект да съдържа четири реда, така че също така лесно да се демонстрира как да отрежете точно до серията, от която се нуждаете.
Първо, имаме няколко променливи, за да задържим началото и края на диапазона, който ни интересува:
DECLARE @s DATE = '2012-01-01', @e DATE = '2012-01-03';
Сега, ако започнем само с простия генератор на серии, може да изглежда така. Ще добавя ORDER BY
и тук, само за да сме в безопасност, тъй като никога не можем да разчитаме на предположенията, които правим за реда.
;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
За да преобразуваме това в поредица от дати, можем просто да приложим DATEADD()
от началната дата:
;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
Това все още не е съвсем правилно, тъй като нашият диапазон започва на 2-ри вместо от 1-ви. Така че, за да използваме нашата начална дата като база, трябва да преобразуваме нашия набор от базиран на 1 в базиран на 0. Можем да направим това, като извадим 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
Почти там! Просто трябва да ограничим резултата от нашия източник на по-голяма серия, което можем да направим, като подадем DATEDIFF
, в дни, между началото и края на диапазона, до TOP
оператор – и след това добавяне на 1 (тъй като DATEDIFF
по същество отчита отворен диапазон).
;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
Добавяне на реални данни
Сега, за да видим как бихме се присъединили към друга таблица, за да извлечем отчет, можем просто да използваме новата ни заявка и външно обединяване спрямо изходните данни.
;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;
(Обърнете внимание, че вече не можем да казваме COUNT(*)
, тъй като това ще брои лявата страна, която винаги ще бъде 1.)
Друг начин да напишете това би бил:
;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;
Това би трябвало да улесни да си представите как бихте заменили водещия CTE с генериране на последователност от дати от всеки източник, който изберете. Ще преминем през тях (с изключение на рекурсивния CTE подход, който служи само за изкривяване на графики), използвайки AdventureWorks2012, но ще използваме SalesOrderHeaderEnlarged
таблица, която създадох от този скрипт от Джонатан Кехайяс. Добавих индекс, за да помогна с тази конкретна заявка:
CREATE INDEX d_so ON Sales.SalesOrderHeaderEnlarged(OrderDate);
Също така имайте предвид, че избирам произволен период от време, за който знам, че съществува в таблицата.
Таблица с числа
;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;
План (щракнете за увеличаване):
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;
План (щракнете за увеличаване):
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;
План (щракнете за увеличаване):
Натрупани CTEs
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;
План (щракнете за увеличаване):
Сега, за дълъг обхват от една година, това няма да го намали, тъй като произвежда само 100 реда. За една година ще трябва да покрием 366 реда (за да отчетем потенциалните високосни години), така че ще изглежда така:
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;
План (щракнете за увеличаване):
Таблица с календари
Това е нов, за който не говорихме много в предишните две публикации. Ако използвате серия от дати за много заявки, тогава трябва да помислите да имате както таблица с числа, така и таблица с календар. Същият аргумент важи и за това колко пространство наистина е необходимо и колко бърз ще бъде достъпът, когато таблицата се отправя често запитвания. Например, за да се съхраняват 30 години дати, са необходими по-малко от 11 000 реда (точният брой зависи от това колко високосни години обхващате) и заема само 200 KB. Да, правилно прочетохте:200 килобайта . (И компресиран, е само 136 KB.)
За да генерираме календарна таблица с данни за 30 години, като приемем, че вече сте се убедили, че наличието на таблица с числа е добро нещо, можем да направим това:
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);
Сега, за да използваме тази таблица в календара в нашата заявка за отчет за продажбите, можем да напишем много по-проста заявка:
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;
План (щракнете за увеличаване):
Ефективност
Създадох както компресирани, така и некомпресирани копия на таблиците с числата и календара и тествах диапазон от една седмица, диапазон от един месец и диапазон от една година. Изпълнявах и заявки със студен и топъл кеш, но това се оказа до голяма степен незначително.
Продължителност, в милисекунди, за генериране на диапазон от една седмица
Продължителност, в милисекунди, за генериране на едномесечен диапазон
Продължителност, в милисекунди, за генериране на едногодишен диапазон
Допълнение
Пол Уайт (блог | @SQL_Kiwi) посочи, че можете да принудите таблицата Numbers да създаде много по-ефективен план, като използвате следната заявка:
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;
На този етап няма да пускам отново всички тестове за производителност (упражнение за читателя!), но ще допусна, че ще генерира по-добри или подобни времена. Все пак смятам, че таблицата в календара е полезно нещо, дори ако не е строго необходимо.
Заключение
Резултатите говорят сами за себе си. За генериране на серия от числа, подходът на таблицата с числа печели, но само незначително – дори при 1 000 000 реда. И за поредица от дати, в долния край, няма да видите голяма разлика между различните техники. Въпреки това е съвсем ясно, че с увеличаването на диапазона ви от време, особено когато имате работа с голяма таблица с източник, таблицата на календара наистина демонстрира своята стойност – особено като се има предвид ниския отпечатък на паметта. Дори с шантавата метрична система на Канада, 60 милисекунди са много по-добри от около 10 *секунди*, когато са натрупали само 200 KB на диска.
Надявам се, че сте харесали тази малка поредица; това е тема, която възнамерявам да преразгледам от векове.
[ Част 1 | Част 2 | Част 3 ]