Calculate Price For Overlapping Date Range - sql
I have a simple table named HotelRate
HID | START_DATE | END_DATE | PRICE_PER_DAY
--------------------------------------
1 01/1/2015 10/1/2015 100
1 11/1/2015 20/1/2015 75
1 21/1/2015 30/1/2015 110
what is the most simple way to calculate price for Hotel Room if user queries for Total Price between 5/1/2015 to 25/1/2015.
I have checked :
How can I query for overlapping date ranges?
Overlapping date range MySQL
but none of it is making much sense to me.
I have tried a couple queries but those seems like hitting arrow in the blind. Can someone suggest me a simple and elegant way to do it ?
#JamesZ
On running the first query i get
start_date end_date duration price_per_day
---------- ---------- ----------- -------------
2015-01-01 2015-01-10 5 100
2015-01-11 2015-01-20 9 75
2015-01-21 2015-01-30 4 110
For first range 5 is OK, second range it should be 10, third be 5
How days are calculated : Total no of nights between start & end date, which is same as days difference
05-Jan-15 06-Jan-15 1 Night
06-Jan-15 07-Jan-15 1 Night
07-Jan-15 08-Jan-15 1 Night
08-Jan-15 09-Jan-15 1 Night
09-Jan-15 10-Jan-15 1 Night
10-Jan-15 11-Jan-15 1 Night
11-Jan-15 12-Jan-15 1 Night
12-Jan-15 13-Jan-15 1 Night
13-Jan-15 14-Jan-15 1 Night
14-Jan-15 15-Jan-15 1 Night
15-Jan-15 16-Jan-15 1 Night
16-Jan-15 17-Jan-15 1 Night
17-Jan-15 18-Jan-15 1 Night
18-Jan-15 19-Jan-15 1 Night
19-Jan-15 20-Jan-15 1 Night
20-Jan-15 21-Jan-15 1 Night
21-Jan-15 22-Jan-15 1 Night
22-Jan-15 23-Jan-15 1 Night
23-Jan-15 24-Jan-15 1 Night
24-Jan-15 25-Jan-15 1 Night
Count : 20 Night
Something like this should do the trick:
declare #startdate date, #enddate date
set #startdate = '20150105'
set #enddate = '20150125'
select
start_date,
end_date,
datediff(
day,
case when #startdate > start_date then #startdate else start_date end,
case when #enddate < end_date then #enddate else end_date end) as duration,
price_per_day
from
reservation
where
end_date >= #startdate and
start_date <= #enddate
This just handles the overlapping ranges with case so that if the reservation start is the correct one to use, it takes it, otherwise the search criteria, and same thing for end date. The days and price are here separate, but you can just multiply them to get the result.
SQL Fiddle: http://sqlfiddle.com/#!3/4027b3/1
Edit, this way to get total sum:
declare #startdate date, #enddate date
set #startdate = '20150105'
set #enddate = '20150125'
select
sum(datediff(
day,
case when #startdate > start_date then #startdate else start_date end,
case when #enddate < end_date then #enddate else end_date end)
* price_per_day)
from
reservation
where
end_date >= #startdate and
start_date <= #enddate
You will need a calendar table, but every database should have one.
The actual implementation is always user and DBMS specific (e.g. MS SQL Server), so searching for "calendar table" + yourDBMS will probably reveal some source code for your system.
select HID, sum(PRICE_PER_DAY)
from calendar_table as c
join HotelRate
on calendar_date between START_DATE and END_DATE
group by HID
This is easy to handle if you have an existing table of dates to work with. Don't have one already? Below you'll find two functions to help you get started. This is how you use them:
-- Arguments can be passed in any order
SELECT * FROM dbo.RangeDate('2015-12-31', '2015-01-01');
SELECT * FROM dbo.RangeSmallInt(10, 0);
SELECT A.HID, SUM(A.PRICE_PER_DAY)
FROM dbo.RangeDate('2000-01-01', '2020-12-31') Calendar
JOIN HotelRate A
ON Calendar.D BETWEEN A.START_DATE and A.END_DATE
GROUP BY A.HID;
You can use the RangeDate function as a calendar or you can use it to build your own calendar function/table.
-- Generate a range of up to 65,536 contiguous DATES
CREATE FUNCTION dbo.RangeDate (
#date1 DATE = NULL
, #date2 DATE = NULL
)
RETURNS TABLE
AS
RETURN (
SELECT D = DATEADD(DAY, A.N, CASE WHEN #date1 <= #date2 THEN #date1 ELSE #date2 END)
FROM dbo.RangeSmallInt(
CASE WHEN #date1 IS NOT NULL AND #date2 IS NOT NULL THEN 0 END
, ABS(DATEDIFF(DAY, #date1, #date2))
) A
);
-- Generate a range of up to 65,536 contiguous BIGINTS
CREATE FUNCTION dbo.RangeSmallInt (
#n1 BIGINT = NULL
, #n2 BIGINT = NULL
)
RETURNS TABLE
AS
RETURN (
WITH Numbers AS (
SELECT N FROM(VALUES
(1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 16
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 32
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 48
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 64
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 80
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 96
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 112
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 128
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 144
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 160
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 176
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 192
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 208
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 224
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 240
, (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 256
) V (N)
)
SELECT TOP (
CASE
WHEN #n1 IS NOT NULL AND #n2 IS NOT NULL THEN ABS(#n2 - #n1) + 1
ELSE 0
END
)
N = ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) - 1 + CASE WHEN #n1 <= #n2 THEN #n1 ELSE #n2 END
FROM Numbers A, Numbers B
WHERE ABS(#n2 - #n1) + 1 < 65537
);
You can use this which will work out the price per period and then sum it up for the total cost. It uses a case statement to work out how many days are in each period so in your example this is 5,9 and 4:
Declare #startdate date = '2015-01-05',
#todate date = '2015-01-25'
Select sum(price_per_period) as TotalPrice -- The cost for all periods is summed to give a total
from
-- First it works out the number of days in the period with a case statement and then
-- multiplies this by the daily rate to get the total for that period
(Select price_per_day * case when Start_date <= #startdate then DATEDIFF(day, #startdate,end_date) else
case when Start_date > #startdate and end_date < #todate then DATEDIFF(day, start_date,end_date) else
case when Start_date > #startdate and end_date >= #todate then DATEDIFF(day, start_date, #todate) end
end
end price_per_period
from pricetable
where (Start_date between #Startdate and #todate) or
(end_date between #Startdate and #todate)
) a
This eliminates the need for a seperate calendar table
SQL Fiddle: http://www.sqlfiddle.com/#!6/25e63/4/0
This should be fast enough as you first generate calendar and then using only join. Also for total price per hotel could be achieved with grouping sets:
Data definition:
create table HotelRate(HID int, START_DATE date, END_DATE date, PRICE_PER_DAY int);
insert into HotelRate values
(1, '20150101', '20150110', 100),
(1, '20150111', '20150120', 75),
(1, '20150121', '20150130', 110),
(2, '20150101', '20150110', 10),
(2, '20150111', '20150120', 5),
(2, '20150121', '20150130', 50)
Query:
declare #sd date = '20150105' , #ed date = '20150125'
;with c as(select #sd d union all select dateadd(dd, 1, d) from c where d < #ed)
select h.HID, h.START_DATE, h.END_DATE, sum(PRICE_PER_DAY) PRICE
from c join HotelRate h on c.d >= h.START_DATE and c.d < h.END_DATE
group by grouping sets((h.HID, h.START_DATE, h.END_DATE),(h.HID))
Output:
HID START_DATE END_DATE PRICE
1 2015-01-01 2015-01-10 500
1 2015-01-11 2015-01-20 675
1 2015-01-21 2015-01-30 550
1 (null) (null) 1725
2 2015-01-01 2015-01-10 50
2 2015-01-11 2015-01-20 45
2 2015-01-21 2015-01-30 250
2 (null) (null) 345
This can be further optimized with tally tables. And even more, if you create calendar table in your database, it will be instant.
Here is the fiddle http://sqlfiddle.com/#!3/25e7bc/1
Suppose you have created some calendar table Calendar(d date) which contains dates starting from for instance 1900-01-01 ending 2100-01-01. Add indexes on Calendar and HotelRange tables on date columns. Then above query can be rewritten as:
select h.HID, h.START_DATE, h.END_DATE, sum(PRICE_PER_DAY) PRICE
from Calendar c join HotelRate h on c.d >= h.START_DATE and c.d < h.END_DATE
where c.d between #sd and #ed
group by grouping sets((h.HID, h.START_DATE, h.END_DATE),(h.HID))
Related
Looping in SQL Server from 1 to 60
I have a table T1 as below I need to copy the data from T1 to another table called T2. T2 has an additional column called 'Month' and each record from T1 needs to be copied to T2 60 times, with Month value ranging from 1 to 60. I have been trying something like this and need the MONTH value to be taken dynamically , like a loop from 1 to 60. Could someone help please? Thank you INSERT INTO T2 SELECT PRODUCT, CUSTOMER, 1 as MONTH FROM T1
We can use a cross join approach: WITH months AS ( SELECT n = v2.n * 10 + v1.n FROM (VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9)) v1(n) CROSS JOIN (VALUES (0), (1), (2), (3), (4), (5), (6)) v2(n) ) INSERT INTO T2 (Product, Customer, Month) SELECT t1.Product, t1.Customer, m.n FROM table1 t1 CROSS JOIN months m WHERE m.n BETWEEN 1 AND 60;
CROSS JOIN to a tally, with the values 1 to 60. -- Thanks #Larnu for the answer.
I used stored procedures in MySQL: DELIMITER $$ CREATE PROCEDURE auto_insert() BEGIN DECLARE i1 INT DEFAULT 1; WHILE i1 <= 60 DO INSERT INTO T2 SELECT *, i1 FROM T1; SET i1 = i1 + 1; END WHILE; END $$ DELIMITER ; CALL auto_insert;
Creating a loop to suggest username+number - SQL Server
I've been trying to create a procedure to implement in user creation. The procedure is meant to see if the username is already taken, in which case it has to print an alternative username. The alternate needs to be constructed from the username+lowest number that is not already taken. For example, if I tried "Ofek" and both "Ofek" and "Ofek1" are taken, I should receive "Ofek2". So far this is what I have: ALTER PROCEDURE [dbo].[SuggestUsername] #username varchar(10) AS BEGIN DECLARE #name varchar(10) IF #username IN (SELECT User_Name FROM Players WHERE User_Name = #username) WHILE (#name IN (SELECT User_Name FROM Players WHERE User_Name = #username)) SET #name = (CONCAT(#username, +1)) PRINT (CONCAT('Username already taken. Please choose a different one, or use: ', #name)) END For some reason, #name comes up as empty whenever I tried running it. Is there a better way to do it? if not, what's wrong with my current code? Thanks in advance! edit I've decided to give up this idea and try to implement it differently (with a count of how many times #username already appears as the additional number). Thanks for all who tried to help.
Since you mention that this is just "for a class" in your comment and I have time to spend because of COVID, here is a trick. CREATE VIEW MyView AS SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) N FROM (VALUES (0), (0), (0), (0), (0), (0), (0), (0), (0)) T(A) CROSS JOIN (VALUES (0), (0), (0), (0), (0), (0), (0), (0), (0)) TT(B) CROSS JOIN (VALUES (0), (0), (0), (0), (0), (0), (0), (0), (0)) TTT(C) CROSS JOIN (VALUES (0), (0), (0), (0), (0), (0), (0), (0), (0)) TTTT(D); -- 6561 number CREATE TABLE Users ( UserName VARCHAR(10) UNIQUE ); INSERT Users VALUES ('Ofek'), ('Ofek1'); CREATE PROCEDURE MyProc #UserName VARCHAR(10) AS BEGIN WITH CTE(N, UN) AS ( SELECT 0, #UserName UNION ALL SELECT N, CONCAT(#UserName, N) FROM MyView ) INSERT Users(UserName) SELECT TOP 1 UN FROM CTE WHERE NOT EXISTS(SELECT 1 FROM Users WHERE UserName = UN) ORDER BY N; END EXEC MyProc 'Ofek'; SELECT * FROM Users; You can see how it's working on a db<>fiddle I won't recommend this as a real solution or a way to work with on a real database, instead you can add a UNIQUE constraint or using EXISTS() to notify the user if the name is already taken without suggesting.
How to select values with a column alias?
If I do this in SQLite3: SELECT * FROM (VALUES (1), (2), (3)) AS "tbl.col" WHERE "tbl"."col" = 1 I get: no such column: tbl.col What is the correct way? Thanks!
I think you are looking for this: SELECT * FROM (VALUES (1), (2), (3)) AS tbl(col) WHERE tbl.col = 1; Note that when you escape an identifier (using "tbl.col"), then that is one name that has a period in it. Not two names. EDIT: I would have expected the above to work, but it doesn't in SQLite. One alternative is to use a CTE: with tbl(col) as ( VALUES (1), (2), (3) ) SELECT * FROM tbl where tbl.col = 1
I found this question which answers my question. SELECT "tbl"."" AS "col" FROM (VALUES (1), (2), (3)) AS "tbl" Basically, in a VALUES the first column name is an empty string "", the second is ":1", third is ":2" and so forth... Hope this helps somebody else.
SQL sectioning/averaging based on different timetag/timestamps and user-chosen input (T-SQL)
I've got the following problem that I would like to cope with - I have a SQL dataset that I would like to section (e.g. like this one): OldTimetag OldValue 2012-05-03 12:47:00 5 2012-05-03 13:00:00 1.3 2012-05-03 13:21:00 7 2012-05-03 14:56:00 5 2012-05-03 14:57:00 0.3 .... .... Now, I want to section (and/or average) the data based on a user-chosen interval - into new timetags, e.g. every 15 minutes with the first timetag as starting point, i.e.: NewTimetag NewValue 2012-05-03 12:47:00 4.507 2012-05-03 13:02:00 1.3 .... .... The main constraint is that the value next to the timetag is always valid, until the next timetag appears. So the value of 5 at timetag 2012-05-03 12:47:00 is valid for the next 13 minutes until 13:00:00. The value for the first 15 minutes from 12:47:00 would be (13*5+2*1.3)/15 = 4.507. In the next 15 minutes, at 13:02:00 the value is simply equal to 1.3... (and so on) I've come so long, that it is a good idea to make an "artificial table" first, to later join it with the old table. I'm generating that table by: DECLARE #intStart datetime, #intEnd datetime SELECT #intStart =min(OldTimetag), #intEnd = MAX(OldTimetag) FROM OldTable where OldTimetag between '2012-05-03 12:47:00' and '2012-05-03 14:57:00' Declare #ArtificalTable table (NewTimeTag datetime, NewValue Float) Declare #MinuteSlicer Int Set #MinuteSlicer = 15 Insert #Hallo Select #intStart, null While ( #intStart < #intEnd ) BEGIN Insert #ArtificalTable Select DATEADD(mi,#MinuteSlicer, #intStart), Null Set #intStart = DATEADD(mi,#MinuteSlicer,#intStart) If #intEnd <= DATEADD(mi,#MinuteSlicer,#intStart) Break End This gives me an output like: NewTimetag NewValue 2012-05-03 12:47:00 Null 2012-05-03 13:02:00 Null .... .... However, I'm having problems with the next step, how to join the tables correctly - can anyone give me a hint?
Here is one way of doing it. Sample Data: declare #data table(OldTimetag datetime2, OldValue numeric(5,2)); Insert into #data(OldTimetag, OldValue) Values ('2012-05-03 12:47:00', 5) , ('2012-05-03 13:00:00', 1.3) , ('2012-05-03 13:21:00', 7) , ('2012-05-03 14:56:00', 5) , ('2012-05-03 14:57:00', 0.3); Your custom range size in minutes: declare #mins int = 15; List is used to quickly compute an ordered list of number from 0 to n Where n <= to the number of minutes between the first and the last OldTimetag. With list(n) as ( Select top(Select 1+DATEDIFF(minute, min(OldTimetag), max(OldTimetag)) From #data) ROW_NUMBER() over(order by (select 1))-1 From ( Select 1 From (values(1), (1), (1), (1), (1), (1), (1), (1), (1), (1)) as x1(n) Cross Join (values(1), (1), (1), (1), (1), (1), (1), (1), (1), (1)) as x2(n) Cross Join (values(1), (1), (1), (1), (1), (1), (1), (1), (1), (1)) as x3(n) ) as x(n) ) Select NewTimetag = DATEADD(minute, #mins*(l.n/#mins), MIN(r.startTime)), NewValue = AVG(d.oldValue) From list l Cross Join (Select startTime = min(OldTimetag) From #data) as r Cross Apply (Select maxTimetag = MAX(OldTimetag) From #data Where OldTimetag <= DATEADD(minute, n, startTime)) as mx Inner Join #data d on d.OldTimetag = mx.maxTimetag Group By l.n/#mins Cross Join is used to mix each number from the ordered list with the first OldTimetag from your data. Cross Apply is used to get the nearest OldTimetag before each minute created with the Cross Join. Inner Join then matches the nearest OldTimetag with your data in order to retrieved oldValue. Select only have to calculate the average for each range on #mins minutes and its NewTimetag. It works well for a range of up to 1000 minutes between the min and max OldTimetag. If you need to go beyond this limit, you can add a a 4th line in the list CTE: Cross Join (values(1), (1), (1), (1), (1), (1), (1), (1), (1), (1)) as x4(n) => up to 10.000 Cross Join (values(1), (1), (1), (1), (1), (1), (1), (1), (1), (1)) as x5(n) => up to 100.000 ...
One way is to determine the intervals (an interval is generated if it contains at least one timestamp), augment the time table with the next timestamp and then calculate the averages for each such interval by intersecting the intervals with the time table. IF OBJECT_ID('tempdb..#values') IS NOT NULL DROP TABLE #values CREATE TABLE #values (pk int identity, time datetime, value numeric(10,4)) INSERT INTO #values VALUES ('2012-05-03 12:47:00', 5) INSERT INTO #values VALUES ('2012-05-03 13:00:00', 1.3) INSERT INTO #values VALUES ('2012-05-03 13:21:00', 7) INSERT INTO #values VALUES ('2012-05-03 14:56:00', 5) INSERT INTO #values VALUES ('2012-05-03 14:57:00', 0.3) DECLARE #timeSpanMinutes int SET #timeSpanMinutes=15 DECLARE #startTime datetime, #endTtime datetime SELECT #startTime=MIN(time) FROM #values SELECT #endTtime =DATEADD(MINUTE,(DATEDIFF(MINUTE,#startTime,MAX(time)) /#timeSpanMinutes+1)*#timeSpanMinutes, #startTime) FROM #values -- MAX(time) multiple SELECT intervals.start , SUM(value*(DATEDIFF(MINUTE -- minutes in intersection of [start,end] and [time,next] , CASE WHEN time<start THEN start ELSE time END -- Maximum(time,start) , CASE WHEN next<DATEADD(MINUTE,#timeSpanMinutes,intervals.start) THEN next ELSE DATEADD(MINUTE,#timeSpanMinutes,intervals.start) END -- Minimum(next,end) )*1.0/#timeSpanMinutes)) as average FROM (SELECT DISTINCT DATEADD(MINUTE, (DATEDIFF(MINUTE,#startTime,time) /#timeSpanMinutes)*#timeSpanMinutes, #startTime) AS start FROM #values -- round start to multiple of #timeSpanMinutes UNION SELECT DISTINCT DATEADD(MINUTE,#timeSpanMinutes+(DATEDIFF(MINUTE,#startTime,time) /#timeSpanMinutes)*#timeSpanMinutes, #startTime) FROM #values -- union distinct with same as above but shifted with #timeSpanMinutes ) intervals -- intervals start time (end is calculated as start + #timeSpanMinutes) INNER JOIN (SELECT v.*,ISNULL((SELECT MIN(time) FROM #values WHERE time>v.time),#endTtime) as next FROM #values v -- add next column to #values ) vals ON vals.next>=intervals.start and vals.time<=DATEADD(MINUTE,#timeSpanMinutes,start) WHERE intervals.start<>#endTtime GROUP BY intervals.start ORDER BY intervals.start
Get nth weekday from XDate in SQL Server
I have to get date repeatedly for every N months. I have XDate to Start from. I want the nth week's mth weekday's date. N is say 2 - I have to get for every 2 month XDate is suppose tomorrow's date. So, Starting from tomorrow m is 7 - So, get date of every Saturday n is 2 - of second week. I could not even think for start point for this complex logic. Any suggestion how should I start - pseudo code Thanks in advance,
First, this is where a calendar table comes in handy. The following code creates a table called calendar and populates it with dates starting in 2000. It also has a column called NthWeekdayInMonth. For example, if you look at the entries for 1/29/05 through 1/31/05 you'll see that this column is set to a 5 because those were the 5th Saturday, Sunday, and Monday of the month. CREATE TABLE Calendar ( [Date] date NOT NULL, [NthWeekdayInMonth] int, CONSTRAINT PK_Calendar PRIMARY KEY CLUSTERED ([Date]) WITH FILLFACTOR = 100 ) ;WITH cte AS ( SELECT DATEADD(d, (a.Number * 256) + b.Number, '01/01/2000') AS [Date] FROM ( SELECT number FROM master..spt_values WHERE type = 'P' AND number <= 255 ) a (Number), ( SELECT number FROM master..spt_values WHERE type = 'P' AND number <= 255 ) b (Number) ) INSERT INTO Calendar SELECT [Date], ROW_NUMBER() OVER (PARTITION BY YEAR([Date]), MONTH([Date]), DATEPART(dw, [Date]) ORDER BY [Date]) FROM cte ORDER BY [Date] GO Now that we have a calendar table the rest is fairly straightforward. I did deviate from your design in one respect but you should be able to adjust it if needed. In my implementation, the starting date is literally the first date that should be returned. So a starting date of 1/11/2014, looking every 2 months would return: 2014-01-11 2014-03-08 2014-05-10 2014-07-12 By passing the first date the code can figure out what day of the week it was and what week of the month. Passing those values in is redundant. The test code is below... DECLARE #startDate date DECLARE #everyNMonths int DECLARE #numResults int DECLARE #nthAppearanceOfDay int SET #startDate = '01/11/2014' -- First occurence is on this date SET #everyNMonths = 2 -- Skip every n months SET #numResults = 4 -- Max # of results to return -- Figure out which x-day of the month this is. For example, if the starting -- date is 1/11/2014 that was the second Saturday so this will be set to 2. SELECT #nthAppearanceOfDay = NthWeekdayInMonth FROM calendar WHERE [date] = #startDate -- Use a CTE to get all the months involved in this calculation ;WITH candidateMonths AS ( SELECT 1 AS [resultnum], #startDate AS [date] UNION ALL SELECT resultnum + 1, DATEADD(month, #everyNMonths, [date]) FROM candidateMonths WHERE resultnum + 1 <= #numResults ) -- Now evaluate every date for each of the candidate months. If the day of week matches -- that of the start date AND it is the Nth occurrence of that day of week in the month -- include it SELECT c.[Date] FROM candidateMonths cm INNER JOIN calendar c ON ( (YEAR(c.[Date]) = YEAR(cm.[Date])) AND (MONTH(c.[Date]) = MONTH(cm.[Date]))) WHERE (DATEPART(dw, c.[date]) = DATEPART(dw, #startDate)) -- Same day of week AND (c.NthWeekdayInMonth = #nthAppearanceOfDay) -- Same week of month
I've been experimenting with the following code: SELECT * FROM dbo.NthWeekday(GETDATE(), 1, 1); SELECT * FROM dbo.NthWeekday(GETDATE(), 1, -1); Where 1 is Sunday and 7 is Saturday regardless of the ##DATEFIRST setting. A positive value for n (or 0) will return the Next Nth Weekday while a negative value for n returns the Previous Nth Weekday. I don't fully understand what you want but if I gathered correctly: just getting the Nth Weekday is not enough. You want to do this repeatedly for X months as well. This is the tentative code I'd use: DECLARE #date DATE = GETDATE(); DECLARE #numMonths INT = -5 DECLARE #weekday INT = 1; DECLARE #n INT = 2; SELECT C.D FROM dbo.RangeSmallInt(0, #numMonths - SIGN(#numMonths)) A CROSS APPLY ( -- MonthBegin SELECT DT = DATEADD(m, DATEDIFF(m, 0, #date) + A.N, 0) ) B CROSS APPLY dbo.NthWeekday(B.DT, #weekday, #n) C; Results: 2014-12-14 2015-01-11 2015-02-08 2015-03-08 2015-04-12 Which you could wrap in a table-valued function much like I have done with NthWeekday and RangeSmallInt. The RangeSmallInt function call can be replaced with a numbers table, tally CTE, or whatever terminology/style you're comfortable with. How it works: We start by generating a set of numbers beginning with 0 because we want the function to be inclusive. (#numMonths - SIGN(#numMonths)) handles the addition or subtraction of 1/0 from #numMonths based on the sign of #numMonths. This ensures that the proper range of integers (in the above case: 0 through -4) are generated for our next trick. Once we have a range of integers to work with we can use them to offset the date. In this case we want to find out the beginning of the month for X months. If we had a function that could return the Nth Month Begin Date then we would simply pass the integers we already have to the function and get out the dates we want. So that's exactly what we do using CROSS APPLY. Now that we have the beginning of the month for X months solved all we need to do is apply our NthWeekday function to these dates. Nth Weekday: CREATE FUNCTION dbo.NthWeekday ( #date DATE = NULL , #weekday INT = NULL , #n INT = 1 ) RETURNS TABLE AS RETURN ( SELECT D = CASE SIGN(#n) WHEN -1 THEN DATEADD(d, -(DATEPART(dw, #date) + ##DATEFIRST - #weekday) % 7 + ((#n + 1) * 7), #date) ELSE DATEADD(d, (#weekday - DATEPART(dw, #date) + ##DATEFIRST) % 7 + ((#n - SIGN(#n)) * 7), #date) END ); RangeSmallInt: -- Generate a range of up to 65,536 contiguous BIGINTS CREATE FUNCTION dbo.RangeSmallInt ( #num1 BIGINT = NULL , #num2 BIGINT = NULL ) RETURNS TABLE AS RETURN ( WITH Numbers(N) AS ( SELECT N FROM(VALUES (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 16 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 32 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 48 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 64 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 80 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 96 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 112 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 128 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 144 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 160 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 176 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 192 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 208 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 224 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 240 , (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1), (1) -- 256 ) V (N) ) SELECT TOP ( CASE WHEN #num1 IS NOT NULL AND #num2 IS NOT NULL THEN ABS(#num1 - #num2) + 1 ELSE 0 END ) N = ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) + CASE WHEN #num1 <= #num2 THEN #num1 ELSE #num2 END - 1 FROM Numbers A , Numbers B WHERE ABS(#num1 - #num2) + 1 < 65537 );