Commit de135289 authored by R.W.Majeed's avatar R.W.Majeed

rewrote parsing to allow time zone offset at any time accuracy

parent 758fb772
......@@ -3,6 +3,7 @@ package de.sekmi.histream;
import java.text.ParseException;
import java.text.ParsePosition;
import java.time.DateTimeException;
import java.time.Instant;
/*
* #%L
......@@ -26,6 +27,7 @@ import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.OffsetTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
......@@ -37,6 +39,7 @@ import java.time.temporal.Temporal;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalField;
import java.time.temporal.TemporalUnit;
import java.time.temporal.UnsupportedTemporalTypeException;
import java.util.Date;
import java.util.Objects;
......@@ -47,11 +50,14 @@ import de.sekmi.histream.xml.DateTimeAccuracyAdapter;
/**
* Local date and time with specified accuracy. Maximum resolution is seconds.
* For supported accuracy, see {@link #setAccuracy(ChronoUnit)}.
* @author Raphael
* @author R.W.Majeed
*
*/
@XmlJavaTypeAdapter(DateTimeAccuracyAdapter.class)
public class DateTimeAccuracy implements Temporal, Comparable<DateTimeAccuracy> {
static final String PARTIAL_FORMATTER_PATTERN = "u[-M[-d['T'H[:m[:s[.S]]][X]]]]";
static final DateTimeFormatter PARTIAL_FORMATTER = DateTimeFormatter.ofPattern(PARTIAL_FORMATTER_PATTERN);
// TODO why not use instant, since we always calculate UTC? or Offset/ZonedDateTime?
private LocalDateTime dateTime;
private ChronoUnit accuracy;
......@@ -97,7 +103,18 @@ public class DateTimeAccuracy implements Temporal, Comparable<DateTimeAccuracy>
dateTime.truncatedTo(accuracy);
}
// Temporal interface behaves like undelaying dateTime
/**
* Convert the partial date time to an instant.
* Will return the minimum instant for the given accuracy.
* E.g. accuracy of YEAR will return the the first second in the given year.
* @return minimum instant within the given accuracy
*/
public Instant toInstantMin(){
return dateTime.toInstant(ZoneOffset.UTC);
}
// TODO toInstantMax() (increase field at accuracy and subtract one millisecond)
// Temporal interface behaves like underlaying dateTime
@Override
public long getLong(TemporalField arg0) {return dateTime.getLong(arg0);}
@Override
......@@ -161,14 +178,16 @@ public class DateTimeAccuracy implements Temporal, Comparable<DateTimeAccuracy>
* @param digits digits to add
*/
private static void appendWithZeroPrefix(StringBuilder builder, TemporalAccessor date, TemporalField field, int digits){
int v = date.get(field);
padZeros(builder,date.get(field), digits);
}
private static void padZeros(StringBuilder builder, int value, int digits){
int pow = 1;
for( int i=1; i<digits; i++ )pow *= 10;
while( v < pow && pow > 1 ){
while( value < pow && pow > 1 ){
builder.append('0');
pow /= 10;
}
builder.append(v);
builder.append(value);
}
/**
* Convert the date to a partial ISO 8601 date time string.
......@@ -195,8 +214,9 @@ public class DateTimeAccuracy implements Temporal, Comparable<DateTimeAccuracy>
TemporalAccessor dt;
if( tz != null ){
// use timezone information
dt = dateTime.atZone(tz);
// use timezone information.
// Assume that dateTime is given in UTC. For output convert to destination timezone.
dt = dateTime.atOffset(ZoneOffset.UTC).atZoneSameInstant(tz);
}else{
// no zone info, output will not have offset
dt = dateTime;
......@@ -214,84 +234,91 @@ public class DateTimeAccuracy implements Temporal, Comparable<DateTimeAccuracy>
if( tz != null && i >= 3 ){
// hours present
// add zone offset
String of = ((ZonedDateTime)dt).getOffset().normalized().toString();
b.append(of);
int os = ((ZonedDateTime)dt).getOffset().getTotalSeconds();
if( os == 0 ){
// output Z
b.append('Z');
}else{
// append sign and four characters
if( os < 0 ){
b.append('-');
}else{
b.append('+');
}
// hours
int ox = os / 3600;
os = os % 3600;
padZeros(b,ox,2);
// minutes
ox = os / 60;
padZeros(b,ox,2);
// ignore seconds, not part of ISO
}
}
return b.toString();
}
/**
* Parses a partial ISO 8601 date time string.
* [-]CCYY-MM-DDThh:mm:ss[Z|(+|-)hh:mm]
* [-]CCYY-MM-DDThh:mm:ss[Z|(+|-)hhmm]
* <p>
* At least the year must be specified. All other fields can be left out.
*
* @param str ISO 8601 string
* @return date time with accuracy as derived from parse
* @throws ParseException for unparsable string
* @throws IllegalArgumentException unparsable string (old unchecked exception)
*/
public static DateTimeAccuracy parsePartialIso8601(String str)throws ParseException{
if( str.length() < 4 )throw new ParseException("Need at least 4 characters for year: "+str, str.length());
// parse year
int year = Integer.parseInt(str.substring(0, 4));
if( str.length() == 4 ){ // specified to accuracy of years
return new DateTimeAccuracy(year);
}else if( str.length() < 7 || str.charAt(4) != '-' ){
throw new ParseException("Expected YYYY-MM", Integer.min(4, str.length()));
}
// parse month
int month = Integer.parseInt(str.substring(5, 7));
if( str.length() == 7 ){ // specified to accuracy of months
return new DateTimeAccuracy(year, month);
}else if( str.length() < 10 || str.charAt(7) != '-' ){
throw new ParseException("Expected YYYY-MM-DD", Integer.min(7, str.length()));
}
// parse day
int day = Integer.parseInt(str.substring(8, 10));
if( str.length() == 10 ){ // specified to accuracy of days
return new DateTimeAccuracy(year, month, day);
}else if( str.length() < 13 || str.charAt(10) != 'T' ){
throw new ParseException("Expected yyyy-mm-ddThh", Integer.min(10, str.length()));
}
// parse hours
int hours = Integer.parseInt(str.substring(11, 13));
if( str.length() == 13 ){ // specified to accuracy of hours
return new DateTimeAccuracy(year, month, day, hours);
}else if( str.length() < 16 || str.charAt(13) != ':' ){
throw new ParseException("Expected yyyy-mm-ddThh:mm", Integer.min(13, str.length()));
}
// parse minutes
int mins = Integer.parseInt(str.substring(14, 16));
if( str.length() == 16 ){ // specified to accuracy of minutes
return new DateTimeAccuracy(year, month, day, hours, mins);
}else if( str.length() < 19 || str.charAt(16) != ':' ){
throw new ParseException("Expected yyyy-mm-ddThh:mm:ss", Integer.min(16, str.length()));
ParsePosition pos = new ParsePosition(0);
TemporalAccessor a = PARTIAL_FORMATTER.parseUnresolved(str, pos);
// first check that everything was parsed
if( pos.getErrorIndex() != -1 ){
throw new ParseException("Parse error at position "+pos.getErrorIndex(), pos.getErrorIndex());
}else if( pos.getIndex() != str.length() ){
throw new ParseException("Unparsed text found at index "+pos.getIndex()+": "+str.substring(pos.getIndex()), pos.getIndex());
}
// parse seconds
int secs = Integer.parseInt(str.substring(17, 19));
if( str.length() == 19 || (str.length() == 20 && str.charAt(19) == 'Z') ){ // specified to accuracy of seconds
return new DateTimeAccuracy(year, month, day, hours, mins, secs);
}else if( str.length() < 25 || !(str.charAt(19) != '+' || str.charAt(19) != '-') ){
throw new ParseException("Expected yyyy-mm-ddThh:mm:ss[Z|+oo:oo]", 19);
}else if( str.length() != 25 || str.charAt(22) != ':' ){
// handles longer input and missing : in offset
throw new ParseException("Expected yyyy-mm-ddThh:mm:ss[Z|+oo:oo]", 22);
// everything parsed without error
// now check for accuracy
ChronoUnit accuracy;
LocalDateTime dateTime;
if( a.isSupported(ChronoField.NANO_OF_SECOND) ){
// maximum accuracy of nanoseconds
// not supported yet, truncate to seconds
accuracy = ChronoUnit.NANOS;
dateTime = LocalDateTime.from(a);
}else if( a.isSupported(ChronoField.SECOND_OF_MINUTE) ){
accuracy = ChronoUnit.SECONDS;
dateTime = LocalDateTime.of(a.get(ChronoField.YEAR), a.get(ChronoField.MONTH_OF_YEAR), a.get(ChronoField.DAY_OF_MONTH), a.get(ChronoField.HOUR_OF_DAY), a.get(ChronoField.MINUTE_OF_HOUR), a.get(ChronoField.SECOND_OF_MINUTE));
}else if( a.isSupported(ChronoField.MINUTE_OF_HOUR) ){
accuracy = ChronoUnit.MINUTES;
dateTime = LocalDateTime.of(a.get(ChronoField.YEAR), a.get(ChronoField.MONTH_OF_YEAR), a.get(ChronoField.DAY_OF_MONTH), a.get(ChronoField.HOUR_OF_DAY), a.get(ChronoField.MINUTE_OF_HOUR));
}else if( a.isSupported(ChronoField.HOUR_OF_DAY) ){
accuracy = ChronoUnit.HOURS;
dateTime = LocalDateTime.of(a.get(ChronoField.YEAR), a.get(ChronoField.MONTH_OF_YEAR), a.get(ChronoField.DAY_OF_MONTH), a.get(ChronoField.HOUR_OF_DAY), 0);
}else if( a.isSupported(ChronoField.DAY_OF_MONTH) ){
accuracy = ChronoUnit.DAYS;
dateTime = LocalDateTime.of(a.get(ChronoField.YEAR), a.get(ChronoField.MONTH_OF_YEAR), a.get(ChronoField.DAY_OF_MONTH), 0, 0);
}else if( a.isSupported(ChronoField.MONTH_OF_YEAR) ){
dateTime = LocalDateTime.of(a.get(ChronoField.YEAR), a.get(ChronoField.MONTH_OF_YEAR), 1, 0, 0);
accuracy = ChronoUnit.MONTHS;
}else{
DateTimeAccuracy me = new DateTimeAccuracy(year, month, day, hours, mins, secs);
// parse time zone
ZoneOffset of = ZoneOffset.ofHoursMinutes(
Integer.parseInt(str.substring(20, 22)),
Integer.parseInt(str.substring(24, 25))
);
// format requires at least year
accuracy = ChronoUnit.YEARS;
dateTime = LocalDateTime.of(a.get(ChronoField.YEAR), 1, 1, 0, 0);
}
// check for zone offset
ZoneOffset off = null;
if( a.isSupported(ChronoField.OFFSET_SECONDS) ){
off = ZoneOffset.ofTotalSeconds(a.get(ChronoField.OFFSET_SECONDS));
// adjust to UTC
// TODO unit test for this behavior
me.dateTime = me.dateTime.atOffset(of).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
return me;
dateTime = dateTime.atOffset(off).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
}
// unparsed data (longer input) will be handled above
//throw new ParseException("Unparsed data at index 26", 26);
DateTimeAccuracy me = new DateTimeAccuracy(dateTime);
me.accuracy = accuracy;
return me;
}
/**
......
......@@ -7,8 +7,11 @@ import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.UnsupportedTemporalTypeException;
import org.junit.Assert;
import static org.junit.Assert.*;
import org.junit.Test;
public class TestDateTimeAccuracy {
......@@ -34,10 +37,62 @@ public class TestDateTimeAccuracy {
a = DateTimeAccuracy.parse(formatter, "01.02.2003 13");
Assert.assertEquals(ChronoUnit.HOURS, a.getAccuracy());
Assert.assertEquals("2003-02-01T13", a.toPartialIso8601(null));
Assert.assertEquals("2003-02-01T13+08:00", a.toPartialIso8601(tz));
Assert.assertEquals("2003-02-01T21+0800", a.toPartialIso8601(tz));
}
@Test
public void verifyDateTimeFormatter(){
TemporalAccessor a;
DateTimeFormatter f = DateTimeFormatter.ISO_DATE_TIME;
// zone offset missing, field should be not available
a = f.parse("2001-02-03T04:05:06");
Assert.assertFalse(a.isSupported(ChronoField.OFFSET_SECONDS));
try{
a.get(ChronoField.OFFSET_SECONDS);
Assert.fail("Expected exception not thrown");
}catch( UnsupportedTemporalTypeException e ){
// expected outcome
}
// zero zone offset, field should be available
a = f.parse("2001-02-03T04:05:06Z");
Assert.assertEquals(0, a.get(ChronoField.OFFSET_SECONDS));
a = f.parse("2001-02-03T04:05:06+00:00");
Assert.assertEquals(0, a.get(ChronoField.OFFSET_SECONDS));
a = f.parse("2001-02-03T04:05"); // seconds can be omitted
// test the partial timestamp formatter
f = DateTimeFormatter.ofPattern("u[-M[-d['T'H[:m[:s[.S]]][X]]]]");
}
@Test
public void verifyParsingIncompleteIsoTimestamp() throws ParseException{
DateTimeAccuracy a;
a = DateTimeAccuracy.parsePartialIso8601("2001");
assertEquals(ChronoUnit.YEARS, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02");
assertEquals(ChronoUnit.MONTHS, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03");
assertEquals(ChronoUnit.DAYS, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04");
assertEquals(ChronoUnit.HOURS, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04:05");
assertEquals(ChronoUnit.MINUTES, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04:05:06");
assertEquals(ChronoUnit.SECONDS, a.getAccuracy());
// verify zone offset
// for second accuracy
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04:05:06+0800");
assertEquals(ChronoUnit.SECONDS, a.getAccuracy());
// zone offset calculation
assertEquals(DateTimeAccuracy.parsePartialIso8601("2001-02-02T20:05:06Z"), a);
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04+0800");
assertEquals(ChronoUnit.HOURS, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04Z");
assertEquals(ChronoUnit.HOURS, a.getAccuracy());
a = DateTimeAccuracy.parsePartialIso8601("2001-02-03T04:05+0800");
assertEquals(ChronoUnit.MINUTES, a.getAccuracy());
}
@Test
public void testFormatExceedsText(){
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("d.M.u[ H[:m[:s]]]");
......@@ -63,7 +118,7 @@ public class TestDateTimeAccuracy {
}
// TODO test more aspects of zone offset parsing
DateTimeAccuracy.parsePartialIso8601("2003-02-01T04:05:06Z");
DateTimeAccuracy a = DateTimeAccuracy.parsePartialIso8601("2003-02-01T04:05:06+01:00");
DateTimeAccuracy a = DateTimeAccuracy.parsePartialIso8601("2003-02-01T04:05:06+0100");
// make sure the date is adjusted to UTC
Assert.assertEquals(3, a.get(ChronoField.HOUR_OF_DAY));
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment