ZonedDateTime comparison: expected: [Etc/UTC] but was: [UTC]

10,184

Solution 1

You can convert the ZonedDateTime objects to Instant, as the other answers/comments already told.

ZonedDateTime::isEqual

Or you can use the isEqual method, which compares if both ZonedDateTime instances correspond to the same Instant:

ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime zoneDateTimeEtcUtc = now.withZoneSameInstant(ZoneId.of("Etc/UTC"));
ZonedDateTime zoneDateTimeUtc = now.withZoneSameInstant(ZoneId.of("UTC"));

Assert.assertTrue(zoneDateTimeEtcUtc.isEqual(zoneDateTimeUtc));

Solution 2

The class ZonedDateTime uses in its equals()-method the comparison of inner ZoneId-members. So we see in that class (source code in Java-8):

/**
 * Checks if this time-zone ID is equal to another time-zone ID.
 * <p>
 * The comparison is based on the ID.
 *
 * @param obj  the object to check, null returns false
 * @return true if this is equal to the other time-zone ID
 */
@Override
public boolean equals(Object obj) {
    if (this == obj) {
       return true;
    }
    if (obj instanceof ZoneId) {
        ZoneId other = (ZoneId) obj;
        return getId().equals(other.getId());
    }
    return false;
}

The lexical representations "Etc/UTC" and "UTC" are obviously different strings so the zoneId-comparison trivially yields false. This behaviour is described in the javadoc, so we have no bug. I stress the statement of the documentation:

The comparison is based on the ID.

That said, a ZoneId is like a named pointer to the zone data and does not represent the data themselves.

But I assume that you rather want to compare the rules of both different zone-ids and not the lexical representations. Then ask the rules:

ZoneId z1 = ZoneId.of("Etc/UTC");
ZoneId z2 = ZoneId.of("UTC");
System.out.println(z1.equals(z2)); // false
System.out.println(z1.getRules().equals(z2.getRules())); // true

So you could use the comparison of zone rules and the other non-zone-related members of ZonedDateTime (a little bit awkward).

By the way, I strongly recommend not to use "Etc/..."-identifiers because (with the exception of "Etc/GMT" or "Etc/UTC") their offset signs are in reverse than what is usually expected.

Another important remark about the comparison of ZonedDateTime-instances. Look here:

System.out.println(zoneDateTimeEtcUtc.compareTo(zoneDateTimeUtc)); // -16
System.out.println(z1.getId().compareTo(z2.getId())); // -16

We see that the comparison of ZonedDateTime-instances with same instant and local timestamp is based on the lexical comparison of zone-ids. Usually not what most users would expect. But it is no bug, too, because this behaviour is described in the API. This is another reason why I don't like to work with the type ZonedDateTime. It is inherently too complex. You should only use it for intermediate type conversions between Instant and the local types IMHO. This concretely means: Before you compare ZonedDateTime-instances, please convert them (usually to Instant) and then compare.

Update from 2018-02-01:

The JDK-issue which was opened for this question has been closed by Oracle as "Not an issue".

Solution 3

ZoneDateTime should be converted to OffsetDateTime and then compared with compareTo(..) if you want to compare the time.

ZoneDateTime.equals and ZoneDateTime.compareTo compare if the instant and the zone identifier, i.e. tth string identifying the timezone.

ZoneDateTime is an instant and a zone (with its id and not only an offset) while OffsetDateTime is an instant and a zone offset. If you want to compare the time between the two ZoneDateTime objects, you should use OffsetDateTime.

The method ZoneId.of(..)is parsing the string you give and transform it if needed. ZoneId represents the timezone and not the offset, i.e. the time shift with the GMT timezone. While ZoneOffset reprensents the offset. https://docs.oracle.com/javase/8/docs/api/java/time/ZoneId.html#of-java.lang.String-

If the zone ID equals 'GMT', 'UTC' or 'UT' then the result is a ZoneId with the same ID and rules equivalent to ZoneOffset.UTC.

If the zone ID starts with 'UTC+', 'UTC-', 'GMT+', 'GMT-', 'UT+' or 'UT-' then the ID is a prefixed offset-based ID. The ID is split in two, with a two or three letter prefix and a suffix starting with the sign. The suffix is parsed as a ZoneOffset. The result will be a ZoneId with the specified UTC/GMT/UT prefix and the normalized offset ID as per ZoneOffset.getId(). The rules of the returned ZoneId will be equivalent to the parsed ZoneOffset.

All other IDs are parsed as region-based zone IDs. Region IDs must match the regular expression [A-Za-z][A-Za-z0-9~/._+-]+ otherwise a DateTimeException is thrown. If the zone ID is not in the configured set of IDs, ZoneRulesException is thrown. The detailed format of the region ID depends on the group supplying the data. The default set of data is supplied by the IANA Time Zone Database (TZDB). This has region IDs of the form '{area}/{city}', such as 'Europe/Paris' or 'America/New_York'. This is compatible with most IDs from TimeZone.

so UTC+0 is transformed to UTC while Etc/UTC is kept without modification

ZoneDateTime.compareTo compares the string of the id ("Etc/UTC".compareTo("UTC") == 16). That's the reason you should use OffsetDateTime.

Share:
10,184
Andremoniy
Author by

Andremoniy

For contacts: andremoniy at sign gmail dot com. I am open for team-lead/tech-lead positions.

Updated on June 07, 2022

Comments

  • Andremoniy
    Andremoniy about 2 years

    I was comparing two dates which seem to be equal, but they contain a different name of zones: one is Etc/UTC, another is UTC.

    According to this question: Is there a difference between the UTC and Etc/UTC time zones? - this two zones are the same. But my tests fail:

    import org.junit.Test;
    import java.sql.Timestamp;
    import java.time.ZoneId;
    import java.time.ZonedDateTime;
    import static org.junit.Assert.assertEquals;
    
    public class TestZoneDateTime {
    
        @Test
        public void compareEtcUtcWithUtc() {
            ZonedDateTime now = ZonedDateTime.now();
            ZonedDateTime zoneDateTimeEtcUtc = now.withZoneSameInstant(ZoneId.of("Etc/UTC"));
            ZonedDateTime zoneDateTimeUtc = now.withZoneSameInstant(ZoneId.of("UTC"));
    
            // This is okay
            assertEquals(Timestamp.from(zoneDateTimeEtcUtc.toInstant()), Timestamp.from(zoneDateTimeUtc.toInstant()));
            // This one fails
            assertEquals(zoneDateTimeEtcUtc,zoneDateTimeUtc);
    
            // This fails as well (of course previous line should be commented!)
            assertEquals(0, zoneDateTimeEtcUtc.compareTo(zoneDateTimeUtc));
        }
    }
    

    The result:

    java.lang.AssertionError: 
    Expected :2018-01-26T13:55:57.087Z[Etc/UTC]
    Actual   :2018-01-26T13:55:57.087Z[UTC]
    

    More specifically, I would expect, that ZoneId.of("UTC") would be equal to ZoneId.of("Etc/UTC"), but they aren't!

    As @NicolasHenneaux suggested, I should probably use compareTo(...) method. That's good idea, but zoneDateTimeEtcUtc.compareTo(zoneDateTimeUtc) returns -16 value, because of this implementation inside ZoneDateTime:

    cmp = getZone().getId().compareTo(other.getZone().getId());
    

    Assertion result:

    java.lang.AssertionError: 
    Expected :0
    Actual   :-16
    

    So the problem lies somewhere in ZoneId implementation. But I still would expect that if both zone ids are valid and both designate the same zone, then they should be equal.

    My question is: is it a library bug, or I am doing something wrong?

    UPDATE

    Several people tried to convince me that it is a normal behaviour, and it is normal that the implementation of comparison methods uses String id representation of the ZoneId. In this case I should ask, why does the following test runs okay?

        @Test
        public void compareUtc0WithUtc() {
            ZonedDateTime now = ZonedDateTime.now();
            ZoneId utcZone = ZoneId.of("UTC");
            ZonedDateTime zonedDateTimeUtc = now.withZoneSameInstant(utcZone);
            ZoneId utc0Zone = ZoneId.of("UTC+0");
            ZonedDateTime zonedDateTimeUtc0 = now.withZoneSameInstant(utc0Zone);
    
            // This is okay
            assertEquals(Timestamp.from(zonedDateTimeUtc.toInstant()), Timestamp.from(zonedDateTimeUtc0.toInstant()));
            assertEquals(0, zonedDateTimeUtc.compareTo(zonedDateTimeUtc0));
            assertEquals(zonedDateTimeUtc,zonedDateTimeUtc0);
        }
    

    If Etc/UTC is the same as UTC, then I see two options:

    • compareTo/equals method shouldn't use ZoneId id, but should compare their rules
    • Zone.of(...) is broken and should treat Etc/UTC and UTC as the same time zones.

    Otherwise I don't see why UTC+0 and UTC work fine.

    UPDATE-2 I have reported a bug, ID : 9052414. Will see what Oracle team will decide.

    UPDATE-3 The bug report accepted (don't know will they close it as "won't fix" or not): https://bugs.openjdk.java.net/browse/JDK-8196398

  • Andremoniy
    Andremoniy over 6 years
    zoneDateTimeEtcUtc.compareTo(zoneDateTimeUtc) returns -16.
  • Eugene
    Eugene over 6 years
    now this is what I meant in the comments, there has to be a way to actually compare them... 1+
  • Andremoniy
    Andremoniy over 6 years
    Thank you for this analysis. But we all can read source codes and read javadocs. :-) Seriously, don't be offended :-) . This doesn't explain why zoneDateTimeEtcUtc.compareTo(zoneDateTimeUtc) is not equal to 0. If zones are equal, so date-time representations with these zones should be equal as well. That is why I consider this as a bug in the impementation
  • Andremoniy
    Andremoniy over 6 years
    @Eugene comparing rules of Zones doesn't solve the initial problem with comparing two ZonedDateTime constructed with them...
  • Anton Malyshev
    Anton Malyshev over 6 years
    Obvious bug in java design/implementation I think. Why to use all that fancy objects if they even don't work in terms of comparison
  • Andremoniy
    Andremoniy over 6 years
    To Meno, an additional comment: in fact I didn't use Etc/UTC explicitly (this is just from test). The problem came from some other code where this zone was assigned automatically without my desire...
  • Eugene
    Eugene over 6 years
    @Andremoniy I know, this was just to say that at least there's a way to compare them I guess
  • Andremoniy
    Andremoniy over 6 years
    @Eugene to compare Zones - looks like, yes, there exists a way. But it is useless, as we can not compare ZonedDateTimes...
  • Andremoniy
    Andremoniy over 6 years
    And probably kind of this comparison z1.getRules().equals(z2.getRules()) should be done inside ZonedDateTime.compareTo method instead of comparing ZoneId's.ID's. That is a bug in my opinion
  • Bubletan
    Bubletan over 6 years
    You can do something like zdt1.toInstant().equals(zdt2.toInstant()) && zdt1.getZone().getRules().equals(zdt2.getZone().getRules()) or zdt1.withFixedOffsetZone().equals(zdt2.withFixedOffsetZone()‌​). It's not very elegant but works.
  • Meno Hochschild
    Meno Hochschild over 6 years
    @Andremoniy Good observation. The comparison finally delegates to ZoneId.getId()-comparison, see my answer.
  • Andremoniy
    Andremoniy over 6 years
    BTW, @Nicolas, it is said in the javadoc also: ` It is "consistent with equals", as defined by Comparable`. So equals is also proper way to compare these objects
  • Meno Hochschild
    Meno Hochschild over 6 years
    @Andremoniy The statement "consistent with equals" does not necessarily means that it is a pure temporal comparison. In fact, other non-temporal state like the lexical representation of ZoneId is taken into account here. As said in my answer: A ZoneId is semantically only a pointer with name referencing rules/data but does not represent the ruels itself. Oracle will never accept that this is a bug because they have even described in the official API in all details this strange behaviour. No chance to get it changed in Java. IMHO a design error to make ZonedDateTime comparable at all
  • Andremoniy
    Andremoniy over 6 years
    @MenoHochschild IMHO a design error to make ZonedDateTime comparable at all - I agree!!!
  • Andremoniy
    Andremoniy over 6 years
    @MenoHochschild But take a note please and see my update: nevertheless UTC+0 and UTC are treated as equal.
  • Meno Hochschild
    Meno Hochschild over 6 years
    @Andremoniy The fact that ZonedDateTime implements the Comparable-interface calls for problems. Your wish that the comparison does a rule-based comparison of ZoneId is understandable but neglects a) that there is another non-temporal state like the Chronology and b) the comparison can break in context of deserialization (hard to understand on first glance). My personal view how useful ZonedDateTime is can be found here The type ZonedDateTime is simply too complex in many subtile ways. Just having Instant and local types should be preferred.
  • Meno Hochschild
    Meno Hochschild over 6 years
    @Andremoniy Good observation about UTC+0 and UTC. Here Java does an inner normalization inside the class ZoneId but not with "Etc/...". Apparently inconsistent! But even this "feature" is described by Oracle.
  • Nicolas Henneaux
    Nicolas Henneaux over 6 years
    @MenoHochschild it is exactly specified.
  • Meno Hochschild
    Meno Hochschild over 6 years
    Exactly true, so Oracle will refuse to accept this post as a bug.
  • Basil Bourque
    Basil Bourque over 6 years
    Regarding your first sentence, if you want to compare instants convert to Instant, not OffsetDateTime. ZonedDateTime::toInstant()
  • Nicolas Henneaux
    Nicolas Henneaux over 6 years
    @BasilBourque right my explanation was not clear, I have updated it.
  • Andremoniy
    Andremoniy over 6 years
    isEqual is really a good observation! Your answer doesn't provide an exact answer to my question, but I am upvoting it for this good idea.