Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does JDBC/Postgres compare a timezone-less java.util.Date with a Timestamp?

We have a Postgres table that has two TIMESTAMP WITHOUT TIME ZONE columns, prc_sta_dt and prc_end_dt. We check to see whether a java.util.Date falls in between the start and end dates.

Here is some of the Java code, which is simplified but gets the point across.

// This format expects a String such as 2018-12-03T10:00:00
// With a date and a time, but no time zone

String timestamp = "2018-12-03T10:00:00";
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
Date searchDate = formatter.parse(timestamp);

// Here's the Postgres query

String query = "select promotion_cd from promotions " + 
               "where prc_sta_dt <= :srch_dt and prc_end_dt >= :srch_dt";

Map<String, Object> map = new HashMap<String, Object>();
map.put("srch_dt", searchDate);

List<Promotion> promotions = jdbcTemplate.query(query, map, promotionMapper);

In our Postgres table, we have promotions that start at 9am on 12/3/2018 and end at 3pm on the same day. The prc_sta_dt and prc_end_dt colums in our database for these rows are 2018-12-03 09:00:00.0 and 2018-12-03 15:00:00.0

Question: When JDBC/Postgres accepts our searchDate and compares it to these timestamps, will it accept the given search date of 10am (2018-12-03T10:00:00) or will it treat this time as being under the time zone that the server is running on, and then convert that to UTC?

For example, if the server is running in Chicago, then will it interpret 10 am as 10am CST and then convert that to 4pm UTC before doing the comparison in the database? If so then we're out of luck!

I doubt this would happen, but I just want to make sure so there are no surprises.

like image 975
ktm5124 Avatar asked Dec 03 '18 19:12

ktm5124


1 Answers

Wrong data type, Date is not a date

A java.util.Date object represents a moment in UTC, a specific point on the timeline. So it represents the combination of a date, a time-of-day, and an offset-from-UTC of zero (for UTC itself). Among the many poor design choices in this terrible class is its misleading name that has confused countless Java programmers.

TIMESTAMP WITHOUT TIME ZONE

If you care about moments, then your database column should not be of type TIMESTAMP WITHOUT TIME ZONE. That data type represents a date and a time-of-day without any concept of time zone or offset-from-UTC. As such, by definition, that type cannot represent a moment, is not a point on the timeline. This type should only be used when you mean a date-with-time anywhere or everywhere.

Examples:

  • “Christmas starts after stroke of midnight at beginning of December 25, 2018” where Christmas in Kiribati comes first, India later, and Africa even later.
  • “Company-wide memo: Each of our factories in Delhi, Düsseldorf, and Detroit will close one hour early at 16:00 on January 21st” where 4 PM at each factory is three different moments, each several hours apart.

TIMESTAMP WITH TIME ZONE

When tracking specific a specific moment, a single point on the timeline, use a column of type TIMESTAMP WITH TIME ZONE. In Postgres, such values are stored in UTC. Any time zone or offset info submitted with an input is used to adjust into UTC, then the zone/offset info is discarded.

BEWARE: Some tools may have the well-intentioned but unfortunate anti-feature of injecting a time zone after retrieving the value in UTC, thereby misrepresenting what was actually stored.

Comparing a moment to values of TIMESTAMP WITHOUT TIME ZONE

As for comparing a moment to values in your column of type TIMESTAMP WITHOUT TIME ZONE, doing so would generally not make sense.

But if you are clear-headed and educated about date-time handling, and making this comparison is sensible in your business logic, let's forge on.

Wrong classes

You are using lousy, terrible, awful date-time classes (Date, SimpleDateFormat, etc.) that were supplanted years ago by the java.time classes. Do yourself a favor: Stop using the legacy date-time classes. Use only java.time.

If given a moment as a java.util.Date, use the new methods added to the old classes to convert. In particular, java.util.Date is replaced by Instant.

Instant instant = myJavaUtilDate.toInstant() ;  // Convert from legacy class to modern class.

Specify the time zone in which you want to adjust your Instant moment in UTC for comparison. For example, if your database was built by someone who did not understand proper date-time handling, and has been using the TIMESTAMP WITHOUT TIME ZONE column to store date-with-time values that were taken from the wall-clock time of Québec, then use the time zone America/Montreal.

Specify a proper time zone name in the format of continent/region, such as America/Montreal, Africa/Casablanca, or Pacific/Auckland. Never use the 2-4 letter abbreviation such as EST or IST as they are not true time zones, not standardized, and not even unique(!).

ZoneId z = ZoneId.of( "America/Montreal" ) ;

Apply that zone to our Instant to get a ZonedDateTime object.

ZonedDateTime zdt = instant.atZone( z ) ;

Our resulting ZonedDateTime object represents the same moment as the Instant object, same point on the timeline, but viewed with a different wall-clock time.

To hammer a square-peg into a round-hole, let's convert that ZonedDateTime object to a LocalDateTime object, thereby stripping away the time zone information and leaving only a date-with-time-of-day value.

LocalDateTime ldt = zdt.toLocalDateTime() ;

Half-Open

where prc_sta_dt <= :srch_dt and prc_end_dt >= :srch_dt

That logic is prone to failure. Generally, the best practice in date-time handling when defining a span-of-time to use Half-Open, where the beginning is inclusive while the ending is exclusive.

So use this:

WHERE instant >= start_col AND instant < stop_col ;

For a PreparedStatement, we would have placeholders.

WHERE ? >= start_col AND ? < stop_col ;

On the Java side, as of JDBC 4.2 we can directly exchange java.time objects with the database via getObject and setObject methods.

You might be able to pass an Instant depending on your JDBC driver. Support for Instant is not required by the JDBC spec. So try it, or read the doc for your driver.

myPreparedStatement.setObject( 1 , instant ) ;
myPreparedStatement.setObject( 2 , instant ) ;

If Instant is not supported, convert from Instant to an OffsetDateTime set to UTC. Support for OffsetDateTime is required by the spec.

myPreparedStatement.setObject( 1 , instant.atOffset( ZoneOffset.UTC ) ) ;
myPreparedStatement.setObject( 2 , instant.atOffset( ZoneOffset.UTC ) ) ;

Retrieval.

OffsetDateTime odt = myResultSet.getObject( … , OffsetDateTime.class ) ;

Always specify time zone

For example, if the server is running in Chicago, then will it interpret 10 am as 10am CST and then convert that to 4pm UTC before doing the comparison in the database?

A programmer should never depend on the time zone (or locale, by the way) currently set as the default on the host OS or JVM. Both are out of your control. And both can change at any moment during runtime!

Always specify the time zone by passing the optional argument to various date-time methods. Making those optional was a design flaw in java.time in my opinion, as programmers all too often ignore the issue of time zone, at their own peril. But that is one of very few design flaws in an amazingly useful and elegant framework.

Notice in our code above we specified the desired/expected time zone. The current default time zone of our host OS, our Postgres database connection, and our JVM will not alter the behavior of our code.

Current moment

If you want the current moment use any of these:

  • Instant.now()
    Always in UTC, by definition.
  • OffsetDateTime.now( someZoneOffset )
    Current moment as seen in the wall-clock time of a particular offset-from-UTC.
  • ZonedDateTime.now( someZoneId )
    Current moment as seen in the wall-clock time used by the people living in a particular region.

Java 7 and ThreeTen-Backport

If you are using Java 7, then you have no java.time classes built-in. Fortunately, the inventor of JSR 310 and java.time, Stephen Colebourne, also led the ThreeTen-Backport project to produce a library providing most of the java.time functionality to Java 6 & 7.

Here is a complete example app in a single .java file showing the use of back-port in Java 7 with the H2 Database Engine.

In Java 7, JDBC 4.2 is not available, so we cannot directly use the modern classes. We fall back to using java.sql.Timestamp which actually represents a moment in UTC, but which H2 stores into a column of TIMESTAMP WITHOUT TIME ZONE taking the date and the time-of-day as-is (using the wall-clock time of UTC) while ignoring the UTC aspect. I have not tried this in Postgres, but I expect you will see the same behavior.

package com.basilbourque.example;

import java.sql.*;

import org.threeten.bp.*;

public class App {
    static final public String databaseConnectionString = "jdbc:h2:mem:localdatetime_example;DB_CLOSE_DELAY=-1";  // The `DB_CLOSE_DELAY=-1` keeps the in-memory database around for multiple connections.

    public static void main ( String[] args ) {
        App app = new App();
        app.doIt();
    }

    private void doIt () {
        System.out.println( "Bonjour tout le monde!" );

//        java.sql.Timestamp ts = DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 1 , 23 , 12 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
//        System.out.println( ts );

        this.makeDatabase();

        java.util.Date d = new java.util.Date(); // Capture the current moment using terrible old date-time class that is now legacy, supplanted years ago by the class `java.time.Instant`.
        this.fetchRowsContainingMoment( d );
    }

    private void makeDatabase () {
        try {
            Class.forName( "org.h2.Driver" );
        } catch ( ClassNotFoundException e ) {
            e.printStackTrace();
        }

        try (
            Connection conn = DriverManager.getConnection( databaseConnectionString ) ;  // The `mem` means “In-Memory”, as in “Not persisted to disk”, good for a demo.
            Statement stmt = conn.createStatement() ;
        ) {
            String sql = "CREATE TABLE event_ ( \n" +
                             "  pkey_ IDENTITY NOT NULL PRIMARY KEY , \n" +
                             "  name_ VARCHAR NOT NULL , \n" +
                             "  start_ TIMESTAMP WITHOUT TIME ZONE NOT NULL , \n" +
                             "  stop_ TIMESTAMP WITHOUT TIME ZONE NOT NULL \n" +
                             ");";
            stmt.execute( sql );

            // Insert row.
            sql = "INSERT INTO event_ ( name_ , start_ , stop_ ) VALUES ( ? , ? , ? ) ;";
            try (
                PreparedStatement preparedStatement = conn.prepareStatement( sql ) ;
            ) {
                preparedStatement.setObject( 1 , "Alpha" );
                // We have to “fake it until we make it”, using a `java.sql.Timestamp` with its value in UTC while pretending it is not in a zone or offset.
                // The legacy date-time classes lack a way to represent a date with time-of-day without any time zone or offset-from-UTC.
                // The legacy classes have no counterpart to `TIMESTAMP WITHOUT TIME ZONE` in SQL, and have no counterpart to `java.time.LocalDateTime` in Java.
                preparedStatement.setObject( 2 , DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 1 , 23 , 12 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() ) );
                preparedStatement.setObject( 3 , DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 2 , 23 , 12 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() ) );
                preparedStatement.executeUpdate();

                preparedStatement.setString( 1 , "Beta" );
                preparedStatement.setObject( 2 , DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 4 , 23 , 14 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() ) );
                preparedStatement.setObject( 3 , DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 5 , 23 , 14 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() ) );
                preparedStatement.executeUpdate();

                preparedStatement.setString( 1 , "Gamma" );
                preparedStatement.setObject( 2 , DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 11 , 23 , 16 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() ) );
                preparedStatement.setObject( 3 , DateTimeUtils.toSqlTimestamp( ZonedDateTime.of( 2018 , 12 , 23 , 16 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() ) );
                preparedStatement.executeUpdate();
            }
        } catch ( SQLException e ) {
            e.printStackTrace();
        }
    }

    private void fetchRowsContainingMoment ( java.util.Date moment ) {
        // Immediately convert the legacy class `java.util.Date` to a modern `java.time.Instant`.
        Instant instant = DateTimeUtils.toInstant( moment );
        System.out.println( "instant.toString(): " + instant );
        String sql = "SELECT * FROM event_ WHERE ? >= start_ AND ? < stop_ ORDER BY start_ ;";

        try (
            Connection conn = DriverManager.getConnection( databaseConnectionString ) ;
            PreparedStatement pstmt = conn.prepareStatement( sql ) ;
        ) {
            java.sql.Timestamp ts = DateTimeUtils.toSqlTimestamp( instant );
            pstmt.setTimestamp( 1 , ts );
            pstmt.setTimestamp( 2 , ts );

            try ( ResultSet rs = pstmt.executeQuery() ; ) {
                while ( rs.next() ) {
                    //Retrieve by column name
                    Integer pkey = rs.getInt( "pkey_" );
                    String name = rs.getString( "name_" );
                    java.sql.Timestamp start = rs.getTimestamp( "start_" );
                    java.sql.Timestamp stop = rs.getTimestamp( "stop_" );

                    // Instantiate a `Course` object for this data.
                    System.out.println( "Event pkey: " + pkey + " | name: " + name + " | start: " + start + " | stop: " + stop );
                }
            }
        } catch ( SQLException e ) {
            e.printStackTrace();
        }
    }
}

When run.

instant.toString(): 2018-12-04T05:06:02.573Z

Event pkey: 3 | name: Gamma | start: 2018-11-23 16:30:00.0 | stop: 2018-12-23 16:30:00.0

Java 8 without ThreeTen-Backport

And here is that same example, conceptually, but in Java 8 or later where we can use the java.time classes built-in without the ThreeTen-Backport library.

package com.basilbourque.example;

import java.sql.*;

import java.time.*;

public class App {
    static final public String databaseConnectionString = "jdbc:h2:mem:localdatetime_example;DB_CLOSE_DELAY=-1";  // The `DB_CLOSE_DELAY=-1` keeps the in-memory database around for multiple connections.

    public static void main ( String[] args ) {
        App app = new App();
        app.doIt();
    }

    private void doIt ( ) {
        System.out.println( "Bonjour tout le monde!" );

        this.makeDatabase();

        java.util.Date d = new java.util.Date(); // Capture the current moment using terrible old date-time class that is now legacy, supplanted years ago by the class `java.time.Instant`.
        this.fetchRowsContainingMoment( d );
    }

    private void makeDatabase ( ) {
        try {
            Class.forName( "org.h2.Driver" );
        } catch ( ClassNotFoundException e ) {
            e.printStackTrace();
        }

        try (
                Connection conn = DriverManager.getConnection( databaseConnectionString ) ;  // The `mem` means “In-Memory”, as in “Not persisted to disk”, good for a demo.
                Statement stmt = conn.createStatement() ;
        ) {
            String sql = "CREATE TABLE event_ ( \n" +
                    "  pkey_ IDENTITY NOT NULL PRIMARY KEY , \n" +
                    "  name_ VARCHAR NOT NULL , \n" +
                    "  start_ TIMESTAMP WITHOUT TIME ZONE NOT NULL , \n" +
                    "  stop_ TIMESTAMP WITHOUT TIME ZONE NOT NULL \n" +
                    ");";
            stmt.execute( sql );

            // Insert row.
            sql = "INSERT INTO event_ ( name_ , start_ , stop_ ) VALUES ( ? , ? , ? ) ;";
            try (
                    PreparedStatement preparedStatement = conn.prepareStatement( sql ) ;
            ) {
                preparedStatement.setObject( 1 , "Alpha" );
                // We have to “fake it until we make it”, using a `java.sql.Timestamp` with its value in UTC while pretending it is not in a zone or offset.
                // The legacy date-time classes lack a way to represent a date with time-of-day without any time zone or offset-from-UTC.
                // The legacy classes have no counterpart to `TIMESTAMP WITHOUT TIME ZONE` in SQL, and have no counterpart to `java.time.LocalDateTime` in Java.
                preparedStatement.setObject( 2 , ZonedDateTime.of( 2018 , 1 , 23 , 12 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
                ;
                preparedStatement.setObject( 3 , ZonedDateTime.of( 2018 , 2 , 23 , 12 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
                preparedStatement.executeUpdate();

                preparedStatement.setString( 1 , "Beta" );
                preparedStatement.setObject( 2 , ZonedDateTime.of( 2018 , 4 , 23 , 14 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
                preparedStatement.setObject( 3 , ZonedDateTime.of( 2018 , 5 , 23 , 14 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
                preparedStatement.executeUpdate();

                preparedStatement.setString( 1 , "Gamma" );
                preparedStatement.setObject( 2 , ZonedDateTime.of( 2018 , 11 , 23 , 16 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
                preparedStatement.setObject( 3 , ZonedDateTime.of( 2018 , 12 , 23 , 16 , 30 , 0 , 0 , ZoneId.of( "America/Montreal" ) ).toLocalDateTime() );
                preparedStatement.executeUpdate();
            }
        } catch ( SQLException e ) {
            e.printStackTrace();
        }
    }

    private void fetchRowsContainingMoment ( java.util.Date moment ) {
        // Immediately convert the legacy class `java.util.Date` to a modern `java.time.Instant`.
        Instant instant = moment.toInstant();
        System.out.println( "instant.toString(): " + instant );
        String sql = "SELECT * FROM event_ WHERE ? >= start_ AND ? < stop_ ORDER BY start_ ;";

        try (
                Connection conn = DriverManager.getConnection( databaseConnectionString ) ;
                PreparedStatement pstmt = conn.prepareStatement( sql ) ;
        ) {
            pstmt.setObject( 1 , instant );
            pstmt.setObject( 2 , instant );

            try ( ResultSet rs = pstmt.executeQuery() ; ) {
                while ( rs.next() ) {
                    //Retrieve by column name
                    Integer pkey = rs.getInt( "pkey_" );
                    String name = rs.getString( "name_" );
                    Instant start = rs.getObject( "start_" , OffsetDateTime.class ).toInstant();
                    Instant stop = rs.getObject( "stop_" , OffsetDateTime.class ).toInstant();

                    // Instantiate a `Course` object for this data.
                    System.out.println( "Event pkey: " + pkey + " | name: " + name + " | start: " + start + " | stop: " + stop );
                }
            }
        } catch ( SQLException e ) {
            e.printStackTrace();
        }
    }
}

When run.

instant.toString(): 2018-12-04T05:10:54.635Z

Event pkey: 3 | name: Gamma | start: 2018-11-24T00:30:00Z | stop: 2018-12-24T00:30:00Z


About java.time

The java.time framework is built into Java 8 and later. These classes supplant the troublesome old legacy date-time classes such as java.util.Date, Calendar, & SimpleDateFormat.

The Joda-Time project, now in maintenance mode, advises migration to the java.time classes.

To learn more, see the Oracle Tutorial. And search Stack Overflow for many examples and explanations. Specification is JSR 310.

You may exchange java.time objects directly with your database. Use a JDBC driver compliant with JDBC 4.2 or later. No need for strings, no need for java.sql.* classes.

Where to obtain the java.time classes?

  • Java SE 8, Java SE 9, Java SE 10, Java SE 11, and later - Part of the standard Java API with a bundled implementation.
    • Java 9 adds some minor features and fixes.
  • Java SE 6 and Java SE 7
    • Most of the java.time functionality is back-ported to Java 6 & 7 in ThreeTen-Backport.
  • Android
    • Later versions of Android bundle implementations of the java.time classes.
    • For earlier Android (<26), the ThreeTenABP project adapts ThreeTen-Backport (mentioned above). See How to use ThreeTenABP….

The ThreeTen-Extra project extends java.time with additional classes. This project is a proving ground for possible future additions to java.time. You may find some useful classes here such as Interval, YearWeek, YearQuarter, and more.

like image 140
Basil Bourque Avatar answered Sep 29 '22 05:09

Basil Bourque