Skip to content

Commit 9bf54ae

Browse files
committed
Support RFC-3339 parsing and formatting in Dates and SimpleDateFormats, re #60
1 parent 9ca983c commit 9bf54ae

File tree

3 files changed

+120
-51
lines changed

3 files changed

+120
-51
lines changed

src/main/java/org/libj/util/Dates.java

+44-3
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,23 @@ public static long dropMilliseconds(final long time) {
434434
* @throws ParseException If a parsing error has occurred.
435435
*/
436436
public static long iso8601ToEpochMilli(final String iso8601) throws ParseException {
437+
return iso8601ToEpochMilli(iso8601, 'T');
438+
}
439+
440+
/**
441+
* Converts a <a href="https://datatracker.ietf.org/doc/html/rfc3339">RFC-3339</a> formatted date-time string to epoch millis.
442+
*
443+
* @param rfc3339 The <a href="https://datatracker.ietf.org/doc/html/rfc3339">RFC-3339</a> formatted date-time string to convert.
444+
* @return The millis representation of the <a href="https://datatracker.ietf.org/doc/html/rfc3339">RFC-3339</a> formatted date-time
445+
* string.
446+
* @throws NullPointerException If {@code rfc3339} is null.
447+
* @throws ParseException If a parsing error has occurred.
448+
*/
449+
public static long rfc3339ToEpochMilli(final String rfc3339) throws ParseException {
450+
return iso8601ToEpochMilli(rfc3339, ' ');
451+
}
452+
453+
private static long iso8601ToEpochMilli(final String iso8601, final char del) throws ParseException {
437454
final int len = iso8601.length();
438455
// The minimum length of a iso8601 dateTime is 14
439456
if (len < 14)
@@ -458,7 +475,7 @@ public static long iso8601ToEpochMilli(final String iso8601) throws ParseExcepti
458475
if (date == -1)
459476
throw new ParseException("Unparseable date: \"" + iso8601 + "\"", i - 2);
460477

461-
if (iso8601.charAt(i) == 'T')
478+
if (iso8601.charAt(i) == del)
462479
++i;
463480

464481
if (len < i + 2)
@@ -571,8 +588,7 @@ else if (ch == '-')
571588
calendar.set(Calendar.HOUR_OF_DAY, hour);
572589
calendar.set(Calendar.MINUTE, minute);
573590
calendar.set(Calendar.SECOND, second);
574-
if (millis > 0)
575-
calendar.set(Calendar.MILLISECOND, millis);
591+
calendar.set(Calendar.MILLISECOND, millis);
576592

577593
if (offset != 0)
578594
calendar.add(Calendar.MINUTE, offset);
@@ -591,6 +607,31 @@ else if (ch == '-')
591607
*/
592608
public static String epochMilliToIso8601(final long epochMilli) {
593609
final String iso8601 = SimpleDateFormats.ISO_8601.get().format(epochMilli);
610+
611+
if ((epochMilli / 1000) * 1000 == epochMilli)
612+
return iso8601.substring(0, iso8601.length() - 4) + "Z";
613+
614+
if ((epochMilli / 100) * 100 == epochMilli)
615+
return iso8601.substring(0, iso8601.length() - 2) + "Z";
616+
617+
if ((epochMilli / 10) * 10 == epochMilli)
618+
return iso8601.substring(0, iso8601.length() - 1) + "Z";
619+
620+
return iso8601 + "Z";
621+
}
622+
623+
/**
624+
* Converts the provided epoch millis to a <a href="https://datatracker.ietf.org/doc/html/rfc3339">RFC-3339</a> formatted date-time
625+
* string representation.
626+
*
627+
* @param epochMilli The epoch millis to convert to <a href="https://datatracker.ietf.org/doc/html/rfc3339">RFC-3339</a> formatted
628+
* date-time string representation.
629+
* @return A <a href="https://datatracker.ietf.org/doc/html/rfc3339">RFC-3339</a> formatted date-time string representation of the
630+
* provided epoch millis.
631+
*/
632+
public static String epochMilliToRfc3339(final long epochMilli) {
633+
final String iso8601 = SimpleDateFormats.RFC_3339.get().format(epochMilli);
634+
594635
if ((epochMilli / 1000) * 1000 == epochMilli)
595636
return iso8601.substring(0, iso8601.length() - 4) + "Z";
596637

src/main/java/org/libj/util/SimpleDateFormats.java

+7-6
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@ public static final class IsoDate extends Date {
129129
private final ThreadLocal<SimpleDateFormat> format;
130130
private final int index;
131131

132-
private IsoDate(final long time, final TimeZone timeZone, final ThreadLocal<SimpleDateFormat> format, final int index) {
133-
super(time);
132+
private IsoDate(final long timeMs, final TimeZone timeZone, final ThreadLocal<SimpleDateFormat> format, final int index) {
133+
super(timeMs);
134134
this.timeZone = timeZone;
135135
this.format = format;
136136
this.index = index;
@@ -174,15 +174,16 @@ public String toString() {
174174
* Returns a new {@link Date} object representing the {@code time} with the provided {@link SimpleDateFormat} that is to be used by
175175
* the {@link Date#toString()} of the returned date.
176176
*
177-
* @param time The milliseconds since January 1, 1970, 00:00:00 GMT.
177+
* @param timeMs The milliseconds since January 1, 1970, 00:00:00 GMT.
178178
* @param format The {@link SimpleDateFormat} that is to be used by the {@link Date#toString()} of the returned date.
179179
* @return A new {@link Date} object representing the {@code time}.
180180
*/
181-
public static Date newDate(final long time, final ThreadLocal<SimpleDateFormat> format) {
182-
return new IsoDate(time, TimeZone.getDefault(), format, 0);
181+
public static Date newDate(final long timeMs, final ThreadLocal<SimpleDateFormat> format) {
182+
return new IsoDate(timeMs, TimeZone.getDefault(), format, 0);
183183
}
184184

185-
public static final ThreadLocal<SimpleDateFormat> ISO_8601 = SimpleDateFormats.newSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
185+
public static final ThreadLocal<SimpleDateFormat> ISO_8601 = SimpleDateFormats.newSimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS");
186+
public static final ThreadLocal<SimpleDateFormat> RFC_3339 = SimpleDateFormats.newSimpleDateFormat("yyyy-MM-dd' 'HH:mm:ss", "yyyy-MM-dd' 'HH:mm:ss.SSS");
186187
public static final ThreadLocal<SimpleDateFormat> RFC_1123 = SimpleDateFormats.newSimpleDateFormat(Locale.US, "EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss zz");
187188

188189
public static ThreadLocal<SimpleDateFormat> newSimpleDateFormat(final Locale locale, final String ... patterns) {

src/test/java/org/libj/util/DatesTest.java

+69-42
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.time.OffsetDateTime;
2424
import java.util.Random;
2525
import java.util.TimeZone;
26+
import java.util.function.ToLongFunction;
2627

2728
import org.junit.After;
2829
import org.junit.Before;
@@ -53,74 +54,100 @@ public void testDatePart() {
5354
assertEquals("Iteration: " + i + " " + j, j, Dates.dropDatePart(Dates.MILLISECONDS_IN_DAY * i + j));
5455
}
5556

56-
private static String removeDashes(final String iso8601) {
57-
final StringBuilder builder = new StringBuilder(iso8601);
58-
builder.delete(7, 8);
59-
builder.delete(4, 5);
60-
return builder.toString();
57+
private static String removeDashes(final String str) {
58+
final StringBuilder b = new StringBuilder(str);
59+
b.delete(7, 8);
60+
b.delete(4, 5);
61+
return b.toString();
6162
}
6263

63-
private static void assertEpochEquals(final long expected, final String iso8601) throws ParseException {
64-
final long actual = Dates.iso8601ToEpochMilli(iso8601);
65-
assertEquals(iso8601 + ": " + expected + " != " + expected + " ~ " + Math.abs(expected - actual), expected, actual, 1);
64+
private static void assertEpochEquals(final ToLongFunction<String> fn, final long expected, final String str) throws ParseException {
65+
final long actual = fn.applyAsLong(str);
66+
assertEquals(str + ": " + expected + " != " + actual + " ~ " + Math.abs(expected - actual), expected, actual);
6667
}
6768

68-
private static void testTime2(final long expected, final String iso8601) throws ParseException {
69-
assertEpochEquals(expected, iso8601);
70-
assertEpochEquals(expected, removeDashes(iso8601));
71-
assertEpochEquals(expected, iso8601.replace(":", ""));
72-
assertEpochEquals(expected, iso8601.replace("T", ""));
73-
assertEpochEquals(expected, removeDashes(iso8601).replace(":", ""));
74-
assertEpochEquals(expected, removeDashes(iso8601).replace(":", "").replace("T", ""));
69+
private static void testTime3(final ToLongFunction<String> fn, final long expected, final String str) throws ParseException {
70+
assertEpochEquals(fn, expected, str);
71+
assertEpochEquals(fn, expected, removeDashes(str));
72+
assertEpochEquals(fn, expected, str.replace(":", ""));
73+
assertEpochEquals(fn, expected, str.replace("T", ""));
74+
assertEpochEquals(fn, expected, removeDashes(str).replace(":", ""));
75+
assertEpochEquals(fn, expected, removeDashes(str).replace(":", "").replace("T", ""));
7576
}
7677

77-
private static void testTime(final long expected, String iso8601) throws ParseException {
78-
testTime2(expected, iso8601);
79-
iso8601 = iso8601.substring(0, iso8601.length() - 1);
78+
private static void testTime2(final ToLongFunction<String> fn, final long expected, String str) throws ParseException {
79+
testTime3(fn, expected, str);
80+
str = str.substring(0, str.length() - 1);
8081

8182
final int hourOffset = (int)(Math.random() * 18);
8283
final String offset = Strings.pad(String.valueOf(hourOffset), RIGHT, 2, '0');
83-
testTime2(expected - hourOffset * 60 * 60 * 1000, iso8601 + "+" + offset);
84-
testTime2(expected + hourOffset * 60 * 60 * 1000, iso8601 + "-" + offset);
84+
testTime3(fn, expected - hourOffset * 60 * 60 * 1000, str + "+" + offset);
85+
testTime3(fn, expected + hourOffset * 60 * 60 * 1000, str + "-" + offset);
8586

8687
final int minOffset = (int)(Math.random() * 60);
8788
final String offset2 = offset + ":" + Strings.pad(String.valueOf(minOffset), RIGHT, 2, '0');
88-
testTime2(expected - (hourOffset * 60 + minOffset) * 60 * 1000, iso8601 + "+" + offset2);
89-
testTime2(expected + (hourOffset * 60 + minOffset) * 60 * 1000, iso8601 + "-" + offset2);
89+
testTime3(fn, expected - (hourOffset * 60 + minOffset) * 60 * 1000, str + "+" + offset2);
90+
testTime3(fn, expected + (hourOffset * 60 + minOffset) * 60 * 1000, str + "-" + offset2);
9091

9192
final String offset3 = offset + Strings.pad(String.valueOf(minOffset), RIGHT, 2, '0');
92-
testTime2(expected - (hourOffset * 60 + minOffset) * 60 * 1000, iso8601 + "+" + offset3);
93-
testTime2(expected + (hourOffset * 60 + minOffset) * 60 * 1000, iso8601 + "-" + offset3);
93+
testTime3(fn, expected - (hourOffset * 60 + minOffset) * 60 * 1000, str + "+" + offset3);
94+
testTime3(fn, expected + (hourOffset * 60 + minOffset) * 60 * 1000, str + "-" + offset3);
9495
}
9596

96-
private static long usingTimeApi(final String iso8601) {
97-
OffsetDateTime odt = OffsetDateTime.parse(iso8601);
97+
private static void testTime1(final ToLongFunction<String> fn, long expected, String str, final char del) throws ParseException {
98+
str = str.replace('T', del);
99+
testTime2(fn, expected, str);
100+
101+
final int i = str.lastIndexOf('.') + 1;
102+
final int end = str.length() - 1;
103+
104+
final String mils = str.substring(i, i + Math.min(3, end - i));
105+
int millis = Integer.parseInt(mils);
106+
millis *= mils.length() == 1 ? 100 : mils.length() == 2 ? 10 : 1;
107+
108+
str = str.substring(0, i - 1) + str.charAt(end);
109+
expected -= millis;
110+
111+
testTime2(fn, expected, str);
112+
}
113+
114+
private static long usingTimeApi(final String str) {
115+
OffsetDateTime odt = OffsetDateTime.parse(str);
98116
Instant instant = odt.toInstant();
99117
return instant.getEpochSecond() * 1000;
100118
}
101119

102-
@Test
103-
public void testIso8601ToEpochMilli() throws ParseException {
104-
long timeMs = Dates.iso8601ToEpochMilli("2020-05-24T09:20:55.5Z");
105-
testTime(timeMs, "2020-05-24T09:20:55.5Z");
106-
testTime(timeMs, "2020-05-24T09:20:55.50Z");
107-
testTime(timeMs, "2020-05-24T09:20:55.500Z");
108-
testTime(timeMs, "2020-05-24T09:20:55.5002Z");
109-
testTime(timeMs, "2020-05-24T09:20:55.50021Z");
110-
testTime(timeMs, "2020-05-24T09:20:55.500210Z");
111-
testTime(timeMs, "2020-05-24T09:20:55.5002101Z");
112-
testTime(timeMs, "2020-05-24T09:20:55.50021012Z");
113-
testTime(timeMs, "2020-05-24T09:20:55.500210123Z");
114-
testTime(timeMs, "2020-05-24T09:20:55.5002101234Z");
115-
testTime(timeMs, "2020-05-24T09:20:55.50021012345Z");
120+
private static void testTime(final ToLongFunction<String> fn, final char del) throws ParseException {
121+
long timeMs = fn.applyAsLong("2020-05-24" + del + "09:20:55.5Z");
122+
testTime1(fn, timeMs, "2020-05-24T09:20:55.5Z", del);
123+
testTime1(fn, timeMs, "2020-05-24T09:20:55.50Z", del);
124+
testTime1(fn, timeMs, "2020-05-24T09:20:55.500Z", del);
125+
testTime1(fn, timeMs, "2020-05-24T09:20:55.5002Z", del);
126+
testTime1(fn, timeMs, "2020-05-24T09:20:55.50021Z", del);
127+
testTime1(fn, timeMs, "2020-05-24T09:20:55.500210Z", del);
128+
testTime1(fn, timeMs, "2020-05-24T09:20:55.5002101Z", del);
129+
testTime1(fn, timeMs, "2020-05-24T09:20:55.50021012Z", del);
130+
testTime1(fn, timeMs, "2020-05-24T09:20:55.500210123Z", del);
131+
testTime1(fn, timeMs, "2020-05-24T09:20:55.5002101234Z", del);
132+
testTime1(fn, timeMs, "2020-05-24T09:20:55.50021012345Z", del);
116133

117134
for (int i = 0; i < 100; ++i) { // [N]
118135
timeMs = System.currentTimeMillis();
119-
final String iso8601 = Dates.epochMilliToIso8601(timeMs);
120-
testTime(timeMs, iso8601);
136+
final String str = Dates.epochMilliToIso8601(timeMs);
137+
testTime1(fn, timeMs, str, del);
121138
}
122139
}
123140

141+
@Test
142+
public void testRfc3339ToEpochMilli() throws ParseException {
143+
testTime(Dates::rfc3339ToEpochMilli, ' ');
144+
}
145+
146+
@Test
147+
public void testIso8601ToEpochMilli() throws ParseException {
148+
testTime(Dates::iso8601ToEpochMilli, 'T');
149+
}
150+
124151
@Test
125152
public void testDur() {
126153
for (int i = 0; i < 1000000; ++i) { // [N]

0 commit comments

Comments
 (0)