Convert a date range to an interval description
-
01-10-2020 - |
Pergunta
A requirement in a recent project was to report when a resource would be fully consumed. As well as the exhaustion calendar date I was asked to show the remaining time in English-like format, something like "1 year, 3 months to go."
The built-in DATEDIFF
function
Returns the count ... of the specified datepart boundaries crossed between the specified startdate and enddate.
If used as-is this could produce misleading or confusing results. For example, using an interval of YEAR would show 1999-12-31 (YYYY-MM-DD) and 2000-01-01 to be one year apart whereas common sense would say these dates are separated by only 1 day. Conversely using an interval of DAY 1999-12-31 and 2010-12-31 are separated by 4,018 days while most people would see "11 years" as a better description.
Starting from the number of days and calculating months and years from there would be prone to leap year and size-of-month errors.
I got to wondering how this could be implemented in the various SQL dialects? Example output includes:
create table TestData(
FromDate date not null,
ToDate date not null,
ExpectedResult varchar(100) not null); -- exact formatting is unimportant
insert TestData (FromDate, ToDate, ExpectedResult)
values ('1999-12-31', '1999-12-31', '0 days'),
('1999-12-31', '2000-01-01', '1 day'),
('2000-01-01', '2000-02-01', '1 month'),
('2000-02-01', '2000-03-01', '1 month'), -- month length not important
('2000-01-28', '2000-02-29', '1 month, 1 day'), -- leap years to be accounted for
('2000-01-01', '2000-12-31', '11 months, 30 days'),
('2000-02-28', '2000-03-01', '2 days'),
('2001-02-28', '2001-03-01', '1 day'), -- not a leap year
('2000-01-01', '2001-01-01', '1 year'),
('2000-01-01', '2011-01-01', '11 years'),
('9999-12-30', '9999-12-31', '1 day'), -- catch overflow in date calculations
('1900-01-01', '9999-12-31', '8099 years 11 months 30 days'); -- min(date) to max(date)
I happen to be using SQL Server 2008R2 but I am interested to learn how other dialects would handle this.
Solução
The following solution is for SQL Server. The approach is similar to Serg's in that the query uses only the DATEADD and DATEDIFF functions. It does not, however, account for negative intervals (FromDate > ToDate), and it derives years and months from the total month difference:
WITH
MonthDiff AS
(
SELECT
t.FromDate,
t.ToDate,
t.ExpectedResult,
Months = x.Months - CASE WHEN DAY(t.FromDate) > DAY(t.ToDate) THEN 1 ELSE 0 END
FROM
dbo.TestData AS t
CROSS APPLY (SELECT DATEDIFF(MONTH, t.FromDate, t.ToDate)) AS x (Months)
)
SELECT
t.FromDate,
t.ToDate,
t.ExpectedResult,
Result = ISNULL(NULLIF(ISNULL(x.Years + CASE x.Years WHEN '1' THEN ' year ' ELSE ' years ' END, '')
+ ISNULL(x.Months + CASE x.Months WHEN '1' THEN ' month ' ELSE ' months ' END, '')
+ ISNULL(x.Days + CASE x.Days WHEN '1' THEN ' day ' ELSE ' days ' END, ''), ''), '0 days')
FROM
MonthDiff AS t
CROSS APPLY
(
SELECT
CAST(NULLIF(t.Months / 12, 0) AS varchar(10)),
CAST(NULLIF(t.Months % 12, 0) AS varchar(10)),
CAST(NULLIF(DATEDIFF(DAY, DATEADD(MONTH, t.Months, t.FromDate), t.ToDate), 0) AS varchar(10))
) AS x (Years, Months, Days)
;
Output:
FromDate ToDate ExpectedResult Result
---------- ---------- ----------------------------- -----------------------------
1999-12-31 1999-12-31 0 days 0 days
1999-12-31 2000-01-01 1 day 1 day
2000-01-01 2000-02-01 1 month 1 month
2000-02-01 2000-03-01 1 month 1 month
2000-01-28 2000-02-29 1 month, 1 day 1 month 1 day
2000-01-01 2000-12-31 11 months, 30 days 11 months 30 days
2000-02-28 2000-03-01 2 days 2 days
2001-02-28 2001-03-01 1 day 1 day
2000-01-01 2001-01-01 1 year 1 year
2000-01-01 2011-01-01 11 years 11 years
9999-12-30 9999-12-31 1 day 1 day
1900-01-01 9999-12-31 8099 years 11 months 30 days 8099 years 11 months 30 days
Outras dicas
This answer shows an implementation using a SQL Server (2005+) CLR function.
-- Enable CLR (if necessary)
EXECUTE sys.sp_configure
@configname = 'clr enabled',
@configvalue = 1;
RECONFIGURE;
Assembly and function
CREATE ASSEMBLY DBA
AUTHORIZATION dbo
FROM 0x4D5A90000300000004000000FFFF0000B800000000000000400000000000000000000000000000000000000000000000000000000000000000000000800000000E1FBA0E00B409CD21B8014CCD21546869732070726F6772616D2063616E6E6F742062652072756E20696E20444F53206D6F64652E0D0D0A2400000000000000504500004C010300B11134570000000000000000E00002210B010B00000C000000060000000000000E2A0000002000000040000000000010002000000002000004000000000000000400000000000000008000000002000000000000030040850000100000100000000010000010000000000000100000000000000000000000B42900005700000000400000A802000000000000000000000000000000000000006000000C0000007C2800001C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000080000000000000000000000082000004800000000000000000000002E74657874000000140A000000200000000C000000020000000000000000000000000000200000602E72737263000000A80200000040000000040000000E0000000000000000000000000000400000402E72656C6F6300000C0000000060000000020000001200000000000000000000000000004000004200000000000000000000000000000000F0290000000000004800000002000500EC210000900600000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000133003008601000001000011020A0F01280600000A0F00280600000A590B160C160D072C5C0F00280700000A0F01280700000A30200F00280700000A0F01280700000A33140F00280800000A0F01280800000A31040717590B120007280900000A0A2B1D120017280A00000A03280B00000A2C5B0817580C120017280A00000A0A0603280C00000A2C451200280600000A7E0D00000A13051205280600000A33C31200280700000A7E0D00000A13061206280700000A33AC2B150917580D120023000000000000F03F280E00000A0A0603280C00000A2DE21F64730F00000A13040716313D1104076F1000000A26110407172E0772010000702B05720F0000706F1100000A2611040816300B091630077E1200000A2B05721B0000706F1100000A26081631391104086F1000000A26110408172E0772210000702B0572310000706F1100000A261104091630077E1200000A2B05721B0000706F1100000A2609163006072D24082D211104096F1000000A26110409172E07723F0000702B05724B0000706F1100000A2611046F1300000A2A1E02281400000A2A000042534A4201000100000000000C00000076322E302E35303732370000000005006C000000A8010000237E000014020000F001000023537472696E6773000000000404000058000000235553005C0400001000000023475549440000006C0400002402000023426C6F620000000000000002000001471502000900000000FA253300160000010000000A000000020000000200000003000000140000000500000001000000010000000200000000000A0001000000000006003D0036000600440036000A008E0073000600BB00A8001300CF0000000600FE00DE0006001E01DE000A00460173000600C501B9010600DA0136000000000001000000000001000100010010001800000005000100010050200000000096004D000A000100E22100000000861861001200040000000000000000000100A00000000200A500190061001200210061004800310061004E0039006100120041006100120011005B01B60111006401B60111006E01B60111007601BA0111007F01BA0111008901C00111009C01C0011100A801C8011100B101CC01490061004E004900D301D2014900D301D8015100E101DE010900E701E10109006100120020002B00530024000B0016002E001300F3012E001B00FC012E0023000502E5010480000000000000000000000000000000003C01000002000000000000000000000001002D000000000002000000000000000000000001006700000000000000003C4D6F64756C653E004461746162617365312E646C6C0055736572446566696E656446756E6374696F6E73006D73636F726C69620053797374656D004F626A656374004461746554696D6500496E74657276616C4465736372697074696F6E002E63746F720053797374656D2E44617461004D6963726F736F66742E53716C5365727665722E5365727665720053716C46616365744174747269627574650046726F6D00546F0053797374656D2E446961676E6F73746963730044656275676761626C6541747472696275746500446562756767696E674D6F6465730053797374656D2E52756E74696D652E436F6D70696C6572536572766963657300436F6D70696C6174696F6E52656C61786174696F6E734174747269627574650052756E74696D65436F6D7061746962696C697479417474726962757465004461746162617365310053716C46756E6374696F6E417474726962757465006765745F59656172006765745F4D6F6E7468006765745F446179004164645965617273004164644D6F6E746873006F705F4C6573735468616E4F72457175616C006F705F4C6573735468616E004D617856616C756500416464446179730053797374656D2E5465787400537472696E674275696C64657200417070656E6400537472696E6700456D70747900546F537472696E6700000D200079006500610072007300000B2000790065006100720000052C002000000F20006D006F006E00740068007300000D20006D006F006E0074006800000B200064006100790073000009200064006100790000000000AFDAAB526E833740886DDFF9139712E60008B77A5C561934E0890700020E1109110903200001310100030054020D497346697865644C656E6774680054020A49734E756C6C61626C65005408074D617853697A656400000005200101111504200101088161010005005455794D6963726F736F66742E53716C5365727665722E5365727665722E446174614163636573734B696E642C2053797374656D2E446174612C2056657273696F6E3D322E302E302E302C2043756C747572653D6E65757472616C2C205075626C69634B6579546F6B656E3D623737613563353631393334653038390A446174614163636573730000000054557F4D6963726F736F66742E53716C5365727665722E5365727665722E53797374656D446174614163636573734B696E642C2053797374656D2E446174612C2056657273696F6E3D322E302E302E302C2043756C747572653D6E65757472616C2C205075626C69634B6579546F6B656E3D623737613563353631393334653038391053797374656D446174614163636573730000000054020F497344657465726D696E69737469630154020949735072656369736501540E044E616D6513496E74657276616C4465736372697074696F6E0320000805200111090807000202110911090306110905200111090D05200112250805200112250E02060E0320000E0D070711090808081225110911090801000200000000000801000800000000001E01000100540216577261704E6F6E457863657074696F6E5468726F77730100000000B111345700000000020000001C01000098280000980A000052534453F841C8A989DDDC4098D9FD78225EB30502000000633A5C55736572735C7061756C775C4F6E6544726976655C446F63756D656E74735C56697375616C2053747564696F20323031355C50726F6A656374735C4461746162617365315C4461746162617365315C6F626A5C52656C656173655C4461746162617365312E706462000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000DC2900000000000000000000FE290000002000000000000000000000000000000000000000000000F02900000000000000000000000000000000000000005F436F72446C6C4D61696E006D73636F7265652E646C6C0000000000FF2500200010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001001000000018000080000000000000000000000000000001000100000030000080000000000000000000000000000001000000000048000000584000004C02000000000000000000004C0234000000560053005F00560045005200530049004F004E005F0049004E0046004F0000000000BD04EFFE00000100000000000000000000000000000000003F000000000000000400000002000000000000000000000000000000440000000100560061007200460069006C00650049006E0066006F00000000002400040000005400720061006E0073006C006100740069006F006E00000000000000B004AC010000010053007400720069006E006700460069006C00650049006E0066006F0000008801000001003000300030003000300034006200300000002C0002000100460069006C0065004400650073006300720069007000740069006F006E000000000020000000300008000100460069006C006500560065007200730069006F006E000000000030002E0030002E0030002E00300000003C000E00010049006E007400650072006E0061006C004E0061006D00650000004400610074006100620061007300650031002E0064006C006C0000002800020001004C006500670061006C0043006F00700079007200690067006800740000002000000044000E0001004F0072006900670069006E0061006C00460069006C0065006E0061006D00650000004400610074006100620061007300650031002E0064006C006C000000340008000100500072006F006400750063007400560065007200730069006F006E00000030002E0030002E0030002E003000000038000800010041007300730065006D0062006C0079002000560065007200730069006F006E00000030002E0030002E0030002E0030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000C000000103A00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
WITH PERMISSION_SET = SAFE;
GO
CREATE FUNCTION dbo.IntervalDescription
(
@From date,
@To date
)
RETURNS nvarchar(100)
AS EXTERNAL NAME
DBA.UserDefinedFunctions.IntervalDescription;
Usage
SELECT
TD.FromDate,
TD.ToDate,
TD.ExpectedResult,
IntervalDescription = dbo.IntervalDescription(TD.FromDate, TD.ToDate)
FROM dbo.TestData AS TD;
Result
Source
I am not a C# programmer!
using Microsoft.SqlServer.Server;
using System;
using System.Text;
public partial class UserDefinedFunctions
{
[SqlFunction
(
DataAccess = DataAccessKind.None,
SystemDataAccess = SystemDataAccessKind.None,
IsDeterministic = true,
IsPrecise = true,
Name = "IntervalDescription"
)
]
[return: SqlFacet(IsFixedLength = false, IsNullable = false, MaxSize = 100)]
public static string IntervalDescription(DateTime From, DateTime To)
{
var workDate = From;
int years = To.Year - From.Year;
int months = 0;
int days = 0;
if (years != 0)
{
if (From.Month > To.Month || (From.Month == To.Month && From.Day > To.Day))
{
years--;
}
workDate = workDate.AddYears(years);
}
while (workDate < To && (workDate.Year != DateTime.MaxValue.Year || workDate.Month != DateTime.MaxValue.Month))
{
if (workDate.AddMonths(1) <= To)
{
months++;
workDate = workDate.AddMonths(1);
}
else
{
break;
}
}
while (workDate < To)
{
days++;
workDate = workDate.AddDays(1);
}
StringBuilder sb = new StringBuilder(100);
if (years > 0)
{
sb.Append(years);
sb.Append(years == 1 ? " year" : " years");
sb.Append((months > 0 || days > 0) ? ", " : string.Empty);
}
if (months > 0)
{
sb.Append(months);
sb.Append(months == 1 ? " month" : " months");
sb.Append(days > 0 ? ", " : string.Empty);
}
if (days > 0 || (years == 0 && months == 0))
{
sb.Append(days);
sb.Append(days == 1 ? " day" : " days");
}
return
sb.ToString();
}
}
My version, implemented in SQL Server 2008R2 SP2.
CREATE FUNCTION dbo.ReadableInterval(
@FromDate AS date,
@ToDate AS date
)
RETURNS TABLE AS RETURN
(
with YearStep as
(
select
max(n1.Number) as YearNumber
from dbo.Numbers as n1
where n1.Number <= DATEDIFF(YEAR, @FromDate, @ToDate) -- see comment (A)
and DATEADD(YEAR, n1.Number, @FromDate) <= @ToDate -- see comment (B)
)
, MonthStep as
(
select
max(n2.Number) as MonthNumber
from dbo.Numbers as n2
cross apply YearStep as y1
where n2.Number <= DATEDIFF(MONTH, DATEADD(YEAR, y1.YearNumber, @FromDate), @ToDate)
and DATEADD(MONTH, n2.Number, DATEADD(YEAR, y1.YearNumber, @FromDate)) <= @ToDate
)
, DayStep as
(
select
DATEDIFF(day, DATEADD(MONTH, m1.MonthNumber, DATEADD(YEAR, y2.YearNumber, @FromDate)), @ToDate) as DayNumber
from MonthStep as m1
cross apply YearStep as y2
)
select
y.YearNumber,
m.MonthNumber,
d.DayNumber
from YearStep as y
cross apply MonthStep as m
cross apply DayStep as d
)
With the given test data the results are
select
td.FromDate,
td.ToDate,
td.ExpectedResult,
ri.YearNumber as Years,
ri.MonthNumber as Months,
ri.DayNumber as [Days]
from dbo.TestData as td
cross apply dbo.ReadableInterval(td.FromDate, td.ToDate) as ri;
FromDate ToDate ExpectedResult Years Months Days
---------- ---------- ---------------------------- ----- ------ ----
1999-12-31 1999-12-31 0 days 0 0 0
1999-12-31 2000-01-01 1 day 0 0 1
2000-01-01 2000-02-01 1 month 0 1 0
2000-02-01 2000-03-01 1 month 0 1 0
2000-01-28 2000-02-29 1 month, 1 day 0 1 1
2000-01-01 2000-12-31 11 months, 30 days 0 11 30
2000-02-28 2000-03-01 2 days 0 0 2
2001-02-28 2001-03-01 1 day 0 0 1
2000-01-01 2001-01-01 1 year 1 0 0
2000-01-01 2011-01-01 11 years 11 0 0
9999-12-30 9999-12-31 1 day 0 0 1
1900-01-01 9999-12-31 8099 years 11 months 30 days 8099 11 30
Explanation
My general approach is to step forward from the earlier date, first in years, then months, then in days. At each level of granularity the objective is to get as close to the end date without going over it, then continue at the next lower level.
I use a numbers table to facilitate the close-to-but-not-over calculation. From this table and DATEADD
I can find the largest number of years/ months/ days that precede ToDate
- comment (B) in the code.
Since I was looking for the MAX number and my Numbers table is clustered on it, the optimizer was performing a descending scan, feeding values to DATEADD. This was causing date overflow errors as Numbers contains over 100,000 rows. DATEADD(YEAR, 100000, @FromDate)
is greater than 9999-12-31 and an error is raised. Predicate (A) gives an upper limit on the Number value from which the backward scan starts, avoiding the date overflow. Consequently the query plan traverses very few rows for even very large date ranges.
This approach is used for finding years and months, except the starting point for months is brought forward by however many years I found in the first CTE. DAYS is my lowest level of granularity so a simple DATEDIFF is sufficient.
This could be extended to finer granularity, returning the interval in hours, minutes and seconds if required.
PostgreSQL supports the age
function out-of-the-box:
select
FromDate,
ToDate,
ExpectedResult,
age(ToDate, FromDate)
from TestData;
This gives the desired result, give-or-take some additional time values.
FromDate ToDate ExpectedResult age
---------- ---------- ---------------------------- --------------------------
1999-12-31 1999-12-31 0 days 00:00:00
1999-12-31 2000-01-01 1 day 1 day
2000-01-01 2000-02-01 1 month 1 mon
2000-02-01 2000-03-01 1 month 1 mon
2000-01-28 2000-02-29 1 month, 1 day 1 mon 1 day
2000-01-01 2000-12-31 11 months, 30 days 11 mons 30 days
2000-02-28 2000-03-01 2 days 2 days
2001-02-28 2001-03-01 1 day 1 day
2000-01-01 2001-01-01 1 year 1 year
2000-01-01 2011-01-01 11 years 11 years
9999-12-30 9999-12-31 1 day 1 day
1900-01-01 9999-12-31 8099 years 11 months 30 days 8099 years 11 mons 30 days
Version with no number
table or tally required. Gives the same result on Michael Green's test data. They differ on data where @FromDate > @ToDate
. ReadableInterval2
returns negative values contrary to nulls.
CREATE FUNCTION dbo.ReadableInterval2(
@FromDate AS date,
@ToDate AS date
)
RETURNS TABLE AS RETURN
(with checkData as (
select
fromDate = case when @FromDate > @ToDate then @ToDate else @FromDate end,
toDate = case when @FromDate <= @ToDate then @ToDate else @FromDate end,
k = case when @FromDate > @ToDate then -1 else 1 end
), MonthStep as (
select k, FromDate, ToDate,
YearNumber = x.months / 12,
MonthNumber = x.months % 12
from checkdata
cross apply(
select months = DATEDIFF(MONTH, FromDate, ToDate)
- case when DAY(FromDate) > DAY(ToDate) then 1 else 0 end
) x
)
select YearNumber = k*YearNumber,
MonthNumber = k*MonthNumber,
DayNumber = k*DATEDIFF(day, DATEADD(MONTH, MonthNumber, DATEADD(YEAR, YearNumber, FromDate)), ToDate)
from MonthStep
)