2020import java .util .Date ;
2121import java .util .GregorianCalendar ;
2222import java .util .TimeZone ;
23+ import java .util .regex .Matcher ;
24+ import java .util .regex .Pattern ;
2325
2426/**
2527 * Immutable representation of a date with an optional time and an optional time zone based on <a
@@ -38,6 +40,12 @@ public final class DateTime implements Serializable {
3840
3941 private static final TimeZone GMT = TimeZone .getTimeZone ("GMT" );
4042
43+ /** Regular expression for parsing RFC3339 date/times. */
44+ private static final Pattern RFC3339_PATTERN = Pattern .compile (
45+ "^(\\ d{4})-(\\ d{2})-(\\ d{2})" // yyyy-MM-dd
46+ + "([Tt](\\ d{2}):(\\ d{2}):(\\ d{2})(\\ .\\ d+)?)?" // 'T'HH:mm:ss.milliseconds
47+ + "([Zz]|([+-])(\\ d{2}):(\\ d{2}))?" ); // 'Z' or time zone shift HH:mm following '+' or '-'
48+
4149 /**
4250 * Date/time value expressed as the number of ms since the Unix epoch.
4351 *
@@ -123,12 +131,22 @@ public DateTime(boolean dateOnly, long value, Integer tzShift) {
123131 * Instantiates {@link DateTime} from an <a href='http://tools.ietf.org/html/rfc3339'>RFC 3339</a>
124132 * date/time value.
125133 *
134+ * <p>
135+ * Upgrade warning: in prior version 1.17, this method required milliseconds to be exactly 3
136+ * digits (if included), and did not throw an exception for all types of invalid input values, but
137+ * starting in version 1.18, the parsing done by this method has become more strict to enforce
138+ * that only valid RFC3339 strings are entered, and if not, it throws a
139+ * {@link NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of
140+ * milliseconds digits is now allowed.
141+ * </p>
142+ *
126143 * @param value an <a href='http://tools.ietf.org/html/rfc3339'>RFC 3339</a> date/time value.
127144 * @since 1.11
128145 */
129146 public DateTime (String value ) {
130- // TODO(rmistry): Move the implementation of parseRfc3339 into this constructor. Implementation
131- // of parseRfc3339 can then do "return new DateTime(str);".
147+ // Note, the following refactoring is being considered: Move the implementation of parseRfc3339
148+ // into this constructor. Implementation of parseRfc3339 can then do
149+ // "return new DateTime(str);".
132150 DateTime dateTime = parseRfc3339 (value );
133151 this .dateOnly = dateTime .dateOnly ;
134152 this .value = dateTime .value ;
@@ -248,58 +266,75 @@ public int hashCode() {
248266 * Parses an RFC 3339 date/time value.
249267 *
250268 * <p>
269+ * Upgrade warning: in prior version 1.17, this method required milliseconds to be exactly 3
270+ * digits (if included), and did not throw an exception for all types of invalid input values, but
271+ * starting in version 1.18, the parsing done by this method has become more strict to enforce
272+ * that only valid RFC3339 strings are entered, and if not, it throws a
273+ * {@link NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of
274+ * milliseconds digits is now allowed.
275+ * </p>
276+ *
277+ * <p>
251278 * For the date-only case, the time zone is ignored and the hourOfDay, minute, second, and
252279 * millisecond parameters are set to zero.
253280 * </p>
281+ *
282+ * @param str Date/time string in RFC3339 format
283+ * @throws NumberFormatException if {@code str} doesn't match the RFC3339 standard format; an
284+ * exception is thrown if {@code str} doesn't match {@code RFC3339_REGEX} or if it
285+ * contains a time zone shift but no time.
254286 */
255287 public static DateTime parseRfc3339 (String str ) throws NumberFormatException {
256- try {
257- int year = Integer .parseInt (str .substring (0 , 4 ));
258- int month = Integer .parseInt (str .substring (5 , 7 )) - 1 ;
259- int day = Integer .parseInt (str .substring (8 , 10 ));
260- int tzIndex ;
261- int length = str .length ();
262- boolean dateOnly = length <= 10 || Character .toUpperCase (str .charAt (10 )) != 'T' ;
263- int hourOfDay = 0 ;
264- int minute = 0 ;
265- int second = 0 ;
266- int milliseconds = 0 ;
267- Integer tzShiftInteger = null ;
268- if (dateOnly ) {
269- tzIndex = Integer .MAX_VALUE ;
270- } else {
271- hourOfDay = Integer .parseInt (str .substring (11 , 13 ));
272- minute = Integer .parseInt (str .substring (14 , 16 ));
273- second = Integer .parseInt (str .substring (17 , 19 ));
274- if (str .charAt (19 ) == '.' ) {
275- milliseconds = Integer .parseInt (str .substring (20 , 23 ));
276- tzIndex = 23 ;
277- } else {
278- tzIndex = 19 ;
279- }
288+ Matcher matcher = RFC3339_PATTERN .matcher (str );
289+ if (!matcher .matches ()) {
290+ throw new NumberFormatException ("Invalid date/time format: " + str );
291+ }
292+
293+ int year = Integer .parseInt (matcher .group (1 )); // yyyy
294+ int month = Integer .parseInt (matcher .group (2 )) - 1 ; // MM
295+ int day = Integer .parseInt (matcher .group (3 )); // dd
296+ boolean isTimeGiven = matcher .group (4 ) != null ; // 'T'HH:mm:ss.milliseconds
297+ String tzShiftRegexGroup = matcher .group (9 ); // 'Z', or time zone shift HH:mm following '+'/'-'
298+ boolean isTzShiftGiven = tzShiftRegexGroup != null ;
299+ int hourOfDay = 0 ;
300+ int minute = 0 ;
301+ int second = 0 ;
302+ int milliseconds = 0 ;
303+ Integer tzShiftInteger = null ;
304+
305+ if (isTzShiftGiven && !isTimeGiven ) {
306+ throw new NumberFormatException ("Invalid date/time format, cannot specify time zone shift" +
307+ " without specifying time: " + str );
308+ }
309+
310+ if (isTimeGiven ) {
311+ hourOfDay = Integer .parseInt (matcher .group (5 )); // HH
312+ minute = Integer .parseInt (matcher .group (6 )); // mm
313+ second = Integer .parseInt (matcher .group (7 )); // ss
314+ if (matcher .group (8 ) != null ) { // contains .milliseconds?
315+ milliseconds = Integer .parseInt (matcher .group (8 ).substring (1 )); // milliseconds
280316 }
281- Calendar dateTime = new GregorianCalendar ( GMT );
282- dateTime . set ( year , month , day , hourOfDay , minute , second );
283- dateTime .set (Calendar . MILLISECOND , milliseconds );
284- long value = dateTime .getTimeInMillis ( );
285- if ( length > tzIndex ) {
286- int tzShift ;
287- if (Character . toUpperCase ( str . charAt ( tzIndex )) == 'Z' ) {
288- tzShift = 0 ;
289- } else {
290- tzShift = Integer . parseInt ( str . substring ( tzIndex + 1 , tzIndex + 3 )) * 60
291- + Integer . parseInt ( str . substring ( tzIndex + 4 , tzIndex + 6 ));
292- if ( str . charAt ( tzIndex ) == '-' ) {
293- tzShift = - tzShift ;
294- }
295- value -= tzShift * 60000L ;
317+ }
318+ Calendar dateTime = new GregorianCalendar ( GMT );
319+ dateTime .set (year , month , day , hourOfDay , minute , second );
320+ dateTime .set ( Calendar . MILLISECOND , milliseconds );
321+ long value = dateTime . getTimeInMillis ();
322+
323+ if (isTimeGiven && isTzShiftGiven ) {
324+ int tzShift ;
325+ if ( Character . toUpperCase ( tzShiftRegexGroup . charAt ( 0 )) == 'Z' ) {
326+ tzShift = 0 ;
327+ } else {
328+ tzShift = Integer . parseInt ( matcher . group ( 11 )) * 60 // time zone shift HH
329+ + Integer . parseInt ( matcher . group ( 12 )); // time zone shift mm
330+ if ( matcher . group ( 10 ). charAt ( 0 ) == '-' ) { // time zone shift + or -
331+ tzShift = - tzShift ;
296332 }
297- tzShiftInteger = tzShift ;
333+ value - = tzShift * 60000L ; // e.g. if 1 hour ahead of UTC, subtract an hour to get UTC time
298334 }
299- return new DateTime (dateOnly , value , tzShiftInteger );
300- } catch (StringIndexOutOfBoundsException e ) {
301- throw new NumberFormatException ("Invalid date/time format: " + str );
335+ tzShiftInteger = tzShift ;
302336 }
337+ return new DateTime (!isTimeGiven , value , tzShiftInteger );
303338 }
304339
305340 /** Appends a zero-padded number to a string builder. */
0 commit comments