Wcześniej w tej serii (Część 1 | Część 2) mówiliśmy o generowaniu szeregu liczb przy użyciu różnych technik. Chociaż interesujące i przydatne w niektórych scenariuszach, bardziej praktycznym zastosowaniem jest generowanie serii ciągłych dat; na przykład raport, który wymaga pokazania wszystkich dni w miesiącu, nawet jeśli niektóre dni nie zawierały transakcji.
W poprzednim poście wspomniałem, że łatwo wyprowadzić serię dni z szeregu liczb. Ponieważ ustaliliśmy już wiele sposobów na wyprowadzenie szeregu liczb, przyjrzyjmy się, jak wygląda następny krok. Zacznijmy bardzo prosto i udawajmy, że chcemy generować raport przez trzy dni, od 1 do 3 stycznia, i dołączyć wiersz dla każdego dnia. Staromodnym sposobem byłoby utworzenie tabeli #temp, utworzenie pętli, posiadanie zmiennej przechowującej bieżący dzień, wstawienie w pętli wiersza do tabeli #temp do końca zakresu, a następnie użycie # tabela tymczasowa do połączenia zewnętrznego z naszymi danymi źródłowymi. To więcej kodu, niż chciałbym tutaj przedstawić, nie wspominając o produkcji, utrzymaniu i uczeniu kolegów.
Proste rozpoczęcie
Przy ustalonej sekwencji liczb (niezależnie od wybranej metody) zadanie to staje się znacznie łatwiejsze. W tym przykładzie mogę zastąpić złożone generatory sekwencji bardzo prostą unią, ponieważ potrzebuję tylko trzech dni. Zamierzam sprawić, by ten zestaw zawierał cztery rzędy, aby łatwo było również zademonstrować, jak przyciąć dokładnie taką serię, jakiej potrzebujesz.
Po pierwsze, mamy kilka zmiennych, które przechowują początek i koniec interesującego nas zakresu:
DECLARE @s DATE = '2012-01-01', @e DATE = '2012-01-03';
Teraz, jeśli zaczniemy od prostego generatora szeregów, może to wyglądać tak. Zamierzam dodać ORDER BY
również tutaj, aby być bezpiecznym, ponieważ nigdy nie możemy polegać na założeniach, które przyjmujemy na temat porządku.
;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
Aby przekonwertować to na serię dat, możemy po prostu zastosować DATEADD()
od daty rozpoczęcia:
;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
To wciąż nie jest w porządku, ponieważ nasz zakres zaczyna się od drugiego, a nie od pierwszego. Aby więc użyć naszej daty początkowej jako podstawy, musimy przekonwertować nasz zestaw z opartego na 1 na oparty na 0. Możemy to zrobić, odejmując 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
Prawie na miejscu! Musimy tylko ograniczyć wynik z naszego większego źródła serii, co możemy zrobić, podając DATEDIFF
, w dniach, między początkiem a końcem zakresu, do TOP
operator – a następnie dodawanie 1 (od DATEDIFF
zasadniczo zgłasza zakres otwarty).
;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
Dodawanie prawdziwych danych
Teraz, aby zobaczyć, jak połączylibyśmy się z inną tabelą w celu uzyskania raportu, możemy po prostu użyć tego naszego nowego zapytania i sprzężenia zewnętrznego względem danych źródłowych.
;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;
(Zauważ, że nie możemy już powiedzieć COUNT(*)
, ponieważ będzie to liczyć lewą stronę, która zawsze będzie równa 1.)
Innym sposobem napisania tego byłoby:
;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;
Powinno to ułatwić wyobrażenie sobie, jak zastąpić wiodący CTE generowaniem sekwencji dat z dowolnego wybranego źródła. Przejdziemy przez te (z wyjątkiem rekurencyjnego podejścia CTE, które służyło tylko do pochylania wykresów), używając AdventureWorks2012, ale użyjemy SalesOrderHeaderEnlarged
tabela, którą stworzyłem z tego skryptu przez Jonathana Kehayiasa. Dodałem indeks, aby pomóc w tym konkretnym zapytaniu:
CREATE INDEX d_so ON Sales.SalesOrderHeaderEnlarged(OrderDate);
Pamiętaj też, że wybieram dowolny zakres dat, o którym wiem, że istnieje w tabeli.
Tabela liczb
;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;
Plan (kliknij, aby powiększyć):
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;
Plan (kliknij, aby powiększyć):
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;
Plan (kliknij, aby powiększyć):
Skumulowane 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;
Plan (kliknij, aby powiększyć):
Teraz, przy rocznym zasięgu, to go nie przytnie, ponieważ daje tylko 100 rzędów. Przez rok musielibyśmy pokryć 366 wierszy (ze względu na potencjalne lata przestępne), więc wyglądałoby to tak:
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;
Plan (kliknij, aby powiększyć):
Tabela kalendarza
To nowy, o którym nie mówiliśmy zbyt wiele w poprzednich dwóch postach. Jeśli używasz serii dat dla wielu zapytań, powinieneś rozważyć posiadanie zarówno tabeli liczb, jak i tabeli kalendarza. Ten sam argument dotyczy tego, ile miejsca jest naprawdę potrzebne i jak szybki będzie dostęp w przypadku częstych zapytań do tabeli. Na przykład, aby przechowywać 30 lat dat, wymaga mniej niż 11 000 wierszy (dokładna liczba zależy od tego, ile lat przestępnych obejmujesz) i zajmuje zaledwie 200 KB. Tak, dobrze przeczytałeś:200 kilobajtów . (I skompresowany, to tylko 136 KB).
Aby wygenerować tabelę kalendarza z 30-letnimi danymi, zakładając, że jesteś już przekonany, że posiadanie tabeli liczb to dobra rzecz, możemy to zrobić:
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);
Teraz, aby użyć tej tabeli Kalendarz w naszym zapytaniu raportu sprzedaży, możemy napisać znacznie prostsze zapytanie:
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;
Plan (kliknij, aby powiększyć):
Wydajność
Utworzyłem zarówno skompresowane, jak i nieskompresowane kopie tabel Numbers i Calendar oraz przetestowałem zakres jednego tygodnia, jednego miesiąca i jednego roku. Uruchomiłem również zapytania z zimną pamięcią podręczną i ciepłą pamięcią podręczną, ale okazało się to w dużej mierze nieistotne.
Czas generowania tygodniowego zakresu w milisekundach
Czas trwania w milisekundach do wygenerowania zakresu obejmującego cały miesiąc
Czas wygenerowania rocznego zakresu w milisekundach
Uzupełnienie
Paul White (blog | @SQL_Kiwi) zwrócił uwagę, że możesz wymusić użycie tabeli Numbers, aby stworzyć znacznie wydajniejszy plan za pomocą następującego zapytania:
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;
W tym momencie nie zamierzam ponownie przeprowadzać wszystkich testów wydajnościowych (ćwiczenie dla czytelnika!), ale zakładam, że wygeneruje to lepsze lub podobne czasy. Mimo to uważam, że tabela kalendarza jest przydatna, nawet jeśli nie jest to absolutnie konieczne.
Wniosek
Wyniki mówią same za siebie. W przypadku generowania serii liczb, podejście tabeli liczb wygrywa, ale tylko marginalnie – nawet przy 1 000 000 wierszy. A w przypadku serii dat, na dole, nie zobaczysz dużej różnicy między różnymi technikami. Jest jednak całkiem jasne, że wraz ze wzrostem zakresu dat, szczególnie gdy mamy do czynienia z dużą tabelą źródłową, tabela Kalendarz naprawdę pokazuje swoją wartość – zwłaszcza biorąc pod uwagę jej małą ilość pamięci. Nawet przy zwariowanym kanadyjskim systemie metrycznym 60 milisekund jest o wiele lepszy niż około 10 * sekund *, gdy na dysku jest tylko 200 KB.
Mam nadzieję, że podobała Ci się ta mała seria; to temat, do którego chciałem wrócić od wieków.
[ Część 1 | Część 2 | Część 3 ]