diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java index ea9a23198..b10536025 100644 --- a/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java +++ b/src/main/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParser.java @@ -46,7 +46,7 @@ @InternalApi public class TimestampParser extends Parser { - private static final int MICROSECONDS_IN_SECOND = 1000000; + private static final long MICROSECONDS_IN_SECOND = 1_000_000L; private static final long NANOSECONDS_IN_MICROSECONDS = 1000L; private static final char TIMESTAMP_SEPARATOR = 'T'; private static final char EMPTY_SPACE = ' '; @@ -122,11 +122,19 @@ public static Timestamp toTimestamp(@Nonnull byte[] data) { throw SpannerExceptionFactory.newSpannerException( ErrorCode.INVALID_ARGUMENT, "Invalid length for timestamptz: " + data.length); } - long pgMicros = ByteConverter.int8(data, 0); - com.google.cloud.Timestamp ts = com.google.cloud.Timestamp.ofTimeMicroseconds(pgMicros); - long javaSeconds = ts.getSeconds() + PG_EPOCH_SECONDS; - int javaNanos = ts.getNanos(); - return Timestamp.ofTimeSecondsAndNanos(javaSeconds, javaNanos); + long pgMicroseconds = ByteConverter.int8(data, 0); + // Use floor division and floor modulo so that negative microsecond offsets (timestamps before + // 2000-01-01) properly adjust whole seconds and produce a non-negative fractional remainder. + long pgSeconds = Math.floorDiv(pgMicroseconds, MICROSECONDS_IN_SECOND); + long remainingMicroseconds = Math.floorMod(pgMicroseconds, MICROSECONDS_IN_SECOND); + long javaSeconds = pgSeconds + PG_EPOCH_SECONDS; + int javaNanoseconds = (int) (remainingMicroseconds * NANOSECONDS_IN_MICROSECONDS); + try { + return Timestamp.ofTimeSecondsAndNanos(javaSeconds, javaNanoseconds); + } catch (IllegalArgumentException illegalArgumentException) { + throw PGExceptionFactory.newPGException( + "timestamp out of range", SQLState.DatetimeFieldOverflow); + } } /** diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java index fe66f239c..cd0bf936f 100644 --- a/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java +++ b/src/test/java/com/google/cloud/spanner/pgadapter/parsers/TimestampParserTest.java @@ -30,11 +30,14 @@ import com.google.cloud.spanner.Struct; import com.google.cloud.spanner.Type; import com.google.cloud.spanner.Type.StructField; +import com.google.cloud.spanner.Value; import com.google.cloud.spanner.pgadapter.ProxyServer.DataFormat; import com.google.cloud.spanner.pgadapter.error.PGException; +import com.google.cloud.spanner.pgadapter.error.SQLState; import com.google.cloud.spanner.pgadapter.parsers.Parser.FormatCode; import com.google.cloud.spanner.pgadapter.session.SessionState; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; @@ -51,15 +54,17 @@ public class TimestampParserTest { @Test public void testToTimestamp() { - long micros = new Random().nextLong(); - if (micros < -62135596800000L) { - micros = -62135596800000L; - } else if (micros > 253402300799000L) { - micros = 253402300799000L; + long minMicroseconds = -62135596800_000_000L; + long maxMicroseconds = 253402300799_999_999L; + long microseconds = new Random().nextLong(); + if (microseconds < minMicroseconds) { + microseconds = minMicroseconds; + } else if (microseconds > maxMicroseconds) { + microseconds = maxMicroseconds; } byte[] data = new byte[8]; - ByteConverter.int8(data, 0, micros - PG_EPOCH_SECONDS * 1000_000L); - assertEquals(Timestamp.ofTimeMicroseconds(micros), TimestampParser.toTimestamp(data)); + ByteConverter.int8(data, 0, microseconds - PG_EPOCH_SECONDS * 1000_000L); + assertEquals(Timestamp.ofTimeMicroseconds(microseconds), TimestampParser.toTimestamp(data)); SpannerException spannerException = assertThrows(SpannerException.class, () -> TimestampParser.toTimestamp(new byte[4])); @@ -72,6 +77,139 @@ public void testToTimestamp() { assertNull(new TimestampParser(null, mock(SessionState.class)).binaryParse()); } + @Test + public void testToTimestamp_earliestTimestampAcceptedBySpanner() { + Timestamp minTimestamp = Timestamp.parseTimestamp("0001-01-01T00:00:00Z"); + byte[] data = new byte[8]; + long pgMicroseconds = (minTimestamp.getSeconds() - PG_EPOCH_SECONDS) * 1_000_000L; + ByteConverter.int8(data, 0, pgMicroseconds); + + assertEquals(minTimestamp, TimestampParser.toTimestamp(data)); + assertArrayEquals( + data, + new TimestampParser(TimestampParser.toTimestamp(data), mock(SessionState.class)) + .binaryParse()); + } + + @Test + public void testToTimestamp_yearsBefore0031() { + String[] timestampStrings = + new String[] { + "0001-01-01T00:00:00Z", + "0001-06-15T12:30:45.123456Z", + "0005-11-20T08:15:00.654321Z", + "0010-03-01T00:00:00Z", + "0020-02-29T10:20:30.999999Z", + "0030-12-31T23:59:59.999999Z", + }; + for (String timestampString : timestampStrings) { + Timestamp expectedTimestamp = Timestamp.parseTimestamp(timestampString); + byte[] data = new byte[8]; + long pgMicroseconds = + ((expectedTimestamp.getSeconds() - PG_EPOCH_SECONDS) * 1_000_000L) + + (expectedTimestamp.getNanos() / 1_000L); + ByteConverter.int8(data, 0, pgMicroseconds); + + assertEquals(expectedTimestamp, TimestampParser.toTimestamp(data)); + assertEquals( + expectedTimestamp, + TimestampParser.toTimestamp(data, FormatCode.BINARY, mock(SessionState.class))); + + ImmutableMap.Builder parametersBuilder = ImmutableMap.builder(); + TimestampParser.bind( + parametersBuilder, "p1", data, FormatCode.BINARY, mock(SessionState.class)); + assertEquals(Value.timestamp(expectedTimestamp), parametersBuilder.build().get("p1")); + + assertArrayEquals( + data, new TimestampParser(expectedTimestamp, mock(SessionState.class)).binaryParse()); + } + } + + @Test + public void testToTimestamp_latestTimestampAcceptedBySpanner() { + Timestamp maxTimestamp = Timestamp.parseTimestamp("9999-12-31T23:59:59.999999Z"); + byte[] data = new byte[8]; + long pgMicroseconds = + ((maxTimestamp.getSeconds() - PG_EPOCH_SECONDS) * 1_000_000L) + + (maxTimestamp.getNanos() / 1_000L); + ByteConverter.int8(data, 0, pgMicroseconds); + + assertEquals(maxTimestamp, TimestampParser.toTimestamp(data)); + assertArrayEquals( + data, + new TimestampParser(TimestampParser.toTimestamp(data), mock(SessionState.class)) + .binaryParse()); + } + + @Test + public void testToTimestamp_outOfRange() { + // 1 microsecond before 0001-01-01 00:00:00Z + Timestamp minTimestamp = Timestamp.parseTimestamp("0001-01-01T00:00:00Z"); + long beforeMinMicroseconds = (minTimestamp.getSeconds() - PG_EPOCH_SECONDS) * 1_000_000L - 1L; + byte[] beforeMinData = new byte[8]; + ByteConverter.int8(beforeMinData, 0, beforeMinMicroseconds); + + PGException beforeMinException = + assertThrows(PGException.class, () -> TimestampParser.toTimestamp(beforeMinData)); + assertEquals(SQLState.DatetimeFieldOverflow, beforeMinException.getSQLState()); + assertEquals("timestamp out of range", beforeMinException.getMessage()); + + // 1 microsecond after 9999-12-31 23:59:59.999999Z + Timestamp maxTimestamp = Timestamp.parseTimestamp("9999-12-31T23:59:59.999999Z"); + long afterMaxMicroseconds = + ((maxTimestamp.getSeconds() - PG_EPOCH_SECONDS) * 1_000_000L) + + (maxTimestamp.getNanos() / 1_000L) + + 1L; + byte[] afterMaxData = new byte[8]; + ByteConverter.int8(afterMaxData, 0, afterMaxMicroseconds); + + PGException afterMaxException = + assertThrows(PGException.class, () -> TimestampParser.toTimestamp(afterMaxData)); + assertEquals(SQLState.DatetimeFieldOverflow, afterMaxException.getSQLState()); + assertEquals("timestamp out of range", afterMaxException.getMessage()); + + // Long.MIN_VALUE + byte[] minimumLongData = new byte[8]; + ByteConverter.int8(minimumLongData, 0, Long.MIN_VALUE); + PGException minimumLongException = + assertThrows(PGException.class, () -> TimestampParser.toTimestamp(minimumLongData)); + assertEquals(SQLState.DatetimeFieldOverflow, minimumLongException.getSQLState()); + assertEquals("timestamp out of range", minimumLongException.getMessage()); + + // Long.MAX_VALUE + byte[] maximumLongData = new byte[8]; + ByteConverter.int8(maximumLongData, 0, Long.MAX_VALUE); + PGException maximumLongException = + assertThrows(PGException.class, () -> TimestampParser.toTimestamp(maximumLongData)); + assertEquals(SQLState.DatetimeFieldOverflow, maximumLongException.getSQLState()); + assertEquals("timestamp out of range", maximumLongException.getMessage()); + + // FormatCode.BINARY via toTimestamp(data, formatCode, sessionState) + PGException binaryFormatException = + assertThrows( + PGException.class, + () -> + TimestampParser.toTimestamp( + beforeMinData, FormatCode.BINARY, mock(SessionState.class))); + assertEquals(SQLState.DatetimeFieldOverflow, binaryFormatException.getSQLState()); + assertEquals("timestamp out of range", binaryFormatException.getMessage()); + + // FormatCode.BINARY via bind(...) + ImmutableMap.Builder parametersBuilder = ImmutableMap.builder(); + PGException bindException = + assertThrows( + PGException.class, + () -> + TimestampParser.bind( + parametersBuilder, + "p1", + beforeMinData, + FormatCode.BINARY, + mock(SessionState.class))); + assertEquals(SQLState.DatetimeFieldOverflow, bindException.getSQLState()); + assertEquals("timestamp out of range", bindException.getMessage()); + } + @Test public void testSpannerParse() { assertEquals( diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/statements/StatementTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/statements/StatementTest.java index ab9ebbffd..7e42fe9bb 100644 --- a/src/test/java/com/google/cloud/spanner/pgadapter/statements/StatementTest.java +++ b/src/test/java/com/google/cloud/spanner/pgadapter/statements/StatementTest.java @@ -31,6 +31,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.cloud.Timestamp; import com.google.cloud.spanner.DatabaseClient; import com.google.cloud.spanner.DatabaseId; import com.google.cloud.spanner.Dialect; @@ -53,6 +54,7 @@ import com.google.cloud.spanner.pgadapter.error.SQLState; import com.google.cloud.spanner.pgadapter.metadata.ConnectionMetadata; import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata; +import com.google.cloud.spanner.pgadapter.parsers.Parser; import com.google.cloud.spanner.pgadapter.session.SessionState; import com.google.cloud.spanner.pgadapter.utils.ClientAutoDetector.WellKnownClient; import com.google.cloud.spanner.pgadapter.utils.Metrics; @@ -85,6 +87,7 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.postgresql.core.Oid; +import org.postgresql.util.ByteConverter; @RunWith(JUnit4.class) public class StatementTest { @@ -404,6 +407,55 @@ public void testPreparedStatementIllegalTypeThrowsException() { boundStatement.getParameters().get("p1")); } + @Test + public void testPreparedStatementBinaryTimestamptzParam() { + when(connectionHandler.getConnectionMetadata()).thenReturn(connectionMetadata); + ExtendedQueryProtocolHandler extendedQueryProtocolHandler = + mock(ExtendedQueryProtocolHandler.class); + when(connectionHandler.getExtendedQueryProtocolHandler()) + .thenReturn(extendedQueryProtocolHandler); + when(extendedQueryProtocolHandler.getBackendConnection()).thenReturn(backendConnection); + SessionState sessionState = mock(SessionState.class); + when(backendConnection.getSessionState()).thenReturn(sessionState); + + String sqlStatement = "SELECT * FROM users WHERE created_at = $1"; + int[] parameterDataTypes = new int[] {Oid.TIMESTAMPTZ}; + + IntermediatePreparedStatement intermediateStatement = + new IntermediatePreparedStatement( + connectionHandler, + options, + "", + parameterDataTypes, + parse(sqlStatement), + Statement.of(sqlStatement)); + + Timestamp earliestTimestamp = Timestamp.parseTimestamp("0001-01-01T00:00:00Z"); + byte[] binaryData = new byte[8]; + long pgMicroseconds = (earliestTimestamp.getSeconds() - Parser.PG_EPOCH_SECONDS) * 1_000_000L; + ByteConverter.int8(binaryData, 0, pgMicroseconds); + + byte[][] parameters = new byte[][] {binaryData}; + short[] formatCodes = new short[] {(short) 1}; + + IntermediatePortalStatement portalStatement = + intermediateStatement.createPortal("", parameters, formatCodes, NO_FORMAT_CODES); + Statement boundStatement = portalStatement.bind(Statement.of(sqlStatement)); + assertEquals(Value.timestamp(earliestTimestamp), boundStatement.getParameters().get("p1")); + + // Out of range value: 1 microsecond before 0001-01-01 00:00:00Z + byte[] outOfRangeData = new byte[8]; + ByteConverter.int8(outOfRangeData, 0, pgMicroseconds - 1L); + IntermediatePortalStatement outOfRangePortalStatement = + intermediateStatement.createPortal( + "", new byte[][] {outOfRangeData}, formatCodes, NO_FORMAT_CODES); + PGException exception = + assertThrows( + PGException.class, () -> outOfRangePortalStatement.bind(Statement.of(sqlStatement))); + assertEquals(SQLState.DatetimeFieldOverflow, exception.getSQLState()); + assertEquals("timestamp out of range", exception.getMessage()); + } + @Test public void testPreparedStatementDescribeDoesNotThrowException() { when(connectionHandler.getSpannerConnection()).thenReturn(connection);