Date arithmetic with Julian days, BC dates, and Oracle rules

Here are routines that can handle date arithmetic on BC dates, Julian day functions, and simulation of Oracle's support of old-style-calendar dates -- including simulation of an Oracle bug. So the routines are good for extending the range of useable dates, compact storage, and import/export between DBMSs that have different rules.

If you need to refresh your understanding of dates, read our old-but-lovely article first: The Oracle Calendar.

I wrote the main routines with standard SQL so they should run on
any DBMS that supports the standard, but tested only with
MySQL and MariaDB.


ocelot_date_to_julianday
Return number of days since 4713-01-01, given yyyy-mm-dd [BC] date
ocelot_date_validate
Return okay or error, given yyyy-mm-dd BC|AD date which may be invalid
ocelot_date_datediff
Return number of days difference, given two yyyy-mm-dd [BC] dates
ocelot_date_test
Return 'OK' after a thorough test of the entire range of dates

All function arguments look like this:
yyyy-mm-dd [BC] ... CHAR|VARCHAR. yyyy-mm-dd is the standard date format for year and month and date, optionally followed by a space and 'BC'. If 'BC' is missing, 'AD' is assumed. Must be between 4713-01-01 BC and 9999-12-31 for Julian-calendar dates, or between 4714-11-24 BC and 9999-12-31 for Gregorian-calendar dates. Routines will return bad results if dates are invalid, if there is any doubt then run ocelot_date_validate() first.
julian_day ... INTEGER. For an explanation of what a "Julian day number" is, see Wikipedia. Do not confuse with "Julian-calendar date" -- the name is similar but Julian days can be converted to or from dates in the Gregorian calendar too. Must be between 0 (which is 4713-01-01 BC) and a maximum (which is 9999-12-31).
'J' or 'G' or 'O' ... CHAR. This is an "options" flag. 'J' means use the Julian (old style) calendar. 'G' means use the Gregorian (new style) calendar.'O' means use the Oracle rules, which we described in the earlier article. If options is not 'J' or 'G' or 'O', 'G' is assumed.



Example expressions:

#1 ocelot_date_to_julianday('0001-01-01','G') returns 1721426
#2 ocelot_date_to_julianday('0001-01-01','J') returns 1721424
#3 ocelot_date_to_julianday('4712-01-01 BC', 'O') returns 0
#4 ocelot_date_datediff('0001-01-01','0001-01-01 BC','G') returns 366
#5 ocelot_date_to_julianday('1492-10-12','J')%7; returns 4
/* Explanations: #3 returns 0 because there's a year 0000,
#4 returns 366 because 0001 BC is a leap year,
#5 returns weekday = 4 for the original Columbus Day
and he used a Julian calendar. */

The source code

The code is original but the general idea is not -- I gratefully acknowledge Peter Baum's 1998 article "Date Algorithms".

I use the Ocelot GUI (ocelotgui) when I write routines for MySQL/MariaDB. Since it recognizes all their syntax quirks it can give me hints when I'm typing something wrong, and saves me from the hassles of "delimiter". And it has a debugger. Version 1.0.8 was released yesterday for download via github.

I start with a standard 2-clause BSD license and then show the CREATE statements for each routine. To install: just cut-and-paste what follows this paragraph until the end of this section. If you are not using ocelotgui you will have to say DELIMITER // and put // at the end of each CREATE statement.

/*
Copyright (c) 2019 Ocelot Computer Services Inc.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

*/

/*
ocelot_date_to_julianday(yyyy-mm-dd[ BC], J|G|O) Return number of days
------------------------
If J: will return 0 for '4713-01-01 BC', all calculations use Julian calendar
If G: will return 0 for '4714-11-24 BC', all calculations use Gregorian calendar
If O: will return 0 for '4712-01-01 BC', switch between calendars after 1582-10-04
*/
CREATE FUNCTION ocelot_date_to_julianday(in_date VARCHAR(25), options CHAR(1)) RETURNS DECIMAL(8)
LANGUAGE SQL DETERMINISTIC CONTAINS SQL
BEGIN
DECLARE year, month, day, century, leap INT;
DECLARE jd DOUBLE PRECISION;
DECLARE bc_as_char CHAR(2);
SET year = CAST(SUBSTRING(in_date FROM 1 FOR 4) AS DECIMAL(8));
SET month = CAST(SUBSTRING(in_date FROM 6 FOR 2) AS DECIMAL(8));
SET day = CAST(SUBSTRING(in_date FROM 9 FOR 2) AS DECIMAL(8));
SET bc_as_char = SUBSTRING(in_date FROM CHAR_LENGTH(in_date) - 1 FOR 2);
IF bc_as_char = 'BC' THEN
IF options = 'O' THEN SET year = 0 - year;
ELSE SET year = (0 - year) + 1; END IF;
END IF;
IF month <= 2 THEN SET year = year - 1; SET month = month + 12; END IF; IF options = 'G' OR (options = 'O' AND in_date >= '1582-10-05' AND bc_as_char <> 'BC') THEN
SET century = FLOOR(year / 100.0);
SET leap = 2 - century + FLOOR(century / 4.0);
ELSE
SET leap = 0;
END IF;
SET jd = FLOOR(365.25 * (year + 4716)) + FLOOR(30.6001 * (month + 1)) + day + leap - 1524;
RETURN CAST(jd AS DECIMAL(8));
END;

/*
ocelot_date_validate (yyyy-mm-dd[ BC] date, J|G|O) Return 'OK' or 'Error ...'
--------------------
Possible errors:
Format of first parameter is not 'yyyy-mm-dd' or 'yyyy-mm-dd BC'.
Second parameter is not 'J' or 'G' or 'O'.
Minimum date = 4713-01-01 BC if J, 4712-01-01 BC if O, 4714-11-14 BC if G.
Maximum date = 9999-12-31.
If 'O': 0001-mm-dd BC, or between 1582-10-05 and 1582-10-14.
nnnn-02-29 if nnnn is not a leap year.
Month not between 1 and 12.
Day not between 1 and maximum for month.
Otherwise return 'OK'.
*/
CREATE FUNCTION ocelot_date_validate(in_date VARCHAR(25), options CHAR(1)) RETURNS VARCHAR(50)
LANGUAGE SQL DETERMINISTIC CONTAINS SQL
BEGIN
DECLARE year, month, day, leap_days DECIMAL(8);
DECLARE bc_or_ad VARCHAR(3) DEFAULT '';
IF options IS NULL
OR (options <> 'J' AND options <> 'G' AND options <> 'O') THEN
RETURN 'Error, Options must be J or G or O';
END IF;
IF in_date IS NULL
OR (CHAR_LENGTH(in_date) <> 10 AND CHAR_LENGTH(in_date) <> 13)
OR SUBSTRING(in_date FROM 1 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 2 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 3 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 4 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 5 FOR 1) <> '-'
OR SUBSTRING(in_date FROM 6 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 7 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 8 FOR 1) <> '-'
OR SUBSTRING(in_date FROM 9 FOR 1) NOT BETWEEN '0' AND '9'
OR SUBSTRING(in_date FROM 10 FOR 1) NOT BETWEEN '0' AND '9' THEN
RETURN 'Error, Date format is not nnnn-nn-nn';
END IF;
IF CHAR_LENGTH(in_date) = 13 THEN
SET bc_or_ad = SUBSTRING(in_date FROM 11 FOR 3);
IF bc_or_ad <> ' BC' THEN
RETURN 'Error, only space + BC is allowed after yyyy-mm-dd';
END IF;
END IF;
SET year = CAST(SUBSTRING(in_date FROM 1 FOR 4) AS DECIMAL(8));
SET month = CAST(SUBSTRING(in_date FROM 6 FOR 2) AS DECIMAL(8));
SET day = CAST(SUBSTRING(in_date FROM 9 FOR 2) AS DECIMAL(8));
IF year = 0 THEN
RETURN 'Error, year 0';
END IF;
IF bc_or_ad = ' BC' THEN
IF options = 'J' AND year > 4713 THEN
RETURN 'Error, minimum date = 4713-01-01 BC';
END IF;
IF options = 'O' AND year > 4712 THEN
RETURN 'Error, minimum date = 4712-01-01 BC';
END IF;
IF OPTIONS = 'G' THEN
IF year > 4714
OR (year = 4714 AND month < 11) OR (Year = 4714 AND month = 11 AND day < 24) THEN RETURN 'Error, minimum date = 4714-11-24 BC'; END IF; END IF; END IF; IF month = 0 OR month > 12 THEN RETURN 'Error, month not between 1 and 12'; END IF;
SET leap_days = 0;
IF month = 2 AND day = 29 THEN
IF bc_or_ad = ' BC' AND options <> 'O' THEN SET year = year - 1; END IF;
IF year % 4 = 0 THEN
IF options = 'J' OR (options = 'O' AND (bc_or_ad = ' BC' OR SUBSTRING(in_date FROM 1 FOR 10) < '1582-10-04')) THEN SET leap_days = 1; ELSE IF year % 100 <> 0 OR year % 400 = 0 THEN
SET leap_days = 1;
END IF;
END IF;
END IF;
IF leap_days = 0 THEN RETURN 'Error, February 29, not a leap year'; END IF;
END IF;
IF month = 1 AND day > 31
OR month = 2 AND day - leap_days > 28
OR month = 3 AND day > 31
OR month = 4 AND day > 30
OR month = 5 AND day > 31
OR month = 6 AND day > 30
OR month = 7 AND day > 31
OR month = 8 AND day > 31
OR month = 9 AND day > 30
OR month = 10 AND day > 31
OR month = 11 AND day > 30
OR month = 12 AND day > 31 THEN
RETURN 'Error, day > maximum day in mnth';
END IF;
IF options = 'O'
AND bc_or_ad <> ' BC'
AND SUBSTRING(in_date FROM 1 FOR 10) BETWEEN '1582-10-05' AND '1582-10-14' THEN
RETURN 'Error, Date during Julian-to-Gregorian cutover';
END IF;
RETURN 'OK';
END;

/*
ocelot_date_datediff(date, date, J|G|O) Return number of days between two dates
--------------------
Results for positive Gregorian will be the same as MySQL/MariaDB datediff().
This is an extension of datediff() which works with BC Gregorian and other calendars.
Mostly it's just to show how easily a routine can be written if there is a
Julian-day function.
*/
CREATE FUNCTION ocelot_date_datediff(date_1 VARCHAR(25), date_2 VARCHAR(25), options CHAR(1)) RETURNS INT
LANGUAGE SQL DETERMINISTIC CONTAINS SQL
RETURN ocelot_date_to_julianday(date_1, options) - ocelot_date_to_julianday(date_2, options);

/*
ocelot_date_test(J|G|O) Test that all legal dates have the correct Julian day
----------------
You only need to run this once. The Julian day routine looks bizarre so this
test is here to give assurance that the ocelot_date_to_julianday function is okay.
Start with a counter integer = 0 and a yyyy-mm-dd BC date = the minimum for the calendar.
For each iteration of the loop, increment the counter and increment the date,
call ocelot_date_to_julianday and check that it returns a value equal to the counter.
Stop when date is 9999-12-31.
For Oracle emulation we do not check dates which are invalid due to cutover or bugs.
Bonus test: positive Gregorian dates must match MySQL|MariaDB datediff results.
Bonus test: check validity of each incremented date.
*/
CREATE FUNCTION ocelot_date_test(options CHAR(1)) RETURNS CHAR(50)
LANGUAGE SQL DETERMINISTIC CONTAINS SQL
BEGIN
DECLARE tmp VARCHAR(25);
DECLARE tmp_validity VARCHAR(50);
DECLARE year_as_char, month_as_char, day_as_char VARCHAR(25);
DECLARE year_as_int, month_as_int, day_as_int DECIMAL(8);
DECLARE ju, ju2 INT;
DECLARE bc_as_char VARCHAR(3) DEFAULT '';
DECLARE is_leap INT DEFAULT 1;
IF options = 'J' THEN
SET ju = 0; SET tmp = '4713-01-01 BC'; SET bc_as_char = ' BC'; SET is_leap = 1;
END IF;
IF options = 'G' THEN
SET ju = 0; SET tmp = '4714-11-24 BC'; SET bc_as_char = ' BC'; SET is_leap = 0;
END IF;
IF options = 'O' THEN
SET ju = 0; SET tmp = '4712-01-01 BC'; SET bc_as_char = ' BC'; SET is_leap = 1;
END IF;
WHILE tmp <> '10000-01-01' DO
IF options <> 'O'
OR SUBSTRING(tmp FROM 1 FOR 4) <> '0000'
OR bc_as_char <> ' BC' THEN
SET tmp_validity = ocelot_date_validate(tmp, options);
IF tmp_validity <> 'OK' THEN RETURN tmp_validity; END IF;
END IF;
SET ju2 = ocelot_date_to_julianday(tmp, options);
IF ju2 <> ju OR ju2 IS NULL THEN RETURN CONCAT('Fail ', tmp); END IF;

IF options = 'G' and bc_as_char <> ' BC' THEN
IF ju2 - 1721426 <> DATEDIFF(tmp,'0001-01-01') THEN
RETURN CONCAT('Difference from datediff() ', tmp);
END IF;
END IF;
SET year_as_char = SUBSTRING(tmp FROM 1 FOR 4);
SET month_as_char = SUBSTRING(tmp FROM 6 FOR 2);
SET day_as_char = SUBSTRING(tmp FROM 9 FOR 2);
SET year_as_int = CAST(year_as_char AS DECIMAL(8));
SET month_as_int = CAST(month_as_char AS DECIMAL(8));
SET day_as_int = CAST(day_as_char AS DECIMAL(8));
/* Increase day */
SET day_as_int = day_as_int + 1;
IF options = 'O' AND year_as_int = 1582 AND month_as_int = 10 AND day_as_int = 5 AND bc_as_char <> ' BC' THEN
SET day_as_int = day_as_int + 10;
END IF;
IF month_as_int = 1 AND day_as_int > 31
OR month_as_int = 2 AND day_as_int - is_leap > 28
OR month_as_int = 3 AND day_as_int > 31
OR month_as_int = 4 AND day_as_int > 30
OR month_as_int = 5 AND day_as_int > 31
OR month_as_int = 6 AND day_as_int > 30
OR month_as_int = 7 AND day_as_int > 31
OR month_as_int = 8 AND day_as_int > 31
OR month_as_int = 9 AND day_as_int > 30
OR month_as_int = 10 AND day_as_int > 31
OR month_as_int = 11 AND day_as_int > 30
OR month_as_int = 12 AND day_as_int > 31 THEN
/* Increase month */
SET day_as_int = 1;
SET month_as_int = month_as_int + 1;
IF month_as_int > 12 THEN
/* Increase year */
SET month_as_int = 1;
IF bc_as_char = ' BC' THEN SET year_as_int = year_as_int - 1;
ELSE SET year_as_int = year_as_int + 1; END IF;
IF (year_as_int = 0 AND (options = 'J' OR options = 'G'))
OR (year_as_int =-1 AND options = 'O') THEN
SET year_as_int = 1;
SET bc_as_char = '';
SET is_leap = 0;
END IF;
/* Recalculate is_leap */
BEGIN
DECLARE divisible_year_as_int INT;
SET divisible_year_as_int = year_as_int;
IF bc_as_char <> ' BC' OR options = 'O' THEN
SET divisible_year_as_int = year_as_int;
ELSE
SET divisible_year_as_int = year_as_int - 1;
END IF;
SET is_leap = 0;
IF divisible_year_as_int % 4 = 0 THEN
SET is_leap = 1;
IF options = 'G'
OR (options = 'O' AND bc_as_char <> ' BC' AND year_as_int > 1582) THEN
IF divisible_year_as_int % 100 = 0
AND divisible_year_as_int % 400 <> 0 THEN
SET is_leap = 0;
END IF;
END IF;
END IF;
END;
END IF;
END IF;
SET day_as_char = CAST(day_as_int AS CHAR);
IF LENGTH(day_as_char) = 1 THEN SET day_as_char = CONCAT('0', day_as_char); END IF;
SET month_as_char = CAST(month_as_int AS CHAR);
IF LENGTH(month_as_char) = 1 THEN SET month_as_char = CONCAT('0', month_as_char); END IF;
SET year_as_char = CAST(year_as_int AS CHAR);
WHILE LENGTH(year_as_char) < 4 DO SET year_as_char = CONCAT('0', year_as_char); END WHILE; SET tmp = CONCAT(year_as_char, '-', month_as_char, '-', day_as_char, bc_as_char); SET ju = ju + 1; END WHILE; RETURN CONCAT('OK ', tmp); END;

, January 9, 2019. No Comments on Date arithmetic with Julian days, BC dates, and Oracle rules. Category: MySQL / MariaDB, Standard SQL.

About pgulutzan

Co-author of four computer books. Software Architect at MySQL/Sun/Oracle from 2003-2011, and at HP for a little while after that. Currently with Ocelot Computer Services Inc. in Edmonton Canada.

Leave a Reply

Your email address will not be published.