Skip to content

Commit

Permalink
fix: support local date times not in time zone
Browse files Browse the repository at this point in the history
Local date time support introduced in #474 has subtle edge cases. These
occur when trying to read a LocalDateTime that does not exist in the VM
default time zone because it falls in a "not existing" time during a
daylight saving time offset transition. For LocalDateTime instances
this should not be an issue since they are explicitly not associated
with a time zone. This commit tests all time zones and timestamps from
TimezoneTest except 2015-06-30T23:59:60, this is only valid with a time
zone in order to know whether it is actually a leap second.

Fix local date time edge cases introduced by #474.
Closes #493
  • Loading branch information
marschall authored and vlsi committed Jan 24, 2016
1 parent a6a61be commit 61384ec
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 34 deletions.
30 changes: 25 additions & 5 deletions pgjdbc/src/main/java/org/postgresql/jdbc/PgResultSet.java
Expand Up @@ -586,6 +586,30 @@ public Timestamp getTimestamp(int i, java.util.Calendar cal) throws SQLException
return connection.getTimestampUtils().toTimestamp(cal, string);
}

//#if mvn.project.property.postgresql.jdbc.spec >= "JDBC4.2"
private LocalDateTime getLocalDateTime(int i) throws SQLException {
checkResultSet(i);
if (wasNullFlag) {
return null;
}

int col = i - 1;
int oid = fields[col].getOID();
if (oid != Oid.TIMESTAMP) {
throw new PSQLException(
GT.tr("Cannot convert the column of type {0} to requested type {1}.",
new Object[]{Oid.toString(oid), "timestamp"}),
PSQLState.DATA_TYPE_MISMATCH);
}
if (isBinary(i)) {
return connection.getTimestampUtils().toLocalDateTimeBin(this_row[col]);
}

String string = getString(i);
return connection.getTimestampUtils().toLocalDateTime(string);
}
//#endif


public java.sql.Date getDate(String c, java.util.Calendar cal) throws SQLException {
return getDate(findColumn(c), cal);
Expand Down Expand Up @@ -3299,11 +3323,7 @@ public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
}
} else if (type == LocalDateTime.class) {
if (sqlType == Types.TIMESTAMP) {
Timestamp timestampValue = getTimestamp(columnIndex);
if (wasNull()) {
return null;
}
return type.cast(timestampValue.toLocalDateTime());
return type.cast(getLocalDateTime(columnIndex));
} else {
throw new SQLException("conversion to " + type + " from " + sqlType + " not supported");
}
Expand Down
108 changes: 101 additions & 7 deletions pgjdbc/src/main/java/org/postgresql/jdbc/TimestampUtils.java
Expand Up @@ -120,6 +120,17 @@ private static class ParsedTimestamp {
Calendar tz = null;
}

private static class ParsedBinaryTimestamp {
Infinity infinity = null;
long millis = 0;
int nanos = 0;
}

enum Infinity {
POSITIVE,
NEGATIVE;
}

/**
* Load date/time information into the provided calendar returning the fractional seconds.
*/
Expand Down Expand Up @@ -342,6 +353,43 @@ public synchronized Timestamp toTimestamp(Calendar cal, String s) throws SQLExce
return result;
}

//#if mvn.project.property.postgresql.jdbc.spec >= "JDBC4.2"
/**
* Parse a string and return a LocalDateTime representing its value.
*
* @param s The ISO formated date string to parse.
* @return null if s is null or a LocalDateTime of the parsed string s.
* @throws SQLException if there is a problem parsing s.
*/
public LocalDateTime toLocalDateTime(String s) throws SQLException {
if (s == null) {
return null;
}

int slen = s.length();

// convert postgres's infinity values to internal infinity magic value
if (slen == 8 && s.equals("infinity")) {
return LocalDateTime.MAX;
}

if (slen == 9 && s.equals("-infinity")) {
return LocalDateTime.MIN;
}

ParsedTimestamp ts = parseBackendTimestamp(s);

// intentionally ignore time zone
// 2004-10-19 10:23:54+03:00 is 2004-10-19 10:23:54 locally
LocalDateTime result = LocalDateTime.of(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second, ts.nanos);
if (ts.era == GregorianCalendar.BC) {
return result.with(ChronoField.ERA, IsoEra.BCE.getValue());
} else {
return result;
}
}
//#endif

public synchronized Time toTime(Calendar cal, String s) throws SQLException {
// 1) Parse backend string
Timestamp timestamp = toTimestamp(cal, s);
Expand Down Expand Up @@ -757,9 +805,24 @@ public Time toTimeBin(TimeZone tz, byte[] bytes) throws PSQLException {
public Timestamp toTimestampBin(TimeZone tz, byte[] bytes, boolean timestamptz)
throws PSQLException {

ParsedBinaryTimestamp parsedTimestamp = this.toParsedTimestampBin(tz, bytes, timestamptz);
if (parsedTimestamp.infinity == Infinity.POSITIVE) {
return new Timestamp(PGStatement.DATE_POSITIVE_INFINITY);
} else if (parsedTimestamp.infinity == Infinity.NEGATIVE) {
return new Timestamp(PGStatement.DATE_NEGATIVE_INFINITY);
}

Timestamp ts = new Timestamp(parsedTimestamp.millis);
ts.setNanos(parsedTimestamp.nanos);
return ts;
}

private ParsedBinaryTimestamp toParsedTimestampBin(TimeZone tz, byte[] bytes, boolean timestamptz)
throws PSQLException {

if (bytes.length != 8) {
throw new PSQLException(GT.tr("Unsupported binary encoding of {0}.", "timestamp"),
PSQLState.BAD_DATETIME_FORMAT);
PSQLState.BAD_DATETIME_FORMAT);
}

long secs;
Expand All @@ -768,9 +831,13 @@ public Timestamp toTimestampBin(TimeZone tz, byte[] bytes, boolean timestamptz)
if (usesDouble) {
double time = ByteConverter.float8(bytes, 0);
if (time == Double.POSITIVE_INFINITY) {
return new Timestamp(PGStatement.DATE_POSITIVE_INFINITY);
ParsedBinaryTimestamp ts = new ParsedBinaryTimestamp();
ts.infinity = Infinity.POSITIVE;
return ts;
} else if (time == Double.NEGATIVE_INFINITY) {
return new Timestamp(PGStatement.DATE_NEGATIVE_INFINITY);
ParsedBinaryTimestamp ts = new ParsedBinaryTimestamp();
ts.infinity = Infinity.NEGATIVE;
return ts;
}

secs = (long) time;
Expand All @@ -782,9 +849,13 @@ public Timestamp toTimestampBin(TimeZone tz, byte[] bytes, boolean timestamptz)
// and can actually be confusing because there are timestamps
// that are larger than infinite
if (time == Long.MAX_VALUE) {
return new Timestamp(PGStatement.DATE_POSITIVE_INFINITY);
ParsedBinaryTimestamp ts = new ParsedBinaryTimestamp();
ts.infinity = Infinity.POSITIVE;
return ts;
} else if (time == Long.MIN_VALUE) {
return new Timestamp(PGStatement.DATE_NEGATIVE_INFINITY);
ParsedBinaryTimestamp ts = new ParsedBinaryTimestamp();
ts.infinity = Infinity.NEGATIVE;
return ts;
}

secs = time / 1000000;
Expand All @@ -804,11 +875,34 @@ public Timestamp toTimestampBin(TimeZone tz, byte[] bytes, boolean timestamptz)
millis = guessTimestamp(millis, tz);
}

Timestamp ts = new Timestamp(millis);
ts.setNanos(nanos);
ParsedBinaryTimestamp ts = new ParsedBinaryTimestamp();
ts.millis = millis;
ts.nanos = nanos;
return ts;
}

//#if mvn.project.property.postgresql.jdbc.spec >= "JDBC4.2"
/**
* Returns the local date time object matching the given bytes with {@link Oid#TIMESTAMP} or
* {@link Oid#TIMESTAMPTZ}.
*
* @param bytes The binary encoded local date time value.
* @return The parsed local date time object.
* @throws PSQLException If binary format could not be parsed.
*/
public LocalDateTime toLocalDateTimeBin(byte[] bytes) throws PSQLException {

ParsedBinaryTimestamp parsedTimestamp = this.toParsedTimestampBin(null, bytes, true);
if (parsedTimestamp.infinity == Infinity.POSITIVE) {
return LocalDateTime.MAX;
} else if (parsedTimestamp.infinity == Infinity.NEGATIVE) {
return LocalDateTime.MAX;
}

return LocalDateTime.ofEpochSecond(parsedTimestamp.millis / 1000L, parsedTimestamp.nanos, ZoneOffset.UTC);
}
//#endif

/**
* Given a UTC timestamp {@code millis} finds another point in time that is rendered in given time
* zone {@code tz} exactly as "millis in UTC".
Expand Down
@@ -0,0 +1,16 @@
package org.postgresql.test.jdbc42;

import java.util.Properties;

public class GetObject310BinaryTest extends GetObject310Test {

public GetObject310BinaryTest(String name) {
super(name);
}

@Override
protected void updateProperties(Properties props) {
forceBinary(props);
}

}
@@ -1,35 +1,38 @@
package org.postgresql.test.jdbc42;

import org.postgresql.test.TestUtil;
import org.postgresql.test.jdbc2.BaseTest;

import junit.framework.TestCase;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TimeZone;

public class GetObject310Test extends BaseTest {

public class GetObject310Test extends TestCase {
private static final TimeZone saveTZ = TimeZone.getDefault();

private static final ZoneOffset UTC = ZoneOffset.UTC; // +0000 always
private static final ZoneOffset GMT03 = ZoneOffset.of("+03:00"); // +0300 always
private static final ZoneOffset GMT05 = ZoneOffset.of("-05:00"); // -0500 always
private static final ZoneOffset GMT13 = ZoneOffset.of("+13:00"); // +1300 always

private Connection _conn;

public GetObject310Test(String name) {
super(name);
}

protected void setUp() throws Exception {
_conn = TestUtil.openDB();
TestUtil.createTable(_conn, "table1", "timestamp_without_time_zone_column timestamp without time zone,"
super.setUp();
TestUtil.createTable(con, "table1", "timestamp_without_time_zone_column timestamp without time zone,"
+ "timestamp_with_time_zone_column timestamp with time zone,"
+ "date_column date,"
+ "time_without_time_zone_column time without time zone,"
Expand All @@ -38,15 +41,16 @@ protected void setUp() throws Exception {
}

protected void tearDown() throws SQLException {
TestUtil.dropTable(_conn, "table1");
TestUtil.closeDB( _conn );
TimeZone.setDefault(saveTZ);
TestUtil.dropTable(con, "table1");
super.tearDown();
}

/**
* Test the behavior getObject for date columns.
*/
public void testGetLocalDate() throws SQLException {
Statement stmt = _conn.createStatement();
Statement stmt = con.createStatement();
stmt.executeUpdate(TestUtil.insertSQL("table1","date_column","DATE '1999-01-08'"));

ResultSet rs = stmt.executeQuery(TestUtil.selectSQL("table1", "date_column"));
Expand All @@ -64,7 +68,7 @@ public void testGetLocalDate() throws SQLException {
* Test the behavior getObject for time columns.
*/
public void testGetLocalTime() throws SQLException {
Statement stmt = _conn.createStatement();
Statement stmt = con.createStatement();
stmt.executeUpdate(TestUtil.insertSQL("table1","time_without_time_zone_column","TIME '04:05:06'"));

ResultSet rs = stmt.executeQuery(TestUtil.selectSQL("table1", "time_without_time_zone_column"));
Expand All @@ -82,17 +86,59 @@ public void testGetLocalTime() throws SQLException {
* Test the behavior getObject for timestamp columns.
*/
public void testGetLocalDateTime() throws SQLException {
Statement stmt = _conn.createStatement();
stmt.executeUpdate(TestUtil.insertSQL("table1","timestamp_without_time_zone_column","TIMESTAMP '2004-10-19 10:23:54'"));
List<String> zoneIdsToTest = new ArrayList<String>();
zoneIdsToTest.add("Africa/Casablanca"); // It is something like GMT+0..GMT+1
zoneIdsToTest.add("America/Adak"); // It is something like GMT-10..GMT-9
zoneIdsToTest.add("Atlantic/Azores"); // It is something like GMT-1..GMT+0
zoneIdsToTest.add("Europe/Moscow"); // It is something like GMT+3..GMT+4 for 2000s
zoneIdsToTest.add("Pacific/Apia"); // It is something like GMT+13..GMT+14
zoneIdsToTest.add("Pacific/Niue"); // It is something like GMT-11..GMT-11
for (int i = -12; i <= 13; i++) {
zoneIdsToTest.add(String.format("GMT%+02d", i));
}

ResultSet rs = stmt.executeQuery(TestUtil.selectSQL("table1", "timestamp_without_time_zone_column"));
List<String> datesToTest = Arrays.asList("2015-09-03T12:00:00", "2015-06-30T23:59:58",
"1997-06-30T23:59:59", "1997-07-01T00:00:00", "2012-06-30T23:59:59", "2012-07-01T00:00:00",
"2015-06-30T23:59:59", "2015-07-01T00:00:00", "2005-12-31T23:59:59", "2006-01-01T00:00:00",
"2008-12-31T23:59:59", "2009-01-01T00:00:00", /* "2015-06-30T23:59:60", */ "2015-07-31T00:00:00",
"2015-07-31T00:00:01",

// On 2000-03-26 02:00:00 Moscow went to DST, thus local time became 03:00:00
"2000-03-26T01:59:59", "2000-03-26T02:00:00", "2000-03-26T02:00:01", "2000-03-26T02:59:59",
"2000-03-26T03:00:00", "2000-03-26T03:00:01", "2000-03-26T03:59:59", "2000-03-26T04:00:00",
"2000-03-26T04:00:01",

// On 2000-10-29 03:00:00 Moscow went to regular time, thus local time became 02:00:00
"2000-10-29T01:59:59", "2000-10-29T02:00:00", "2000-10-29T02:00:01", "2000-10-29T02:59:59",
"2000-10-29T03:00:00", "2000-10-29T03:00:01", "2000-10-29T03:59:59", "2000-10-29T04:00:00",
"2000-10-29T04:00:01");

for (String zoneId : zoneIdsToTest) {
ZoneId zone = ZoneId.of(zoneId);
for (String date : datesToTest) {
localTimestamps(zone, date);
}
}
}

public void localTimestamps(ZoneId zoneId, String timestamp) throws SQLException {
TimeZone.setDefault(TimeZone.getTimeZone(zoneId));
Statement stmt = con.createStatement();
try {
assertTrue(rs.next());
LocalDateTime localDateTime = LocalDateTime.of(2004, 10, 19, 10, 23, 54);
assertEquals(localDateTime, rs.getObject("timestamp_without_time_zone_column", LocalDateTime.class));
assertEquals(localDateTime, rs.getObject(1, LocalDateTime.class));
stmt.executeUpdate(TestUtil.insertSQL("table1","timestamp_without_time_zone_column","TIMESTAMP '" + timestamp + "'"));

ResultSet rs = stmt.executeQuery(TestUtil.selectSQL("table1", "timestamp_without_time_zone_column"));
try {
assertTrue(rs.next());
LocalDateTime localDateTime = LocalDateTime.parse(timestamp);
assertEquals(localDateTime, rs.getObject("timestamp_without_time_zone_column", LocalDateTime.class));
assertEquals(localDateTime, rs.getObject(1, LocalDateTime.class));
} finally {
rs.close();
}
stmt.executeUpdate("DELETE FROM table1");
} finally {
rs.close();
stmt.close();
}
}

Expand All @@ -107,7 +153,7 @@ public void testGetTimestampWithTimeZone() throws SQLException {
}

private void runGetOffsetDateTime(ZoneOffset offset) throws SQLException {
Statement stmt = _conn.createStatement();
Statement stmt = con.createStatement();
try {
stmt.executeUpdate(TestUtil.insertSQL("table1","timestamp_with_time_zone_column","TIMESTAMP WITH TIME ZONE '2004-10-19 10:23:54" + offset.toString() + "'"));

Expand Down
Expand Up @@ -13,7 +13,12 @@
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
@SuiteClasses({SimpleJdbc42Test.class, CustomizeDefaultFetchSizeTest.class, GetObject310Test.class, SetObject310Test.class})
@SuiteClasses({
SimpleJdbc42Test.class,
CustomizeDefaultFetchSizeTest.class,
GetObject310Test.class,
GetObject310BinaryTest.class,
SetObject310Test.class})
public class Jdbc42TestSuite {

}

0 comments on commit 61384ec

Please sign in to comment.