From 9a5dc332f4a229c34ae950a4d6a7ec55efd3aee7 Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Fri, 16 Oct 2026 14:26:57 +0000 Subject: [PATCH] fix(bigquery-jdbc): format TIMESTAMP as UTC in getString and add multi-zone TIMESTAMP(12) tests --- .../bigquery/jdbc/BigQueryBaseArray.java | 15 +++ .../bigquery/jdbc/BigQueryBaseResultSet.java | 10 +- .../bigquery/jdbc/BigQueryBaseStruct.java | 8 +- .../jdbc/BigQueryTemporalUtility.java | 23 ++++ .../bigquery/jdbc/BigQueryTypeRegistry.java | 6 +- .../jdbc/BigQueryArrowResultSetTest.java | 113 ++++++++++++++++++ .../jdbc/BigQueryJsonResultSetTest.java | 103 ++++++++++++++++ .../bigquery/jdbc/it/ITBigQueryJDBCTest.java | 14 +-- .../jdbc/it/ITJdbcTimestampPicosTest.java | 73 +++++------ .../jdbc/it/ITTimestampTimeZoneTest.java | 3 - 10 files changed, 314 insertions(+), 54 deletions(-) diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java index ac207e78e261..290d90bc3bdc 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java @@ -30,6 +30,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Struct; +import java.sql.Timestamp; import java.util.Arrays; import java.util.Base64; import java.util.Map; @@ -176,6 +177,20 @@ public String toString() { element == null ? "null" : Base64.getEncoder().encodeToString((byte[]) element)) .collect(Collectors.joining(", ", "[", "]")); } + if (this.schema.getType().getStandardType() == StandardSQLTypeName.TIMESTAMP) { + return Arrays.stream(array) + .map( + element -> { + if (element == null) { + return "null"; + } + if (element instanceof Timestamp) { + return BigQueryTemporalUtility.formatTimestampToUtcString((Timestamp) element); + } + return element.toString(); + }) + .collect(Collectors.joining(", ", "[", "]")); + } return Arrays.deepToString(array); } catch (SQLException e) { LOG.warning("Error converting array to string"); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java index 6a3499b2a0ca..9d9d5063cf00 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseResultSet.java @@ -236,6 +236,9 @@ private StandardSQLTypeName getStandardSQLTypeName(int columnIndex) throws SQLEx throw logAndCreateException("Invalid column index: " + columnIndex); } Field field = this.schemaFieldList.get(columnIndex - 1); + if (BigQueryBaseArray.isArray(field)) { + return StandardSQLTypeName.ARRAY; + } return field.getType().getStandardType(); } } @@ -338,12 +341,7 @@ protected int getColumnIndex(String columnLabel) throws SQLException { @Override public String getString(int columnIndex) throws SQLException { LOG.finestTrace("getString"); - try { - Object value = getObject(columnIndex); - return BigQueryTypeRegistry.convert(value, String.class); - } catch (BigQueryJdbcException e) { - throw createCoercionException(columnIndex, String.class, e); - } + return getObject(columnIndex, String.class); } @Override diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java index 7caf0cdfe9b2..4a5e801a6247 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java @@ -79,7 +79,8 @@ public String toString() { if (i > 0) { sb.append(","); } - String fieldName = schema.get(i).getName(); + Field field = schema.get(i); + String fieldName = field.getName(); Object value = attributes[i]; sb.append("\"").append(fieldName.replace("\"", "\\\"")).append("\":"); @@ -89,6 +90,11 @@ public String toString() { } else if (value instanceof String || value instanceof org.apache.arrow.vector.util.Text) { String stringValue = value.toString().replace("\"", "\\\""); sb.append("\"").append(stringValue).append("\""); + } else if (value instanceof Timestamp + && field.getType().getStandardType() == StandardSQLTypeName.TIMESTAMP) { + sb.append("\"") + .append(BigQueryTemporalUtility.formatTimestampToUtcString((Timestamp) value)) + .append("\""); } else if (value instanceof Timestamp || value instanceof Date || value instanceof Time) { sb.append("\"").append(value.toString()).append("\""); } else if (value instanceof byte[]) { 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 da74855cf13c..c5833e3922d8 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 @@ -33,7 +33,9 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; import java.util.Calendar; import java.util.TimeZone; @@ -49,6 +51,12 @@ final class BigQueryTemporalUtility { private static final BigDecimal MICROS_PER_SECOND = new BigDecimal("1000000"); private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss."); + private static final DateTimeFormatter UTC_TIMESTAMP_TO_STRING_FORMATTER = + new DateTimeFormatterBuilder() + .appendPattern("uuuu-MM-dd HH:mm:ss.") + .appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, false) + .toFormatter() + .withZone(ZoneOffset.UTC); static final String TIMESTAMP_PICOSECONDS_TYPE_NAME = "TIMESTAMP_PICOSECONDS"; private BigQueryTemporalUtility() {} @@ -333,6 +341,17 @@ static String formatTimestampStringFromMicroseconds( return sb.toString(); } + /** + * Formats a {@link Timestamp} in UTC using {@link Timestamp#toString()}'s fractional-second + * convention (1 to 9 digits with trailing zeros stripped after the first decimal place). + */ + static String formatTimestampToUtcString(Timestamp ts) { + if (ts == null) { + return null; + } + return UTC_TIMESTAMP_TO_STRING_FORMATTER.format(ts.toInstant()); + } + /** * Formats a timestamp value (which may be a {@link Long} epoch microsecond, an ISO-8601 string, a * {@link java.sql.Timestamp}, or an epoch decimal string) into a standard UTC JDBC timestamp @@ -346,6 +365,10 @@ static String formatTimestampValue(Object value, boolean enableTimestampPicos) if (value instanceof Long) { return formatTimestampStringFromMicroseconds((Long) value, enableTimestampPicos); } + if (value instanceof Timestamp) { + return formatTimestampStringFromIso( + formatTimestampToUtcString((Timestamp) value), enableTimestampPicos); + } String str = value.toString(); if (str.indexOf(':') >= 0) { return formatTimestampStringFromIso(str, enableTimestampPicos); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java index 4d09e3febd0a..bb9bec5a0ac9 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java @@ -330,7 +330,8 @@ static TypeDescriptor createTimestampDescriptor() { // Modern fast-path: Bypass intermediate object creation for JSR-310 targets if (targetClass == Instant.class || targetClass == OffsetDateTime.class - || targetClass == ZonedDateTime.class) { + || targetClass == ZonedDateTime.class + || targetClass == String.class) { Instant instant; if (val instanceof Instant) { instant = (Instant) val; @@ -361,6 +362,9 @@ static TypeDescriptor createTimestampDescriptor() { if (targetClass == ZonedDateTime.class) { return instant.atZone(ZoneOffset.UTC); } + if (targetClass == String.class) { + return TIMESTAMP_FORMATTER.format(instant.atOffset(ZoneOffset.UTC)); + } } // Legacy path: Box values into java.sql.Timestamp diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java index d1b72fce1605..8681fd9559a5 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSetTest.java @@ -47,6 +47,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Arrays; @@ -75,6 +76,7 @@ import org.apache.arrow.vector.complex.impl.UnionListWriter; import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.util.JsonStringArrayList; +import org.apache.arrow.vector.util.JsonStringHashMap; import org.apache.arrow.vector.util.Text; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -658,4 +660,115 @@ public void testCalendarGettersOnTimestampIgnoreCalendar(String jvmZone) TimeZone.setDefault(originalDefault); } } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testGetStringOnTimestampAndDatetimeAcrossJvmZones(String jvmZone) + throws SQLException, IOException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + Instant ref = Instant.parse("2025-01-01T12:34:56.123Z"); + BigQueryArrowResultSet rs = createTemporalArrowResultSet(ref); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getString("ts")).isEqualTo("2025-01-01 12:34:56.123000"); + assertThat(rs.getObject("ts", String.class)).isEqualTo("2025-01-01 12:34:56.123000"); + assertThat(rs.getString("dt")).isEqualTo("2025-01-01 12:34:56.123000"); + + long tsMicros = ref.getEpochSecond() * 1_000_000L + ref.getNano() / 1_000L; + LocalDateTime dtLocal = LocalDateTime.of(2025, 1, 1, 12, 34, 56, 123_000_000); + + Field tsArraySchema = + Field.newBuilder("ts_array", StandardSQLTypeName.TIMESTAMP) + .setMode(Field.Mode.REPEATED) + .build(); + JsonStringArrayList tsList = new JsonStringArrayList<>(); + tsList.add(tsMicros); + BigQueryArrowArray tsArrowArray = new BigQueryArrowArray(tsArraySchema, tsList); + assertThat(tsArrowArray.toString()).isEqualTo("[2025-01-01 12:34:56.123]"); + try (ResultSet nestedTsRs = tsArrowArray.getResultSet()) { + assertThat(nestedTsRs.next()).isTrue(); + assertThat(nestedTsRs.getString(2)).isEqualTo("2025-01-01 12:34:56.123000"); + } + + Field dtArraySchema = + Field.newBuilder("dt_array", StandardSQLTypeName.DATETIME) + .setMode(Field.Mode.REPEATED) + .build(); + JsonStringArrayList dtList = new JsonStringArrayList<>(); + dtList.add(dtLocal); + BigQueryArrowArray dtArrowArray = new BigQueryArrowArray(dtArraySchema, dtList); + assertThat(dtArrowArray.toString()).isEqualTo("[2025-01-01 12:34:56.123]"); + + FieldList structSchema = + FieldList.of( + Field.of("ts", StandardSQLTypeName.TIMESTAMP), + Field.of("dt", StandardSQLTypeName.DATETIME)); + JsonStringHashMap structValues = new JsonStringHashMap<>(); + structValues.put("ts", tsMicros); + structValues.put("dt", dtLocal); + BigQueryArrowStruct arrowStruct = new BigQueryArrowStruct(structSchema, structValues); + assertThat(arrowStruct.toString()) + .isEqualTo("{\"ts\":\"2025-01-01 12:34:56.123\",\"dt\":\"2025-01-01 12:34:56.123\"}"); + } finally { + TimeZone.setDefault(originalDefault); + } + } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testTimestampPicosAccessorsAcrossJvmZones(String jvmZone) + throws SQLException, IOException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + VectorSchemaRoot root = getPicosVectorSchemaRoot(); + ArrowRecordBatch batch = + ArrowRecordBatch.newBuilder() + .setSerializedRecordBatch(serializeVectorSchemaRoot(root)) + .build(); + BlockingQueue picosBuffer = new LinkedBlockingDeque<>(); + picosBuffer.add(BigQueryArrowBatchWrapper.of(batch)); + picosBuffer.add(BigQueryArrowBatchWrapper.of(null, true)); + + BigQueryStatement picosStmt = mock(BigQueryStatement.class); + doReturn(true).when(picosStmt).isEnableTimestampPicos(); + + Schema schema = + Schema.of( + Field.newBuilder("picosTimestamp", StandardSQLTypeName.TIMESTAMP) + .setTimestampPrecision(12L) + .build()); + ArrowSchema arrowSchema = + ArrowSchema.newBuilder().setSerializedSchema(serializeSchema(root.getSchema())).build(); + + BigQueryArrowResultSet rs = + BigQueryArrowResultSet.of( + schema, arrowSchema, 1, picosStmt, picosBuffer, mock(Future.class), null); + assertThat(rs.next()).isTrue(); + + Instant expectedInstant = Instant.parse("2026-04-08T10:00:00.123456789Z"); + ZonedDateTime expectedLocal = expectedInstant.atZone(ZoneId.of(jvmZone)); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Europe/London")); + + assertThat(rs.getString("picosTimestamp")).isEqualTo("2026-04-08 10:00:00.123456789123"); + assertThat(rs.getTimestamp("picosTimestamp").toInstant()).isEqualTo(expectedInstant); + assertThat(rs.getTimestamp("picosTimestamp", cal).toInstant()).isEqualTo(expectedInstant); + assertThat(rs.getObject("picosTimestamp", Instant.class)).isEqualTo(expectedInstant); + assertThat(rs.getObject("picosTimestamp", OffsetDateTime.class).toInstant()) + .isEqualTo(expectedInstant); + assertThat(rs.getObject("picosTimestamp", LocalDateTime.class)) + .isEqualTo(expectedLocal.toLocalDateTime()); + assertThat(rs.getDate("picosTimestamp").toLocalDate()).isEqualTo(expectedLocal.toLocalDate()); + assertThat(rs.getDate("picosTimestamp", cal).toLocalDate()) + .isEqualTo(expectedLocal.toLocalDate()); + assertThat(rs.getTime("picosTimestamp").toLocalTime()) + .isEqualTo(expectedLocal.toLocalTime().withNano(0)); + assertThat(rs.getTime("picosTimestamp", cal).toLocalTime()) + .isEqualTo(expectedLocal.toLocalTime().withNano(0)); + } finally { + TimeZone.setDefault(originalDefault); + } + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSetTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSetTest.java index 00cab3fdf0fd..295fd8844306 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSetTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSetTest.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.mock; import com.google.cloud.bigquery.Field; +import com.google.cloud.bigquery.Field.Mode; import com.google.cloud.bigquery.FieldElementType; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.FieldValue; @@ -55,6 +56,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Calendar; @@ -784,4 +786,105 @@ public void testCalendarGettersOnTimestampIgnoreCalendar(String jvmZone) throws TimeZone.setDefault(originalDefault); } } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testGetStringOnTimestampAndDatetimeAcrossJvmZones(String jvmZone) + throws SQLException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + Field tsSubField = Field.of("ts", StandardSQLTypeName.TIMESTAMP); + Field dtSubField = Field.of("dt", StandardSQLTypeName.DATETIME); + FieldList fields = + FieldList.of( + Field.of("ts", StandardSQLTypeName.TIMESTAMP), + Field.of("dt", StandardSQLTypeName.DATETIME), + Field.newBuilder("ts_array", StandardSQLTypeName.TIMESTAMP) + .setMode(Mode.REPEATED) + .build(), + Field.newBuilder("dt_array", StandardSQLTypeName.DATETIME) + .setMode(Mode.REPEATED) + .build(), + Field.of("ts_struct", StandardSQLTypeName.STRUCT, FieldList.of(tsSubField)), + Field.of("dt_struct", StandardSQLTypeName.STRUCT, FieldList.of(dtSubField))); + FieldValue tsVal = FieldValue.of(Attribute.PRIMITIVE, "1735734896.123000"); + FieldValue dtVal = FieldValue.of(Attribute.PRIMITIVE, "2025-01-01 12:34:56.123000"); + FieldValueList row = + FieldValueList.of( + ImmutableList.of( + tsVal, + dtVal, + FieldValue.of(Attribute.REPEATED, ImmutableList.of(tsVal)), + FieldValue.of(Attribute.REPEATED, ImmutableList.of(dtVal)), + FieldValue.of( + Attribute.RECORD, + FieldValueList.of(ImmutableList.of(tsVal), FieldList.of(tsSubField))), + FieldValue.of( + Attribute.RECORD, + FieldValueList.of(ImmutableList.of(dtVal), FieldList.of(dtSubField)))), + fields); + BigQueryJsonResultSet rs = createJsonResultSet(fields, row, false); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getString("ts")).isEqualTo("2025-01-01 12:34:56.123000"); + assertThat(rs.getObject("ts", String.class)).isEqualTo("2025-01-01 12:34:56.123000"); + assertThat(rs.getString("dt")).isEqualTo("2025-01-01 12:34:56.123000"); + assertThat(rs.getString("ts_array")).isEqualTo("[2025-01-01 12:34:56.123]"); + assertThat(rs.getString("dt_array")).isEqualTo("[2025-01-01 12:34:56.123]"); + assertThat(rs.getString("ts_struct")).isEqualTo("{\"ts\":\"2025-01-01 12:34:56.123\"}"); + assertThat(rs.getString("dt_struct")).isEqualTo("{\"dt\":\"2025-01-01 12:34:56.123\"}"); + + try (ResultSet tsArrayRs = rs.getArray("ts_array").getResultSet(); + ResultSet dtArrayRs = rs.getArray("dt_array").getResultSet()) { + assertThat(tsArrayRs.next()).isTrue(); + assertThat(tsArrayRs.getString(2)).isEqualTo("2025-01-01 12:34:56.123000"); + assertThat(dtArrayRs.next()).isTrue(); + assertThat(dtArrayRs.getString(2)).isEqualTo("2025-01-01 12:34:56.123000"); + } + } finally { + TimeZone.setDefault(originalDefault); + } + } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testTimestampPicosAccessorsAcrossJvmZones(String jvmZone) throws SQLException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + Field picosField = + Field.newBuilder("picosTs", StandardSQLTypeName.TIMESTAMP) + .setTimestampPrecision(12L) + .build(); + FieldList fields = FieldList.of(picosField); + FieldValueList row = + FieldValueList.of( + ImmutableList.of(FieldValue.of(Attribute.PRIMITIVE, "1735734896.123456789123")), + fields); + BigQueryJsonResultSet rs = createJsonResultSet(fields, row, true); + assertThat(rs.next()).isTrue(); + + Instant expectedInstant = Instant.parse("2025-01-01T12:34:56.123456789Z"); + ZonedDateTime expectedLocal = expectedInstant.atZone(ZoneId.of(jvmZone)); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Europe/London")); + + assertThat(rs.getString("picosTs")).isEqualTo("2025-01-01 12:34:56.123456789123"); + assertThat(rs.getTimestamp("picosTs").toInstant()).isEqualTo(expectedInstant); + assertThat(rs.getTimestamp("picosTs", cal).toInstant()).isEqualTo(expectedInstant); + assertThat(rs.getObject("picosTs", Instant.class)).isEqualTo(expectedInstant); + assertThat(rs.getObject("picosTs", OffsetDateTime.class).toInstant()) + .isEqualTo(expectedInstant); + assertThat(rs.getObject("picosTs", LocalDateTime.class)) + .isEqualTo(expectedLocal.toLocalDateTime()); + assertThat(rs.getDate("picosTs").toLocalDate()).isEqualTo(expectedLocal.toLocalDate()); + assertThat(rs.getDate("picosTs", cal).toLocalDate()).isEqualTo(expectedLocal.toLocalDate()); + assertThat(rs.getTime("picosTs").toLocalTime()) + .isEqualTo(expectedLocal.toLocalTime().withNano(0)); + assertThat(rs.getTime("picosTs", cal).toLocalTime()) + .isEqualTo(expectedLocal.toLocalTime().withNano(0)); + } finally { + TimeZone.setDefault(originalDefault); + } + } } 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 1021c0017af4..366e95b70d46 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 @@ -56,6 +56,7 @@ import java.sql.Timestamp; import java.sql.Types; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; @@ -2693,12 +2694,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() @@ -2726,7 +2722,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]") @@ -2901,11 +2897,13 @@ public void validateGetTime() throws Exception { @Test public void validateGetDate() throws Exception { + LocalDate expectedTimestampLocalDate = + Instant.parse("2023-07-28T12:30:00Z").atZone(ZoneId.systemDefault()).toLocalDate(); final ImmutableMap result = new ImmutableMap.Builder() .put("dateField", Date.valueOf("2023-07-28")) .put("dateTimeField", Date.valueOf("2023-07-28")) - .put("timestampFiled", Date.valueOf("2023-07-28")) + .put("timestampFiled", Date.valueOf(expectedTimestampLocalDate)) .build(); BiFunction getter = (s, i) -> { 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 5955248b27c1..213bf789efc8 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 @@ -40,9 +40,7 @@ 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; @@ -79,9 +77,6 @@ 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 String DDL_CREATE_PICOS_TABLE = "CREATE OR REPLACE TABLE `%1$s.%2$s.%3$s` (\n" + " id INT64,\n" @@ -235,18 +230,6 @@ private static void assertReadPath(ResultSet rs, boolean arrowExpected, String z + rs.getClass().getSimpleName())); } - /** - * 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. - */ - private static String atJvmZone(String utcLiteral) { - return LocalDateTime.parse(utcLiteral.replace(' ', 'T')) - .atOffset(ZoneOffset.UTC) - .atZoneSameInstant(ZoneId.systemDefault()) - .format(JVM_ZONE_FORMATTER); - } - @Test public void testDefaultBehavior_picosDisabled_returnsMicrosecondsAndTimestampType() throws SQLException { @@ -261,9 +244,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"); @@ -299,7 +282,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)"); @@ -340,7 +323,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")); @@ -623,13 +606,17 @@ 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 + @ParameterizedTest(name = "JVM zone {0}, arrow={1}") + @MethodSource("jvmZonesAndReadPaths") @Tag("advanced") - public void testTemporalAccessors_picosEnabled_truncateToJavaPrecision() throws SQLException { - String url = getPicosConnectionUrl(true, false); + public void testTemporalAccessors_picosEnabled_truncateToJavaPrecision(String zone, boolean arrow) + throws SQLException { + useJvmZone(zone); + String url = getPicosConnectionUrl(true, arrow); String query = String.format( - "SELECT ts_picos FROM `%s.%s.%s` WHERE id = 1", DEFAULT_CATALOG, DATASET, TABLE_NAME); + "SELECT ts_picos FROM `%s.%s.%s` ORDER BY id ASC", + 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")); @@ -637,36 +624,52 @@ public void testTemporalAccessors_picosEnabled_truncateToJavaPrecision() throws try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(query)) { - + assertReadPath(rs, arrow, zone); 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.getTimestamp("ts_picos").toInstant(), label(zone, "getTimestamp")), + () -> + assertEquals( + moment, + rs.getObject("ts_picos", Instant.class), + label(zone, "getObject(Instant)")), () -> assertEquals( moment, rs.getObject("ts_picos", OffsetDateTime.class).toInstant(), - "getObject(OffsetDateTime)"), + label(zone, "getObject(OffsetDateTime)")), () -> assertEquals( local.toLocalDateTime(), rs.getObject("ts_picos", LocalDateTime.class), - "getObject(LocalDateTime)"), - () -> assertEquals(local.toLocalDate(), rs.getDate("ts_picos").toLocalDate(), "getDate"), + label(zone, "getObject(LocalDateTime)")), + () -> + assertEquals( + local.toLocalDate(), + rs.getDate("ts_picos").toLocalDate(), + label(zone, "getDate")), () -> assertEquals( - local.toLocalTime().withNano(0), rs.getTime("ts_picos").toLocalTime(), "getTime"), + local.toLocalTime().withNano(0), + rs.getTime("ts_picos").toLocalTime(), + label(zone, "getTime")), () -> assertEquals( - moment, rs.getTimestamp("ts_picos", cal).toInstant(), "getTimestamp(cal)"), + moment, + rs.getTimestamp("ts_picos", cal).toInstant(), + label(zone, "getTimestamp(cal)")), () -> assertEquals( - local.toLocalDate(), rs.getDate("ts_picos", cal).toLocalDate(), "getDate(cal)"), + local.toLocalDate(), + rs.getDate("ts_picos", cal).toLocalDate(), + label(zone, "getDate(cal)")), () -> assertEquals( local.toLocalTime().withNano(0), rs.getTime("ts_picos", cal).toLocalTime(), - "getTime(cal)")); + label(zone, "getTime(cal)"))); } } 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 f014838435e3..58ddd8f9462b 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 @@ -47,7 +47,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; @@ -479,7 +478,6 @@ public void testCalendarGettersOnTimestamp_matchNoCalendarGetters(String zone) } } - @Disabled @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") public void testGetString_returnsUtcDigits(String zone) throws SQLException { @@ -522,7 +520,6 @@ public void testRoundTrip_getTimestampThenSetTimestamp(String zone) throws SQLEx @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") - @Disabled public void testRoundTrip_getStringThenSetString(String zone) throws SQLException { useJvmZone(zone); try (Connection conn = connect();