[Edited from original – my colleague pointed out that the method I was using for getting the weekday returned different results depending on the DATEFIRST setting – the new solution is actually cleaner!]
A colleague of mine asked me for a utility to find the nth weekday of the month given a date, a number and a word such as “Wednesday”. He further challenged me to see if I could do it in one statement.
So for example, a call such as this:
SELECT dbo.nth_weekday_of_month_fn(GETDATE(),'Wednesday',3)
When run on any day in July, 2011, will return the following:
-----------------------
2011-07-20 00:00:00.000
(1 row(s) affected)
Which is the 3rd Wednesday of this month. The date parameter serves only to identify the month (and year), so any day or time information can be ignored. It is a fairly straightforward problem, and when thought of iteratively, straightforward solutions start coming to mind – but iterative thinking is not going to solve this in a single statement.
From a set-based perspective, you think of the set of dates that make up the month in question, then simply choosing the right one – so the first item of business is to get a set of all the dates for the month in question. As with many solutions, this will require a numbers or tally table (simply a table with nothing but numbers in it). Many people have such a table at their disposal, but if you don’t (and you don’t need very many numbers – here we’ll only need a maximum of 31) you can use master..spt_values as discussed in a previous post. So starting with the first day of the month, if we add the numbers from our tally table in days, we end up with a list of days of the month (we’re clearly violating the one-statement rule, but stay tuned – we’ll bring it all together):
DECLARE @dt DATETIME = GETDATE();
DECLARE @dow VARCHAR(100) = 'Wednesday';
DECLARE @num INT = 3;
--This will take off any time portion and set the value to the beginning of the month
SET @dt = DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0);
SELECT DATEADD(DAY,number-1,@dt) AS MonthDate
FROM MASTER..spt_values
WHERE number > 0
AND TYPE = 'P'
AND DATEADD(DAY,number-1,@dt) >= @dt
AND DATEADD(DAY,number-1,@dt) < DATEADD(MONTH,1,@dt)
MonthDate
-----------------------
2011-07-01 00:00:00.000
2011-07-02 00:00:00.000
2011-07-03 00:00:00.000
2011-07-04 00:00:00.000
2011-07-05 00:00:00.000
2011-07-06 00:00:00.000
2011-07-07 00:00:00.000
2011-07-08 00:00:00.000
2011-07-09 00:00:00.000
2011-07-10 00:00:00.000
2011-07-11 00:00:00.000
2011-07-12 00:00:00.000
2011-07-13 00:00:00.000
2011-07-14 00:00:00.000
2011-07-15 00:00:00.000
2011-07-16 00:00:00.000
2011-07-17 00:00:00.000
2011-07-18 00:00:00.000
2011-07-19 00:00:00.000
2011-07-20 00:00:00.000
2011-07-21 00:00:00.000
2011-07-22 00:00:00.000
2011-07-23 00:00:00.000
2011-07-24 00:00:00.000
2011-07-25 00:00:00.000
2011-07-26 00:00:00.000
2011-07-27 00:00:00.000
2011-07-28 00:00:00.000
2011-07-29 00:00:00.000
2011-07-30 00:00:00.000
2011-07-31 00:00:00.000
(31 row(s) affected)
So considering this set, we need only pull out the Wednesdays (or whatever) and count them. We can pull out the “Wednesdays” one of two ways: by using a filter with either DATENAME or DATEPART – in this case we’re expressing the weekday in “name” format so DATENAME makes the most sense – also, the number DATEPART returns depends on the DATEFIRST setting. Once we find the weekday, we can index them with a row number:
DECLARE @dt DATETIME = GETDATE();
DECLARE @dow VARCHAR(100) = 'Wednesday';
DECLARE @num INT = 3;
--This will take off any time portion and set the value to the beginning of the month
SET @dt = DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0);
SELECT DATEADD(DAY,number-1,@dt) AS MonthDate,
ROW_NUMBER() OVER(ORDER BY number) AS DayIndex
FROM MASTER..spt_values
WHERE number > 0
AND TYPE = 'P'
AND DATEADD(DAY,number-1,@dt) >= @dt
AND DATEADD(DAY,number-1,@dt) < DATEADD(MONTH,1,@dt)
AND DATENAME(dw,DATEADD(DAY,number-1,@dt)) LIKE @dow + '%'
MonthDate DayIndex
----------------------- --------------------
2011-07-06 00:00:00.000 1
2011-07-13 00:00:00.000 2
2011-07-20 00:00:00.000 3
2011-07-27 00:00:00.000 4
(4 row(s) affected)
We now have only the Wednesdays of the month, appropriately indexed. All we have to do now is filter for the 3rd Wednesday. Since we cannot use a windowing function in a where clause, we have to wrap this query in a CTE or some structure in order to be able to apply our filter:
DECLARE @dt DATETIME = GETDATE();
DECLARE @dow VARCHAR(100) = 'Wednesday';
DECLARE @num INT = 3;
--This will take off any time portion and set the value to the beginning of the month
SET @dt = DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0);
WITH MonthDays AS
(
SELECT DATEADD(DAY,number-1,@dt) AS MonthDate,
ROW_NUMBER() OVER(ORDER BY number) AS DayIndex
FROM MASTER..spt_values
WHERE number > 0
AND TYPE = 'P'
AND DATEADD(DAY,number-1,@dt) >= @dt
AND DATEADD(DAY,number-1,@dt) < DATEADD(MONTH,1,@dt)
AND DATENAME(dw,DATEADD(DAY,number-1,@dt)) LIKE @dow + '%'
)
SELECT MonthDate
FROM MonthDays
WHERE DayIndex = @num
MonthDate
-----------------------
2011-07-20 00:00:00.000
(1 row(s) affected)
So we’re done, right? Well, there’s still the matter of getting it into one statement – it’s two statements now, not counting the declaration/assignments – you can factor the part that strips off the time and normalizes the root date into the statement, but it looks pretty messy…
DECLARE @dt DATETIME = GETDATE();
DECLARE @dow VARCHAR(100) = 'Wednesday';
DECLARE @num INT = 3;
WITH MonthDays AS
(
SELECT DATEADD(DAY,number-1,DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0)) AS MonthDate,
ROW_NUMBER() OVER(ORDER BY number) AS DayIndex
FROM MASTER..spt_values
WHERE number > 0
AND TYPE = 'P'
AND DATEADD(DAY,number-1,DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0)) >= DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0)
AND DATEADD(DAY,number-1,DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0)) < DATEADD(MONTH,1,DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0))
AND DATENAME(dw,DATEADD(DAY,number-1,DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0))) LIKE @dow + '%'
)
SELECT MonthDate
FROM MonthDays
WHERE DayIndex = @num
And voila! – the answer in one statement. Here is how I would do it in the form of a function. I use a second CTE here to clean up some of the mess while still finding the answer in a single statement (there is also an alternate way of filtering for the current month here – normally you shouldn’t apply a function to the “left” side of a search argument, but a) we know that this is a small set and b) we have to apply DATENAME anyway, so we will definitely be scanning):
CREATE FUNCTION nth_weekday_of_month_fn (
@dt DATETIME,
@dow VARCHAR(10),
@num INT
)
RETURNS DATETIME
AS
BEGIN
DECLARE @RetDate DATETIME;
WITH MonthDays AS
(
SELECT DATEADD(DAY,number-1,DATEADD(MONTH,DATEDIFF(MONTH,0,@dt),0)) AS MonthDate
FROM MASTER..spt_values
WHERE number > 0
AND TYPE = 'P'
)
, WeekDays AS
(
SELECT *, ROW_NUMBER() OVER(ORDER BY MonthDate) DayIndex
FROM MonthDays
WHERE YEAR(@dt) = YEAR(MonthDate)
AND MONTH(@dt) = MONTH(MonthDate)
AND DATENAME(dw,MonthDate) LIKE @dow + '%'
)
SELECT @RetDate = MonthDate
FROM WeekDays
WHERE DayIndex = @num RETURN (@RetDate) END
GO
SELECT dbo.nth_weekday_of_month_fn(GETDATE(),'Wednesday',3)