Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for nanosecond precision when parsing rfc3339 strings #752

Merged
merged 5 commits into from Jul 31, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what our plans are for Java 8, but if we could use java.time I think we could avoid introducing this class and associated public API. @chingor13 has any final decision been made on that?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This library is very heavily used on android, so even if we move google-cloud-java to Java 8, we'd likely avoid using new features for a long time. java.time was introduced in Android at API level 26 (Oreo).

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
Expand Up @@ -14,7 +14,11 @@

package com.google.api.client.util;

import com.google.api.client.util.DateTime.SecondsAndNanos;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TimeZone;
import junit.framework.TestCase;

Expand Down Expand Up @@ -142,6 +146,89 @@ 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() {
Map<String, SecondsAndNanos> map = new HashMap<>();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it seems odd to collect these into a map to just iterate over them. We could implement a helper test assertion like assertParsedRdc3339(String, SecondsAndNanos)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I've changed the test case accordingly.

map.put("2018-03-01T10:11:12.999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999000000));
map.put("2018-10-28T02:00:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540684800L, 0));
map.put("2018-10-28T03:00:00+01:00", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0));
map.put("2018-01-01T00:00:00.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1514764800L, 1));
map.put("2018-10-28T02:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0));
map.put(
"2018-12-31T23:59:59.999999999Z",
SecondsAndNanos.ofSecondsAndNanos(1546300799L, 999999999));
map.put("2018-03-01T10:11:12.9999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999900000));
map.put("2018-03-01T10:11:12.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 1));
map.put(
"2018-03-01T10:11:12.100000000Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000));
map.put(
"2018-03-01T10:11:12.100000001Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000001));
map.put("2018-03-01T10:11:12-10:00", SecondsAndNanos.ofSecondsAndNanos(1519935072L, 0));
map.put(
"2018-03-01T10:11:12.999999999Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999999999));
map.put("2018-03-01T10:11:12-12:00", SecondsAndNanos.ofSecondsAndNanos(1519942272L, 0));
map.put("2018-10-28T03:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540695600L, 0));
map.put("2018-10-28T02:30:00Z", SecondsAndNanos.ofSecondsAndNanos(1540693800L, 0));
map.put("2018-03-01T10:11:12.123Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123000000));
map.put("2018-10-28T02:30:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540686600L, 0));
map.put(
"2018-03-01T10:11:12.123456789Z",
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123456789));
map.put("2018-03-01T10:11:12.1000Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000));

for (Entry<String, SecondsAndNanos> entry : map.entrySet()) {
SecondsAndNanos gTimestamp = DateTime.parseRfc3339ToSecondsAndNanos(entry.getKey());
assertEquals(
"Seconds for " + entry + " do not match",
gTimestamp.getSeconds(),
entry.getValue().getSeconds());
assertEquals(
"Nanos for " + entry + " do not match",
gTimestamp.getNanos(),
entry.getValue().getNanos());
}
}

public void testParseAndFormatRfc3339() {
Expand Down