Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
@InternalApi
public class TimestampParser extends Parser<Timestamp> {

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 = ' ';
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can optimize this by avoiding the second division/modulo operation (Math.floorMod). Since we already have pgSeconds (the result of Math.floorDiv), we can compute the remainder using multiplication and subtraction, which is significantly faster.

Suggested change
long remainingMicroseconds = Math.floorMod(pgMicroseconds, MICROSECONDS_IN_SECOND);
long remainingMicroseconds = pgMicroseconds - pgSeconds * MICROSECONDS_IN_SECOND;

@olavloite olavloite Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is not worth the added cognitive complexity (and significantly faster is maybe true in relative sense, but we are talking about nanoseconds here...)

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);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]));
Expand All @@ -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<String, Value> 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<String, Value> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading