Did you know that your API doesn’t support BC time?

While doing Randomized Testing I found out that my Jackson formatter works incorrectly sometimes and my tests fail in 50% of cases. Here is a minimal test to reproduce it easier:

import static io.qala.datagen.RandomDate.beforeNow;
import static org.testng.Assert.assertEquals;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;

@Test public void parsesOutSameTimeThatWasFormatted() {
    OffsetDateTime original = beforeNow().offsetDateTime();
    DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXXX");
    String asString = format.format(original);
    OffsetDateTime parsed = OffsetDateTime.parse(asString, format);
    assertEquals(original, parsed);
}

The error looks like this:

 Expected :+49890859-01-13T22:22:36
 Actual   :-49890858-01-13T22:22:36

Any guess what’s wrong and how to fix it? Here is a clue - the issue reproduces only with negative years.

Interesting facts about date formatters and ISO8601 format

Fact #1: ISO8601 doesn’t allow years before 1582-10-15 by default (click). Since the format uses Gregorian Calendar, which was established in 1582, the dates before that don’t make sense for this calendar. But these dates still can be extrapolated using Proleptic Gregorian Calendar. So it’s okay to use the dates before 1582 but both client and server should understand that this is not a real Gregorian date and people were using different calendars back then. In books they usually mention the date and "(Old Style)" or "(New Style)" comment or some calendar name to clear out any possible confusion.

Fact #2: Gregorian/Julian calendars don’t have 0 year (click) - for these calendars 1 BC is followed by 1 AD year. But from math perspective it’s easier to have 0 year, so ISO8601 uses Astronomical Year Numbering which means that year 0 is 1 BC. That means that 2 BC is year -1 and so on. Incidentally the assertion above would fail for dates with year 0 too (not only for negative years).

Fact #3: In the well-known SimpleDateFormat, the only way you could've denoted BC dates is using era (G letter). So you couldn't express BC dates in ISO8601 compatible format using SimpleDateFormat at all. Years denoted as yyyy will not be negative. If we try to format negative (or zero) year they turn into positive years. So 0000 year turns into year 0001. And -0001 year turns into 0002 by the formatter. This is because yyyy is the year of an era - you should know the era to understand which years we’re transferring. Issue JDK-4482478 explains that this behaviour is compatible with ANSI C.

Fact #4: Finally, in Java8 the new Time API adds DateTimeFormatter which keeps the "backward compatibility" and treats yyyy the same, but it introduces a notion of uuuu which finally allows using negative years.

Solutions

There are 2 possible solutions to use BC years:

  • Using BC/AD explicitly (in formatter patterns this is denoted as G). Example: yyyyG-MM-dd'T'HH:mm:ss.SSS. But BC/AD are not part of ISO8601 so this change is not preferable.
  • Using DateTimeFormatter's uuuu instead of yyyy. While yyyy is the year of an era, the uuuu is just a year where + and - designate the era.

So the final solution is to use: uuuu-MM-dd'T'HH:mm:ss.SSSXXXX. Since I see yyyy in Java projects all the time I assume there are a lot of products around that don’t support BC dates. This is probably not a big deal since we don’t normally work with BC dates, but still is a curious fact that I wouldn’t run into without test randomization.

Does your project handle BC dates correctly?