Reputation: 617
I am trying to create a function which will return the whole number of periods that have elapsed between two dates, NOT the number of boundaries crossed.
As an example myFunc(Hour, '01:31','03:20') = 1 (would become 2 at "03:31")
I am running into 3 issues:
Date calculations are complicated and I would like to keep my code neat and as simple as possible, so I'd like to use built-in date function instead of reinventing the Gregorian calendar.
DateDiff takes a datepart argument, which has resisted all my attempts to pass as a variable, except via dynamic sql.
I need to call this in a select statement so it can't be a stored procedure, but functions in SQL refuse to run either dynamic SQL or stored procedures.
(I ran into a couple issues with overloading DateDiff since I am running SSMS 2012 and therefore don't have DateDiff_BIG)
Here is my code:
[dbo].[DDif]
(
@Start datetime
,@End datetime
,@Period varchar = null
)
RETURNS bigint
Declare @Dif bigint
if @Period is null set @Period = 'Y'
@p varchar(20) = case
when @Period = 'Y' then 'yy'
when @Period in('S','Q') then 'q'
when @Period = 'M' then 'm'
when @Period in('B','W') then 'ww'
when @Period = 'D' then 'd'
when @Period = 'h' then 'hh'
when @Period = 'm' then 'n'
when @Period = 's' then 's'
End
@sQry varchar(8000) = 'Set @Dif = DateDiff('+@p+',@Start,@End) + case when DateAdd('+@p+',DateDiff('+@p+',@Start,@End),@Start)>@End then -1 else 0 end'
execute @sQry
if @Period in ('S','B') return @Dif/2
return @Dif
I am looking for any one of 3 solutions:
A non-dynamic way to pass dateparts as variables
A way to run dynamic SQL from a function
A clean/simple implementation of a different approach to calculating duration between two datetimes for a user-specified period length.
Note: Had to remove a few small parts of my code that stack overflow didn't like, so I know that it won't run exactly as it appears.
Edit: Thank you to Mark for the solution. Here is my current implementation:
FUNCTION [dbo].[DDif]
(
@Start datetime
,@End datetime
,@Period varchar = null
)
RETURNS bigint
AS
BEGIN
if @Period is null set @Period = 'Y'
declare @m int = DATEDIFF(Month,@Start,@End) + case when DATEADD(MONTH,DATEDIFF(Month,@Start,@End),@Start)>@End then -1 else 0 END
declare @s bigint = 86400*DATEDIFF(DAY, CAST(@Start as date),CAST(@End as date))+datediff(s,cast(@Start as time),cast(@End as time))
declare @num bigint = case
when @Period = 'Y' then 12
when @Period = 'S' then 6
when @Period = 'Q' then 3
when @Period = 'M' then 1
when @Period = 'B' then 1209600
when @Period = 'W' then 604800
when @Period = 'D' then 86400
when @Period = 'h' then 3600
when @Period = 'm' then 60
when @Period = 's' then 1
end
return case
when @Period in ('Y','S','Q','M') then @m
when @Period in ('B','W','D','h','m','s') then @s
end / @num
Upvotes: 1
Views: 1089
Reputation: 4058
Considering all the answers and comments I have finally reached a solution for this problem.
I have implemented a function to handle basic add/subtract for DATETIME2
datatype, and then I used it in a function to get intervals elapsed between two dates.
All calculations are at maximum precision of DATETIME2
and BIGINT
and it should handle all cases.
this is the function to implement basic DATETIME2
arithmetic:
DROP FUNCTION FN_DATE2_MATH;
GO
CREATE FUNCTION FN_DATE2_MATH(
@D1 AS DATETIME2(7),
@D2 AS DATETIME2(7),
@OP AS INT=1, -- 1 = SUM, -1 = SUBTRACT
@OVERFLOW AS INT = NULL -- NULL = NULL VALUE, ELSE OVERFLOW ERROR
)
RETURNS DATETIME2(7)
AS
BEGIN
IF (@OP = -1) AND (@D1>@D2) BEGIN
DECLARE @DT DATETIME2(7) = @D1
SET @D1 = @D2
SET @D2 = @DT
END
DECLARE @B1 VARBINARY(8) = CONVERT(VARBINARY(8), REVERSE(SUBSTRING(CONVERT(VARBINARY(9), @D1),2,8)))
DECLARE @DD1 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @B1),1,3)
DECLARE @NS1 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @B1),4,5)
DECLARE @B2 VARBINARY(8) = CONVERT(VARBINARY(8), REVERSE(SUBSTRING(CONVERT(VARBINARY(9), @D2),2,8)))
DECLARE @DD2 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @B2),1,3)
DECLARE @NS2 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @B2),4,5)
DECLARE @DDR AS BIGINT
DECLARE @NSR AS BIGINT
IF @OP = 1 BEGIN
SET @NSR = CONVERT(BIGINT, @NS2) + CONVERT(BIGINT, @NS1)
IF @NSR>=864000000000 BEGIN
SET @NSR = @NSR - CONVERT(BIGINT, 864000000000)
SET @DD1 = CONVERT(VARBINARY(8), CONVERT(BIGINT, @DD1)-1)
END
SET @DDR = CONVERT(BIGINT, @DD2) + CONVERT(BIGINT, @DD1)
END ELSE
IF @OP = -1 BEGIN
SET @NSR = CONVERT(BIGINT, @NS2) - CONVERT(BIGINT, @NS1)
IF @NSR<0 BEGIN
SET @NSR = @NSR + CONVERT(BIGINT, 864000000000)
SET @DD1 = CONVERT(VARBINARY(8), CONVERT(BIGINT, @DD1)+1)
END
SET @DDR = CONVERT(BIGINT, @DD2) - CONVERT(BIGINT, @DD1)
END
-- CHECK OVERFLOW
IF @DDR NOT BETWEEN 0 AND 3652058 BEGIN
IF @OVERFLOW IS NULL
RETURN NULL
ELSE
RETURN DATEADD(DD, -1, CONVERT(DATETIME2(7), 0x070000000000000000)) -- GENERATE OVERFLOW
END
DECLARE @BR VARBINARY(8) = CONVERT(VARBINARY(3), @DDR)+CONVERT(VARBINARY(5), @NSR)
SET @BR = CONVERT(VARBINARY(8), REVERSE(@BR))
RETURN CONVERT(DATETIME2(7), 0x07+@BR)
END
GO
and this is the function to get periods elapsed:
DROP FUNCTION FN_DATE_DIFF2;
GO
CREATE FUNCTION FN_DATE_DIFF2(
@INTERVALTYPE AS VARCHAR(11),
@START AS DATETIME2(7),
@END AS DATETIME2(7)
)
RETURNS BIGINT
AS
BEGIN
DECLARE @DATEPART INT = CASE
WHEN @INTERVALTYPE IN ('0','nanosecond','ns') THEN 0
WHEN @INTERVALTYPE IN ('1','microsecond','mcs') THEN 1
WHEN @INTERVALTYPE IN ('2','millisecond','ms') THEN 2
WHEN @INTERVALTYPE IN ('3','second','ss','s') THEN 3
WHEN @INTERVALTYPE IN ('4','minute','mi','n') THEN 4
WHEN @INTERVALTYPE IN ('5','hour','hh') THEN 5
WHEN @INTERVALTYPE IN ('6','day','dd','d') THEN 6
WHEN @INTERVALTYPE IN ('7','week','wk','ww') THEN 7
WHEN @INTERVALTYPE IN ('8','month','mm','m') THEN 8
WHEN @INTERVALTYPE IN ('9','quarter','qq','q') THEN 9
WHEN @INTERVALTYPE IN ('10','year','yy','yyyy') THEN 10
ELSE
6 -- DEFAULT TO DAYS
END
DECLARE @BN0 VARBINARY(8) = 0x0000000000000000 -- 0001-01-01 00:00:00.0000000
DECLARE @DT0 AS DATETIME2(7) = CONVERT(DATETIME2(7), 0x07+@BN0) -- datetime2(7) = 0
--DECLARE @BNX VARBINARY(8) = 0xFFBF692AC9DAB937 -- 9999-12-31 23:59:59.9999999
--DECLARE @DTX AS DATETIME2(7) = CONVERT(DATETIME2(7), 0x07+@BNX) -- datetime2(7) = 0
DECLARE @DT1 AS DATETIME2(7)
DECLARE @DT2 AS DATETIME2(7)
DECLARE @DP AS DATETIME2(7)
DECLARE @VB1 VARBINARY(8) = CONVERT(VARBINARY(8), REVERSE(SUBSTRING(CONVERT(VARBINARY(9), @START),2,8)))
DECLARE @DD1 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @VB1),1,3) -- DAYS FROM 0 TO START
DECLARE @NS1 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @VB1),4,5) -- NS FROM 0 TO START
DECLARE @VB2 VARBINARY(8) = CONVERT(VARBINARY(8), REVERSE(SUBSTRING(CONVERT(VARBINARY(9), @END),2,8)))
DECLARE @DD2 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @VB2),1,3) -- DAYS FROM 0 TO END
DECLARE @NS2 VARBINARY(8) = SUBSTRING(CONVERT(VARBINARY(8), @VB2),4,5) -- NS FROM 0 TO END
DECLARE @NSR AS BIGINT = CONVERT(BIGINT, @NS2) - CONVERT(BIGINT, @NS1) -- NS RESULT NOT BIASED
IF @NSR<0 BEGIN
SET @NSR = @NSR + CONVERT(BIGINT, 864000000000) -- NS RESULT
SET @DD1 = CONVERT(VARBINARY(8), CONVERT(BIGINT, @DD1)+1) -- ADD CARRY
END
DECLARE @DDR AS BIGINT = CONVERT(BIGINT, @DD2) - CONVERT(BIGINT, @DD1) -- DAYS RESULT
DECLARE @RES BIGINT
SET @RES = CASE @DATEPART
WHEN 0 THEN @DDR*CONVERT(BIGINT, 864000000000)+ @NSR -- NS
WHEN 1 THEN @DDR*CONVERT(BIGINT, 86400000000) + @NSR/CONVERT(BIGINT, 10) -- MCS
WHEN 2 THEN @DDR*CONVERT(BIGINT, 86400000) + @NSR/CONVERT(BIGINT, 10000) -- MS
WHEN 3 THEN @DDR*CONVERT(BIGINT, 86400) + @NSR/CONVERT(BIGINT, 10000000) -- SS
WHEN 4 THEN @DDR*CONVERT(BIGINT, 1440) + @NSR/CONVERT(BIGINT, 600000000) -- MI
WHEN 5 THEN @DDR*CONVERT(BIGINT, 24) + @NSR/CONVERT(BIGINT, 36000000000) -- HH
WHEN 6 THEN @DDR -- DD
WHEN 7 THEN @DDR / 7 -- WK (BOTH INT, RES = INT)
END
IF @DATEPART IN (8,9,10) BEGIN
SET @DT1 = CASE @DATEPART
WHEN 8 THEN DATEADD(MM, DATEDIFF(MM, @DT0, @START), @DT0)
WHEN 9 THEN DATEADD(QQ, DATEDIFF(QQ, @DT0, @START), @DT0)
WHEN 10 THEN DATEADD(YY, DATEDIFF(YY, @DT0, @START), @DT0)
END
SET @DP = DBO.FN_DATE2_MATH(@START, @END, -1, 0) -- ELAPSED TIME (DIFF)
SET @DT2 = DBO.FN_DATE2_MATH(@DT1, @DP, 1, 0) -- SHIFT DATE (ADD)
SET @RES = CASE @DATEPART
WHEN 8 THEN DATEDIFF(MM, @DT1, @DT2)
WHEN 9 THEN DATEDIFF(QQ, @DT1, @DT2)
WHEN 10 THEN DATEDIFF(YY, @DT1, @DT2)
END
END
RETURN @RES
END
GO
You can call it this way:
DECLARE @D1 DATETIME2(7)
DECLARE @D2 DATETIME2(7)
DECLARE @DP VARCHAR(20)
SET @D1 = '31/12/2016'
SET @D2 = '01/01/2017'
SET @DP = 'YY'
SELECT @D1 DATE_START, @D2 DATE_END, @DP DATE_PART, DBO.FN_DATE_DIFF2(@DP, @D1, @D2) INTERVALS
SET @D1 = '01:31'
SET @D2 = '03:20'
SET @DP = 'HH'
SELECT @D1 DATE_START, @D2 DATE_END, @DP DATE_PART, DBO.FN_DATE_DIFF2(@DP, @D1, @D2) INTERVALS
SET @D1 = '01/01/0001'
SET @D2 = '31/12/9999 23:59:59.9999999'
SET @DP = 'NS'
SELECT @D1 DATE_START, @D2 DATE_END, @DP DATE_PART, DBO.FN_DATE_DIFF2(@DP, @D1, @D2) INTERVALS
and you will get:
DATE_START DATE_END DATE_PART INTERVALS
2016-12-31 00:00:00.0000000 2017-01-01 00:00:00.0000000 YY 0
1900-01-01 01:31:00.0000000 1900-01-01 03:20:00.0000000 HH 1
0001-01-01 00:00:00.0000000 9999-12-31 23:59:59.9999999 NS 3155378975999999999
this should work
Upvotes: 0
Reputation: 4058
try this,
you can add all DATEPARTS you need.
It will also work as stand alone query, no real need for function, only if you find it useful.
If you don't need nanoseconds precision, you can use datetime datatype instead of datetime2, it could be a bit easier to handle.
EDIT : Corrected boundaries behaviour
UPDATE2 : Huge syntax semplification
UPDATE3 : reverted to original syntax to avoid leap year and different months lenght problem
DROP FUNCTION FN_CALC_DATE_DIFF;
GO
CREATE FUNCTION FN_CALC_DATE_DIFF(
@INTERVALTYPE AS INT = NULL,
@START AS datetime,
@END AS datetime
)
RETURNS BIGINT
AS
BEGIN
RETURN (SELECT CASE @INTERVALTYPE
WHEN 0 THEN DATEDIFF(NS, DATEADD(NS, DATEDIFF(NS, 0, @START), 0), DATEADD(NS, DATEDIFF(NS, 0, @START), 0) + @Start-@End)
WHEN 1 THEN DATEDIFF(MCS, DATEADD(MCS, DATEDIFF(MCS, 0, @START), 0), DATEADD(MCS, DATEDIFF(MCS, 0, @START), 0) + @END-@START)
WHEN 2 THEN DATEDIFF(MS, DATEADD(MS, DATEDIFF(MS, 0, @START), 0), DATEADD(MS, DATEDIFF(MS, 0, @START), 0) + @END-@START)
WHEN 3 THEN DATEDIFF(SS, DATEADD(SS, DATEDIFF(SS, 0, @START), 0), DATEADD(SS, DATEDIFF(SS, 0, @START), 0) + @END-@START)
WHEN 4 THEN DATEDIFF(MI, DATEADD(MI, DATEDIFF(MI, 0, @START), 0), DATEADD(MI, DATEDIFF(MI, 0, @START), 0) + @END-@START)
WHEN 5 THEN DATEDIFF(HH, DATEADD(HH, DATEDIFF(HH, 0, @START), 0), DATEADD(HH, DATEDIFF(HH, 0, @START), 0) + @END-@START)
WHEN 6 THEN DATEDIFF(DD, DATEADD(DD, DATEDIFF(DD, 0, @START), 0), DATEADD(DD, DATEDIFF(DD, 0, @START), 0) + @END-@START)
WHEN 7 THEN DATEDIFF(WK, DATEADD(WK, DATEDIFF(WK, 0, @START), 0), DATEADD(WK, DATEDIFF(WK, 0, @START), 0) + @END-@START)
WHEN 8 THEN DATEDIFF(MM, DATEADD(MM, DATEDIFF(MM, 0, @START), 0), DATEADD(MM, DATEDIFF(MM, 0, @START), 0) + @END-@START)
WHEN 9 THEN DATEDIFF(YY, DATEADD(YY, DATEDIFF(YY, 0, @START), 0), DATEADD(YY, DATEDIFF(YY, 0, @START), 0) + @END-@START)
ELSE
DATEDIFF(SS, DATEADD(SS, DATEDIFF(SS, 0, @START), 0), DATEADD(SS, DATEDIFF(SS, 0, @START), 0) + @END-@START) -- default to
END )
END
GO
this test
declare @d1 datetime = '31/12/2016'
declare @d2 datetime = '01/01/2017'
declare @h1 datetime = '01:31'
declare @h2 datetime = '03:20'
select
DBO.FN_CALC_DATE_DIFF(5, @h1, @h2) DIFF_HOURS,
DBO.FN_CALC_DATE_DIFF(9, @d1, @d2) DIFF_YEARS
will produce
DIFF_HOURS DIFF_YEARS
1 0
Upvotes: 0
Reputation: 45759
You can call DateDiff
with the date part corresponding to the smallest meaningful precision in your timestamps, and then divide to get the interval in the correct unit.
For example, if you store times down to the second, to get the number of hours call DateDiff
with the date part as ss
and then divide by 3600. The idea is that if your times are only accurate to the second, then "crossed 1 second boundary" means the same as "took 1 second" for all measurable purposes, and then you just convert to the units you really need.
If your intervals are long and your timestamps are high precision - e.g. if you need to use microsecond precision for year-long intervals - you may have to call DateDiff_Big instead.
UPDATE
If you need higher precision for longer intervals and can't use DateDiff_Big, then you have to get a little trickier. One way would be:
First separate the dates from the times.
Then date_diff
the date parts, counting in days. (This has a range of several million years.) Multiply the result by 86,400,000 (result will need a 64-bit storage.)
Now date_diff
the times with ms precision.
Note date_diff
does a signed comparison, so the latter diff may be positive or negative. Add it to the part you got by multiplying the diff of the date parts, and now you have an accurate count of ms.
Divide to get the units you want.
UPDATE 2 - I originally claimed nanoseconds would work, but that's because I can't move decimal points correctly.
Upvotes: 4