Java 8 java.time: Adding TemporalUnit in Instant vs LocalDateTime
Asked Answered
C

2

33

I'm playing around with the new java.time package in Java 8. I have a legacy database that gives me java.util.Date, which I convert to Instant.

What I am trying to do is add a period of time that is based off of another database flag. I could be adding days, weeks, months, or years. I don't want to have to care what I am adding, and I would like to be able to add more options in the future.

My first thought was Instant.plus(), but that gives me an UnsupportedTemporalTypeException for values greater than a day. Instant apparently does not support operations on large units of time. Fine, whatever, LocalDateTime does.

So that gives me this code:

private Date adjustDate(Date myDate, TemporalUnit unit){
    Instant instant = myDate.toInstant();
    LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
    dateTime = dateTime.plus(1, unit);
    Instant updatedInstant = dateTime.atZone(ZoneId.systemDefault()).toInstant();
    return new Date(dueInstant.toEpochMilli());
}

Now, this is my first time using the new time API, so I may have missed something here. But it seems clunky to me that I have to go:

Date --> Instant --> LocalDateTime --> do stuff--> Instant --> Date.

Even if I did not have to use the Date part, I would still think it was a bit awkward. So my question is this, am I doing this completely wrong and what is the best way to do this?


Edit: Expanding on the discussion in the comments.

I think I have a better idea now about how LocalDateTime and Instant are playing with java.util.Date and java.sql.Timestamp. Thanks everyone.

Now, a more practical consideration. Let's say a user sends me a date from wherever they are in the world, arbitrary time zone. They send me 2014-04-16T13:00:00 which I can parse into a LocalDateTime. I then convert this directly to a java.sql.Timestamp and persist in my database.

Now, without doing anything else, I pull my java.sql.timestamp from my database, convert to LocalDateTime using timestamp.toLocalDateTime(). All good. Then I return this value to my user using the ISO_DATE_TIME formatting. The result is 2014-04-16T09:00:00.

I assume this difference is because of some type of implicit conversion to/from UTC. I think my default time zone may be getting applied to the value (EDT, UTC-4) which would explain why the number is off by 4 hours.

New question(s). Where is the implicit conversion from local time to UTC happening here? What is the better way to preserve time zones. Should I not be going directly from Local time as a string (2014-04-16T13:00:00) to LocalDateTime? Should I be expecting a time zone from the user input?

Crossruff answered 1/4, 2014 at 17:56 Comment(13)
What value are you meant to be representing here? An Instant doesn't logically know about a calendar system - it's just a point in time - so adding a month to it doesn't make sense. You should also carefully consider whether you really want to use the system time zone - do you want to get different results for the same values, depending on where you're running?Jen
@JonSkeet Would it be more appropriate to arbitrarily pick a ZoneId then? Always GMT or something? It seems like that might come with its own set of issues. Perhaps the problem is that I don't know how to represent my java.util.date. I have a point in time. If certain conditions are met, I want to change that point to be one month (or day, year, whatever) in the future.Crossruff
Using UTC may be the best option, but you really need to think about what your requirements are. What does it even mean to change a point in time (with no time zone or calendar) by one month?Jen
The thing is, a given instant might represent, for example the 28th of February or the 1st of March, depending on the timezone. Adding a month will thus return the 28th of March or the 1st of April, depending on the timezone. Maybe you shouldn't use a Date, but a LocalDateTime instead. This will allow you to add a month in a timezone independant way. And you'll then be able to transform the LocalDateTime to an instant, in a given timezone.Penelope
@JBNizet I would like to do that, but the date is coming from a database (java.sql.timestamp) and mapped with hibernate. I don't think I get the option to change that, at least not in the near future. So it represents a point in time as a number of millis since epoch. Assuming that the client could decide time zone later, as long as I maintain the millis since epoch state, one month later could be 28th Feb, or 1st March depending on the client, and that would be fine. It should be determined by the client later, not be me, wherever I am, manipulating the database.Crossruff
If everything is stored in UTC in your database, you can consider that the Timestamp is an instant in the UTC timezone, and transform it to a LocalDateTime using UTC. You can encapsulate this transformation in your entity, or use jadira which will (AFAIK) allow you to map a LocalDateTime directly.Penelope
@JBNizet Ok good. That approach will work, but it is arguably no different than the code I have above, only the conversions are moved into a framework class (or the entity). But the old Java Date still has to be converted into Instant into LocalDateTime, perform the operations, then convert back into Instant then into Date/Timestamp. So the approach I used above is correct, even if it can be refactored a little?Crossruff
It looks correct to me, if you specify UTC as the timezone. I would use Date.from(Instant) instead of using milliseconds. Future versions of Hibernat will probably support the new time types directly.Penelope
@JBNizet One last question then, hypothetical. If the Dates were originally saved as systemDefault() instead of UTC, then I should be using systemDefault() everywhere right?Crossruff
I'm not sure what you mean by that. A Date doesn't have a timezone, and databases often use UTC internally.Penelope
JDBC requires that drivers interpret times and timestamps without a timezone as being in the local timezone, so unless your time zone is utc, or your driver is not jdbc compliant that assumption is wrong.Hawkshaw
@Crossruff A java.sql.Timestamp has a toLocalDateTime() method and a static valueOf(LocalDateTime). There should be no need to use Instant as an intermediary.Hawkshaw
@MarkRotteveel Thanks! I have edited my question to include a tangentially related question.Crossruff
C
29

I will go ahead and post an answer based on my final solution and a sort of summary of the very long comment chain.

To start, the whole conversion chain of:

Date --> Instant --> LocalDateTime --> Do stuff --> Instant --> Date

Is necessary to preserve the time zone information and still do operations on a Date like object that is aware of a Calendar and all of the context therein. Otherwise we run the risk of implicitly converting to the local time zone, and if we try to put it into a human readable date format, the times may have changed because of this.

For example, the toLocalDateTime() method on the java.sql.Timestamp class implicitly converts to the default time zone. This was undesirable for my purposes, but is not necessarily bad behavior. It is important, however, to be aware of it. That is the issue with converting directly from a legacy java date object into a LocalDateTime object. Since legacy objects are generally assumed to be UTC, the conversion uses the local timezone offset.

Now, lets say our program takes the input of 2014-04-16T13:00:00 and save to a database as a java.sql.Timestamp.

//Parse string into local date. LocalDateTime has no timezone component
LocalDateTime time = LocalDateTime.parse("2014-04-16T13:00:00");

//Convert to Instant with no time zone offset
Instant instant = time.atZone(ZoneOffset.ofHours(0)).toInstant();

//Easy conversion from Instant to the java.sql.Timestamp object
Timestamp timestamp = Timestamp.from(instant);

Now we take a timestamp and add some number of days to it:

Timestamp timestamp = ...

//Convert to LocalDateTime. Use no offset for timezone
LocalDateTime time = LocalDateTime.ofInstant(timestamp.toInstant(), ZoneOffset.ofHours(0));

//Add time. In this case, add one day.
time = time.plus(1, ChronoUnit.DAYS);

//Convert back to instant, again, no time zone offset.
Instant output = time.atZone(ZoneOffset.ofHours(0)).toInstant();

Timestamp savedTimestamp = Timestamp.from(output);

Now we just need to output as a human readable String in the format of ISO_LOCAL_DATE_TIME.

Timestamp timestamp = ....
LocalDateTime time = LocalDateTime.ofInstant(timestamp.toInstant(), ZoneOffset.ofHours(0));
String formatted = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(time);
Crossruff answered 21/4, 2014 at 12:45 Comment(4)
ZoneOffset.ofHours(0) can be better expressed as ZoneOffset.UTC. You may also find it easier to work with ZonedDateTime, as that class supports all the plus/minus behaviour of LocalDateTime, but retains the time-zone information.Dipterous
For the specific use case of days, you don't need (Local/Zoned)Date time, since a duration of days can be added directly to an instant: Timestamp.from(timestamp.toInstant().plus(Duration.ofDays(1)))Loriannlorianna
Adding Duration.ofDays to an Instant will not respect daylight savings or the like, so not recommended. Convert to a ZonedDateTime instead.Nolanolan
What is "recommended" depends on what behaviour you want. Do you want to add units of exactly 24 hours? Then use Instant.plus. Surely if you want to calculate the same time as your input but in n days time, you'll need a time zone so shouldn't be using Instant in the first place? ZoneOffset.UTC and/or ZoneOffset.ofHours(n) also won't pay any attention to changes of time in your current zone because UTC and numbered offsets never change the time.Presa
C
0

No it is much simple(checked on Java 21)

import java.time.Instant;
import java.time.temporal.ChronoUnit;

var instantNow = Instant.now();
instantNow = instantNow.plus(200, ChronoUnit.YEARS)
Carri answered 22/4 at 2:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.