1

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.

NickBeaugié
  • 720
  • 9
  • 17
  • 1
    You'll need to register your own deserializer, see https://stackoverflow.com/a/36418842/6509 – tim_yates Jul 11 '17 at 11:40
  • 1
    If you have any influence over that data source, have them append a `Z` to the end of that date-time string if it is truly intended to be a moment in UTC. The `Z` is short for Zulu and means UTC. – Basil Bourque Jul 11 '17 at 16:49

2 Answers2

3

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.

1

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.

NickBeaugié
  • 720
  • 9
  • 17
  • About naming: (a) variables in Java by convention have an initial lowercase. So `id` and `createdDate`, (b) the name `createdDate` is ambiguous, easily confused for a date-only `LocalDate` value. I suggest something like ‘whenCreated`. – Basil Bourque Jul 11 '17 at 16:40
  • Do not work in `LocalDateTime` when you know the value is meant for an offset or zone. You would be intentionally discarding valuable information. If you are certain the value is intended for UTC, then represent as a `OffsetDateTime` with that assigned offset (`ZoneOffset.UTC`). Study Hugo‘s Answer, which is good except he should have used `OffsetDateTime` instead of `ZonedDateTime` for UTC. – Basil Bourque Jul 11 '17 at 16:44
  • @BasilBourque Indeed, I forgot ot mention `OffsetDateTime`. I've updated my answer, thanks! –  Jul 11 '17 at 17:47
  • @BasilBourque and @Hugo, thanks very much for your help. Regarding the naming of the `createdDate` property, that's a fair comment, but this applies on the service side and I just want my local model for the tests to reflect that naming. – NickBeaugié Jul 12 '17 at 17:29
  • @BasilBourque and @Hugo Regarding the styling, I come from a .NET background so this seemed more natural to me. However, I want to do things the right way, so have taken notice of what you are saying. The trouble is that the service (implemented in .NET) returns the properties in PascalCase. Thus, Gson doesn't by default deserialze. Although I have since discovered I can use `.setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)`, other services I have tests against have different casing (!). I want to avoid separate Gson for separate services right now. I may change my mind later though! – NickBeaugié Jul 12 '17 at 17:30