Skip to content

Commit

Permalink
Add support for nanosecond precision when parsing rfc3339 strings (#752)
Browse files Browse the repository at this point in the history
* support nanosecond precision when parsing rfc3339 strings

* removed hamcrest matchers

* fix javadoc + change exception type

* assert parse directly instead of using a map

* put expected and actual in correct order
  • Loading branch information
olavloite authored and kolea2 committed Jul 31, 2019
1 parent 4916aa3 commit 1d496dc
Show file tree
Hide file tree
Showing 2 changed files with 204 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@

package com.google.api.client.util;

import com.google.common.base.Strings;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Objects;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand All @@ -39,12 +42,12 @@ public final class DateTime implements Serializable {
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");

/** Regular expression for parsing RFC3339 date/times. */
private static final Pattern RFC3339_PATTERN =
Pattern.compile(
"^(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd
+ "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?)?" // 'T'HH:mm:ss.milliseconds
+ "([Zz]|([+-])(\\d{2}):(\\d{2}))?"); // 'Z' or time zone shift HH:mm following '+' or
// '-'
private static final String RFC3339_REGEX =
"(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd
+ "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d{1,9})?)?" // 'T'HH:mm:ss.nanoseconds
+ "([Zz]|([+-])(\\d{2}):(\\d{2}))?"; // 'Z' or time zone shift HH:mm following '+' or '-'

private static final Pattern RFC3339_PATTERN = Pattern.compile(RFC3339_REGEX);

/**
* Date/time value expressed as the number of ms since the Unix epoch.
Expand Down Expand Up @@ -260,6 +263,8 @@ public int hashCode() {
* NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of
* milliseconds digits is now allowed.
*
* <p>Any time information beyond millisecond precision is truncated.
*
* <p>For the date-only case, the time zone is ignored and the hourOfDay, minute, second, and
* millisecond parameters are set to zero.
*
Expand All @@ -269,6 +274,98 @@ public int hashCode() {
* time zone shift but no time.
*/
public static DateTime parseRfc3339(String str) throws NumberFormatException {
return parseRfc3339WithNanoSeconds(str).toDateTime();
}

/**
* Parses an RFC3339 timestamp to a pair of seconds and nanoseconds since Unix Epoch.
*
* @param str Date/time string in RFC3339 format
* @throws IllegalArgumentException if {@code str} doesn't match the RFC3339 standard format; an
* exception is thrown if {@code str} doesn't match {@code RFC3339_REGEX} or if it contains a
* time zone shift but no time.
*/
public static SecondsAndNanos parseRfc3339ToSecondsAndNanos(String str)
throws IllegalArgumentException {
return parseRfc3339WithNanoSeconds(str).toSecondsAndNanos();
}

/** A timestamp represented as the number of seconds and nanoseconds since Epoch. */
public static final class SecondsAndNanos implements Serializable {
private final long seconds;
private final int nanos;

public static SecondsAndNanos ofSecondsAndNanos(long seconds, int nanos) {
return new SecondsAndNanos(seconds, nanos);
}

private SecondsAndNanos(long seconds, int nanos) {
this.seconds = seconds;
this.nanos = nanos;
}

public long getSeconds() {
return seconds;
}

public int getNanos() {
return nanos;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SecondsAndNanos that = (SecondsAndNanos) o;
return seconds == that.seconds && nanos == that.nanos;
}

@Override
public int hashCode() {
return Objects.hash(seconds, nanos);
}

@Override
public String toString() {
return String.format("Seconds: %d, Nanos: %d", seconds, nanos);
}
}

/** Result of parsing a Rfc3339 string. */
private static class Rfc3339ParseResult implements Serializable {
private final long seconds;
private final int nanos;
private final boolean timeGiven;
private final Integer tzShift;

private Rfc3339ParseResult(long seconds, int nanos, boolean timeGiven, Integer tzShift) {
this.seconds = seconds;
this.nanos = nanos;
this.timeGiven = timeGiven;
this.tzShift = tzShift;
}

/**
* Convert this {@link Rfc3339ParseResult} to a {@link DateTime} with millisecond precision. Any
* fraction of a millisecond will be truncated.
*/
private DateTime toDateTime() {
long seconds = TimeUnit.SECONDS.toMillis(this.seconds);
long nanos = TimeUnit.NANOSECONDS.toMillis(this.nanos);
return new DateTime(!timeGiven, seconds + nanos, tzShift);
}

private SecondsAndNanos toSecondsAndNanos() {
return new SecondsAndNanos(seconds, nanos);
}
}

private static Rfc3339ParseResult parseRfc3339WithNanoSeconds(String str)
throws NumberFormatException {
Matcher matcher = RFC3339_PATTERN.matcher(str);
if (!matcher.matches()) {
throw new NumberFormatException("Invalid date/time format: " + str);
Expand All @@ -283,7 +380,7 @@ public static DateTime parseRfc3339(String str) throws NumberFormatException {
int hourOfDay = 0;
int minute = 0;
int second = 0;
int milliseconds = 0;
int nanoseconds = 0;
Integer tzShiftInteger = null;

if (isTzShiftGiven && !isTimeGiven) {
Expand All @@ -297,34 +394,32 @@ public static DateTime parseRfc3339(String str) throws NumberFormatException {
hourOfDay = Integer.parseInt(matcher.group(5)); // HH
minute = Integer.parseInt(matcher.group(6)); // mm
second = Integer.parseInt(matcher.group(7)); // ss
if (matcher.group(8) != null) { // contains .milliseconds?
milliseconds = Integer.parseInt(matcher.group(8).substring(1)); // milliseconds
// The number of digits after the dot may not be 3. Need to renormalize.
int fractionDigits = matcher.group(8).substring(1).length() - 3;
milliseconds = (int) ((float) milliseconds / Math.pow(10, fractionDigits));
if (matcher.group(8) != null) { // contains .nanoseconds?
String fraction = Strings.padEnd(matcher.group(8).substring(1), 9, '0');
nanoseconds = Integer.parseInt(fraction);
}
}
Calendar dateTime = new GregorianCalendar(GMT);
dateTime.set(year, month, day, hourOfDay, minute, second);
dateTime.set(Calendar.MILLISECOND, milliseconds);
long value = dateTime.getTimeInMillis();

if (isTimeGiven && isTzShiftGiven) {
int tzShift;
if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) == 'Z') {
tzShift = 0;
} else {
tzShift =
if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) != 'Z') {
int tzShift =
Integer.parseInt(matcher.group(11)) * 60 // time zone shift HH
+ Integer.parseInt(matcher.group(12)); // time zone shift mm
if (matcher.group(10).charAt(0) == '-') { // time zone shift + or -
tzShift = -tzShift;
}
value -= tzShift * 60000L; // e.g. if 1 hour ahead of UTC, subtract an hour to get UTC time
tzShiftInteger = tzShift;
} else {
tzShiftInteger = 0;
}
tzShiftInteger = tzShift;
}
return new DateTime(!isTimeGiven, value, tzShiftInteger);
// convert to seconds and nanoseconds
long secondsSinceEpoch = value / 1000L;
return new Rfc3339ParseResult(secondsSinceEpoch, nanoseconds, isTimeGiven, tzShiftInteger);
}

/** Appends a zero-padded number to a string builder. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

package com.google.api.client.util;

import com.google.api.client.util.DateTime.SecondsAndNanos;
import java.util.Date;
import java.util.TimeZone;
import junit.framework.TestCase;
Expand Down Expand Up @@ -142,6 +143,94 @@ public void testParseRfc3339() {
assertEquals(
DateTime.parseRfc3339("2007-06-01t18:50:00-04:00").getValue(),
DateTime.parseRfc3339("2007-06-01t22:50:00Z").getValue()); // from Section 4.2 Local Offsets

// Test truncating beyond millisecond precision.
assertEquals(
DateTime.parseRfc3339(
"2018-12-31T23:59:59.999999999Z"), // This value would be rounded up prior to version
// 1.30.2
DateTime.parseRfc3339("2018-12-31T23:59:59.999Z"));
assertEquals(
DateTime.parseRfc3339(
"2018-12-31T23:59:59.9999Z"), // This value would be truncated prior to version 1.30.2
DateTime.parseRfc3339("2018-12-31T23:59:59.999Z"));
}

/**
* The following test values have been generated and verified using the {@link DateTimeFormatter}
* in Java 8.
*
* <pre>
* Timestamp | Seconds | Nanos
* 2018-03-01T10:11:12.999Z | 1519899072 | 999000000
* 2018-10-28T02:00:00+02:00 | 1540684800 | 0
* 2018-10-28T03:00:00+01:00 | 1540692000 | 0
* 2018-01-01T00:00:00.000000001Z | 1514764800 | 1
* 2018-10-28T02:00:00Z | 1540692000 | 0
* 2018-12-31T23:59:59.999999999Z | 1546300799 | 999999999
* 2018-03-01T10:11:12.9999Z | 1519899072 | 999900000
* 2018-03-01T10:11:12.000000001Z | 1519899072 | 1
* 2018-03-01T10:11:12.100000000Z | 1519899072 | 100000000
* 2018-03-01T10:11:12.100000001Z | 1519899072 | 100000001
* 2018-03-01T10:11:12-10:00 | 1519935072 | 0
* 2018-03-01T10:11:12.999999999Z | 1519899072 | 999999999
* 2018-03-01T10:11:12-12:00 | 1519942272 | 0
* 2018-10-28T03:00:00Z | 1540695600 | 0
* 2018-10-28T02:30:00Z | 1540693800 | 0
* 2018-03-01T10:11:12.123Z | 1519899072 | 123000000
* 2018-10-28T02:30:00+02:00 | 1540686600 | 0
* 2018-03-01T10:11:12.123456789Z | 1519899072 | 123456789
* 2018-03-01T10:11:12.1000Z | 1519899072 | 100000000
* </pre>
*/
public void testParseRfc3339ToSecondsAndNanos() {
assertParsedRfc3339(
"2018-03-01T10:11:12.999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999000000));
assertParsedRfc3339(
"2018-10-28T02:00:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540684800L, 0));
assertParsedRfc3339(
"2018-10-28T03:00:00+01:00", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0));
assertParsedRfc3339(
"2018-01-01T00:00:00.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1514764800L, 1));
assertParsedRfc3339("2018-10-28T02:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0));
assertParsedRfc3339(
"2018-12-31T23:59:59.999999999Z",
SecondsAndNanos.ofSecondsAndNanos(1546300799L, 999999999));
assertParsedRfc3339(
"2018-03-01T10:11:12.9999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999900000));
assertParsedRfc3339(
"2018-03-01T10:11:12.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 1));
assertParsedRfc3339(
"2018-03-01T10:11:12.100000000Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000));
assertParsedRfc3339(
"2018-03-01T10:11:12.100000001Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000001));
assertParsedRfc3339(
"2018-03-01T10:11:12-10:00", SecondsAndNanos.ofSecondsAndNanos(1519935072L, 0));
assertParsedRfc3339(
"2018-03-01T10:11:12.999999999Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999999999));
assertParsedRfc3339(
"2018-03-01T10:11:12-12:00", SecondsAndNanos.ofSecondsAndNanos(1519942272L, 0));
assertParsedRfc3339("2018-10-28T03:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540695600L, 0));
assertParsedRfc3339("2018-10-28T02:30:00Z", SecondsAndNanos.ofSecondsAndNanos(1540693800L, 0));
assertParsedRfc3339(
"2018-03-01T10:11:12.123Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123000000));
assertParsedRfc3339(
"2018-10-28T02:30:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540686600L, 0));
assertParsedRfc3339(
"2018-03-01T10:11:12.123456789Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123456789));
assertParsedRfc3339(
"2018-03-01T10:11:12.1000Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000));
}

private void assertParsedRfc3339(String input, SecondsAndNanos expected) {
SecondsAndNanos actual = DateTime.parseRfc3339ToSecondsAndNanos(input);
assertEquals(
"Seconds for " + input + " do not match", expected.getSeconds(), actual.getSeconds());
assertEquals("Nanos for " + input + " do not match", expected.getNanos(), actual.getNanos());
}

public void testParseAndFormatRfc3339() {
Expand Down

0 comments on commit 1d496dc

Please sign in to comment.