diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java index f4a7e41f4d24..e9ad6fa1292d 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatement.java @@ -858,7 +858,7 @@ public void setTime(String parameterName, Time value, Calendar calendar) throws checkClosed(); this.parameterHandler.setParameter( parameterName, - value == null ? null : BigQueryTemporalUtility.convertTimeWithCalendar(value, calendar), + value == null ? null : BigQueryTemporalUtility.convertTimeToCalendar(value, calendar), Time.class, BigQueryStatementParameterType.IN, 0); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java index 2210c681c457..e26c07c2f5da 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java @@ -637,7 +637,7 @@ public void setDate(int parameterIndex, Date value, Calendar calendar) throws SQ @Override public void setTime(int parameterIndex, Time value, Calendar calendar) throws SQLException { checkClosed(); - setTime(parameterIndex, BigQueryTemporalUtility.convertTimeWithCalendar(value, calendar)); + setTime(parameterIndex, BigQueryTemporalUtility.convertTimeToCalendar(value, calendar)); } @Override diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java index 07c5362ff0e9..da74855cf13c 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java @@ -124,16 +124,12 @@ public static Time boxTime(String val, ZoneId zoneId) { // Legacy JDBC 3.0 API (Calendar provided): // Use legacy Calendar manipulation to intentionally replicate old JVM historical DST quirks // for January 1, 1970, ensuring strict backwards compatibility for legacy ORMs. - Calendar targetCal = Calendar.getInstance(TimeZone.getTimeZone(zoneId)); - targetCal.set(Calendar.YEAR, 1970); - targetCal.set(Calendar.MONTH, Calendar.JANUARY); - targetCal.set(Calendar.DAY_OF_MONTH, 1); - targetCal.set(Calendar.HOUR_OF_DAY, localTime.getHour()); - targetCal.set(Calendar.MINUTE, localTime.getMinute()); - targetCal.set(Calendar.SECOND, localTime.getSecond()); - targetCal.set(Calendar.MILLISECOND, localTime.getNano() / 1_000_000); - - return new Time(targetCal.getTimeInMillis()); + return createTimeInCalendar( + Calendar.getInstance(TimeZone.getTimeZone(zoneId)), + localTime.getHour(), + localTime.getMinute(), + localTime.getSecond(), + localTime.getNano() / 1_000_000); } /** @@ -488,22 +484,55 @@ static Time convertTimeWithCalendar(Time time, Calendar cal) { if (time == null || cal == null) { return time; } - ZoneId systemZone = ZoneId.systemDefault(); - ZoneId targetZone = cal.getTimeZone().toZoneId(); + TimeZone systemZone = TimeZone.getDefault(); + TimeZone targetZone = cal.getTimeZone(); if (systemZone.equals(targetZone)) { return time; } - Calendar defaultCal = Calendar.getInstance(); - defaultCal.setTime(time); - - Calendar targetCal = getSafeCalendar(cal); - targetCal.set(Calendar.YEAR, 1970); - targetCal.set(Calendar.MONTH, Calendar.JANUARY); - targetCal.set(Calendar.DAY_OF_MONTH, 1); - targetCal.set(Calendar.HOUR_OF_DAY, defaultCal.get(Calendar.HOUR_OF_DAY)); - targetCal.set(Calendar.MINUTE, defaultCal.get(Calendar.MINUTE)); - targetCal.set(Calendar.SECOND, defaultCal.get(Calendar.SECOND)); - targetCal.set(Calendar.MILLISECOND, defaultCal.get(Calendar.MILLISECOND)); - return new Time(targetCal.getTimeInMillis()); + return shiftTimeBetweenCalendars(time, Calendar.getInstance(), getSafeCalendar(cal)); + } + + /** + * Converts a {@link Time} for writing/inbound parameter setting operations (e.g., {@code + * setTime(..., Calendar)}) by extracting its local time fields as interpreted in the target + * {@link Calendar}'s timezone and normalizing them back into 1970-01-01 in the system-default + * timezone. + * + * @param time the time instant specified relative to the target Calendar + * @param cal the Calendar containing the source timezone + * @return the normalized Time on 1970-01-01 in the system-default timezone + */ + static Time convertTimeToCalendar(Time time, Calendar cal) { + if (time == null || cal == null) { + return time; + } + TimeZone systemZone = TimeZone.getDefault(); + TimeZone targetZone = cal.getTimeZone(); + if (systemZone.equals(targetZone)) { + return time; + } + return shiftTimeBetweenCalendars(time, getSafeCalendar(cal), Calendar.getInstance()); + } + + private static Time shiftTimeBetweenCalendars(Time time, Calendar sourceCal, Calendar destCal) { + sourceCal.setTime(time); + return createTimeInCalendar( + destCal, + sourceCal.get(Calendar.HOUR_OF_DAY), + sourceCal.get(Calendar.MINUTE), + sourceCal.get(Calendar.SECOND), + sourceCal.get(Calendar.MILLISECOND)); + } + + private static Time createTimeInCalendar( + Calendar destCal, int hour, int minute, int second, int millisecond) { + destCal.set(Calendar.YEAR, 1970); + destCal.set(Calendar.MONTH, Calendar.JANUARY); + destCal.set(Calendar.DAY_OF_MONTH, 1); + destCal.set(Calendar.HOUR_OF_DAY, hour); + destCal.set(Calendar.MINUTE, minute); + destCal.set(Calendar.SECOND, second); + destCal.set(Calendar.MILLISECOND, millisecond); + return new Time(destCal.getTimeInMillis()); } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java index 363d5973d8f4..50e425de7030 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryCallableStatementTest.java @@ -1029,19 +1029,22 @@ public void testSetTimeParamByName() throws SQLException { assertEquals(expected, actual); } - @Test - public void testSetTimeCalParamByName() throws SQLException { + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testSetTimeCalParamByName(String zoneId) throws SQLException { BigQueryCallableStatement statement = new BigQueryCallableStatement(bigQueryConnection, "call testProc('?')"); assertNotNull(statement); - Time expectedTime = Time.valueOf("12:34:56"); - Calendar expectedCal = Calendar.getInstance(); + Calendar expectedCal = Calendar.getInstance(TimeZone.getTimeZone(zoneId)); + expectedCal.clear(); + expectedCal.set(1970, Calendar.JANUARY, 1, 12, 34, 56); + Time expectedTime = new Time(expectedCal.getTimeInMillis()); statement.setTime(PARAM_KEY, expectedTime, expectedCal); Time actual = statement.getTime(PARAM_KEY); - assertNotNull(actual); + assertEquals("12:34:56", actual.toString()); actual = statement.getTime(PARAM_KEY, expectedCal); - assertNotNull(actual); + assertEquals(expectedTime, actual); } @Test diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java index b18a18d470ec..5f2e1db845a2 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java @@ -226,15 +226,20 @@ public void testSetDateWithCalendar() throws Exception { assertEquals(originalMillis, cal.getTimeInMillis()); } - @Test - public void testSetTimeWithCalendar() throws Exception { - Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testSetTimeWithCalendar(String zoneId) throws Exception { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zoneId)); cal.setTimeInMillis(1000000L); long originalMillis = cal.getTimeInMillis(); - Time time = new Time(43200000L); // 12:00:00 + Calendar sourceCal = Calendar.getInstance(TimeZone.getTimeZone(zoneId)); + sourceCal.clear(); + sourceCal.set(1970, Calendar.JANUARY, 1, 12, 34, 56); + Time time = new Time(sourceCal.getTimeInMillis()); preparedStatement.setTime(1, time, cal); assertEquals(Time.class, preparedStatement.parameterHandler.getType(1)); + assertEquals("12:34:56", preparedStatement.parameterHandler.getParameter(1).toString()); assertEquals(originalMillis, cal.getTimeInMillis()); } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java index 598ad57bfab9..45f3d49c2fe8 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java @@ -148,6 +148,65 @@ public void testConvertTimeWithCalendar() { assertThat(resultCal.get(Calendar.SECOND)).isEqualTo(45); } + @Test + public void testConvertTimeToCalendar() { + Time time = Time.valueOf("15:30:45"); + Calendar cal = Calendar.getInstance(); + + // Null checks + assertThat(BigQueryTemporalUtility.convertTimeToCalendar(null, cal)).isNull(); + assertThat(BigQueryTemporalUtility.convertTimeToCalendar(time, null)).isSameInstanceAs(time); + + // Same timezone fast-path + Calendar sameZoneCal = Calendar.getInstance(TimeZone.getDefault()); + assertThat(BigQueryTemporalUtility.convertTimeToCalendar(time, sameZoneCal)) + .isSameInstanceAs(time); + + // Different timezone + ZoneId tokyoZone = ZoneId.of("Asia/Tokyo"); + Calendar tokyoCal = Calendar.getInstance(TimeZone.getTimeZone(tokyoZone)); + long tokyoTimeMillis = + LocalTime.of(15, 30, 45) + .atDate(LocalDate.of(1970, 1, 1)) + .atZone(tokyoZone) + .toInstant() + .toEpochMilli(); + Time result = + BigQueryTemporalUtility.convertTimeToCalendar(new Time(tokyoTimeMillis), tokyoCal); + long expectedSystemMillis = + LocalTime.of(15, 30, 45) + .atDate(LocalDate.of(1970, 1, 1)) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + assertThat(result.getTime()).isEqualTo(expectedSystemMillis); + assertThat(result.toString()).isEqualTo("15:30:45"); + } + + @Test + public void testCrossTimezoneTimeRoundtrip() { + ZoneId writeZone = ZoneId.of("America/Los_Angeles"); + ZoneId readZone = ZoneId.of("Asia/Tokyo"); + Calendar writeCal = Calendar.getInstance(TimeZone.getTimeZone(writeZone)); + Calendar readCal = Calendar.getInstance(TimeZone.getTimeZone(readZone)); + + Time inputTime = + new Time( + LocalTime.of(23, 15, 30) + .atDate(LocalDate.of(1970, 1, 1)) + .atZone(writeZone) + .toInstant() + .toEpochMilli()); + Time normalizedTime = BigQueryTemporalUtility.convertTimeToCalendar(inputTime, writeCal); + Time readTime = BigQueryTemporalUtility.convertTimeWithCalendar(normalizedTime, readCal); + + Calendar verifyCal = Calendar.getInstance(TimeZone.getTimeZone(readZone)); + verifyCal.setTime(readTime); + assertThat(verifyCal.get(Calendar.HOUR_OF_DAY)).isEqualTo(23); + assertThat(verifyCal.get(Calendar.MINUTE)).isEqualTo(15); + assertThat(verifyCal.get(Calendar.SECOND)).isEqualTo(30); + } + @Test public void testBoxDateTimeAndDate() { Timestamp ts = 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 a6fc7bbe7c61..1021c0017af4 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; @@ -1739,12 +1741,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); @@ -1753,22 +1759,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 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 index 09353dafeffb..f014838435e3 100644 --- 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 @@ -219,7 +219,6 @@ public void testSetObjectZonedDateTime_storesSameMoment(String zone) throws SQLE */ @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") - @Disabled public void testSetTimeWithCalendar_sendsTimeInCalendarZone(String zone) throws SQLException { useJvmZone(zone); Time time = Time.valueOf("12:34:56");