Added ISO8601 complete support for date deserialization
This commit is contained in:
parent
e48c780389
commit
bcd52a1fdc
@ -20,11 +20,14 @@ import java.lang.reflect.Type;
|
||||
import java.sql.Timestamp;
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.ParsePosition;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import com.google.gson.internal.bind.util.ISO8601Utils;
|
||||
|
||||
/**
|
||||
* This type adapter supports three subclasses of date: Date, Timestamp, and
|
||||
* java.sql.Date.
|
||||
@ -38,7 +41,6 @@ final class DefaultDateTypeAdapter implements JsonSerializer<Date>, JsonDeserial
|
||||
|
||||
private final DateFormat enUsFormat;
|
||||
private final DateFormat localFormat;
|
||||
private final DateFormat iso8601Format;
|
||||
|
||||
DefaultDateTypeAdapter() {
|
||||
this(DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.US),
|
||||
@ -61,8 +63,6 @@ final class DefaultDateTypeAdapter implements JsonSerializer<Date>, JsonDeserial
|
||||
DefaultDateTypeAdapter(DateFormat enUsFormat, DateFormat localFormat) {
|
||||
this.enUsFormat = enUsFormat;
|
||||
this.localFormat = localFormat;
|
||||
this.iso8601Format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
|
||||
this.iso8601Format.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
}
|
||||
|
||||
// These methods need to be synchronized since JDK DateFormat classes are not thread-safe
|
||||
@ -97,14 +97,12 @@ final class DefaultDateTypeAdapter implements JsonSerializer<Date>, JsonDeserial
|
||||
synchronized (localFormat) {
|
||||
try {
|
||||
return localFormat.parse(json.getAsString());
|
||||
} catch (ParseException ignored) {
|
||||
}
|
||||
} catch (ParseException ignored) {}
|
||||
try {
|
||||
return enUsFormat.parse(json.getAsString());
|
||||
} catch (ParseException ignored) {
|
||||
}
|
||||
} catch (ParseException ignored) {}
|
||||
try {
|
||||
return iso8601Format.parse(json.getAsString());
|
||||
return ISO8601Utils.parse(json.getAsString(), new ParsePosition(0));
|
||||
} catch (ParseException e) {
|
||||
throw new JsonSyntaxException(json.getAsString(), e);
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.TypeAdapterFactory;
|
||||
import com.google.gson.internal.bind.util.ISO8601Utils;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
@ -27,10 +28,9 @@ import com.google.gson.stream.JsonWriter;
|
||||
import java.io.IOException;
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.text.ParsePosition;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
/**
|
||||
* Adapter for Date. Although this class appears stateless, it is not.
|
||||
@ -50,13 +50,6 @@ public final class DateTypeAdapter extends TypeAdapter<Date> {
|
||||
= DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.US);
|
||||
private final DateFormat localFormat
|
||||
= DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT);
|
||||
private final DateFormat iso8601Format = buildIso8601Format();
|
||||
|
||||
private static DateFormat buildIso8601Format() {
|
||||
DateFormat iso8601Format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
|
||||
iso8601Format.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
return iso8601Format;
|
||||
}
|
||||
|
||||
@Override public Date read(JsonReader in) throws IOException {
|
||||
if (in.peek() == JsonToken.NULL) {
|
||||
@ -76,7 +69,7 @@ public final class DateTypeAdapter extends TypeAdapter<Date> {
|
||||
} catch (ParseException ignored) {
|
||||
}
|
||||
try {
|
||||
return iso8601Format.parse(json);
|
||||
return ISO8601Utils.parse(json, new ParsePosition(0));
|
||||
} catch (ParseException e) {
|
||||
throw new JsonSyntaxException(json, e);
|
||||
}
|
||||
@ -90,4 +83,6 @@ public final class DateTypeAdapter extends TypeAdapter<Date> {
|
||||
String dateFormatAsString = enUsFormat.format(value);
|
||||
out.value(dateFormatAsString);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,386 @@
|
||||
package com.google.gson.internal.bind.util;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.ParsePosition;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC friendly than using SimpleDateFormat so
|
||||
* highly suitable if you (un)serialize lots of date objects.
|
||||
*
|
||||
* Supported parse format: [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]
|
||||
*
|
||||
* @see <a href="http://www.w3.org/TR/NOTE-datetime">this specification</a>
|
||||
*/
|
||||
//Date parsing code from Jackson databind ISO8601Utils.java
|
||||
// https://github.com/FasterXML/jackson-databind/blob/master/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
|
||||
public class ISO8601Utils
|
||||
{
|
||||
/**
|
||||
* ID to represent the 'UTC' string, default timezone since Jackson 2.7
|
||||
*
|
||||
* @since 2.7
|
||||
*/
|
||||
private static final String UTC_ID = "UTC";
|
||||
/**
|
||||
* The UTC timezone, prefetched to avoid more lookups.
|
||||
*
|
||||
* @since 2.7
|
||||
*/
|
||||
private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID);
|
||||
|
||||
/*
|
||||
/**********************************************************
|
||||
/* Formatting
|
||||
/**********************************************************
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision)
|
||||
*
|
||||
* @param date the date to format
|
||||
* @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ'
|
||||
*/
|
||||
public static String format(Date date) {
|
||||
return format(date, false, TIMEZONE_UTC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone)
|
||||
*
|
||||
* @param date the date to format
|
||||
* @param millis true to include millis precision otherwise false
|
||||
* @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z'
|
||||
*/
|
||||
public static String format(Date date, boolean millis) {
|
||||
return format(date, millis, TIMEZONE_UTC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
|
||||
*
|
||||
* @param date the date to format
|
||||
* @param millis true to include millis precision otherwise false
|
||||
* @param tz timezone to use for the formatting (UTC will produce 'Z')
|
||||
* @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
|
||||
*/
|
||||
public static String format(Date date, boolean millis, TimeZone tz) {
|
||||
Calendar calendar = new GregorianCalendar(tz, Locale.US);
|
||||
calendar.setTime(date);
|
||||
|
||||
// estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
|
||||
int capacity = "yyyy-MM-ddThh:mm:ss".length();
|
||||
capacity += millis ? ".sss".length() : 0;
|
||||
capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length();
|
||||
StringBuilder formatted = new StringBuilder(capacity);
|
||||
|
||||
padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length());
|
||||
formatted.append('-');
|
||||
padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length());
|
||||
formatted.append('-');
|
||||
padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length());
|
||||
formatted.append('T');
|
||||
padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length());
|
||||
formatted.append(':');
|
||||
padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length());
|
||||
formatted.append(':');
|
||||
padInt(formatted, calendar.get(Calendar.SECOND), "ss".length());
|
||||
if (millis) {
|
||||
formatted.append('.');
|
||||
padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length());
|
||||
}
|
||||
|
||||
int offset = tz.getOffset(calendar.getTimeInMillis());
|
||||
if (offset != 0) {
|
||||
int hours = Math.abs((offset / (60 * 1000)) / 60);
|
||||
int minutes = Math.abs((offset / (60 * 1000)) % 60);
|
||||
formatted.append(offset < 0 ? '-' : '+');
|
||||
padInt(formatted, hours, "hh".length());
|
||||
formatted.append(':');
|
||||
padInt(formatted, minutes, "mm".length());
|
||||
} else {
|
||||
formatted.append('Z');
|
||||
}
|
||||
|
||||
return formatted.toString();
|
||||
}
|
||||
|
||||
/*
|
||||
/**********************************************************
|
||||
/* Parsing
|
||||
/**********************************************************
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a date from ISO-8601 formatted string. It expects a format
|
||||
* [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]
|
||||
*
|
||||
* @param date ISO string to parse in the appropriate format.
|
||||
* @param pos The position to start parsing from, updated to where parsing stopped.
|
||||
* @return the parsed date
|
||||
* @throws ParseException if the date is not in the appropriate format
|
||||
*/
|
||||
public static Date parse(String date, ParsePosition pos) throws ParseException {
|
||||
Exception fail = null;
|
||||
try {
|
||||
int offset = pos.getIndex();
|
||||
|
||||
// extract year
|
||||
int year = parseInt(date, offset, offset += 4);
|
||||
if (checkOffset(date, offset, '-')) {
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
// extract month
|
||||
int month = parseInt(date, offset, offset += 2);
|
||||
if (checkOffset(date, offset, '-')) {
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
// extract day
|
||||
int day = parseInt(date, offset, offset += 2);
|
||||
// default time value
|
||||
int hour = 0;
|
||||
int minutes = 0;
|
||||
int seconds = 0;
|
||||
int milliseconds = 0; // always use 0 otherwise returned date will include millis of current time
|
||||
|
||||
// if the value has no time component (and no time zone), we are done
|
||||
boolean hasT = checkOffset(date, offset, 'T');
|
||||
|
||||
if (!hasT && (date.length() <= offset)) {
|
||||
Calendar calendar = new GregorianCalendar(year, month - 1, day);
|
||||
|
||||
pos.setIndex(offset);
|
||||
return calendar.getTime();
|
||||
}
|
||||
|
||||
if (hasT) {
|
||||
|
||||
// extract hours, minutes, seconds and milliseconds
|
||||
hour = parseInt(date, offset += 1, offset += 2);
|
||||
if (checkOffset(date, offset, ':')) {
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
minutes = parseInt(date, offset, offset += 2);
|
||||
if (checkOffset(date, offset, ':')) {
|
||||
offset += 1;
|
||||
}
|
||||
// second and milliseconds can be optional
|
||||
if (date.length() > offset) {
|
||||
char c = date.charAt(offset);
|
||||
if (c != 'Z' && c != '+' && c != '-') {
|
||||
seconds = parseInt(date, offset, offset += 2);
|
||||
if (seconds > 59 && seconds < 63) seconds = 59; // truncate up to 3 leap seconds
|
||||
// milliseconds can be optional in the format
|
||||
if (checkOffset(date, offset, '.')) {
|
||||
offset += 1;
|
||||
int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit
|
||||
int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits
|
||||
int fraction = parseInt(date, offset, parseEndOffset);
|
||||
// compensate for "missing" digits
|
||||
switch (parseEndOffset - offset) { // number of digits parsed
|
||||
case 2:
|
||||
milliseconds = fraction * 10;
|
||||
break;
|
||||
case 1:
|
||||
milliseconds = fraction * 100;
|
||||
break;
|
||||
default:
|
||||
milliseconds = fraction;
|
||||
}
|
||||
offset = endOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extract timezone
|
||||
if (date.length() <= offset) {
|
||||
throw new IllegalArgumentException("No time zone indicator");
|
||||
}
|
||||
|
||||
TimeZone timezone = null;
|
||||
char timezoneIndicator = date.charAt(offset);
|
||||
|
||||
if (timezoneIndicator == 'Z') {
|
||||
timezone = TIMEZONE_UTC;
|
||||
offset += 1;
|
||||
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
|
||||
String timezoneOffset = date.substring(offset);
|
||||
offset += timezoneOffset.length();
|
||||
// 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00"
|
||||
if ("+0000".equals(timezoneOffset) || "+00:00".equals(timezoneOffset)) {
|
||||
timezone = TIMEZONE_UTC;
|
||||
} else {
|
||||
// 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC...
|
||||
// not sure why, but that's the way it looks. Further, Javadocs for
|
||||
// `java.util.TimeZone` specifically instruct use of GMT as base for
|
||||
// custom timezones... odd.
|
||||
String timezoneId = "GMT" + timezoneOffset;
|
||||
// String timezoneId = "UTC" + timezoneOffset;
|
||||
|
||||
timezone = TimeZone.getTimeZone(timezoneId);
|
||||
|
||||
String act = timezone.getID();
|
||||
if (!act.equals(timezoneId)) {
|
||||
/* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given
|
||||
* one without. If so, don't sweat.
|
||||
* Yes, very inefficient. Hopefully not hit often.
|
||||
* If it becomes a perf problem, add 'loose' comparison instead.
|
||||
*/
|
||||
String cleaned = act.replace(":", "");
|
||||
if (!cleaned.equals(timezoneId)) {
|
||||
throw new IndexOutOfBoundsException("Mismatching time zone indicator: "+timezoneId+" given, resolves to "
|
||||
+timezone.getID());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new IndexOutOfBoundsException("Invalid time zone indicator '" + timezoneIndicator+"'");
|
||||
}
|
||||
|
||||
Calendar calendar = new GregorianCalendar(timezone);
|
||||
calendar.setLenient(false);
|
||||
calendar.set(Calendar.YEAR, year);
|
||||
calendar.set(Calendar.MONTH, month - 1);
|
||||
calendar.set(Calendar.DAY_OF_MONTH, day);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, hour);
|
||||
calendar.set(Calendar.MINUTE, minutes);
|
||||
calendar.set(Calendar.SECOND, seconds);
|
||||
calendar.set(Calendar.MILLISECOND, milliseconds);
|
||||
|
||||
pos.setIndex(offset);
|
||||
return calendar.getTime();
|
||||
// If we get a ParseException it'll already have the right message/offset.
|
||||
// Other exception types can convert here.
|
||||
} catch (IndexOutOfBoundsException e) {
|
||||
fail = e;
|
||||
} catch (NumberFormatException e) {
|
||||
fail = e;
|
||||
} catch (IllegalArgumentException e) {
|
||||
fail = e;
|
||||
}
|
||||
String input = (date == null) ? null : ('"' + date + "'");
|
||||
String msg = fail.getMessage();
|
||||
if (msg == null || msg.isEmpty()) {
|
||||
msg = "("+fail.getClass().getName()+")";
|
||||
}
|
||||
ParseException ex = new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex());
|
||||
ex.initCause(fail);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the expected character exist at the given offset in the value.
|
||||
*
|
||||
* @param value the string to check at the specified offset
|
||||
* @param offset the offset to look for the expected character
|
||||
* @param expected the expected character
|
||||
* @return true if the expected character exist at the given offset
|
||||
*/
|
||||
private static boolean checkOffset(String value, int offset, char expected) {
|
||||
return (offset < value.length()) && (value.charAt(offset) == expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an integer located between 2 given offsets in a string
|
||||
*
|
||||
* @param value the string to parse
|
||||
* @param beginIndex the start index for the integer in the string
|
||||
* @param endIndex the end index for the integer in the string
|
||||
* @return the int
|
||||
* @throws NumberFormatException if the value is not a number
|
||||
*/
|
||||
private static int parseInt(String value, int beginIndex, int endIndex) throws NumberFormatException {
|
||||
if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) {
|
||||
throw new NumberFormatException(value);
|
||||
}
|
||||
// use same logic as in Integer.parseInt() but less generic we're not supporting negative values
|
||||
int i = beginIndex;
|
||||
int result = 0;
|
||||
int digit;
|
||||
if (i < endIndex) {
|
||||
digit = Character.digit(value.charAt(i++), 10);
|
||||
if (digit < 0) {
|
||||
throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
|
||||
}
|
||||
result = -digit;
|
||||
}
|
||||
while (i < endIndex) {
|
||||
digit = Character.digit(value.charAt(i++), 10);
|
||||
if (digit < 0) {
|
||||
throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
|
||||
}
|
||||
result *= 10;
|
||||
result -= digit;
|
||||
}
|
||||
return -result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero pad a number to a specified length
|
||||
*
|
||||
* @param buffer buffer to use for padding
|
||||
* @param value the integer value to pad if necessary.
|
||||
* @param length the length of the string we should zero pad
|
||||
*/
|
||||
private static void padInt(StringBuilder buffer, int value, int length) {
|
||||
String strValue = Integer.toString(value);
|
||||
for (int i = length - strValue.length(); i > 0; i--) {
|
||||
buffer.append('0');
|
||||
}
|
||||
buffer.append(strValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first character in the string that is not a digit, starting at offset.
|
||||
*/
|
||||
private static int indexOfNonDigit(String string, int offset) {
|
||||
for (int i = offset; i < string.length(); i++) {
|
||||
char c = string.charAt(i);
|
||||
if (c < '0' || c > '9') return i;
|
||||
}
|
||||
return string.length();
|
||||
}
|
||||
|
||||
public static void main(String[] args)
|
||||
{
|
||||
final int REPS = 250000;
|
||||
while (true) {
|
||||
long start = System.currentTimeMillis();
|
||||
int resp = test1(REPS, 3);
|
||||
long msecs = System.currentTimeMillis() - start;
|
||||
System.out.println("Pow ("+resp+") -> "+msecs+" ms");
|
||||
|
||||
start = System.currentTimeMillis();
|
||||
resp = test2(REPS, 3);
|
||||
msecs = System.currentTimeMillis() - start;
|
||||
System.out.println("Iter ("+resp+") -> "+msecs+" ms");
|
||||
}
|
||||
}
|
||||
|
||||
static int test1(int reps, int pow)
|
||||
{
|
||||
int resp = 3;
|
||||
while (--reps >= 0) {
|
||||
resp = (int) Math.pow(10, pow);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
static int test2(int reps, int pow)
|
||||
{
|
||||
int resp = 3;
|
||||
while (--reps >= 0) {
|
||||
resp = 10;
|
||||
int p = pow;
|
||||
|
||||
while (--p > 0) {
|
||||
resp *= 10;
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
}
|
@ -124,6 +124,14 @@ public class DefaultDateTypeAdapterTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
public void testDateDeserializationISO8601() throws Exception {
|
||||
DefaultDateTypeAdapter adapter = new DefaultDateTypeAdapter();
|
||||
assertParsed("1970-01-01T00:00:00.000Z", adapter);
|
||||
assertParsed("1970-01-01T00:00Z", adapter);
|
||||
assertParsed("1970-01-01T00:00:00+00:00", adapter);
|
||||
assertParsed("1970-01-01T01:00:00+01:00", adapter);
|
||||
}
|
||||
|
||||
public void testDateSerialization() throws Exception {
|
||||
int dateStyle = DateFormat.LONG;
|
||||
DefaultDateTypeAdapter dateTypeAdapter = new DefaultDateTypeAdapter(dateStyle);
|
||||
|
Loading…
Reference in New Issue
Block a user