-
Notifications
You must be signed in to change notification settings - Fork 466
Add support for nanosecond precision when parsing rfc3339 strings #752
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
Changes from 1 commit
15e808d
5f37367
b53e75f
e8cf84a
1c058b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,11 @@ 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 will be truncated. Prior to version 1.30.2 | ||
| * this method did not have a well-defined behavior of what would happen with any time information | ||
chingor13 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * beyond millisecond precision. This could cause some values with more than millisecond precision | ||
| * to be rounded up instead of 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 +277,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 NumberFormatException if {@code str} doesn't match the RFC3339 standard format; an | ||
chingor13 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * 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 NumberFormatException { | ||
| return parseRfc3339WithNanoSeconds(str).toSecondsAndNanos(); | ||
| } | ||
|
|
||
| /** A timestamp represented as the number of seconds and nanoseconds since Epoch. */ | ||
| public static final class SecondsAndNanos implements Serializable { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
|
@@ -283,7 +383,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) { | ||
|
|
@@ -297,34 +397,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. */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,15 @@ | |
|
|
||
| package com.google.api.client.util; | ||
|
|
||
| import static org.hamcrest.CoreMatchers.equalTo; | ||
| import static org.hamcrest.CoreMatchers.is; | ||
| import static org.junit.Assert.assertThat; | ||
|
|
||
| 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; | ||
|
|
||
|
|
@@ -142,6 +150,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<>(); | ||
|
||
| 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()); | ||
| assertThat( | ||
| "Seconds for " + entry + " do not match", | ||
| gTimestamp.getSeconds(), | ||
| is(equalTo(entry.getValue().getSeconds()))); | ||
| assertThat( | ||
| "Nanos for " + entry + " do not match", | ||
| gTimestamp.getNanos(), | ||
| is(equalTo(entry.getValue().getNanos()))); | ||
| } | ||
| } | ||
|
|
||
| public void testParseAndFormatRfc3339() { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.