2

Question

Maybe I am misusing the API here or missing some piece of information. Is this a bug or a design flaw of the API?

Follow up

Lately I was using java.time (in scala's REPL but it is not really of interest) to parse and format a date which also contains an offset but no time like this: "2018-03-24+01:00"

scala> :paste
// Entering paste mode (ctrl-D to finish)

import java.time._
import java.time.temporal._
import java.time.format._
import java.util._

val f = DateTimeFormatter.ISO_OFFSET_DATE
  .withLocale(Locale.GERMAN)
  .withZone(ZoneId.of("GMT"))

val t = f.parse("2018-03-24+01:00")

// Exiting paste mode, now interpreting.

import java.time._
import java.time.temporal._
import java.time.format._
import java.util._
f: java.time.format.DateTimeFormatter = ParseCaseSensitive(false)(Value(Year,4,10,EXCEEDS_PAD)'-'Value(MonthOfYear,2)'-'Value(DayOfMonth,2))Offset(+HH:MM:ss,'Z')
t: java.time.temporal.TemporalAccessor = {OffsetSeconds=3600},ISO,GMT resolved to 2018-03-24

So far so good, when trying to create an Instant.from(t) this will lead to an exception:

scala> Instant.from(t)
java.time.DateTimeException: Unable to obtain Instant from TemporalAccessor: {OffsetSeconds=3600},ISO,GMT resolved to 2018-03-24 of type java.time.format.Parsed
  at java.time.Instant.from(Instant.java:378)
  ... 28 elided
Caused by: java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: InstantSeconds
  at java.time.format.Parsed.getLong(Parsed.java:203)
  at java.time.Instant.from(Instant.java:373)
  ... 28 more

This is expected or at least plausible to me since the required ChronoFields are missing. A fix to this particular scenario could be to provide a sane default time like LocalDate.MIDNIGHT and construct an Instant manually after calling parse on f:

scala> :paste
// Entering paste mode (ctrl-D to finish)

val i = OffsetDateTime.of(
  LocalDate.from(t),
  LocalTime.MIDNIGHT,
  ZoneOffset.from(t)
).toInstant


// Exiting paste mode, now interpreting.

i: java.time.Instant = 2018-03-23T23:00:00Z

Well, this fixes my current problem but feels unsatisfying. Then I wondered if the API would be able to do round trips in general when using the formatters that are time preserving. Therefore I came up with the list of available java.time.format.DateTimeFormatter instances and threw them on a small test function to verify this:

val dfts = scala.collection.immutable.Map(
  "BASIC_ISO_DATE"       -> DateTimeFormatter.BASIC_ISO_DATE,
  "ISO_INSTANT"          -> DateTimeFormatter.ISO_INSTANT,
  "ISO_LOCAL_TIME"       -> DateTimeFormatter.ISO_LOCAL_TIME,
  "ISO_OFFSET_TIME"      -> DateTimeFormatter.ISO_OFFSET_TIME,
  "ISO_WEEK_DATE"        -> DateTimeFormatter.ISO_WEEK_DATE,
  "ISO_DATE"             -> DateTimeFormatter.ISO_DATE,
  "ISO_LOCAL_DATE"       -> DateTimeFormatter.ISO_LOCAL_DATE,
  "ISO_OFFSET_DATE"      -> DateTimeFormatter.ISO_OFFSET_DATE,
  "ISO_ORDINAL_DATE"     -> DateTimeFormatter.ISO_ORDINAL_DATE,
  "ISO_ZONED_DATE_TIME"  -> DateTimeFormatter.ISO_ZONED_DATE_TIME,
  "ISO_DATE_TIME"        -> DateTimeFormatter.ISO_DATE_TIME,
  "ISO_LOCAL_DATE_TIME"  -> DateTimeFormatter.ISO_LOCAL_DATE_TIME,
  "ISO_OFFSET_DATE_TIME" -> DateTimeFormatter.ISO_OFFSET_DATE_TIME,
  "ISO_TIME"             -> DateTimeFormatter.ISO_TIME,
  "RFC_1123_DATE_TIME"   -> DateTimeFormatter.RFC_1123_DATE_TIME
)

def test(f: DateTimeFormatter) = scala.util.Try {
  Instant.from(f.parse(f.format(Instant.now)))
}

dfts.mapValues(test)
  .mapValues(_.toString)
  .mapValues(_.replace("java.time.temporal.UnsupportedTemporalTypeException", "j.t.t.UTTE"))
  .map{ case (k,v) => f"$k%20s : $v" }
  .foreach(println)

This produces the following output:

            ISO_DATE : Failure(j.t.t.UTTE: Unsupported field: Year)
 ISO_ZONED_DATE_TIME : Failure(j.t.t.UTTE: Unsupported field: Year)
      BASIC_ISO_DATE : Failure(j.t.t.UTTE: Unsupported field: Year)
      ISO_LOCAL_TIME : Failure(j.t.t.UTTE: Unsupported field: HourOfDay)
    ISO_ORDINAL_DATE : Failure(j.t.t.UTTE: Unsupported field: Year)
      ISO_LOCAL_DATE : Failure(j.t.t.UTTE: Unsupported field: Year)
       ISO_DATE_TIME : Failure(j.t.t.UTTE: Unsupported field: Year)
         ISO_INSTANT : Success(2018-03-25T07:48:48.360Z)
 ISO_LOCAL_DATE_TIME : Failure(j.t.t.UTTE: Unsupported field: Year)
     ISO_OFFSET_TIME : Failure(j.t.t.UTTE: Unsupported field: HourOfDay)
ISO_OFFSET_DATE_TIME : Failure(j.t.t.UTTE: Unsupported field: Year)
  RFC_1123_DATE_TIME : Failure(j.t.t.UTTE: Unsupported field: DayOfMonth)
     ISO_OFFSET_DATE : Failure(j.t.t.UTTE: Unsupported field: Year)
            ISO_TIME : Failure(j.t.t.UTTE: Unsupported field: HourOfDay)
       ISO_WEEK_DATE : Failure(j.t.t.UTTE: Unsupported field: WeekBasedYear)

Thus only the ISO_INSTANT formatter is currently working as at least I would expect it to work and is thus able to work in a round-trip scenario.

whatever
  • 110
  • 4
isaias-b
  • 2,255
  • 2
  • 25
  • 38

1 Answers1

4

You can use a DateTimeFormatterBuilder and define default values for the time fields. The code is in Java, but it should be easy to port it to Scala:

DateTimeFormatter fmt = new DateTimeFormatterBuilder()
    // date and offset
    .append(DateTimeFormatter.ISO_OFFSET_DATE)
    // default value for hour
    .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
    // default value for minute
    .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
    // create formatter
    .toFormatter(Locale.GERMAN);

Instant instant = Instant.from(fmt.parse("2018-03-24+01:00")); // 2018-03-23T23:00:00Z

The input doesn't have any time-related fields (hours, minutes, etc), so when parsing it, you need to set the time for yourself, either by using a specific LocalTime (as you did), or by defining default values as above.

There's no such thing as a "time preserving" formatter. When you format a date to a string, it prints only the fields configured in the formatter. When parsing this string back to a date/time class, you need to add the missing fields somehow.

And your code trying all the built-in formatters is actually failing to format the Instant, not to parse it. That's because an Instant represents just a count from Unix epoch, and any of the built-in formatters (except for ISO_INSTANT) try to get date/time related fields from it (such as the year, month, day, hour, minute, etc), but the Instant has no notion of it, because it represents just a count of nanoseconds from epoch, and it needs to be attached to a timezone to get the date/time related fields - more details here: https://stackoverflow.com/a/27483371/9552515

If you think it's a bug, go on and register it at Oracle. But I believe this is by design. The answer I linked above is from the java.time API's creator and reinforces this behaviour, and there's also this comment where he explains this point. If this is a design flaw, that's a matter of opinion (and IMO, it's not). That being said, I don't think this is likely to be changed.

whatever
  • 110
  • 4
  • 1
    Good answer. I agree in believing that the OPs expectations disagree with the expectations of the designers of `java.time`. I regard the observed behaviour as correct. I see no bug. – Ole V.V. Mar 26 '18 at 18:40