diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java index 6189f7ea75be..ed63da4a8671 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java @@ -1390,15 +1390,14 @@ private BigQuery getBigQueryConnection() { bigQueryOptions.setRetrySettings(retry_settings_builder.build()); } - // ISO8601_STRING is the only output format carrying the full 12-digit fraction - // ("YYYY-MM-DDTHH:MM:SS.FFFFFFFFFFFFZ"). The default FLOAT64 and the INT64 alternative both - // cap out at microseconds. - if (this.enableTimestampPicos) { - bigQueryOptions.setDataFormatOptions( - DataFormatOptions.newBuilder() - .timestampFormatOptions(DataFormatOptions.TimestampFormatOptions.ISO8601_STRING) - .build()); - } + // Default FLOAT64 timestamps lose microseconds far from the epoch. ISO8601_STRING is the only + // format carrying picoseconds; BigQuery rejects it combined with useInt64Timestamp. + bigQueryOptions.setDataFormatOptions( + this.enableTimestampPicos + ? DataFormatOptions.newBuilder() + .timestampFormatOptions(DataFormatOptions.TimestampFormatOptions.ISO8601_STRING) + .build() + : DataFormatOptions.newBuilder().useInt64Timestamp(true).build()); if (this.catalog != null) { bigQueryOptions.setProjectId(this.catalog); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryFieldValueListWrapper.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryFieldValueListWrapper.java index 64d8ba75bfd7..efdf7cdc7a83 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryFieldValueListWrapper.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryFieldValueListWrapper.java @@ -70,11 +70,13 @@ static boolean[] createComplexColumnFlags(FieldList fieldList) { boolean[] isComplex = new boolean[size]; for (int i = 0; i < size; i++) { Field field = fieldList.get(i); + // TIMESTAMP keeps its FieldValue so int64-encoded values stay identifiable. isComplex[i] = isArray(field) || isStruct(field) || (field.getType() != null - && field.getType().getStandardType() == StandardSQLTypeName.RANGE); + && (field.getType().getStandardType() == StandardSQLTypeName.RANGE + || field.getType().getStandardType() == StandardSQLTypeName.TIMESTAMP)); } return isComplex; } 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 bc5817979379..ea0836aba6ae 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 @@ -439,10 +439,12 @@ static String formatRange(Field field, Object value, boolean enableTimestampPico throws BigQueryJdbcException { Object start; Object end; + boolean int64Timestamps = false; if (value instanceof FieldValue) { Range range = ((FieldValue) value).getRangeValue(); start = range.getStart().isNull() ? null : range.getStart().getStringValue(); end = range.getEnd().isNull() ? null : range.getEnd().getStringValue(); + int64Timestamps = Boolean.TRUE.equals(((FieldValue) value).getUseInt64Timestamps()); } else if (value instanceof Map) { start = ((Map) value).get("start"); end = ((Map) value).get("end"); @@ -450,6 +452,14 @@ static String formatRange(Field field, Object value, boolean enableTimestampPico return value == null ? null : value.toString(); } StandardSQLTypeName elementType = getRangeElementType(field, start != null ? start : end); + if (int64Timestamps && elementType == StandardSQLTypeName.TIMESTAMP) { + try { + start = start == null ? null : Long.valueOf((String) start); + end = end == null ? null : Long.valueOf((String) end); + } catch (NumberFormatException e) { + throw new BigQueryJdbcException("Invalid RANGE bound: " + value, e); + } + } return "[" + formatRangeBound(start, elementType, 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 5fc18693a129..dbfc8e775197 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 @@ -829,7 +829,7 @@ public static Object convert(Object input, StandardSQLTypeName bqType, ZoneId zo if (fv.isNull()) { return null; } - input = fv.getValue(); + input = unwrap(fv, bqType); } int ordinal = bqType.ordinal(); if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) { @@ -860,7 +860,7 @@ public static T convert( if (fv.isNull()) { return null; } - input = fv.getValue(); + input = unwrap(fv, bqType); } int ordinal = bqType.ordinal(); if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) { @@ -886,6 +886,20 @@ public static T convert( } } + /** Returns int64-encoded TIMESTAMP values as epoch micros, as the Arrow path delivers them. */ + private static Object unwrap(FieldValue fv, StandardSQLTypeName bqType) + throws BigQueryJdbcException { + if (bqType != StandardSQLTypeName.TIMESTAMP + || !Boolean.TRUE.equals(fv.getUseInt64Timestamps())) { + return fv.getValue(); + } + try { + return Long.valueOf(fv.getStringValue()); + } catch (NumberFormatException e) { + throw new BigQueryJdbcException("Invalid int64 TIMESTAMP value: " + fv.getValue(), e); + } + } + private static TypeDescriptor getDescriptorForClass(Class clazz) { TypeDescriptor descriptor = DESCRIPTORS_BY_CLASS.get(clazz); if (descriptor != null) { diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryConnectionTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryConnectionTest.java index 5f3846873b53..5c76648ca55a 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryConnectionTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryConnectionTest.java @@ -277,10 +277,9 @@ public void testTimestampPicosControlsDataFormatOptions() throws SQLException { } try (BigQueryConnection connection = new BigQueryConnection(BASE_URL)) { - assertEquals( - DataFormatOptions.TimestampFormatOptions.TIMESTAMP_OUTPUT_FORMAT_UNSPECIFIED, - connection.getBigQuery().getOptions().getDataFormatOptions().timestampFormatOptions(), - "Default connections must not alter the timestamp wire format"); + assertTrue( + connection.getBigQuery().getOptions().getDataFormatOptions().useInt64Timestamp(), + "Default connections must request lossless int64 timestamps"); } } 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 9b9287e2e123..50b795be9470 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 @@ -668,6 +668,28 @@ public void testRangeTimestampPicosEnabled() throws SQLException { .isEqualTo("[2023-03-30 11:14:19.123456789123, 2023-03-30 11:14:20.123456789123)"); } + @Test + public void testInt64Timestamps() throws SQLException { + Field rangeField = + Field.newBuilder("rangeTs", StandardSQLTypeName.RANGE) + .setRangeElementType(FieldElementType.newBuilder().setType("TIMESTAMP").build()) + .build(); + FieldList fields = FieldList.of(Field.of("ts", StandardSQLTypeName.TIMESTAMP), rangeField); + FieldValueList row = + FieldValueList.of( + ImmutableList.of( + FieldValue.of(Attribute.PRIMITIVE, "253402300799999999", true), + FieldValue.of(Attribute.RANGE, Range.of("[-1, 253402300799999999)"), true)), + fields); + BigQueryJsonResultSet rs = createJsonResultSet(fields, row, false); + assertThat(rs.next()).isTrue(); + assertThat(rs.getString("ts")).isEqualTo("9999-12-31 23:59:59.999999"); + assertThat(rs.getTimestamp("ts").toInstant()) + .isEqualTo(Instant.parse("9999-12-31T23:59:59.999999Z")); + assertThat(rs.getString("rangeTs")) + .isEqualTo("[1969-12-31 23:59:59.999999, 9999-12-31 23:59:59.999999)"); + } + @Test public void testRangeTimestampPicosDisabled() throws SQLException { Field rangeField = 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 30a4a0a2ad71..d246bd912379 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 @@ -81,7 +81,8 @@ public class ITTimestampTimeZoneTest extends ITBase { + " STRUCT(TIMESTAMP '2025-01-01 12:34:56.123 UTC' AS ts) AS ts_struct," // REST API sends these as scientific notation ("1.0E-6", "-1.0E-6"). + " TIMESTAMP '1970-01-01 00:00:00.000001 UTC' AS ts_post_epoch," - + " TIMESTAMP '1969-12-31 23:59:59.999999 UTC' AS ts_pre_epoch"; + + " TIMESTAMP '1969-12-31 23:59:59.999999 UTC' AS ts_pre_epoch," + + " TIMESTAMP '9999-12-31 23:59:59.999999 UTC' AS ts_max"; // 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))"; @@ -364,7 +365,12 @@ public void testGetTimestampAndGetObject_returnMoment(String zone) throws SQLExc assertEquals( Instant.parse("1969-12-31T23:59:59.999999Z"), rs.getTimestamp("ts_pre_epoch").toInstant(), - label(zone, "getTimestamp just before the epoch"))); + label(zone, "getTimestamp just before the epoch")), + () -> + assertEquals( + Instant.parse("9999-12-31T23:59:59.999999Z"), + rs.getTimestamp("ts_max").toInstant(), + label(zone, "getTimestamp at the maximum TIMESTAMP"))); } } @@ -614,7 +620,12 @@ public void testGetString_returnsUtcDigits(String zone) throws SQLException { assertEquals( "1969-12-31 23:59:59.999999", rs.getString("ts_pre_epoch"), - label(zone, "getString just before the epoch"))); + label(zone, "getString just before the epoch")), + () -> + assertEquals( + "9999-12-31 23:59:59.999999", + rs.getString("ts_max"), + label(zone, "getString at the maximum TIMESTAMP"))); } }