The concept of age seems quite simple.
Right now I'm 42 years old.
I can be more precise and define it as 42 years, some months and several days.
And at a given date I will be 43 years old, and months and date are reset to 0.
Unfortunately it's hard to calculate with these numbers.
For those who wants to go into detail I'd recommend
ISO 8601 Data elements and interchange formats. Unfortunately I don't have access to this document.
Another good reason is the
explanation for CPANs
DateTime.pm module.
I tried a slightly different approach:
Define the age as integer and fractional part, where the integer part is the age in years as we are used to it, and the fractional part is the number of days already passed divided by the total days in the current year.
This sounds quite simple, but it has some complicated effects due to leap years. So I wrote a little package ( see below) to handle these effects.
The Package is called BX_YEAR_CALC and has 2 functions:
DIFFERENCE (year1 date, year2 date) returns the AGE.
ADDITION (year1 date, diff number) returns the date at a specific AGE.
Here are some examples:
SQL> alter session set nls_date_format='YYYY-MM-DD HH24:MI:SS';
SQL> select bx_year_calc.difference('2004-02-29 12:00:00', '2005-03-01 11:59:00') diff from dual;
DIFF
----------
1.00136796
SQL> select bx_year_calc.addition ('2004-02-29 12:00:00', 1.00136796042617960426179604261796042618) d from dual;
D
-------------------
2005-03-01 11:59:00
SQL> select bx_year_calc.addition ('2005-03-01 11:59:00', -1.00136796042617960426179604261796042618) d from dual;
D
-------------------
2004-03-01 00:00:00
You can see in these examples there are situations - especially around leap years and Feb 29th - where the calculation goes quite wrong. My implementation tries to reduce this effect to a minimum.
Feel free to play with dates and ages, any comments are very welcome.
create or replace PACKAGE BX_YEAR_CALC AS
/* 2018-03-25 - berx - initial - martin.a.berger@gmail.com
BX_YEAR_CALC - to calculate DATES in units or YEARS
equal idea as DATE1 - DATE2 returns a number,
where the integer part are full days and fractional part reflects remaining hours, minutes and seconds
As some years has 365 or 366 days, the last year can have different number of total days,
so the fraction must be calculated based on this.
Only the number of days of the last year (based on the begin) is calculated.
Be aware: there are situations with leap years or gregorian calendar where results are not intuitive.
If you find a bette rimplementation for a given situation, let's discuss
*/
/* returns the "age in years" between the 2 parameters.
integer is quite obvious - it's the years between the start date and the same day (if it exists) in target year.
fraction is remaining days divided by days in the last year
*/
FUNCTION DIFFERENCE(YEAR1 date default sysdate,
YEAR2 date default sysdate)
RETURN number;
/* adds (or substracts - if number is negative) a "age in years" as defined by DIFFERENCE to a given date
for addition, first the integer part is added and then the fraction - based on days in last year.
for subtraction, first the fraction is removed (to be more aligned to addition) and the the integer
*/
FUNCTION ADDITION (YEAR1 date default sysdate,
DIFF number default 0)
RETURN date;
/* returns the next "existing" date to a given "old-date" and a specific other year ("new year")
this handles the situation similar to "today is 2004-02-29. what's the date a year ago?
to avoid ORA-01840: input value not long enough for date format
this function searches the next day "above" (with incr => default 1) or "below" (with incr => -1)
other values for incr are not defined.
*/
FUNCTION next_real_date (
new_year VARCHAR2,
old_date DATE,
incr number default 1
)
return date;
END BX_YEAR_CALC;
CREATE OR REPLACE PACKAGE BODY bx_year_calc AS
c_debug NUMBER := 0;
PROCEDURE debug (
message VARCHAR2
)
AS
BEGIN
IF
c_debug > 0
THEN
dbms_output.put_line(message);
END IF;
END debug;
FUNCTION next_real_date (
new_year VARCHAR2,
old_date DATE,
incr NUMBER DEFAULT 1
) RETURN DATE AS
year2_year NUMBER;
return_date DATE; -- the highest full year related to YEAR1 which is smaller than YEAR2
year_diff NUMBER;
leap_help BOOLEAN := false; -- do we hit a leap-year issue and need to sort out things?
leap_count NUMBER := 0; -- how many days to add to fix leap issue
date_not_valid EXCEPTION;
PRAGMA exception_init ( date_not_valid,-1839 );
BEGIN
if NOT abs(incr)=1 then
raise_application_error( -20001, ' incr msut be +1 or -1 ' );
end if;
WHILE NOT leap_help LOOP
BEGIN
IF
leap_count = 0
THEN
return_date := TO_DATE(new_year || '-' || TO_CHAR(old_date,'MM-DD HH24:MI:SS'),'YYYY-MM-DD HH24:MI:SS');
leap_help := true;
ELSE
return_date := TO_DATE(new_year || '-' || TO_CHAR(old_date + leap_count,'MM-DD'),'YYYY-MM-DD');
leap_help := true; -- this happens only if NO exception is thrown
END IF;
EXCEPTION
WHEN date_not_valid -- ORA-01839: date not valid for month specified
THEN
leap_count := leap_count + incr;
IF -- just a safety in case anything goes wrong
abs(leap_count) > 33
THEN
leap_count := 1 / 0;
END IF;
END;
END LOOP;
RETURN return_date;
END next_real_date;
FUNCTION difference (
year1 DATE,
year2 DATE DEFAULT SYSDATE
) RETURN NUMBER AS
days_lastyear NUMBER; -- how many days the last year has
fract_lastyear NUMBER; -- remaining part as fraction of a year
year2_year NUMBER;
hi_year_lo DATE; -- the highest full year related to YEAR1 which is smaller than YEAR2
hi_year_hi DATE; -- the smallest full year related to YEAR1 which is higher than YEAR2
year_diff NUMBER;
leap_help BOOLEAN := false; -- do we hit a leap-year issue and need to sort out things?
leap_count NUMBER := 0; -- how many days to add to fix leap issue
date_not_valid EXCEPTION;
PRAGMA exception_init ( date_not_valid,-1839 );
BEGIN
IF
year1 > year2
THEN
RETURN difference(year2,year1);
ELSE
year2_year := to_number(TO_CHAR(year2,'YYYY') );
hi_year_lo := next_real_date(TO_CHAR(year2_year,'0000'),year1);
hi_year_hi := next_real_date(TO_CHAR(year2_year + 1,'0000'),year1);
-- how many days are in the last year to take care of
days_lastyear := hi_year_hi - hi_year_lo;
fract_lastyear := ( year2 - hi_year_lo ) / days_lastyear;
year_diff := to_number(TO_CHAR(hi_year_lo,'YYYY') ) - to_number(TO_CHAR(year1,'YYYY') );
RETURN year_diff + fract_lastyear;
END IF;
END difference;
FUNCTION addition (
year1 DATE DEFAULT SYSDATE,
diff NUMBER
) RETURN DATE AS
year_year NUMBER;
year_diff NUMBER;
days_lastyear NUMBER; -- how many days the last year has
hi_year_lo DATE; -- the highest full year related to YEAR1 which is smaller than YEAR2
hi_year_hi DATE; -- the smallest full year related to YEAR1 which is higher than YEAR2
lo_year_lo DATE; -- a temp "lowest date" - only to keep the calculation somehow readable
days_jump_back NUMBER := 15; -- a number bigger than "365 + (15-4)" - google gregorian calendar reform 1582 & 4 October 1582
days_offset NUMBER;
leap_help BOOLEAN := false; -- do we hit a leap-year issue and need to sort out things?
leap_count NUMBER := 0; -- how many days to add to fix leap issue
date_not_valid EXCEPTION;
PRAGMA exception_init ( date_not_valid,-1839 );
BEGIN
IF
diff < 0
THEN
-- first let's substract only the fraction of diff
year_year := to_number(TO_CHAR(year1,'YYYY') );
hi_year_lo := next_real_date(TO_CHAR(year_year - 1,'0000'),year1);
days_lastyear := year1 - hi_year_lo;
hi_year_hi := hi_year_lo + ( days_lastyear * ( diff - trunc(diff) ) );
-- now the easy part - years
RETURN next_real_date(TO_CHAR(to_number(TO_CHAR(hi_year_hi,'YYYY') ) + trunc(diff) + 1,'0000'),hi_year_hi);
-- trunc(diff)+1 is required, as we substracted "-1" in the calculation of "hi_year_lo" above already
ELSIF diff = 0 THEN
RETURN year1;
ELSE
year_year := to_number(TO_CHAR(year1,'YYYY') ) + trunc(diff);
hi_year_lo := next_real_date(TO_CHAR(year_year,'0000'),year1);
hi_year_hi := next_real_date(TO_CHAR(year_year + 1,'0000'),year1);
--
days_lastyear := hi_year_hi - hi_year_lo;
RETURN hi_year_lo + ( days_lastyear * ( diff - trunc(diff) ) );
END IF;
END addition;
END bx_year_calc;