Tuesday, 26 December 2017

T-SQL: Calculating some common relative dates


T-SQL is 'Transact-SQL', the version of SQL used with Microsoft SQL Server. This SQL code may be adaptable for other versions of SQL.


What is covered in this article

There are some things I need to find quite often when working with dates, such as
  • the start date of the current month
  • the start date of the current quarter
  • the start date of the current financial year
  • the end date of the current month
  • the end date of the current quarter
  • the end date of the current financial year
  • how many days have elapsed and how many days remaining in the current month
This article shows ways to calculate all of these using T-SQL. It then shows a version of  a User Defined Function which can be used for calculating these relative dates based on any input date.




Start date of the current month

The following will return the date of the start of the current month:

SELECT DATEADD(m, DATEDIFF(m, 0, GetDate()), 0)

It works in two steps:

First the DATEDIFF() counts how many whole months between now and the SQL zero date (the base datetime 1900-01-01 00:00:00.000)

Then the DATEADD()  adds that number of months back onto the zero date


Start date of the next month

The method above can be adapted to give the Start Date of the Next Month by increasing the number of months to add by 1

SELECT DATEADD(m, DATEDIFF(m, 0, GetDate())+1, 0)

Other similar offsets can be calculated by altering the +1 accordingly



End date of the current month

Versions from SQL Server 2012 have the built in function EOMONTH() for calculating the date of the end of the month based on any given input date. For example

SELECT EOMONTH(Getdate())

 returns   2017-12-31

This is fine if all you want is a Date. But I often need the result as a DateTime when I am working with time based transaction data. And EOMONTH() is not available if you have a version older than SQL Server 2012

One way to derive the End Date of the Current Month is to work from the Start Date of the Next Month (as calculated above) and then deduct one second from it (Generally I only need this to be to the nearest second -  and remembering that deducting is Adding a negative number):

DATEADD(s,-1,<End Date of Current Month>)

SELECT DATEADD(s,-1,(DATEADD(m, DATEDIFF(m, 0, GetDate())+1, 0)))

We could alternatively work from the Start Date of the Current Month (as calculated above). This method adds one month onto the start date - to give the start of the following month - and then deducts one second from it
DATEADD(s,-1,(DATEADD(m,1,<Start Date of Current Month>)))

This method would be ok if we had already calculated and stored the value of <Start Date of Current Month>. But otherwise it is less efficient in a single expression as it ends up doubling the number of DATEADDs of months:

SELECT DATEADD(s,-1,(DATEADD(m,1,DATEADD(m, DATEDIFF(m, 0, GetDate()), 0))))

So we would simplify these and end up with the same code as we had already derived above from First Date of Next Month



Number of days completed in the current month

A quick way to do this is to use DATEPART()  and retrieve the day part of the date

SELECT DATEPART(d, GetDate())

This works ok for situations when it is just number of days elapsed within one month.

A more generic approach is to calculate the number of days using DATEDIFF()

 DATEDIFF(d, <Reference Start Date>, <Reference As At Date>)

This method would allow us to calculate to any starting point (such as a quarter or financial year). But it needs the adjustment of adding one day to compensate for the fact that when using DATETIME the reference date (e.g. today)  will never be complete

 DATEDIFF(d, <Reference Start Date>, <Reference As At Date>)+1

So using this method the following is another method to calculate the number of days elapsed within the current month.

SELECT DATEDIFF(d, DATEADD(m, DATEDIFF(m, 0, GetDate()), 0),GetDate())+1



Number of days remaining in the current month

This can be calculated using a variation of the previous method, in generic form:

 DATEDIFF(d,  <Reference As At Date>, <Reference End Date>)

This can be set out as follows to give days remaining in the current month

SELECT DATEDIFF(d,Getdate(), DATEADD(s,-1,(DATEADD(m, DATEDIFF(m, 0, GetDate())+1, 0))))


Start date of the current quarter

This can be calculated using the same method used above for calculating Start Date of Current Month. The only change is the time interval (set to q for quarter instead of m for month)

SELECT DATEADD(q, DATEDIFF(q, 0, GetDate()), 0)



End date of the current quarter
This can be calculated using the same method used above for calculating End Date of Current Month. The only change is the time interval (set to q for quarter instead of m for month)
SELECT DATEADD(s,-1,(DATEADD(q, DATEDIFF(q, 0, GetDate())+1, 0)))

Number of days completed in the current quarter
This can be calculated using the method set out above adjusted for quarters
DATEDIFF(d, <Reference Start Date>, <Reference As At Date>)

SELECT
 DATEDIFF(d, DATEADD(q, DATEDIFF(q, 0, GetDate()), 0),GetDate())+1


Number of days remaining in the current quarter
This can also be calculated using the method set out above for months, adjusted for quarters
DATEDIFF(d,  <Reference As At Date>, <Reference End Date>)


SELECT DATEDIFF(d,Getdate(), DATEADD(s,-1,(DATEADD(q, DATEDIFF(q, 0, GetDate())+1, 0))))


Start date of the current financial year

The start of the financial year in any analyses I have had to do so far has always been the 1st April. Things can be more complicated if the financial year is taken to start on 5th April or from a different month. Ignoring these possible complications for now, for me finding the start of the financial year simply means rolling back to the most recent previous 1st April from any given reference date.

The method I have devised uses DATEPART() to find the month number for the reference (current) month.The difference between this and 4 (the month number for April) is then the number of months to roll back.

There is a complication though. For months prior to April, this method would create a negative number with the result the date would roll forward not back.

So we need to test for whether the month number of our reference month is less than 4 and if so use a modified calculation in which we deduct an additional 12 months.

I have done this using a case statement
 DATEADD(m,
(CASE WHEN  DATEPART(m,<Reference As At Date>)<4 THEN 4-DATEPART(m,<Reference As At Date>)-12
ELSE 4-DATEPART(m,<Reference As At Date>) END)
,<Reference Month Start Date>)

This logic is not complicated, but it does look complicated when developed into a single standalone select statement

 SELECT DATEADD(m,
(CASE WHEN  DATEPART(m,GetDate())<4 THEN 4-DATEPART(m,GetDate())-12
ELSE 4-DATEPART(m,GetDate()) END)
,DATEADD(m, DATEDIFF(m, 0, GetDate()), 0))

A small simplification is possible by combining the +4 and -12 in the first part to give -8

 SELECT DATEADD(m,
(CASE WHEN  DATEPART(m,GetDate())<4 THEN -DATEPART(m,GetDate())-8
ELSE 4-DATEPART(m,GetDate()) END)
,DATEADD(m, DATEDIFF(m, 0, GetDate()), 0))


Start date of the next financial year

The start date of the next financial year can be obtained by adding 12 months onto the calculation of start of current financial year
 SELECT DATEADD(m,
(CASE WHEN  DATEPART(m,GetDate())<4 THEN 4-DATEPART(m,GetDate())-12+12
ELSE 4-DATEPART(m,GetDate()) +12 END)
,DATEADD(m, DATEDIFF(m, 0, GetDate()), 0))

which simplifies slightly to

 SELECT DATEADD(m,
(CASE WHEN  DATEPART(m,GetDate())<4 THEN 4-DATEPART(m,GetDate())
ELSE 16-DATEPART(m,GetDate())  END)
,DATEADD(m, DATEDIFF(m, 0, GetDate()), 0))


End date of the current financial year
The end date of the current financial year can be calculated by deducting one second from the start of the next  financial year
 SELECT DATEADD(s,-1,DATEADD(m,
(CASE WHEN  DATEPART(m,GetDate())<4 THEN 4-DATEPART(m,GetDate())
ELSE 16-DATEPART(m,GetDate())  END)
,DATEADD(m, DATEDIFF(m, 0, GetDate()), 0)))

A script which calculates all of these as memory variables
You can adapt the following script to meet different requirements. Using variables makes the calculations a little easier to follow. It also allows a generic 'As At Date' to be set.


-------------------------------------------------
-- Date calculations -- sample SQL code
-------------------------------------------------
---------------------------------------------
-- Declare all variables
---------------------------------------------

DECLARE @AsAtDate as DATETIME -- As At Date
DECLARE @MONCurrentStartDate as DATETIME -- Start Date for As At Date's Current Month
DECLARE @MONCurrentEndDate as DATETIME -- End Date for As At Date's Current Month
DECLARE @MONCurrentDaysCompleted as INT =0 -- Numbers of days completed in Current Month
DECLARE @MONCurrentDaysLeft as  INT =0 -- Numbers of days remaining in Current Month
DECLARE @MONNextStartDate as DATETIME -- Start Date for Month following As At Date

DECLARE @QTRCurrentStartDate as DATETIME -- Start Date for As At Date's Current Quarter
DECLARE @QTRCurrentEndDate as DATETIME -- End Date for As At Date's Current Quarter
DECLARE @QTRCurrentDaysCompleted as  INT =0 -- Numbers of days completed in Current Quarter
DECLARE @QTRCurrentDaysLeft as  INT =0 -- Numbers of days remaining in Current Quarter
DECLARE @QTRNextStartDate as DATETIME -- Start Date for Quarter following As At Date

DECLARE @FYRCurrentStartDate as DATETIME -- Start Date for As At Date's Current Financial Year
DECLARE @FYRCurrentEndDate as DATETIME -- End Date for As At Date's Current Financial Year
DECLARE @FYRCurrentDaysCompleted as  INT =0 -- Numbers of days completed in Current Financial Year
DECLARE @FYRCurrentDaysLeft as  INT =0 -- Numbers of days remaining in Current Financial Year
DECLARE @FYRNextStartDate as DATETIME -- Start Date for Financial Year following As At Date

------------------------------------------------------------------------------
-- Set As At Date
------------------------------------------------------------------------------

--SET @AsAtDate = dateadd(d, -1,GETDATE()) -- Set As At Date to yesterday
SET @AsAtDate='1997-03-12' -- Set As At Date to any date to test
----------------------------------------------------------------------------
-- Calculate Month variations (for As At Date)
----------------------------------------------------------------------------

SET  @MONCurrentStartDate = DATEADD(m, DATEDIFF(m, 0, @AsAtDate), 0)
SET  @MONCurrentEndDate=DATEADD(s,-1,(DATEADD(m,1,@MONCurrentStartDate)))
SET  @MONCurrentDaysCompleted=DATEDIFF(d, @MONCurrentStartDate,@AsAtDate)+1
SET  @MONCurrentDaysLeft=DATEDIFF(d,@AsAtDate, @MONCurrentEndDate)
SET  @MONNextStartDate=DATEADD(m, DATEDIFF(m, 0, @AsAtDate)+1, 0)

-------------------------------------------------------------------------------
-- Calculate Quarter variations (for As At Date)
-------------------------------------------------------------------------------

SET  @QTRCurrentStartDate = DATEADD(q, DATEDIFF(q, 0, @AsAtDate), 0)
SET  @QTRCurrentEndDate=dateadd(s,-1,(dateadd(q,1,@QTRCurrentStartDate)))
SET  @QTRNextStartDate= DATEADD(q, DATEDIFF(q, 0, @AsAtDate)+1, 0)
SET  @QTRCurrentDaysCompleted=datediff(d, @QTRCurrentStartDate,@AsAtDate)+1
SET  @QTRCurrentDaysLeft=DATEDIFF(d,@AsAtDate, @QTRCurrentEndDate)

-------------------------------------------------------------------------------
-- Calculate Financial Year variations (for As At Date)
-------------------------------------------------------------------------------

SET  @FYRCurrentStartDate=
DATEADD(m, (case when datepart(m,@AsAtDate)<4 then 4-datepart(m,@AsAtDate)-12 --adjust for Jan to Mar to count back
else 4-DATEPART(m,@AsAtDate) end),@MONCurrentStartDate)

SET  @FYRNextStartDate=
DATEADD(m,(CASE WHEN DATEPART(m,@AsAtDate)<4 THEN 4-DATEPART(m,@AsAtDate)
ELSE 16-DATEPART(m,@AsAtDate) END),@MONCurrentStartDate)

SET  @FYRCurrentEndDate=DATEADD(s,-1,(DATEADD(yy,1,@FYRCurrentStartDate)))
SET  @FYRCurrentDaysCompleted=DATEDIFF(d, @FYRCurrentStartDate,@AsAtDate)+1
SET  @FYRCurrentDaysLeft=DATEDIFF(d,@AsAtDate, @FYRCurrentEndDate)

---------------------
-- Display results
--------------------

SELECT @AsAtDate as [As At],
@MONCurrentStartDate as [Current Month Start],
@MONCurrentEndDate as [Current Month End],
@MONCurrentDaysCompleted as [Current Month Days Completed],
@MONCurrentDaysLeft as [Current Month Days Left],
@MONNextStartDate as [Next Month Start]

SELECT @AsAtDate as [As At],
@QTRCurrentStartDate as [Current Quarter Start],
@QTRCurrentEndDate as [Current Quarter End],
@QTRCurrentDaysCompleted as [Current Quarter Days Completed],
@QTRCurrentDaysLeft as [Current Quarter Days Left],
@QTRNextStartDate as [Next Quarter Start]

SELECT @AsAtDate as [As At],
@FYRCurrentStartDate as [Current Fin Year Start],
@FYRCurrentEndDate as [Current Fin Year End],
@FYRCurrentDaysCompleted as [Current Fin Year Days Completed],
@FYRCurrentDaysLeft as [Current Fin Year Days Left],
@FYRNextStartDate as [Next Fin Year Start]




You can run the script above to test the results of the calculations. In practice you will only need to use some elements of it. But if you keep the overall script you can dip into it and lift the elements you need for any new SQL script.


User-defined Functions for handling relative dates

If you use date calculations such as these a lot, it may be worth creating some User Defined Functions. You could create individual functions for each measure. Or you could create a single function which combines them all. The following code is one version of a multi-purpose function. As input it takes the 'As At Date' and a thee letter code indicating what date to return

---------------------------------------------------------------------------------------
-- Function RelativeDate ()
---------------------------------------------------------------------------------------
-- E.Bolton. Last updated 26/12/2017
---------------------------------------------------------------------------------------
-- Takes any given date @AsAtDate
-- and a three-character return type code @ReturnCode
-- which indicates what to return
-- Returns the relative date
-- Inputvalues for @ReturnCode
--              MCS indicates return Start Date for As At Date's Current Month  
--              MCE indicates return End Date for As At Date's Current Month  
--              MNS indicates return Start Date for Month following As At Date
--              QCS indicates return Start Date for As At Date's Current Quarter  
--              QCE indicates return End Date for As At Date's Current Quarter 
--              QNS indicates return Start Date for Quarter following As At Date
--              FYS indicates return Start Date for As At Date's Current Financial Year
--              FYE   indicates return End Date for As At Date's Current Financial Year 
--              FYN indicates return Start Date for Financial Year following As At Date
---------------------------------------------------------------------------------------
CREATE FUNCTION [dbo].[RelativeDate]
--alter FUNCTION [dbo].[RelativeDate]
  ( @AsAtDate datetime, @ReturnCode Varchar(3))

RETURNS DATETIME
AS
BEGIN
DECLARE @ReturnDate as DATETIME     
DECLARE @MONCurrentStartDate as DATETIME        -- Start Date for As At Date's Current Month  MCS
DECLARE @MONCurrentEndDate as DATETIME -- End Date for As At Date's Current Month  MCE
DECLARE @MONNextStartDate as DATETIME    -- Start Date for Month following As At Date MNS
DECLARE @QTRCurrentStartDate as DATETIME   -- Start Date for As At Date's Current Quarter  QCS
DECLARE @QTRCurrentEndDate as DATETIME                  -- End Date for As At Date's Current Quarter QCE
DECLARE @QTRNextStartDate as DATETIME                    -- Start Date for Quarter following As At Date QNS
DECLARE @FYRCurrentStartDate as DATETIME         -- Start Date for As At Date's Current Financial Year FYS
DECLARE @FYRCurrentEndDate as DATETIME                   -- End Date for As At Date's Current Financial Year FYE 
DECLARE @FYRNextStartDate as DATETIME                     -- Start Date for Financial Year following As At Date FYN
----------------------------- 
 -- Date Calculations
-----------------------------
SET @MONCurrentStartDate =DATEADD(m, DATEDIFF(m, 0, @AsAtDate), 0) 
SET @MONCurrentEndDate=DATEADD(s,-1,(DATEADD(m,1,@MONCurrentStartDate))) 
SET @MONNextStartDate=DATEADD(m, DATEDIFF(m, 0, @AsAtDate)+1, 0)  
SET @QTRCurrentStartDate = DATEADD(q, DATEDIFF(q, 0, @AsAtDate), 0) 
SET @QTRCurrentEndDate=DATEADD(s,-1,(DATEADD(q,1,@QTRCurrentStartDate)))
SET @QTRNextStartDate= DATEADD(q, DATEDIFF(q, 0, @AsAtDate)+1, 0) 
SET @FYRCurrentStartDate=
 DATEADD(m, (case when DATEPART(m,@AsAtDate)<4 then 4-DATEPART(m,@AsAtDate)-12 --adjust for Jan to Mar to count back
 else 4-DATEPART(m,@AsAtDate) end),@MONCurrentStartDate)
SET @FYRCurrentEndDate=DATEADD(s,-1,(dateadd(yy,1,@FYRCurrentStartDate)))
SET @FYRNextStartDate= 
DATEADD(m,(CASE WHEN  DATEPART(m,@AsAtDate)<4 THEN 4-DATEPART(m,@AsAtDate)
ELSE 16-DATEPART(m,@AsAtDate)  END),@MONCurrentStartDate)
--------------------------------------------------
-- Set @ReturnDate based on input code @ReturnCode
--------------------------------------------------
SET @ReturnDate =
(Case when @ReturnCode='MCS' then  @MONCurrentStartDate
when @ReturnCode='MCE' then @MONCurrentEndDate
when @ReturnCode='MNS' then @MONNextStartDate
when @ReturnCode='QCS' then @QTRCurrentStartDate
when @ReturnCode='QCE' then @QTRCurrentEndDate
when @ReturnCode='QNS' then @QTRNextStartDate
when @ReturnCode='FYS' then @FYRCurrentStartDate
when @ReturnCode='FYE' then @FYRCurrentEndDate
when @ReturnCode='FYN' then @FYRNextStartDate
end)


RETURN @ReturnDate 


END

GO

There are various ways that the function above could have been constructed. I have chosen to calculate all the variations and then return the required one. It could be argued that this expends unnecessary resources each time. Alternative versions could be designed with more careful conditional logic. But the code above works ok and is not slow. And some of the dates calculated are used to calculate some of the others



Choices

You can use single, self-contained expressions which calculate the values whenever you need them. This has the advantage of completeness and portability. And when debugging, the source of a problem is easier to locate. But you will end up writing fairly long expressions which are hard to follow and which can get messed up easily

You may find that calculating the values and retaining these in memory variables makes things easier to manage. This may work well if you need to use the same values several times in the SQL script. But in simple scripts, it probably over-complicates things.

You could create a parameter driven User defined Function (along the lines of the one illustrated here) and then call it from all your subsequent scripts. This has the advantage of economy but you will need to ensure that the function has been set up on any database instances on which you want to run it

You could create individual User defined Functions for each type of calculation. Apart from the initial headache of doing this, it will provide a useful palette of options.

As with all these things, inevitably any of this could be done better (more efficiently, made more compact, made more generic, use some ingenious algorithm that will enhance your reputation)

How far you go in tuning and fine polishing SQL scripts is up to you.

I have had to overcome a tendency towards the paralysis of perfectionism by using a 'fit for purpose test'. It goes something like this:

Question 1: Does it work? If Yes move on to Question 2

Question 2: Does it take forever to run (or too long for its context)? If No move on the Question 3

Question 3: Does it crash the server, lock up any key applications, or dim the lights in the building?  If No then move on to Question 4

Question 4: I have a limited amount of lifetime resource. Do I want to spend some of it making this thing that already works ok work even better? Or do I want to use it to solve one of the many other problems I still need to deal with?

Move on to the next challenge.



Before you go

You might want to check out the following 





No comments:

Post a Comment

Crash Course in SQL Part 2: SELECT

The SQL SELECT instruction is the most important single thing to master It is the instruction that actually gives the answers to any que...