diff --git a/histream-core/src/main/java/de/sekmi/histream/DateTimeAccuracy.java b/histream-core/src/main/java/de/sekmi/histream/DateTimeAccuracy.java index c4459121bcdb7ed0945de11d7abe9ba8420df62a..76b00672f0f4ddefaca00f9354b5323cd1d21005 100644 --- a/histream-core/src/main/java/de/sekmi/histream/DateTimeAccuracy.java +++ b/histream-core/src/main/java/de/sekmi/histream/DateTimeAccuracy.java @@ -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 { + 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 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 * @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 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 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 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] + *

+ * 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; } /** diff --git a/histream-core/src/test/java/de/sekmi/histream/TestDateTimeAccuracy.java b/histream-core/src/test/java/de/sekmi/histream/TestDateTimeAccuracy.java index 077ad19440abf0fa8e3723719bb758a57c18fe0e..002880bcd413697e41c03bcd671b3e41469fa584 100644 --- a/histream-core/src/test/java/de/sekmi/histream/TestDateTimeAccuracy.java +++ b/histream-core/src/test/java/de/sekmi/histream/TestDateTimeAccuracy.java @@ -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)); }