SQL Developers Toolbox – Calendar Tables explained
Introduction
The concept of a calendar table is fairly straightforward. Like its friend the tally (or numbers) table, it’s not really part of your data model, but an add-on that can make your life easier. Used properly, it can make complicated requests into simple queries. A good number of posts to the MSDN T-SQL forum are requests on how to handle requests such as “How many days are between two dates?” – A fairly simple question, which is quickly complicated by asking what types of date count. Holidays? Weekends? Other non-business days?
Demo Data
Let’s setup an example using some random data. We’ll build a short schema for a sales database.
DECLARE @invoices TABLE (invoiceID INT, invoiceSeqID INT, orderDate DATE, customerID INT, repID INT, itemID INT, qty INT)
DECLARE @items TABLE (itemID INT, itemName VARCHAR(20), price FLOAT)
DECLARE @reps TABLE (repID INT, repFirstName VARCHAR(20), repLastName VARCHAR(20))
DECLARE @customers TABLE (customerID INT, customerFirstName VARCHAR(20), customerLastName VARCHAR(20), customerCompanyName VARCHAR(20))
DECLARE @i INT = 0, @x INT = 0
DECLARE @rndCustomerID INT = ROUND(((100 - 1 -1) * RAND() + 1), 0) - 1
DECLARE @rndRepID INT = ROUND(((20 - 1 -1) * RAND() + 1), 0) - 1
DECLARE @rndItemID INT = ROUND(((50 - 1 -1) * RAND() + 1), 0) - 1
DECLARE @rndQty INT = ROUND(((5 - 1 -1) * RAND() + 1), 0)
DECLARE @rndOrderDate DATE = DATEADD(DAY, -ROUND(((365 - 0 -1) * RAND() + 0), 0), CURRENT_TIMESTAMP)
DECLARE @rndInvoiceSeq INT = 0
DECLARE @rndFirstNameStr VARCHAR(20) = '', @rndLastNameStr VARCHAR(20) = '', @rndCompanyNameStr VARCHAR(20) = ''
DECLARE @rndPrice FLOAT = 0.0
SET NOCOUNT ON
WHILE @i < 50
BEGIN
SET @x = ROUND(((15 - 10 -1) * RAND() + 10), 0)
SET @rndFirstNameStr = ''
WHILE @x < 20
BEGIN
IF LEN(@rndFirstNameStr) = 0 SET @rndFirstNameStr = @rndFirstNameStr + CHAR(ROUND(((91 - 65 -1) * RAND() + 65), 0)) ELSE SET @rndFirstNameStr = @rndFirstNameStr + CHAR(ROUND(((122 - 97 -1) * RAND() + 97), 0))
SET @x = @x+1
END
SET @rndPrice = ((15 - 0.5 -1) * RAND() + 0.5)
INSERT INTO @items (itemID, itemName, price) VALUES
(@i, @rndFirstNameStr, @rndPrice)
SET @i += 1
END
SET @i = 0
WHILE @i < 100
BEGIN
SET @x = ROUND(((15 - 10 -1) * RAND() + 10), 0)
SET @rndFirstNameStr = ''
WHILE @x < 20
BEGIN
IF LEN(@rndFirstNameStr) = 0 SET @rndFirstNameStr = @rndFirstNameStr + CHAR(ROUND(((91 - 65 -1) * RAND() + 65), 0)) ELSE SET @rndFirstNameStr = @rndFirstNameStr + CHAR(ROUND(((122 - 97 -1) * RAND() + 97), 0))
SET @x = @x+1
END
SET @x = ROUND(((15 - 10 -1) * RAND() + 10), 0)
SET @rndLastNameStr = ''
WHILE @x < 20
BEGIN
IF LEN(@rndLastNameStr) = 0 SET @rndLastNameStr = @rndLastNameStr + CHAR(ROUND(((91 - 65 -1) * RAND() + 65), 0)) ELSE SET @rndLastNameStr = @rndLastNameStr + CHAR(ROUND(((122 - 97 -1) * RAND() + 97), 0))
SET @x = @x+1
END
INSERT INTO @reps (repID, repFirstName, repLastName) VALUES
(@i, @rndFirstNameStr, @rndLastNameStr)
SET @i += 1
END
SET @i = 0
WHILE @i < 100
BEGIN
SET @x = ROUND(((15 - 10 -1) * RAND() + 10), 0)
SET @rndFirstNameStr = ''
WHILE @x < 20
BEGIN
IF LEN(@rndFirstNameStr) = 0 SET @rndFirstNameStr = @rndFirstNameStr + CHAR(ROUND(((91 - 65 -1) * RAND() + 65), 0)) ELSE SET @rndFirstNameStr = @rndFirstNameStr + CHAR(ROUND(((122 - 97 -1) * RAND() + 97), 0))
SET @x = @x+1
END
SET @x = ROUND(((15 - 10 -1) * RAND() + 10), 0)
SET @rndLastNameStr = ''
WHILE @x < 20
BEGIN
IF LEN(@rndLastNameStr) = 0 SET @rndLastNameStr = @rndLastNameStr + CHAR(ROUND(((91 - 65 -1) * RAND() + 65), 0)) ELSE SET @rndLastNameStr = @rndLastNameStr + CHAR(ROUND(((122 - 97 -1) * RAND() + 97), 0))
SET @x = @x+1
END
SET @x = ROUND(((15 - 10 -1) * RAND() + 10), 0)
SET @rndCompanyNameStr = ''
WHILE @x < 20
BEGIN
IF LEN(@rndCompanyNameStr) = 0 SET @rndCompanyNameStr = @rndCompanyNameStr + CHAR(ROUND(((91 - 65 -1) * RAND() + 65), 0)) ELSE SET @rndCompanyNameStr = @rndCompanyNameStr + CHAR(ROUND(((122 - 97 -1) * RAND() + 97), 0))
SET @x = @x+1
END
INSERT INTO @customers (customerID, customerFirstName, customerLastName, customerCompanyName) VALUES
(@i, @rndFirstNameStr, @rndLastNameStr, @rndCompanyNameStr)
SET @i += 1
END
SET @i = 0
WHILE @i < 200
BEGIN
SET @rndInvoiceSeq += 1
SET @rndQty = ROUND(((5 - 1 -1) * RAND() + 1), 0)
SET @rndItemID = ROUND(((50 - 1 -1) * RAND() + 1), 0) - 1
INSERT INTO @invoices (invoiceID, invoiceSeqID, orderDate, customerID, repID, itemID, qty)
VALUES (@i, @rndInvoiceSeq, @rndOrderDate, @rndCustomerID, @rndRepID, @rndItemID, @rndQty)
IF @rndQty % 2 = 1
BEGIN
SET @i += 1
SET @rndInvoiceSeq = 0
SET @rndOrderDate = DATEADD(DAY, -ROUND(((365 - 0 -1) * RAND() + 0), 0), CURRENT_TIMESTAMP)
SET @rndCustomerID = ROUND(((100 - 1 -1) * RAND() + 1), 0) - 1
SET @rndRepID = ROUND(((20 - 1 -1) * RAND() + 1), 0) - 1
END
END
SET NOCOUNT OFF
SELECT *
FROM @invoices i
LEFT OUTER JOIN @customers c
ON i.customerID = c.customerID
LEFT OUTER JOIN @reps r
ON i.repID = r.repID
LEFT OUTER JOIN @items it
ON i.itemID = it.itemID
Each time we run this script, it will create and populate invoices, customers, items and reps. The schema could easily be expanded, but this should be enough for our needs.
Using our test data, we can take a look to see how long our customers take between orders.
;WITH daysBetweenOrders AS (
SELECT c.customerID, c.customerFirstName, c.customerLastName, i.invoiceDate, i.invoiceID
FROM @customers c
LEFT OUTER JOIN @invoices i
ON c.customerID = i.customerID
GROUP BY c.customerID, c.customerFirstName, c.customerLastName, i.invoiceDate, i.invoiceID
)
SELECT d1.customerID, d1.customerFirstName, d1.customerLastName, d1.invoiceID AS thisInvoiceID, d1.invoiceDate AS thisInvoiceDate, d2.invoiceID AS nextInvoiceID, d2.invoiceDate AS nextInvoiceDate,
DATEDIFF(DAY, d1.invoiceDate, d2.invoiceDate) AS daysBetweenOrders
FROM daysBetweenOrders d1
LEFT OUTER JOIN daysBetweenOrders d2
ON d1.customerID = d2.customerID
AND d2.invoiceDate = (SELECT MIN(invoiceDate) FROM @invoices WHERE customerID = d1.customerID and invoiceDate > d1.invoiceDate)
Great. Now we know how many calendar days between orders. But is this really the metric we wanted? Customers can only place orders while we’re open. Let’s say we’re only open between Monday and Friday, and never on holidays. Is it really right to state the number of calendar days when there’s no way for the customer to place an order on some of those days?
Somehow, we’ll have to figure out which days we need to exclude, and remove them from the difference. Enter the calendar table!
;WITH daysBetweenOrders AS (
SELECT c.customerID, c.customerFirstName, c.customerLastName, i.invoiceDate, i.invoiceID
FROM @customers c
LEFT OUTER JOIN @invoices i
ON c.customerID = i.customerID
GROUP BY c.customerID, c.customerFirstName, c.customerLastName, i.invoiceDate, i.invoiceID
)
SELECT d1.customerID, d1.customerFirstName, d1.customerLastName, d1.invoiceID AS thisInvoiceID, d1.invoiceDate AS thisInvoiceDate, d2.invoiceID AS nextInvoiceID, d2.invoiceDate AS nextInvoiceDate,
DATEDIFF(DAY, d1.invoiceDate, d2.invoiceDate) AS calendarDaysBetweenOrders,
NULLIF(COUNT(c.today),0) AS businessDaysBetweenOrders
FROM daysBetweenOrders d1
LEFT OUTER JOIN daysBetweenOrders d2
ON d1.customerID = d2.customerID
AND d2.invoiceDate = (SELECT MIN(invoiceDate) FROM @invoices WHERE customerID = d1.customerID and invoiceDate > d1.invoiceDate)
LEFT OUTER JOIN calendar c
ON c.today BETWEEN d1.invoiceDate AND d2.invoiceDate
AND c.isHoliday = 0
AND c.weekDay <> 1
AND c.weekDay <> 7
GROUP BY d1.customerID, d1.customerFirstName, d1.customerLastName, d1.invoiceID, d1.invoiceDate, d2.invoiceID, d2.invoiceDate
Adding in the calendar table lets us filter the result, excluding predefined holidays, as well as weekends, without the need for pesky date parts.
If you already have a calendar table, go ahead and look at the result. It’ll show you the discrepancy between the two date differences. The longer the range is the wider the gap will be.
If you don’t have a calendar table, read on!
Getting to the point
To build a calendar table, I use three functions. These functions do all of the heavy lifting required to make all the calculations we need about any given date.
The first of these three is
phNthDayOfMonth
CREATE FUNCTION dbo.phNthDayOfMonth (@year INT, @month SMALLINT, @weekday VARCHAR(15), @nth SMALLINT)
RETURNS DATETIME
AS
BEGIN
DECLARE @the_date DATETIME, @c_date DATETIME, @cth SMALLINT
SET @cth = 0
SET @c_date = CONVERT(VARCHAR,@year)+'-'+CONVERT(VARCHAR,@MONTH)+'-01'
WHILE MONTH(@c_date) = @MONTH
BEGIN
IF DATENAME(WEEKDAY,@c_date) = @weekday SET @cth = @cth + 1
IF @cth = @nth and DATENAME(WEEKDAY,@c_date) = @weekday SET @the_date = @c_date
SET @c_date = DATEADD(DAY,1,@c_date)
END
IF @the_date is null
BEGIN
SET @the_date = DATEADD(DAY, DATEPART(WEEKDAY,DATEADD(MONTH,1,CAST(@year AS VARCHAR) + '-' + CAST(@month AS VARCHAR) + '-01'))-7,DATEADD(MONTH,1,CAST(@year AS VARCHAR) + '-' + CAST(@month AS VARCHAR) + '-01'))
END
RETURN @the_date
END
It accepts as parameters a year, month weekday name and the Nth value you want.
Passing it 2015, 1, ‘Monday’, 1 causes it to return 2015-01-05, as January 5th was the first Monday in 2015. Passing it 2015, 1, ‘Monday’, 5 causes it to return 2015-01-26, as January 26th was the last Monday in 2015, it was also the 4th Monday in 2015, but the function is smart enough to return the last possible answer for the month in question in the event that you pass a larger Nth value than occurred. If there were 5 Mondays in January, it would have given you the 5th. In December 2014 there were, so let’s try:
SELECT dbo.phNthDayOfMonth(2014, 12, 'Monday', 5) AS fifth, dbo.phNthDayOfMonth(2014, 12, 'Monday', 4) AS fourth, dbo.phNthDayOfMonth(2014, 12, 'Monday', 1) AS first
Hooray! What am I getting at and why do we care about Nth days? We’ll use this function to help us figure out when those holidays which fall on the Nth weekday of a month, like Thanks-giving, or Labor Day.
Next up, phHolidays. This function finds all the holidays for any given year, and returns them as a table. It needs to be customized to your particular region. In this case it’s configured for Saskatchewan, Canada. Altering the holidays to meet your own needs isn’t too complicated, unless you have things calculated on the lunar cycle, such as Easter. Lucky for you, I did the legwork on that one already.
phHolidays
CREATE FUNCTION phHolidays(@year INT)
RETURNS @table TABLE
(
date DATETIME,
type VARCHAR(10),
name VARCHAR(25)
)
AS
BEGIN
INSERT INTO @table
SELECT CONVERT(DATETIME,CONVERT(VARCHAR,@year)+'-01-01') AS date,'Holiday' AS type ,'New Years Day' AS name UNION ALL
SELECT dbo.phNthDayOfMonth(@year,2, 'Monday',3),'Holiday','Family Day' UNION ALL
SELECT DATEADD(d,0-CASE WHEN DATEPART(WEEKDAY,CONVERT(VARCHAR,@year)+'-05-25') in (1,2) THEN 5+DATEPART(WEEKDAY,CONVERT(VARCHAR,@year)+'-05-25') ELSE DATEPART(WEEKDAY,CONVERT(VARCHAR,@year)+'-05-25')-1 END, CONVERT(VARCHAR,@year)+'-05-25') ,'Holiday','Victoria Day' UNION ALL
SELECT CONVERT(VARCHAR,@year)+'-07-01','Holiday','Canada Day' UNION ALL
SELECT dbo.phNthDayOfMonth(@year,8, 'Monday',1),'Holiday','Civic Holiday' UNION ALL
SELECT dbo.phNthDayOfMonth(@year,9, 'Monday',1),'Holiday','Labour Day' UNION ALL
SELECT dbo.phNthDayofMonth(@year,10,'Monday',2),'Holiday','Thanksgiving' UNION ALL
SELECT CONVERT(VARCHAR,@year)+'-11-11','Holiday','Rememberance Day'UNION ALL
SELECT CONVERT(VARCHAR,@year)+'-12-25','Holiday','Christmas Day' UNION ALL
SELECT CONVERT(VARCHAR,@year)+'-12-26','Holiday','Boxing Day' UNION ALL
SELECT CONVERT(DATETIME,CONVERT(VARCHAR,@year) + '-0'+CONVERT(VARCHAR, FLOOR((((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + 90) / 25))+'-'+CONVERT(VARCHAR,CONVERT(INT,(((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + (FLOOR((((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + 90) / 25)) + 19) % 32,0))),'Holiday','Easter Sunday' UNION ALL
SELECT DATEADD(DAY,-2,CONVERT(DATETIME,CONVERT(VARCHAR,@year) + '-0'+CONVERT(VARCHAR, FLOOR((((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + 90) / 25))+'-'+CONVERT(VARCHAR,CONVERT(INT,(((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + (FLOOR((((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + 90) / 25)) + 19) % 32,0)))),'Holiday','Good Friday' UNION ALL
SELECT DATEADD(DAY,1,CONVERT(DATETIME,CONVERT(VARCHAR,@year) + '-0'+CONVERT(VARCHAR, FLOOR((((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + 90) / 25))+'-'+CONVERT(VARCHAR,CONVERT(INT,(((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + (FLOOR((((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) - (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) + ((((2 * FLOOR((@year / 100.0)) % 4) + (2*((@year % 100) / 4))) - ((@year % 100) % 4) - ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30) + (FLOOR((@year % 19.0 + (11 * ((( (19 * (@year % 19.0)) + FLOOR(@year / 100.0)) - FLOOR((@year / 100.0) / 4) - FLOOR((8 * (@year / 100.0) + 13) / 25.0) + 15) % 30))) / 319)) +32) % 7) + 90) / 25)) + 19) % 32,0)))),'Holiday','Easter Monday'
UPDATE @table
SET date =
CASE WHEN MONTH(date) = 12 AND name != 'Boxing Day' and DATEPART(WEEKDAY,date) = 7 THEN DATEADD(DAY,2,date)
WHEN MONTH(date) = 12 AND name != 'Boxing Day' and DATEPART(WEEKDAY,date) = 1 THEN DATEADD(DAY,1,date)
WHEN MONTH(date) = 12 AND name = 'Boxing Day' and DATEPART(WEEKDAY,date) = 7 THEN DATEADD(DAY,2,date)
WHEN MONTH(date) = 12 AND name = 'Boxing Day' and DATEPART(WEEKDAY,date) = 1 THEN DATEADD(DAY,2,date)
WHEN MONTH(date) = 12 AND name = 'Boxing Day' and DATEPART(WEEKDAY,date) = 2 THEN DATEADD(DAY,1,date)
ELSE date
END
RETURN
END
Yeah. Easter sucks.
The function only requires you to pass it the year in question, and gives you back all the holidays as defined within it. Essentially there are two types of holiday. Those that fall on the Nth day of a month, and those that always fall on the same day.
If you have a set-date holiday, like Canada day, you can define it like so:
SELECT CONVERT(VARCHAR,@year)+'-07-01','Holiday','Canada Day'
Yep, we literally just stuffed the year onto the front of the date and called it done. That was easy. More about these holidays later.
If your holiday is the Nth day of a month, like Family day, which is the third Monday in Febuary, we can call on our friend phNthDayOfTheMonth:
SELECT dbo.phNthDayOfMonth(@year,2, 'Monday',3),'Holiday','Family Day'
Our handy dandy function does the work, and gives us back the right date. If you have super complicated holidays you need to calculate, you’re on your own from here on out. I don’t pretend to understand how the Easter calculation works; it was ported from an anonymous posting in another language. It has been thoroughly tested and seems to line up with the next 30 years or so.
You'll notice there is a section at the end of the function which does some manual updating. Sometimes set date holidays fall on the weekend. These are typically pushed to the next week day. If Boxing day is on a Saturday, it's observed on the following Monday, for example.
So, now we know about the holidays in any given year, what about all the rest of the data for a date?
This is where the big one comes in. It’s not terribly clever, or complicated, it’s just a lot of code. Here’s phCalendar
phCalendar
CREATE FUNCTION phCalendar(@date DATETIME)
RETURNS @table TABLE (now DATETIME, today DATETIME, monthStart DATETIME, monthEnd DATETIME, prevMonthStart DATETIME, prevMonthEnd DATETIME, weekStart DATETIME, weekEnd DATETIME, prevWeekStart DATETIME, prevWeekEnd DATETIME, quarterStart DATETIME, quarterEnd DATETIME, prevQuarterStart DATETIME, prevQuarterEnd DATETIME, yearStart DATETIME, yearEnd DATETIME, prevYearStart DATETIME, prevYearEnd DATETIME, monthEndTS DATETIME, prevMonthEndTS DATETIME, weekEndTS DATETIME, prevWeekEndTS DATETIME, quarterEndTS DATETIME, prevQuarterEndTS DATETIME, yearEndTS DATETIME, prevYearEndTS DATETIME, year SMALLINT, month SMALLINT, day SMALLINT, monthName VARCHAR(15), dayName VARCHAR(15), weekDay SMALLINT, ISOWeekNumber INT, weekNumber INT, isHoliday BIT, holidayName VARCHAR(100))
AS
BEGIN
IF @date IS NULL SET @date = getdate()
INSERT INTO @table
SELECT
@date AS now,
CONVERT(DATETIME,CONVERT(VARCHAR,@date,101)) AS today,
DATEADD(DAY,0-DAY(@date)+1,CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))) AS monthStart,
DATEADD(DAY,-1,DATEADD(MONTH,1,DATEADD(DAY,0-DAY(@date)+1,CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))))) AS monthEnd,
DATEADD(MONTH,-1,DATEADD(DAY,0-DAY(@date)+1,CONVERT(DATETIME,CONVERT(VARCHAR,@date,101)))) AS prevMonthStart,
DATEADD(DAY,-1-DAY(@date)+1,CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))) AS prevMonthEnd,
DATEADD(DAY,1-DATEPART(DW,@date),CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))) AS weekStart,
DATEADD(DAY,7-DATEPART(DW,@date),CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))) AS weekEnd,
DATEADD(DAY,-6-DATEPART(DW,@date),CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))) AS prevWeekStart,
DATEADD(DAY,0-DATEPART(DW,@date),CONVERT(DATETIME,CONVERT(VARCHAR,@date,101))) AS prevWeekEnd,
CONVERT(DATETIME,CONVERT(VARCHAR,YEAR(@date)) +'-'+ RIGHT('0'+CONVERT(VARCHAR,((DATEPART(QUARTER,@date)-1)\*3)+1),2)+'-01') AS quarterStart,
DATEADD(DAY,-1,DATEADD(QUARTER,1,CONVERT(DATETIME,CONVERT(VARCHAR,YEAR(@date)) +'-'+ RIGHT('0'+CONVERT(VARCHAR,((DATEPART(QUARTER,@date)-1)\*3)+1),2)+'-01'))) AS quarterEnd,
CONVERT(DATETIME,CONVERT(VARCHAR,YEAR(DATEADD(QUARTER,-1,@date))) +'-'+ RIGHT('0'+CONVERT(VARCHAR,((DATEPART(QUARTER,DATEADD(QUARTER,-1,@date))-1)\*3)+1),2)+'-01') AS prevquarterStart,
DATEADD(DAY,-1,DATEADD(QUARTER,1,CONVERT(DATETIME,CONVERT(VARCHAR,YEAR(DATEADD(QUARTER,-1,@date))) +'-'+ RIGHT('0'+CONVERT(VARCHAR,((DATEPART(QUARTER,DATEADD(QUARTER,-1,@date))-1)\*3)+1),2)+'-01'))) AS prevQuarterEnd,
DATEADD(DAY,1-DAY(@date),DATEADD(MONTH,1-MONTH(@date),CONVERT(VARCHAR,@date,101))) AS yearStart,
DATEADD(YEAR,1,DATEADD(DAY,0-DAY(@date),DATEADD(MONTH,1-MONTH(@date),CONVERT(VARCHAR,@date,101)))) AS yearEnd,
DATEADD(YEAR,-1,DATEADD(DAY,1-DAY(@date),DATEADD(MONTH,1-MONTH(@date),CONVERT(VARCHAR,@date,101)))) AS prevYearStart,
DATEADD(YEAR,-1,DATEADD(YEAR,1,DATEADD(DAY,0-DAY(@date),DATEADD(MONTH,1-MONTH(@date),CONVERT(VARCHAR,@date,101))))) AS prevYearEnd,
DATEADD(MS,-3,DATEADD(DAY,0,DATEADD(MONTH,1,DATEADD(DAY,0-DAY(@date)+1,CONVERT(DATETIME,CONVERT(VARCHAR,@date,101)))))) AS monthEndTS,
DATEADD(MS,-3,DATEADD(DAY,-1-DAY(@date)+2,CONVERT(DATETIME,CONVERT(VARCHAR,@date,101)))) AS prevMonthEndTS,
DATEADD(MS,-3,DATEADD(DAY,8-DATEPART(DW,@date),CONVERT(DATETIME,CONVERT(VARCHAR,@date,101)))) AS weekEndTS,
DATEADD(MS,-3,DATEADD(DAY,1-DATEPART(DW,@date),CONVERT(DATETIME,CONVERT(VARCHAR,@date,101)))) AS prevWeekEndTS,
DATEADD(MS,-3,DATEADD(DAY,0,DATEADD(QUARTER,1,CONVERT(DATETIME,CONVERT(VARCHAR,YEAR(@date)) +'-'+ RIGHT('0'+CONVERT(VARCHAR,((DATEPART(QUARTER,@date)-1)\*3)+1),2)+'-01')))) AS quarterEndTS,
DATEADD(MS,-3,DATEADD(DAY,0,DATEADD(QUARTER,1,CONVERT(DATETIME,CONVERT(VARCHAR,YEAR(DATEADD(QUARTER,-1,@date))) +'-'+ RIGHT('0'+CONVERT(VARCHAR,((DATEPART(QUARTER,DATEADD(QUARTER,-1,@date))-1)\*3)+1),2)+'-01')))) AS prevQuarterEndTS,
DATEADD(MS,-3,DATEADD(YEAR,1,DATEADD(DAY,1-DAY(@date),DATEADD(MONTH,1-MONTH(@date),CONVERT(VARCHAR,@date,101))))) AS yearEndTS,
DATEADD(MS,-3,DATEADD(DAY,1-DAY(@date),DATEADD(MONTH,1-MONTH(@date),CONVERT(VARCHAR,@date,101)))) AS prevYearEndTS,
YEAR(@date) AS year,
MONTH(@date) AS month,
DAY(@Date) AS day,
DATENAME(MONTH,@Date) AS monthName,
DATENAME(WEEKDAY,@date) AS dayName,
DATEPART(WEEKDAY,@date) AS weekDay,
CASE WHEN @date BETWEEN DATEADD(DAY,-4,dbo.phNthDayOfMonth(YEAR(@date)+CASE WHEN MONTH(@date) = 12 THEN 1 ELSE 0 END,1,'Thursday' ,1)) AND DATEADD(DAY,2,dbo.phNthDayOfMonth(YEAR(@date)+CASE WHEN MONTH(@date) = 12 THEN 1 ELSE 0 END,1,'Thursday',1)) AND DATEPART(WEEK,@date) = 53 THEN 1
WHEN @date NOT BETWEEN DATEADD(DAY,-4,dbo.phNthDayOfMonth(YEAR(@date)+CASE WHEN MONTH(@date) = 12 THEN 1 ELSE 0 END,1,'Thursday',1)) AND DATEADD(DAY,2,dbo.phNthDayOfMonth(YEAR(@date)+CASE WHEN MONTH(@date) = 12 THEN 1 ELSE 0 END,1,'Thursday',1)) AND DATEPART(WEEK,@date) = 1 THEN 53
ELSE DATEPART(WEEK,@date) END AS isoWeekNumber,
DATEPART(WEEK,@date) AS weekNumber,
COALESCE((SELECT 1 from dbo.phHolidays(DATEPART(YEAR,@date)) WHERE date = @date),0) AS isHoliday,
(SELECT name from dbo.phHolidays(DATEPART(YEAR,@date)) WHERE date = @date) AS holidayName
RETURN
END
It accepts a date, and returns a pile of information about it, including if it’s a holiday, what Day, month, year, when the nearest end of month is, when the month it’s in runs from, where the month preceding it was and so on.
Now, you can use it stand alone, as a function, or you can opt to use it to fill a permanent table. Doing so just takes a simple loop:
Filling the table
SET NOCOUNT ON
DECLARE @endDate DATETIME, @date DATETIME
SET @endDate = '2014-12-31'
SET @date = '2014-01-01'
SELECT *
INTO calendar
FROM phDates(@date)
WHILE @date <= @endDate
BEGIN
SET @date = DATEADD(DAY,1,@date)
INSERT INTO calendar (now, today, monthStart, monthEnd, prevMonthStart, prevMonthEnd, weekStart, weekEnd, prevWeekStart, prevWeekEnd, quarterStart, quarterEnd, prevQuarterStart, prevQuarterEnd, yearStart, yearEnd, prevYearStart, prevYearEnd, monthEndTS, prevMonthEndTS, weekEndTS, prevWeekEndTS, quarterEndTS, prevQuarterEndTS, yearEndTS, prevYearEndTS, year, month, day, monthName, dayName, weekDay, ISOWeekNumber, weekNumber, isHoliday, holidayName)
SELECT now, today, monthStart, monthEnd, prevMonthStart, prevMonthEnd, weekStart, weekEnd, prevWeekStart, prevWeekEnd, quarterStart, quarterEnd, prevQuarterStart, prevQuarterEnd, yearStart, yearEnd, prevYearStart, prevYearEnd, monthEndTS, prevMonthEndTS, weekEndTS, prevWeekEndTS, quarterEndTS, prevQuarterEndTS, yearEndTS, prevYearEndTS, year, month, day, monthName, dayName, weekDay, ISOWeekNumber, weekNumber, isHoliday, holidayName
FROM phDates(@date)
END
SET NOCOUNT OFF
And there you have it. The loop will run between the dates you specify, and populate a table with all that lovely calendar data.
Putting it to use:
Using our example data from the beginning let’s have a play:
Number of invoices per week:
SELECT COUNT(*) AS invoices, c.weekStart
FROM @invoices i
INNER JOIN calendar c
ON i.invoiceDate = c.today
AND i.invoiceSeqID = 1
GROUP BY c.weekStart
Sales per month:
SELECT SUM(it.Price) AS sales, c.monthStart
FROM @invoices i
INNER JOIN @items it
ON i.itemID = it.itemID
INNER JOIN calendar c
ON i.invoiceDate = c.today
GROUP BY c.monthStart
Sales on non-business days:
SELECT SUM(it.Price) AS sales, COUNT(DISTINCT i.invoiceID) AS invoices, COALESCE(c.holidayName,c.dayName)
FROM @invoices i
INNER JOIN @items it
ON i.itemID = it.itemID
INNER JOIN calendar c
ON i.invoiceDate = c.today
AND i.invoiceSeqID = 1
AND (
c.weekDay IN (1,7)
OR c.isHoliday = 1)
GROUP BY COALESCE(c.holidayName,c.dayName)
Percent change over month:
;WITH monthlySales AS (
SELECT SUM(it.Price) AS sales, c.monthStart, c.prevMonthStart
FROM @invoices i
INNER JOIN @items it
ON i.itemID = it.itemID
INNER JOIN calendar c
ON i.invoiceDate = c.today
GROUP BY c.monthStart, c.prevMonthStart
)
SELECT ms.monthStart, ms.sales, ms.prevMonthStart, pms.sales, ((ms.sales - pms.sales) / pms.sales) * 100 AS percentChange
FROM monthlySales ms
LEFT OUTER JOIN monthlySales pms
ON ms.prevMonthStart = pms.monthStart
These are just a few simple examples of how having the calendar table can make your life easier.
You can customize your calendar table as much as you need to, adding new columns (fiscal quarters perhaps?) and metrics as the need arises.