Repository: googleapis/google-http-java-client
Pull request #752 (2019-07-25 11:21:49)

Add support for nanosecond precision when parsing rfc3339 strings
The original parseRfc3339 method of `DateTime` returns a `DateTime` instance that has millisecond precision. The parse method therefore also disregards any time information beyond millisecond precision. For Spanner we would like to add nanosecond precision, and instead of replicating the parse method in the Spanner specific libraries, this change will add nanosecond support to this parse method, while retaining the original behavior of the parseRfc3339 method.

The original parseRfc3339 method allows strings to contain more than millisecond precision. It does however not specify exactly what it does with any additional information, and the behavior is not consistent:
* The input value `2018-12-31T23:59:59.9999Z` would be truncated to `2018-12-31T23:59:59.999Z`
* The input value `2018-12-31T23:59:59.999999999Z` would be rounded up to `2019-01-01T00:00:00Z`

After this change the method will always truncate any time information beyond millisecond precision when returning a `DateTime` instance.

olavloite: > No comment on the design yet, but adding public API normally suggests incrementing the minor version. In this case, I think it would need to bump to 1.4.0 in the pom.xml.

Shouldn't that be 1.31.0?
elharo: You're right. It should be 1.31.0 I get confused when version numbers hit double digits. :-)

Patch:
diff --git a/google-http-client/src/main/java/com/google/api/client/util/DateTime.java b/google-http-client/src/main/java/com/google/api/client/util/DateTime.java
index bd071b32..cd0dcd77 100644
--- a/google-http-client/src/main/java/com/google/api/client/util/DateTime.java
+++ b/google-http-client/src/main/java/com/google/api/client/util/DateTime.java
@@ -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;
 
@@ -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.
@@ -260,6 +263,8 @@ public final class DateTime implements Serializable {
    * 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.
    *
@@ -269,6 +274,98 @@ public final class DateTime implements Serializable {
    *     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);
@@ -283,7 +380,7 @@ public final class DateTime implements Serializable {
     int hourOfDay = 0;
     int minute = 0;
     int second = 0;
-    int milliseconds = 0;
+    int nanoseconds = 0;
     Integer tzShiftInteger = null;
 
     if (isTzShiftGiven && !isTimeGiven) {
@@ -297,34 +394,32 @@ public final class DateTime implements Serializable {
       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. */
diff --git a/pom.xml b/pom.xml
index 149bf70e..1098f327 100644
--- a/pom.xml
+++ b/pom.xml
@@ -360,7 +360,7 @@
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-site-plugin</artifactId>
-          <version>3.7.1</version>
+          <version>3.8.2</version>
         </plugin>
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>


Test patch:
diff --git a/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java b/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java
index 6318e327..c8b9cd51 100644
--- a/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java
+++ b/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java
@@ -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;
@@ -142,6 +143,94 @@ public class DateTimeTest extends TestCase {
     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() {