Skip to content
Permalink
Browse files

fix: proleptic java.time support

Make the java.time support proleptic by not going through
TimestampUtils.toJavaSecs when in binary mode.

Fixes #1534
  • Loading branch information...
marschall committed Aug 3, 2019
1 parent 0600990 commit a6ad4d4c41aa79326fabe5b1c686f9a8910aa331
@@ -58,7 +58,6 @@
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
//#endif
import java.util.ArrayList;
import java.util.Calendar;
@@ -604,6 +603,44 @@ public Timestamp getTimestamp(int i, java.util.Calendar cal) throws SQLException
}

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

int col = i - 1;
int oid = fields[col].getOID();

if (isBinary(i)) {
if (oid == Oid.TIMESTAMPTZ || oid == Oid.TIMESTAMP) {
return connection.getTimestampUtils().toOffsetDateTimeBin(thisRow[col]);
} else if (oid == Oid.TIMETZ) {
// JDBC spec says timetz must be supported
Time time = getTime(i);
return connection.getTimestampUtils().toOffsetDateTime(time);
} else {
throw new PSQLException(
GT.tr("Cannot convert the column of type {0} to requested type {1}.",
Oid.toString(oid), "timestamptz"),
PSQLState.DATA_TYPE_MISMATCH);
}
}

// If this is actually a timestamptz, the server-provided timezone will override
// the one we pass in, which is the desired behaviour. Otherwise, we'll
// interpret the timezone-less value in the provided timezone.
String string = getString(i);
if (oid == Oid.TIMETZ) {
// JDBC spec says timetz must be supported
// If server sends us a TIMETZ, we ensure java counterpart has date of 1970-01-01
Calendar cal = getDefaultCalendar();
Time time = connection.getTimestampUtils().toTime(cal, string);
return connection.getTimestampUtils().toOffsetDateTime(time);
}
return connection.getTimestampUtils().toOffsetDateTime(string);
}

private LocalDateTime getLocalDateTime(int i) throws SQLException {
checkResultSet(i);
if (wasNullFlag) {
@@ -619,8 +656,7 @@ private LocalDateTime getLocalDateTime(int i) throws SQLException {
PSQLState.DATA_TYPE_MISMATCH);
}
if (isBinary(i)) {
TimeZone timeZone = getDefaultCalendar().getTimeZone();
return connection.getTimestampUtils().toLocalDateTimeBin(timeZone, thisRow[col]);
return connection.getTimestampUtils().toLocalDateTimeBin(thisRow[col]);
}

String string = getString(i);
@@ -3366,19 +3402,7 @@ public void updateArray(String columnName, Array x) throws SQLException {
}
} else if (type == OffsetDateTime.class) {
if (sqlType == Types.TIMESTAMP_WITH_TIMEZONE || sqlType == Types.TIMESTAMP) {
Timestamp timestampValue = getTimestamp(columnIndex);
if (wasNull()) {
return null;
}
long time = timestampValue.getTime();
if (time == PGStatement.DATE_POSITIVE_INFINITY) {
return type.cast(OffsetDateTime.MAX);
}
if (time == PGStatement.DATE_NEGATIVE_INFINITY) {
return type.cast(OffsetDateTime.MIN);
}
// Postgres stores everything in UTC and does not keep original time zone
OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(timestampValue.toInstant(), ZoneOffset.UTC);
OffsetDateTime offsetDateTime = getOffsetDateTime(columnIndex);
return type.cast(offsetDateTime);
} else {
throw new PSQLException(GT.tr("conversion to {0} from {1} not supported", type, getPGType(columnIndex)),
@@ -21,6 +21,7 @@
import java.sql.Timestamp;
//#if mvn.project.property.postgresql.jdbc.spec >= "JDBC4.2"
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -469,6 +470,77 @@ public LocalDateTime toLocalDateTime(String s) throws SQLException {
return result;
}
}

/**
* 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 OffsetDateTime toOffsetDateTime(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 OffsetDateTime.MAX;
}

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

ParsedTimestamp ts = parseBackendTimestamp(s);

int offsetSeconds = ts.tz.get(Calendar.ZONE_OFFSET) / 1000;
ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(offsetSeconds);
// Postgres is always UTC
OffsetDateTime result = OffsetDateTime.of(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second, ts.nanos, zoneOffset)
.withOffsetSameInstant(ZoneOffset.UTC);
if (ts.era == GregorianCalendar.BC) {
return result.with(ChronoField.ERA, IsoEra.BCE.getValue());
} else {
return result;
}
}

/**
* Returns the offset date time object matching the given bytes with Oid#TIMETZ.
*
* @param t the time value
* @return the matching offset date time
*/
public OffsetDateTime toOffsetDateTime(Time t) {
// hardcode utc because the backend does not provide us the timezone
// hardoce UNIX epoch, JDBC requires OffsetDateTime but doesn't describe what date should be used
return t.toLocalTime().atDate(LocalDate.of(1970, 1, 1)).atOffset(ZoneOffset.UTC);
}

/**
* Returns the offset date time object matching the given bytes with 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 OffsetDateTime toOffsetDateTimeBin(byte[] bytes) throws PSQLException {
ParsedBinaryTimestamp parsedTimestamp = this.toProlepticParsedTimestampBin(bytes);
if (parsedTimestamp.infinity == Infinity.POSITIVE) {
return OffsetDateTime.MAX;
} else if (parsedTimestamp.infinity == Infinity.NEGATIVE) {
return OffsetDateTime.MIN;
}

// hardcode utc because the backend does not provide us the timezone
// Postgres is always UTC
Instant instant = Instant.ofEpochSecond(parsedTimestamp.millis / 1000L, parsedTimestamp.nanos);
return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC);
}

//#endif

public synchronized Time toTime(Calendar cal, String s) throws SQLException {
@@ -1056,8 +1128,8 @@ public Timestamp toTimestampBin(TimeZone tz, byte[] bytes, boolean timestamptz)
return ts;
}

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

if (bytes.length != 8) {
throw new PSQLException(GT.tr("Unsupported binary encoding of {0}.", "timestamp"),
@@ -1106,6 +1178,24 @@ private ParsedBinaryTimestamp toParsedTimestampBin(TimeZone tz, byte[] bytes, bo
}
nanos *= 1000;

long millis = secs * 1000L;

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

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

ParsedBinaryTimestamp ts = toParsedTimestampBinPlain(bytes);
if (ts.infinity != null) {
return ts;
}

long secs = ts.millis / 1000L;

secs = toJavaSecs(secs);
long millis = secs * 1000L;
if (!timestamptz) {
@@ -1114,31 +1204,48 @@ private ParsedBinaryTimestamp toParsedTimestampBin(TimeZone tz, byte[] bytes, bo
millis = guessTimestamp(millis, tz);
}

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

private ParsedBinaryTimestamp toProlepticParsedTimestampBin(byte[] bytes)
throws PSQLException {

ParsedBinaryTimestamp ts = toParsedTimestampBinPlain(bytes);
if (ts.infinity != null) {
return ts;
}

long secs = ts.millis / 1000L;

// postgres epoc to java epoc
secs += 946684800L;
long millis = secs * 1000L;

ts.millis = millis;
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 tz time zone to use
* @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(TimeZone tz, byte[] bytes) throws PSQLException {
public LocalDateTime toLocalDateTimeBin(byte[] bytes) throws PSQLException {

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

// hardcode utc because the backend does not provide us the timezone
// Postgres is always UTC
return LocalDateTime.ofEpochSecond(parsedTimestamp.millis / 1000L, parsedTimestamp.nanos, ZoneOffset.UTC);
}
//#endif
@@ -1426,4 +1533,5 @@ private static long floorDiv(long x, long y) {
private static long floorMod(long x, long y) {
return x - floorDiv(x, y) * y;
}

}
@@ -30,11 +30,11 @@
public class GetObject310InfinityTests extends BaseTest4 {
private final String expression;
private final String pgType;
private final Class klass;
private final Class<?> klass;
private final Object expectedValue;

public GetObject310InfinityTests(BinaryMode binaryMode, String expression,
String pgType, Class klass, Object expectedValue) {
String pgType, Class<?> klass, Object expectedValue) {
setBinaryMode(binaryMode);
this.expression = expression;
this.pgType = pgType;
@@ -56,7 +56,7 @@ public void setUp() throws Exception {
for (String expression : Arrays.asList("-infinity", "infinity")) {
for (String pgType : Arrays.asList("date", "timestamp",
"timestamp with time zone")) {
for (Class klass : Arrays.asList(LocalDate.class, LocalDateTime.class,
for (Class<?> klass : Arrays.asList(LocalDate.class, LocalDateTime.class,
OffsetDateTime.class)) {
if (klass.equals(LocalDate.class) && !pgType.equals("date")) {
continue;
@@ -19,20 +19,26 @@
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
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.time.temporal.Temporal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.TimeZone;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@RunWith(Parameterized.class)
public class GetObject310Test extends BaseTest4 {
@@ -262,4 +268,63 @@ private void runGetOffsetDateTime(ZoneOffset offset) throws SQLException {
}
}

@Test
public void prolepticCalendar() throws SQLException {

LocalDateTime start = LocalDate.of(1582, 9, 30).atStartOfDay();
LocalDateTime end = LocalDate.of(1582, 10, 16).atStartOfDay();
long numberOfDays = Duration.between(start, end).toDays() + 1L;
List<LocalDateTime> range = Stream.iterate(start, new LocalDateTimePlusOneDay())
.limit(numberOfDays)
.collect(Collectors.toList());

runProlepticTests(LocalDateTime.class, "'1582-09-30 00:00'::timestamp, '1582-10-16 00:00'::timestamp", range);
}

@Test
public void prolepticCalendarTimestampTz() throws SQLException {

OffsetDateTime start = LocalDate.of(1582, 9, 30).atStartOfDay().atOffset(UTC);
OffsetDateTime end = LocalDate.of(1582, 10, 16).atStartOfDay().atOffset(UTC);
long numberOfDays = Duration.between(start, end).toDays() + 1L;
List<OffsetDateTime> range = Stream.iterate(start, new OffsetDateTimePlusOneDay())
.limit(numberOfDays)
.collect(Collectors.toList());

runProlepticTests(OffsetDateTime.class, "'1582-09-30 00:00:00 Z'::timestamptz, '1582-10-16 00:00:00 Z'::timestamptz", range);
}

private <T extends Temporal> void runProlepticTests(Class<T> clazz, String selectRange, List<T> range) throws SQLException {
List<T> temporals = new ArrayList<>(range.size());

PreparedStatement stmt = con.prepareStatement("SELECT * FROM generate_series(" + selectRange + ", '1 day');");
ResultSet rs = stmt.executeQuery();
try {
while (rs.next()) {
T temporal = rs.getObject(1, clazz);
temporals.add(temporal);
}
assertEquals(range, temporals);
} finally {
rs.close();
stmt.close();
}
}

private static class LocalDateTimePlusOneDay implements UnaryOperator<LocalDateTime> {

@Override
public LocalDateTime apply(LocalDateTime x) {
return x.plusDays(1);
}
}

private static class OffsetDateTimePlusOneDay implements UnaryOperator<OffsetDateTime> {

@Override
public OffsetDateTime apply(OffsetDateTime x) {
return x.plusDays(1);
}
}

}
@@ -260,6 +260,9 @@ public void testSetOffsetDateTime() throws SQLException {
// This is a pre-1970 date, so check if it is rounded properly
"1950-07-20T02:00:00",

// Ensure the calendar is proleptic
"1582-09-30T00:00:00", "1582-10-16T00:00:00",

// 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",
@@ -311,7 +314,7 @@ private void offsetTimestamps(ZoneId dataZone, LocalDateTime localDateTime, Stri
"OffsetDateTime=" + data + " (with ZoneId=" + dataZone + "), with TimeZone.default="
+ storeZone + ", setObject(int, Object)", data.toInstant(),
noTypeRes.toInstant());
String withType = rs.getString(1);
String withType = rs.getString(2);
OffsetDateTime withTypeRes = OffsetDateTime.parse(withType.replace(' ', 'T') + ":00");
assertEquals(
"OffsetDateTime=" + data + " (with ZoneId=" + dataZone + "), with TimeZone.default="

0 comments on commit a6ad4d4

Please sign in to comment.
You can’t perform that action at this time.