From a95bb7a22b7806f732c7fd5f17a3a9f9c1751944 Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Fri, 25 Sep 2026 14:19:34 +0000 Subject: [PATCH] test(bigquery-jdbc): reference ITs for timestamp time zone handling --- .../bigquery/jdbc/it/ITBigQueryJDBCTest.java | 74 ++- .../jdbc/it/ITCallableStatementTest.java | 60 +- .../jdbc/it/ITJdbcTimestampPicosTest.java | 94 ++- .../jdbc/it/ITTimestampTimeZoneTest.java | 617 ++++++++++++++++++ .../jdbc/it/suites/ITPresubmitTests.java | 4 +- 5 files changed, 801 insertions(+), 48 deletions(-) create mode 100644 java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java index 999f2a7ad5f8..d7d4faaff1a5 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java @@ -17,6 +17,7 @@ package com.google.cloud.bigquery.jdbc.it; import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -57,6 +58,7 @@ import java.time.Instant; import java.time.LocalTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Calendar; import java.util.Properties; @@ -1655,12 +1657,16 @@ public void testPreparedStatementDateTimeValues() throws SQLException { bigQueryStatement.execute(String.format(createTableQuery, DATASET, TABLE_NAME1)); + Instant moment = Instant.parse("2025-12-03T12:34:56.123Z"); + Time noon = Time.valueOf(LocalTime.NOON); + Date date = Date.valueOf("2025-12-03"); + PreparedStatement insertPs = bigQueryConnection.prepareStatement(insertQuery); insertPs.setString(1, "dishwasher"); insertPs.setInt(2, 1); - insertPs.setTimestamp(3, new Timestamp(System.currentTimeMillis())); - insertPs.setTime(4, Time.valueOf(LocalTime.NOON)); - insertPs.setDate(5, Date.valueOf("2025-12-3")); + insertPs.setTimestamp(3, Timestamp.from(moment)); + insertPs.setTime(4, noon); + insertPs.setDate(5, date); int insertStatus = insertPs.executeUpdate(); assertEquals(1, insertStatus); @@ -1669,22 +1675,65 @@ public void testPreparedStatementDateTimeValues() throws SQLException { Calendar utcCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); insertPs.setString(1, "refrigerator"); insertPs.setInt(2, 2); - insertPs.setTimestamp(3, new Timestamp(System.currentTimeMillis()), utcCal); - insertPs.setTime(4, Time.valueOf(LocalTime.NOON), utcCal); - insertPs.setDate(5, Date.valueOf("2025-12-03"), utcCal); + insertPs.setTimestamp(3, Timestamp.from(moment), utcCal); + insertPs.setTime(4, noon, utcCal); + insertPs.setDate(5, date, utcCal); int insertStatus2 = insertPs.executeUpdate(); assertEquals(1, insertStatus2); + // Read the stored values server-side so no driver temporal conversion is involved. ResultSet rs = bigQueryStatement.executeQuery( - String.format("SELECT COUNT(*) AS row_count\n" + "FROM %s.%s", DATASET, TABLE_NAME1)); - rs.next(); + String.format( + "SELECT IntegerField, UNIX_MILLIS(TimestampField), CAST(TimeField AS STRING)," + + " CAST(DateField AS STRING) FROM %s.%s ORDER BY IntegerField", + DATASET, TABLE_NAME1)); + + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1)); + long plainTimestamp = rs.getLong(2); + String plainTime = rs.getString(3); + String plainDate = rs.getString(4); + assertTrue(rs.next()); assertEquals(2, rs.getInt(1)); + long calTimestamp = rs.getLong(2); + String calTime = rs.getString(3); + String calDate = rs.getString(4); + assertFalse(rs.next()); + // Drop before asserting so a failure does not leak the table. String dropQuery = String.format("DROP TABLE %s.%s", DATASET, TABLE_NAME1); int dropStatus = bigQueryStatement.executeUpdate(dropQuery); assertEquals(0, dropStatus); + + assertAll( + // Row 1: plain setters. + () -> + assertEquals( + moment.toEpochMilli(), plainTimestamp, "setTimestamp must store the moment"), + () -> assertEquals("12:00:00", plainTime, "setTime must send its digits"), + () -> assertEquals("2025-12-03", plainDate, "setDate must send its digits"), + // Row 2: Calendar setters. The Calendar cannot change a TIMESTAMP moment; for TIME and DATE + // it is the zone the value is rendered in. + () -> + assertEquals( + moment.toEpochMilli(), calTimestamp, "setTimestamp(ts, cal) must store the moment"), + () -> + assertEquals( + Instant.ofEpochMilli(noon.getTime()) + .atZone(ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("HH:mm:ss")), + calTime, + "setTime(t, cal) must send the time of t in the Calendar's zone"), + () -> + assertEquals( + Instant.ofEpochMilli(date.getTime()) + .atZone(ZoneOffset.UTC) + .toLocalDate() + .toString(), + calDate, + "setDate(d, cal) must send the date of d in the Calendar's zone")); } @Test @@ -2560,12 +2609,7 @@ public void validateGetString() throws Exception { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); String expectedTimestampString = timestampFormatter.format( - Instant.parse("2023-07-28T12:30:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime()); - String expectedArrayTimestamp = - String.format( - "[%s, %s]", - Timestamp.from(Instant.parse("2023-01-01T01:00:00Z")), - Timestamp.from(Instant.parse("2023-01-01T02:00:00Z"))); + Instant.parse("2023-07-28T12:30:00Z").atZone(ZoneOffset.UTC).toLocalDateTime()); final ImmutableMap stringResults = new ImmutableMap.Builder() @@ -2593,7 +2637,7 @@ public void validateGetString() throws Exception { .put("arrayNumeric", "[10.5, 20.5]") .put("arrayBignumeric", "[100.1, 200.2]") .put("arrayBoolean", "[true, false]") - .put("arrayTimestamp", expectedArrayTimestamp) + .put("arrayTimestamp", "[2023-01-01 01:00:00.0, 2023-01-01 02:00:00.0]") .put("arrayDate", "[2023-01-01, 2023-01-02]") .put("arrayTime", "[01:00:00, 02:00:00]") .put("arrayDatetime", "[2023-01-01 01:00:00.0, 2023-01-01 02:00:00.0]") diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITCallableStatementTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITCallableStatementTest.java index 82f1b142838e..17cef201fa34 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITCallableStatementTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITCallableStatementTest.java @@ -39,9 +39,13 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Calendar; import java.util.Properties; import java.util.Random; +import java.util.TimeZone; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -61,6 +65,10 @@ public class ITCallableStatementTest extends ITBase { private static final String CALLABLE_STMT_DML_DELETE_PROC_NAME = "IT_CALLABLE_STMT_PROC_DML_DELETE_TEST"; private static final String CALLABLE_STMT_DML_TABLE_NAME = "IT_CALLABLE_STMT_PROC_DML_TABLE"; + // The Calendar tests pin the JVM zone so the Calendar always differs from it; otherwise the + // driver skips the conversion. + private static final ZoneId CALENDAR_TEST_JVM_ZONE = ZoneId.of("America/New_York"); + private static final ZoneId CALENDAR_ZONE = ZoneId.of("Asia/Tokyo"); static Connection bigQueryConnection; static BigQuery bigQuery; @@ -316,11 +324,18 @@ public void testSetterGetterDate() throws SQLException { public void testSetterGetterDateCal() throws SQLException { CallableStatement callableStatement = this.bigQueryConnection.prepareCall("call testProc('?')"); assertNotNull(callableStatement); - Date expected = new Date(1L); - Calendar cal = Calendar.getInstance(); - callableStatement.setDate(CALLABLE_STMT_PARAM_KEY, expected, cal); - Date actual = callableStatement.getDate(CALLABLE_STMT_PARAM_KEY, cal); - assertEquals(expected, actual); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(CALENDAR_ZONE)); + Date expected = + new Date(LocalDate.of(2025, 1, 1).atStartOfDay(CALENDAR_ZONE).toInstant().toEpochMilli()); + TimeZone originalJvmZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone(CALENDAR_TEST_JVM_ZONE)); + try { + callableStatement.setDate(CALLABLE_STMT_PARAM_KEY, expected, cal); + Date actual = callableStatement.getDate(CALLABLE_STMT_PARAM_KEY, cal); + assertEquals(expected, actual); + } finally { + TimeZone.setDefault(originalJvmZone); + } } @Test @@ -427,11 +442,22 @@ public void testSetterGetterTime() throws SQLException { public void testSetterGetterTimeCal() throws SQLException { CallableStatement callableStatement = this.bigQueryConnection.prepareCall("call testProc('?')"); assertNotNull(callableStatement); - Time expected = new Time(1L); - Calendar cal = Calendar.getInstance(); - callableStatement.setTime(CALLABLE_STMT_PARAM_KEY, expected, cal); - Time actual = callableStatement.getTime(CALLABLE_STMT_PARAM_KEY, cal); - assertEquals(expected, actual); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(CALENDAR_ZONE)); + Time expected = + new Time( + LocalDateTime.of(1970, 1, 1, 12, 34, 56) + .atZone(CALENDAR_ZONE) + .toInstant() + .toEpochMilli()); + TimeZone originalJvmZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone(CALENDAR_TEST_JVM_ZONE)); + try { + callableStatement.setTime(CALLABLE_STMT_PARAM_KEY, expected, cal); + Time actual = callableStatement.getTime(CALLABLE_STMT_PARAM_KEY, cal); + assertEquals(expected, actual); + } finally { + TimeZone.setDefault(originalJvmZone); + } } @Test @@ -448,11 +474,17 @@ public void testSetterGetterTimestamp() throws SQLException { public void testSetterGetterTimestampCal() throws SQLException { CallableStatement callableStatement = this.bigQueryConnection.prepareCall("call testProc('?')"); assertNotNull(callableStatement); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(CALENDAR_ZONE)); Timestamp expected = new Timestamp(1L); - Calendar cal = Calendar.getInstance(); - callableStatement.setTimestamp(CALLABLE_STMT_PARAM_KEY, expected, cal); - Timestamp actual = callableStatement.getTimestamp(CALLABLE_STMT_PARAM_KEY, cal); - assertEquals(expected, actual); + TimeZone originalJvmZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone(CALENDAR_TEST_JVM_ZONE)); + try { + callableStatement.setTimestamp(CALLABLE_STMT_PARAM_KEY, expected, cal); + Timestamp actual = callableStatement.getTimestamp(CALLABLE_STMT_PARAM_KEY, cal); + assertEquals(expected, actual); + } finally { + TimeZone.setDefault(originalJvmZone); + } } // Block B Tests diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITJdbcTimestampPicosTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITJdbcTimestampPicosTest.java index 3d6461ca0baf..e6e8ebe44da0 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITJdbcTimestampPicosTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITJdbcTimestampPicosTest.java @@ -16,6 +16,7 @@ package com.google.cloud.bigquery.jdbc.it; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -35,10 +36,14 @@ import java.sql.Struct; import java.sql.Timestamp; import java.sql.Types; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Calendar; import java.util.Random; import java.util.TimeZone; import org.junit.jupiter.api.AfterAll; @@ -67,8 +72,8 @@ public class ITJdbcTimestampPicosTest extends ITBase { private static final String TIMESTAMP_BOUNDARY_STANDARD = "2025-06-15 10:20:30.999999"; private static final String TIMESTAMP_BOUNDARY_PICOS = "2025-06-15 10:20:30.999999999999"; - private static final DateTimeFormatter JVM_ZONE_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); + private static final DateTimeFormatter UTC_MICROS_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS").withZone(ZoneOffset.UTC); private static final String DDL_CREATE_PICOS_TABLE = "CREATE OR REPLACE TABLE `%1$s.%2$s.%3$s` (\n" @@ -168,15 +173,11 @@ private static String getPicosConnectionUrl(boolean picosEnabled, boolean arrowE } /** - * Renders a UTC wall-clock literal in the JVM default time zone, with the fixed microsecond - * precision that {@code getString} applies to a plain {@code TIMESTAMP} column. Those values are - * zone dependent, whereas {@code TIMESTAMP(12)} values are returned as verbatim UTC strings. + * The stored {@code TIMESTAMP(12)} value for a written {@code Timestamp}: its moment in UTC, + * truncated to microseconds by the backend (b/419328655). */ - private static String atJvmZone(String utcLiteral) { - return LocalDateTime.parse(utcLiteral.replace(' ', 'T')) - .atOffset(ZoneOffset.UTC) - .atZoneSameInstant(ZoneId.systemDefault()) - .format(JVM_ZONE_FORMATTER); + private static String storedUtcPicos(Timestamp ts) { + return UTC_MICROS_FORMATTER.format(ts.toInstant()) + "000000"; } @Test @@ -193,9 +194,9 @@ public void testDefaultBehavior_picosDisabled_returnsMicrosecondsAndTimestampTyp ResultSet rs = stmt.executeQuery(query)) { assertTrue(rs.next()); - assertEquals(atJvmZone(TIMESTAMP_MODERN_STANDARD), rs.getString("ts_standard")); + assertEquals(TIMESTAMP_MODERN_STANDARD, rs.getString("ts_standard")); // When EnableTimestampPicos is false, picosecond columns truncate to 6 digits - assertEquals(atJvmZone(TIMESTAMP_MODERN_STANDARD), rs.getString("ts_picos")); + assertEquals(TIMESTAMP_MODERN_STANDARD, rs.getString("ts_picos")); Object obj = rs.getObject("ts_picos"); assertTrue(obj instanceof Timestamp, "getObject must return java.sql.Timestamp by default"); @@ -231,7 +232,7 @@ public void testPicosecondsEnabled_arrowPath_preserves12Digits() throws SQLExcep // Row 1: Modern timestamp assertTrue(rs.next()); assertEquals(1, rs.getInt("id")); - assertEquals(atJvmZone(TIMESTAMP_MODERN_STANDARD), rs.getString("ts_standard")); + assertEquals(TIMESTAMP_MODERN_STANDARD, rs.getString("ts_standard")); assertEquals(TIMESTAMP_MODERN_PICOS, rs.getString("ts_picos")); Object obj1 = rs.getObject("ts_picos"); assertTrue(obj1 instanceof String, "getObject must return String for TIMESTAMP(12)"); @@ -272,7 +273,7 @@ public void testPicosecondsEnabled_restJsonPath_preserves12Digits() throws SQLEx // Row 1 assertTrue(rs.next()); assertEquals(1, rs.getInt("id")); - assertEquals(atJvmZone(TIMESTAMP_MODERN_STANDARD), rs.getString("ts_standard")); + assertEquals(TIMESTAMP_MODERN_STANDARD, rs.getString("ts_standard")); assertEquals(TIMESTAMP_MODERN_PICOS, rs.getString("ts_picos")); assertEquals(TIMESTAMP_MODERN_PICOS, rs.getObject("ts_picos")); @@ -424,9 +425,9 @@ public void testPreparedStatement_insertRoundTrip_backendTruncatesToMicroseconds rs.getString("ts_val"), "Backend truncates TIMESTAMP parameters to microseconds; see b/419328655"); assertEquals( - "2026-08-10 15:30:45.123456000000", + storedUtcPicos(nanoTimestamp), rs.getString("ts_nano"), - "Nanosecond tail of a java.sql.Timestamp parameter is truncated by the backend too"); + "setTimestamp must store the moment of the Timestamp, truncated to microseconds"); } } @@ -439,6 +440,8 @@ public void testPreparedStatement_batchInsertRoundTrip() throws SQLException { "INSERT INTO `%s.%s.%s` (id, ts_val, ts_nano) VALUES (?, ?, ?)", DEFAULT_CATALOG, DATASET, INSERT_TABLE_NAME); + Timestamp nanoTimestamp = Timestamp.valueOf("2026-09-01 10:00:00.123456789"); + try (Connection conn = DriverManager.getConnection(url); PreparedStatement ps = conn.prepareStatement(insertSql)) { @@ -447,7 +450,7 @@ public void testPreparedStatement_batchInsertRoundTrip() throws SQLException { // Only the microsecond component survives truncation, so it identifies the row. ps.setObject( 2, "2026-09-01 10:00:00." + String.format("%06d", i) + "789012", Types.TIMESTAMP); - ps.setTimestamp(3, Timestamp.valueOf("2026-09-01 10:00:00.123456789")); + ps.setTimestamp(3, nanoTimestamp); ps.addBatch(); } @@ -457,7 +460,7 @@ public void testPreparedStatement_batchInsertRoundTrip() throws SQLException { String selectSql = String.format( - "SELECT id, ts_val FROM `%s.%s.%s` WHERE id >= 200 AND id < 205 ORDER BY id", + "SELECT id, ts_val, ts_nano FROM `%s.%s.%s` WHERE id >= 200 AND id < 205 ORDER BY id", DEFAULT_CATALOG, DATASET, INSERT_TABLE_NAME); try (Connection conn = DriverManager.getConnection(url); @@ -469,6 +472,10 @@ public void testPreparedStatement_batchInsertRoundTrip() throws SQLException { assertEquals(i, rs.getInt("id")); assertEquals( "2026-09-01 10:00:00." + String.format("%06d", i) + "000000", rs.getString("ts_val")); + assertEquals( + storedUtcPicos(nanoTimestamp), + rs.getString("ts_nano"), + "Batched setTimestamp must store the moment of the Timestamp"); } assertFalse(rs.next(), "Exactly five batched rows are expected"); } @@ -516,6 +523,57 @@ public void testPreparedStatement_setStringPreservesFullPrecision() throws SQLEx } } + /** + * Temporal accessors on a {@code TIMESTAMP(12)} value truncate to the Java type's precision. The + * value is a moment, so a Calendar does not change the result. + */ + @Test + @Tag("advanced") + public void testTemporalAccessors_picosEnabled_truncateToJavaPrecision() throws SQLException { + String url = getPicosConnectionUrl(true, false); + String query = + String.format( + "SELECT ts_picos FROM `%s.%s.%s` WHERE id = 1", DEFAULT_CATALOG, DATASET, TABLE_NAME); + Instant moment = Instant.parse("2025-01-01T12:34:56.123456789Z"); + ZonedDateTime local = moment.atZone(ZoneId.systemDefault()); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Tokyo")); + + try (Connection conn = DriverManager.getConnection(url); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query)) { + + assertTrue(rs.next()); + assertAll( + () -> assertEquals(moment, rs.getTimestamp("ts_picos").toInstant(), "getTimestamp"), + () -> assertEquals(moment, rs.getObject("ts_picos", Instant.class), "getObject(Instant)"), + () -> + assertEquals( + moment, + rs.getObject("ts_picos", OffsetDateTime.class).toInstant(), + "getObject(OffsetDateTime)"), + () -> + assertEquals( + local.toLocalDateTime(), + rs.getObject("ts_picos", LocalDateTime.class), + "getObject(LocalDateTime)"), + () -> assertEquals(local.toLocalDate(), rs.getDate("ts_picos").toLocalDate(), "getDate"), + () -> + assertEquals( + local.toLocalTime().withNano(0), rs.getTime("ts_picos").toLocalTime(), "getTime"), + () -> + assertEquals( + moment, rs.getTimestamp("ts_picos", cal).toInstant(), "getTimestamp(cal)"), + () -> + assertEquals( + local.toLocalDate(), rs.getDate("ts_picos", cal).toLocalDate(), "getDate(cal)"), + () -> + assertEquals( + local.toLocalTime().withNano(0), + rs.getTime("ts_picos", cal).toLocalTime(), + "getTime(cal)")); + } + } + @Test public void testLegacySqlRejection_throwsSqlException() throws SQLException { String url = ITBase.connectionUrl + "EnableTimestampPicos=1;QueryDialect=BIG_QUERY;"; diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java new file mode 100644 index 000000000000..58ddd8f9462b --- /dev/null +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java @@ -0,0 +1,617 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.bigquery.jdbc.it; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.cloud.bigquery.QueryJobConfiguration; +import java.sql.Connection; +import java.sql.Date; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Instant; +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.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Calendar; +import java.util.Random; +import java.util.TimeZone; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Timestamp time zone behaviour under several JVM default zones. Write tests pass the parameter + * straight into a server-side function, so each assertion shows exactly what BigQuery received. + */ +public class ITTimestampTimeZoneTest extends ITBase { + + private static final Instant REF = Instant.parse("2025-01-01T12:34:56.123Z"); + private static final String REF_UTC_STRING = "2025-01-01 12:34:56.123"; + private static final LocalDateTime DT_DIGITS = LocalDateTime.parse("2025-01-01T12:34:56.123"); + private static final LocalDate D_DIGITS = LocalDate.parse("2025-01-01"); + private static final LocalTime T_DIGITS = LocalTime.parse("12:34:56"); + + // Never one of the JVM zones under test, so the Calendar always differs from the JVM zone. + private static final ZoneId CAL_ZONE = ZoneId.of("Asia/Tokyo"); + + private static final String READ_SQL = + "SELECT TIMESTAMP '2025-01-01 12:34:56.123 UTC' AS ts," + + " DATETIME '2025-01-01 12:34:56.123' AS dt," + + " DATE '2025-01-01' AS d," + + " TIME '12:34:56' AS t," + + " [TIMESTAMP '2025-01-01 12:34:56.123 UTC'] AS ts_array," + + " STRUCT(TIMESTAMP '2025-01-01 12:34:56.123 UTC' AS ts) AS ts_struct"; + + // Same coercion as an INSERT into a TIMESTAMP column, for TIMESTAMP and STRING parameters. + private static final String PROBE_TIMESTAMP = "SELECT UNIX_MILLIS(CAST(? AS TIMESTAMP))"; + + private static final String PROBE_DATETIME = "SELECT CAST(CAST(? AS DATETIME) AS STRING)"; + private static final String PROBE_DATE = "SELECT CAST(CAST(? AS DATE) AS STRING)"; + private static final String PROBE_TIME = "SELECT CAST(CAST(? AS TIME) AS STRING)"; + + private static final DateTimeFormatter TIME_DIGITS = DateTimeFormatter.ofPattern("HH:mm:ss"); + + private static final String INSERT_TABLE = "JDBC_IT_TIMESTAMP_TZ_" + new Random().nextInt(99999); + private static String DATASET; + + private TimeZone originalJvmZone; + + private static Stream jvmZones() { + return Stream.of("UTC", "America/New_York", "Asia/Kolkata", "Pacific/Auckland"); + } + + @BeforeAll + public static void createInsertTable() throws InterruptedException { + DATASET = ITBase.getSharedDataset(); + bigQuery.query( + QueryJobConfiguration.of( + String.format( + "CREATE OR REPLACE TABLE `%s.%s.%s` (id INT64, ts TIMESTAMP, dt DATETIME)", + DEFAULT_CATALOG, DATASET, INSERT_TABLE))); + } + + @AfterAll + public static void dropInsertTable() throws InterruptedException { + if (DATASET != null) { + bigQuery.query( + QueryJobConfiguration.of( + String.format( + "DROP TABLE IF EXISTS `%s.%s.%s`", DEFAULT_CATALOG, DATASET, INSERT_TABLE))); + } + } + + @BeforeEach + public void saveJvmZone() { + originalJvmZone = TimeZone.getDefault(); + } + + @AfterEach + public void restoreJvmZone() { + TimeZone.setDefault(originalJvmZone); + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetTimestamp_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setTimestamp(1, Timestamp.from(REF)); + assertEquals(REF, storedInstant(ps), label(zone, "setTimestamp must store the same moment")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetObjectTimestamp_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setObject(1, Timestamp.from(REF)); + assertEquals( + REF, storedInstant(ps), label(zone, "setObject(Timestamp) must store the same moment")); + } + } + + /** The Calendar cannot change the moment of a TIMESTAMP. */ + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetTimestampWithCalendar_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + assertAll( + setTimestampWithCalendar(ps, zone, "UTC"), + setTimestampWithCalendar(ps, zone, "Asia/Kolkata"), + setTimestampWithCalendar(ps, zone, "Pacific/Auckland"), + setTimestampWithCalendar(ps, zone, "America/Chicago")); + } + } + + private static Executable setTimestampWithCalendar( + PreparedStatement ps, String zone, String calZone) { + return () -> { + ps.setTimestamp(1, Timestamp.from(REF), calendar(ZoneId.of(calZone))); + assertEquals( + REF, + storedInstant(ps), + label(zone, "setTimestamp(ts, cal=" + calZone + ") must store the same moment")); + }; + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetObjectInstant_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setObject(1, REF); + assertEquals( + REF, storedInstant(ps), label(zone, "setObject(Instant) must store the same moment")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetObjectOffsetDateTime_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setObject(1, REF.atOffset(ZoneOffset.ofHoursMinutes(5, 30))); + assertEquals( + REF, + storedInstant(ps), + label(zone, "setObject(OffsetDateTime +05:30) must store the same moment")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetObjectZonedDateTime_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setObject(1, REF.atZone(ZoneId.of("Asia/Kolkata"))); + assertEquals( + REF, + storedInstant(ps), + label(zone, "setObject(ZonedDateTime Asia/Kolkata) must store the same moment")); + } + } + + /** + * {@code Time.valueOf("12:34:56")} in New York is 17:34:56Z, so with a UTC Calendar BigQuery must + * receive 17:34:56. + */ + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetTimeWithCalendar_sendsTimeInCalendarZone(String zone) throws SQLException { + useJvmZone(zone); + Time time = Time.valueOf("12:34:56"); + String expected = + Instant.ofEpochMilli(time.getTime()).atZone(ZoneOffset.UTC).format(TIME_DIGITS); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIME)) { + ps.setTime(1, time, calendar(ZoneOffset.UTC)); + assertEquals( + expected, + storedString(ps), + label(zone, "setTime(t, cal=UTC) must send the time of t in the Calendar's zone")); + } + } + + /** Midnight of 2025-01-01 in Kolkata or Auckland is still 2024-12-31 in UTC. */ + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetDateWithCalendar_sendsDateInCalendarZone(String zone) throws SQLException { + useJvmZone(zone); + Date date = Date.valueOf(D_DIGITS); + String expected = + Instant.ofEpochMilli(date.getTime()).atZone(ZoneOffset.UTC).toLocalDate().toString(); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_DATE)) { + ps.setDate(1, date, calendar(ZoneOffset.UTC)); + assertEquals( + expected, + storedString(ps), + label(zone, "setDate(d, cal=UTC) must send the date of d in the Calendar's zone")); + } + } + + /** BigQuery reads a timestamp string without an offset as UTC. */ + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetString_isReadAsUtc(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setString(1, REF_UTC_STRING); + assertEquals(REF, storedInstant(ps), label(zone, "setString must be read as UTC")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetStringWithOffset_storesSameMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setString(1, "2025-01-01T07:34:56.123-05:00"); + assertEquals( + REF, storedInstant(ps), label(zone, "setString with an offset must keep that offset")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetDateAndSetTime_sendDigits(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement date = conn.prepareStatement(PROBE_DATE); + PreparedStatement time = conn.prepareStatement(PROBE_TIME)) { + date.setDate(1, Date.valueOf(D_DIGITS)); + time.setTime(1, Time.valueOf(T_DIGITS)); + assertAll( + () -> assertEquals("2025-01-01", storedString(date), label(zone, "setDate digits")), + () -> assertEquals("12:34:56", storedString(time), label(zone, "setTime digits"))); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testSetObjectLocalDateTime_sendsDigits(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + PreparedStatement ps = conn.prepareStatement(PROBE_DATETIME)) { + ps.setObject(1, DT_DIGITS); + assertEquals( + "2025-01-01 12:34:56.123", + storedString(ps), + label(zone, "setObject(LocalDateTime) must send its digits")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testGetTimestampAndGetObject_returnMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + REF, + rs.getTimestamp("ts").toInstant(), + label(zone, "getTimestamp must return the stored moment")), + () -> + assertEquals( + Timestamp.from(REF), + rs.getObject("ts"), + label(zone, "getObject must return a Timestamp of the stored moment"))); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testGetObjectInstantAndOffsetDateTime_returnMoment(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + REF, + rs.getObject("ts", Instant.class), + label(zone, "getObject(Instant) must return the stored moment")), + () -> + assertEquals( + REF, + rs.getObject("ts", OffsetDateTime.class).toInstant(), + label(zone, "getObject(OffsetDateTime) must return the stored moment"))); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testLocalViewsOfTimestamp_useJvmZone(String zone) throws SQLException { + useJvmZone(zone); + ZonedDateTime local = REF.atZone(ZoneId.of(zone)); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + local.toLocalDateTime(), + rs.getObject("ts", LocalDateTime.class), + label(zone, "getObject(LocalDateTime) on TIMESTAMP")), + () -> + assertEquals( + local.toLocalDate(), + rs.getDate("ts").toLocalDate(), + label(zone, "getDate on TIMESTAMP")), + () -> + assertEquals( + local.toLocalTime().withNano(0), + rs.getTime("ts").toLocalTime(), + label(zone, "getTime on TIMESTAMP"))); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testWallClockTypes_returnDigits(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + "2025-01-01 12:34:56.123000", + rs.getString("dt"), + label(zone, "getString on DATETIME")), + () -> + assertEquals( + DT_DIGITS, + rs.getObject("dt", LocalDateTime.class), + label(zone, "getObject(LocalDateTime) on DATETIME")), + () -> + assertEquals( + DT_DIGITS, + rs.getTimestamp("dt").toLocalDateTime(), + label(zone, "getTimestamp on DATETIME")), + () -> + assertEquals( + D_DIGITS, rs.getDate("dt").toLocalDate(), label(zone, "getDate on DATETIME")), + () -> + assertEquals( + T_DIGITS, rs.getTime("dt").toLocalTime(), label(zone, "getTime on DATETIME")), + () -> + assertEquals(D_DIGITS, rs.getDate("d").toLocalDate(), label(zone, "getDate on DATE")), + () -> + assertEquals( + T_DIGITS, rs.getTime("t").toLocalTime(), label(zone, "getTime on TIME"))); + } + } + + /** For DATETIME, DATE and TIME the Calendar is the zone the digits are interpreted in. */ + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testCalendarGettersOnWallClockTypes_useCalendarZone(String zone) throws SQLException { + useJvmZone(zone); + Calendar cal = calendar(CAL_ZONE); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + DT_DIGITS.atZone(CAL_ZONE).toInstant(), + rs.getTimestamp("dt", cal).toInstant(), + label(zone, "getTimestamp(DATETIME, cal)")), + () -> + assertEquals( + D_DIGITS, + inCalendarZone(rs.getDate("dt", cal).getTime()).toLocalDate(), + label(zone, "getDate(DATETIME, cal)")), + () -> + assertEquals( + DT_DIGITS.toLocalTime().withNano(0), + inCalendarZone(rs.getTime("dt", cal).getTime()).toLocalTime().withNano(0), + label(zone, "getTime(DATETIME, cal)")), + () -> + assertEquals( + D_DIGITS, + inCalendarZone(rs.getDate("d", cal).getTime()).toLocalDate(), + label(zone, "getDate(DATE, cal)")), + () -> + assertEquals( + T_DIGITS, + inCalendarZone(rs.getTime("t", cal).getTime()).toLocalTime().withNano(0), + label(zone, "getTime(TIME, cal)"))); + } + } + + /** A TIMESTAMP is already a moment, so the Calendar does not change the result. */ + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testCalendarGettersOnTimestamp_matchNoCalendarGetters(String zone) + throws SQLException { + useJvmZone(zone); + Calendar cal = calendar(CAL_ZONE); + ZonedDateTime local = REF.atZone(ZoneId.of(zone)); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + REF, + rs.getTimestamp("ts", cal).toInstant(), + label(zone, "getTimestamp(TIMESTAMP, cal)")), + () -> + assertEquals( + local.toLocalDate(), + rs.getDate("ts", cal).toLocalDate(), + label(zone, "getDate(TIMESTAMP, cal)")), + () -> + assertEquals( + local.toLocalTime().withNano(0), + rs.getTime("ts", cal).toLocalTime(), + label(zone, "getTime(TIMESTAMP, cal)"))); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testGetString_returnsUtcDigits(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt)) { + assertAll( + () -> + assertEquals( + "2025-01-01 12:34:56.123000", + rs.getString("ts"), + label(zone, "getString on TIMESTAMP must return UTC digits")), + () -> + assertEquals( + "[2025-01-01 12:34:56.123]", + rs.getString("ts_array"), + label(zone, "getString on ARRAY must return UTC digits")), + () -> + assertEquals( + "{\"ts\":\"2025-01-01 12:34:56.123\"}", + rs.getString("ts_struct"), + label(zone, "getString on STRUCT must return UTC digits"))); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testRoundTrip_getTimestampThenSetTimestamp(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setTimestamp(1, rs.getTimestamp("ts")); + assertEquals( + REF, storedInstant(ps), label(zone, "getTimestamp -> setTimestamp must not move")); + } + } + + @ParameterizedTest(name = "JVM zone {0}") + @MethodSource("jvmZones") + public void testRoundTrip_getStringThenSetString(String zone) throws SQLException { + useJvmZone(zone); + try (Connection conn = connect(); + Statement stmt = conn.createStatement(); + ResultSet rs = readRow(stmt); + PreparedStatement ps = conn.prepareStatement(PROBE_TIMESTAMP)) { + ps.setString(1, rs.getString("ts")); + assertEquals(REF, storedInstant(ps), label(zone, "getString -> setString must not move")); + } + } + + /** Confirms the TIMESTAMP probe behaves like a real INSERT. */ + @Test + public void testInsertWithSetTimestamp_storesSameMoment() throws SQLException { + String zone = "America/New_York"; + useJvmZone(zone); + String table = String.format("`%s.%s.%s`", DEFAULT_CATALOG, DATASET, INSERT_TABLE); + try (Connection conn = connect(); + PreparedStatement insert = + conn.prepareStatement("INSERT INTO " + table + " (id, ts) VALUES (1, ?)"); + PreparedStatement select = + conn.prepareStatement("SELECT UNIX_MILLIS(ts) FROM " + table + " WHERE id = 1")) { + insert.setTimestamp(1, Timestamp.from(REF)); + assertEquals(1, insert.executeUpdate()); + assertEquals( + REF, storedInstant(select), label(zone, "INSERT via setTimestamp must store the moment")); + } + } + + /** + * {@code CAST(? AS DATETIME)} also accepts a TIMESTAMP parameter, so only a real INSERT shows + * whether a {@code LocalDateTime} can be stored in a DATETIME column. + */ + @Test + public void testInsertWithSetObjectLocalDateTime_storesDigits() throws SQLException { + String zone = "America/New_York"; + useJvmZone(zone); + String table = String.format("`%s.%s.%s`", DEFAULT_CATALOG, DATASET, INSERT_TABLE); + try (Connection conn = connect(); + PreparedStatement insert = + conn.prepareStatement("INSERT INTO " + table + " (id, dt) VALUES (2, ?)"); + PreparedStatement select = + conn.prepareStatement("SELECT CAST(dt AS STRING) FROM " + table + " WHERE id = 2")) { + insert.setObject(1, DT_DIGITS); + assertEquals(1, insert.executeUpdate()); + assertEquals( + "2025-01-01 12:34:56.123", + storedString(select), + label(zone, "INSERT via setObject(LocalDateTime) must store its digits")); + } + } + + private static void useJvmZone(String zone) { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(zone))); + } + + private static Connection connect() throws SQLException { + return DriverManager.getConnection(connectionUrl); + } + + private static ResultSet readRow(Statement stmt) throws SQLException { + ResultSet rs = stmt.executeQuery(READ_SQL); + assertTrue(rs.next(), "Reference row must exist"); + return rs; + } + + /** + * Reads {@code UNIX_MILLIS} via {@code getLong}, so no driver temporal conversion is involved. + */ + private static Instant storedInstant(PreparedStatement ps) throws SQLException { + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next(), "Probe must return a row"); + return Instant.ofEpochMilli(rs.getLong(1)); + } + } + + private static String storedString(PreparedStatement ps) throws SQLException { + try (ResultSet rs = ps.executeQuery()) { + assertTrue(rs.next(), "Probe must return a row"); + return rs.getString(1); + } + } + + private static Calendar calendar(ZoneId zone) { + return Calendar.getInstance(TimeZone.getTimeZone(zone)); + } + + private static ZonedDateTime inCalendarZone(long epochMillis) { + return Instant.ofEpochMilli(epochMillis).atZone(CAL_ZONE); + } + + private static String label(String zone, String rule) { + return "[JVM " + zone + "] " + rule; + } +} diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java index e3c97777aa40..d0655841f59d 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java @@ -28,6 +28,7 @@ import com.google.cloud.bigquery.jdbc.it.ITPcntTest; import com.google.cloud.bigquery.jdbc.it.ITResultSetMetadataTest; import com.google.cloud.bigquery.jdbc.it.ITStatementTest; +import com.google.cloud.bigquery.jdbc.it.ITTimestampTimeZoneTest; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; @@ -44,6 +45,7 @@ ITLocalSslValidationTest.class, ITPcntTest.class, ITResultSetMetadataTest.class, - ITStatementTest.class + ITStatementTest.class, + ITTimestampTimeZoneTest.class }) public class ITPresubmitTests {}