NickBeaugié
NickBeaugié

Reputation: 740

Comparing Java Dates and force a date to a particular time zone

I am having trouble doing a date comparison.

I am using Groovy and Spock to write an integration test against a web service.

The test is to first use the web service to create a Thing, then immediately make another call to get the details of the Thing by its ID. I then want to verify that the CreatedDate of the thing is greater than a minute ago.

Here is some JSON of the call

{
  "Id":"696fbd5f-5a0c-4209-8b21-ea7f77e3e09d",
  "CreatedDate":"2017-07-11T10:53:52"
}

So, note no timezone information in the date string; but I know it is UTC.

I'm new to Java (from .NET) and a bit bamboozled by the different date types.

This is my Groovy model of the class, which I use Gson to deserialize:

class Thing {
    public UUID Id
    public Date CreatedDate
}

The deserialization works fine. But the code, which runs in a non-UTC time zone thinks that the date is actually in the local time zone.

I can create a variable representing "1 minute ago" using the Instant class:

def aMinuteAgo = Instant.now().plusSeconds(-60)

And this is how I am trying to do the comparison:

rule.CreatedDate.toInstant().compareTo(aMinuteAgo) < 0

The trouble is, the runtime thinks that the date is local time. There appears to be no overload for me to force .toInstant() into UTC.

I've tried using what I understand to be more modern classes - such as LocalDateTime and ZonedDateTime in my model instead of Date, however Gson doesn't play nice with the deserialization.

Upvotes: 1

Views: 4707

Answers (2)

NickBeaugi&#233;
NickBeaugi&#233;

Reputation: 740

Thanks so much for the comments, they put me on a good path.

For the benefit of anyone else who is stuck with this problem, here is the code I used.

Modified model class:

import java.time.LocalDateTime

class Thing {
   public UUID Id
   public LocalDateTime CreatedDate
}

Utils class (includes method for ZonedDateTime in addition, as that's where I got the original code. It turned out I could make LocalDateTime work for me though. (The setDateFormat is to support Date objects used in other model classes where I don't need to do comparisons, although I can see myself deprecating all that soon).

class Utils {
    static Gson UtilGson = new GsonBuilder()
            .registerTypeAdapter(ZonedDateTime.class, GsonHelper.ZDT_DESERIALIZER)
            .registerTypeAdapter(LocalDateTime.class, GsonHelper.LDT_DESERIALIZER)
            .registerTypeAdapter(OffsetDateTime.class, GsonHelper.ODT_DESERIALIZER)
            .setDateFormat("yyyy-MM-dd'T'HH:mm:ss")
            .create();

    // From https://stackoverflow.com/a/36418842/276036
    static class GsonHelper {

        public static final JsonDeserializer<ZonedDateTime> ZDT_DESERIALIZER = new JsonDeserializer<ZonedDateTime>() {
            @Override
            public ZonedDateTime deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException {
                JsonPrimitive jsonPrimitive = json.getAsJsonPrimitive();
                try {

                    // if provided as String - '2011-12-03T10:15:30+01:00[Europe/Paris]'
                    if(jsonPrimitive.isString()){
                        return ZonedDateTime.parse(jsonPrimitive.getAsString(), DateTimeFormatter.ISO_ZONED_DATE_TIME);
                    }

                    // if provided as Long
                    if(jsonPrimitive.isNumber()){
                        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(jsonPrimitive.getAsLong()), ZoneId.systemDefault());
                    }

                } catch(RuntimeException e){
                    throw new JsonParseException("Unable to parse ZonedDateTime", e);
                }
                throw new JsonParseException("Unable to parse ZonedDateTime");
            }
        };

        public static final JsonDeserializer<LocalDateTime> LDT_DESERIALIZER = new JsonDeserializer<LocalDateTime>() {
            @Override
            public LocalDateTime deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException {
                JsonPrimitive jsonPrimitive = json.getAsJsonPrimitive();
                try {

                    // if provided as String - '2011-12-03T10:15:30'
                    if(jsonPrimitive.isString()){
                        return LocalDateTime.parse(jsonPrimitive.getAsString(), DateTimeFormatter.ISO_DATE_TIME);
                    }

                    // if provided as Long
                    if(jsonPrimitive.isNumber()){
                        return LocalDateTime.ofInstant(Instant.ofEpochMilli(jsonPrimitive.getAsLong()), ZoneId.systemDefault());
                    }

                } catch(RuntimeException e){
                    throw new JsonParseException("Unable to parse LocalDateTime", e);
                }
                throw new JsonParseException("Unable to parse LocalDateTime");
            }

         public static final JsonDeserializer<OffsetDateTime> ODT_DESERIALIZER = new JsonDeserializer<OffsetDateTime>() {
        @Override
        public OffsetDateTime deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException {
            JsonPrimitive jsonPrimitive = json.getAsJsonPrimitive()
            try {

                // if provided as String - '2011-12-03T10:15:30' (i.e. no timezone information e.g. '2011-12-03T10:15:30+01:00[Europe/Paris]')
                // We know our services return UTC dates without specific timezone information so can do this.
                // But if, in future we have a different requirement, we'll have to review.
                if(jsonPrimitive.isString()){
                    LocalDateTime localDateTime = LocalDateTime.parse(jsonPrimitive.getAsString());
                    return localDateTime.atOffset(ZoneOffset.UTC)
                }
            } catch(RuntimeException e){
                throw new JsonParseException("Unable to parse OffsetDateTime", e)
            }
            throw new JsonParseException("Unable to parse OffsetDateTime")
        }
    }
        };
    }

And here is the code that does the comparison (it's Spock/Groovy):

// ... first get the JSON text from the REST call

when:
text = response.responseBody
def thing = Utils.UtilGson.fromJson(text, Thing.class)
def now = OffsetDateTime.now(ZoneOffset.UTC)
def aMinuteAgo = now.plusSeconds(-60)

then:
thing.CreatedDate > aMinuteAgo
thing.CreatedDate < now

It seems more natural to do comparisons with operators. And OffsetDateTime works well when I explicitly use it for UTC. I only use this model to perform integration tests against the services (which themselves are actually implemented in .NET) so the objects are not going to be used outside of my tests.

Upvotes: 1

user7605325
user7605325

Reputation:

The input String has only date and time, and no timezone information, so you can parse it to a LocalDateTime and then convert it to UTC:

// parse date and time
LocalDateTime d = LocalDateTime.parse("2017-07-11T10:53:52");
// convert to UTC
ZonedDateTime z = d.atZone(ZoneOffset.UTC);
// or
OffsetDateTime odt = d.atOffset(ZoneOffset.UTC);
// convert to Instant
Instant instant = z.toInstant();

You can either use the ZonedDateTime, OffsetDateTime or Instant, as all will contain the equivalent date and time in UTC. To get them, you can use a deserializer, as linked in the comments.

To check how many minutes are between this date and the current date, you can use java.time.temporal.ChronoUnit:

ChronoUnit.MINUTES.between(instant, Instant.now());

This will return the number of minutes between instant and the current date/time. You can also use it with a ZonedDateTime or with a OffsetDateTime:

ChronoUnit.MINUTES.between(z, ZonedDateTime.now());
ChronoUnit.MINUTES.between(odt, OffsetDateTime.now());

But as you're working with UTC, an Instant is better (because it's "in UTC" by definition - actually, the Instant represents a point in time, the number of nanoseconds since epoch (1970-01-01T00:00Z) and has no timezone/offset, so you can also think that "it's always in UTC").

You can also use OffsetDateTime if you're sure the dates will always be in UTC. ZonedDateTime also works, but if you don't need timezone rules (keep track of DST rules and so on), then OffsetDateTime is a better choice.

Another difference is that Instant has only the number of nanoseconds since epoch (1970-01-01T00:00Z). If you need fields like day, month, year, hour, minutes, seconds, etc, it's better to use ZonedDateTime or OffsetDateTime.


You can also take a look at the API tutorial, which has a good explanation about the different types.

Upvotes: 3

Related Questions