Skip to content

Commit bc84520

Browse files
fix(bigquery-jdbc): request int64 timestamps so the REST path is lossless (#14890)
b/577457377 ## Summary On the REST path, BigQuery returns `TIMESTAMP` values as float64 seconds by default. That loses microseconds for dates far from 1970, and `9999-12-31 23:59:59.999999 UTC` comes back as year `10000`. The value is rounded by the server, so the driver can't fix it on the client. ## Fix - Connections now request `useInt64Timestamp(true)`, which returns exact epoch microseconds. With `EnableTimestampPicos=1` we still request `ISO8601_STRING`, because BigQuery rejects both together. - The REST path now reads int64 `TIMESTAMP` values as epoch micros, as the Arrow path already does. This covers top-level, STRUCT/ARRAY and RANGE values. ## Tests - `BigQueryConnectionTest`: default connections request int64 timestamps. - `BigQueryJsonResultSetTest`: int64 `TIMESTAMP` and `RANGE<TIMESTAMP>` values decode exactly. - `ITTimestampTimeZoneTest`: adds `9999-12-31 23:59:59.999999 UTC` to the existing `getTimestamp` / `getString` checks across 4 JVM zones. These checks fail without the fix (`+10000-01-01`) and pass with it.
1 parent 66c58d3 commit bc84520

8 files changed

Lines changed: 91 additions & 27 deletions

File tree

‎java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,15 +1390,14 @@ private BigQuery getBigQueryConnection() {
13901390
bigQueryOptions.setRetrySettings(retry_settings_builder.build());
13911391
}
13921392

1393-
// ISO8601_STRING is the only output format carrying the full 12-digit fraction
1394-
// ("YYYY-MM-DDTHH:MM:SS.FFFFFFFFFFFFZ"). The default FLOAT64 and the INT64 alternative both
1395-
// cap out at microseconds.
1396-
if (this.enableTimestampPicos) {
1397-
bigQueryOptions.setDataFormatOptions(
1398-
DataFormatOptions.newBuilder()
1399-
.timestampFormatOptions(DataFormatOptions.TimestampFormatOptions.ISO8601_STRING)
1400-
.build());
1401-
}
1393+
// Default FLOAT64 timestamps lose microseconds far from the epoch. ISO8601_STRING is the only
1394+
// format carrying picoseconds; BigQuery rejects it combined with useInt64Timestamp.
1395+
bigQueryOptions.setDataFormatOptions(
1396+
this.enableTimestampPicos
1397+
? DataFormatOptions.newBuilder()
1398+
.timestampFormatOptions(DataFormatOptions.TimestampFormatOptions.ISO8601_STRING)
1399+
.build()
1400+
: DataFormatOptions.newBuilder().useInt64Timestamp(true).build());
14021401

14031402
if (this.catalog != null) {
14041403
bigQueryOptions.setProjectId(this.catalog);

‎java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryFieldValueListWrapper.java‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ static BigQueryFieldValueListWrapper of(
5858
FieldList fieldList, FieldValueList fieldValueList, boolean[] isComplexColumn) {
5959
boolean[] flags =
6060
isComplexColumn != null ? isComplexColumn : createComplexColumnFlags(fieldList);
61-
Object[] rowValues = unpackRow(fieldValueList, flags);
61+
Object[] rowValues = unpackRow(fieldList, fieldValueList, flags);
6262
return new BigQueryFieldValueListWrapper(fieldList, null, null, rowValues, false, null);
6363
}
6464

@@ -70,16 +70,13 @@ static boolean[] createComplexColumnFlags(FieldList fieldList) {
7070
boolean[] isComplex = new boolean[size];
7171
for (int i = 0; i < size; i++) {
7272
Field field = fieldList.get(i);
73-
isComplex[i] =
74-
isArray(field)
75-
|| isStruct(field)
76-
|| (field.getType() != null
77-
&& field.getType().getStandardType() == StandardSQLTypeName.RANGE);
73+
isComplex[i] = isArray(field) || isStruct(field);
7874
}
7975
return isComplex;
8076
}
8177

82-
static Object[] unpackRow(FieldValueList fieldValueList, boolean[] isComplexColumn) {
78+
static Object[] unpackRow(
79+
FieldList fieldList, FieldValueList fieldValueList, boolean[] isComplexColumn) {
8380
if (fieldValueList == null) {
8481
return null;
8582
}
@@ -90,7 +87,8 @@ static Object[] unpackRow(FieldValueList fieldValueList, boolean[] isComplexColu
9087
if (fv == null || fv.isNull()) {
9188
row[i] = null;
9289
} else if ((i < isComplexColumn.length && isComplexColumn[i])
93-
|| fv.getAttribute() != Attribute.PRIMITIVE) {
90+
|| fv.getAttribute() != Attribute.PRIMITIVE
91+
|| keepsFieldValue(fieldList, i)) {
9492
row[i] = fv;
9593
} else {
9694
row[i] = fv.getStringValue();
@@ -99,6 +97,16 @@ static Object[] unpackRow(FieldValueList fieldValueList, boolean[] isComplexColu
9997
return row;
10098
}
10199

100+
/** RANGE and TIMESTAMP are read from their FieldValue, e.g. to detect int64 encoding. */
101+
private static boolean keepsFieldValue(FieldList fieldList, int index) {
102+
if (fieldList == null || index >= fieldList.size()) {
103+
return false;
104+
}
105+
Field field = fieldList.get(index);
106+
StandardSQLTypeName type = field.getType() == null ? null : field.getType().getStandardType();
107+
return type == StandardSQLTypeName.RANGE || type == StandardSQLTypeName.TIMESTAMP;
108+
}
109+
102110
static BigQueryFieldValueListWrapper getNestedFieldValueListWrapper(
103111
FieldList fieldList, List<FieldValue> arrayFieldValueList, boolean... isLast) {
104112
boolean isLastFlag = isLast != null && isLast.length == 1 && isLast[0];

‎java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtility.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,17 +439,27 @@ static String formatRange(Field field, Object value, boolean enableTimestampPico
439439
throws BigQueryJdbcException {
440440
Object start;
441441
Object end;
442+
boolean int64Timestamps = false;
442443
if (value instanceof FieldValue) {
443444
Range range = ((FieldValue) value).getRangeValue();
444445
start = range.getStart().isNull() ? null : range.getStart().getStringValue();
445446
end = range.getEnd().isNull() ? null : range.getEnd().getStringValue();
447+
int64Timestamps = Boolean.TRUE.equals(((FieldValue) value).getUseInt64Timestamps());
446448
} else if (value instanceof Map) {
447449
start = ((Map<?, ?>) value).get("start");
448450
end = ((Map<?, ?>) value).get("end");
449451
} else {
450452
return value == null ? null : value.toString();
451453
}
452454
StandardSQLTypeName elementType = getRangeElementType(field, start != null ? start : end);
455+
if (int64Timestamps && elementType == StandardSQLTypeName.TIMESTAMP) {
456+
try {
457+
start = start == null ? null : Long.valueOf((String) start);
458+
end = end == null ? null : Long.valueOf((String) end);
459+
} catch (NumberFormatException e) {
460+
throw new BigQueryJdbcException("Invalid RANGE bound: " + value, e);
461+
}
462+
}
453463
return "["
454464
+ formatRangeBound(start, elementType, enableTimestampPicos)
455465
+ ", "

‎java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistry.java‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ public static Object convert(Object input, StandardSQLTypeName bqType, ZoneId zo
829829
if (fv.isNull()) {
830830
return null;
831831
}
832-
input = fv.getValue();
832+
input = unwrap(fv, bqType);
833833
}
834834
int ordinal = bqType.ordinal();
835835
if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) {
@@ -860,7 +860,7 @@ public static <T> T convert(
860860
if (fv.isNull()) {
861861
return null;
862862
}
863-
input = fv.getValue();
863+
input = unwrap(fv, bqType);
864864
}
865865
int ordinal = bqType.ordinal();
866866
if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) {
@@ -886,6 +886,20 @@ public static <T> T convert(
886886
}
887887
}
888888

889+
/** Returns int64-encoded TIMESTAMP values as epoch micros, as the Arrow path delivers them. */
890+
private static Object unwrap(FieldValue fv, StandardSQLTypeName bqType)
891+
throws BigQueryJdbcException {
892+
if (bqType == StandardSQLTypeName.TIMESTAMP
893+
&& Boolean.TRUE.equals(fv.getUseInt64Timestamps())) {
894+
try {
895+
return Long.valueOf(fv.getStringValue());
896+
} catch (NumberFormatException e) {
897+
throw new BigQueryJdbcException("Invalid int64 TIMESTAMP value: " + fv.getValue(), e);
898+
}
899+
}
900+
return fv.getValue();
901+
}
902+
889903
private static TypeDescriptor<?> getDescriptorForClass(Class<?> clazz) {
890904
TypeDescriptor<?> descriptor = DESCRIPTORS_BY_CLASS.get(clazz);
891905
if (descriptor != null) {

‎java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryConnectionTest.java‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,10 +277,9 @@ public void testTimestampPicosControlsDataFormatOptions() throws SQLException {
277277
}
278278

279279
try (BigQueryConnection connection = new BigQueryConnection(BASE_URL)) {
280-
assertEquals(
281-
DataFormatOptions.TimestampFormatOptions.TIMESTAMP_OUTPUT_FORMAT_UNSPECIFIED,
282-
connection.getBigQuery().getOptions().getDataFormatOptions().timestampFormatOptions(),
283-
"Default connections must not alter the timestamp wire format");
280+
assertTrue(
281+
connection.getBigQuery().getOptions().getDataFormatOptions().useInt64Timestamp(),
282+
"Default connections must request lossless int64 timestamps");
284283
}
285284
}
286285

‎java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryFieldValueListWrapperTest.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ public void testMemoryAllocationReduction() throws Exception {
182182
long bytesBefore = threadBean.getThreadAllocatedBytes(threadId);
183183
Object[][] rowBuffers = new Object[rowCount][];
184184
for (int i = 0; i < rowCount; i++) {
185-
rowBuffers[i] = BigQueryFieldValueListWrapper.unpackRow(sampleFvl, isComplexColumn);
185+
rowBuffers[i] =
186+
BigQueryFieldValueListWrapper.unpackRow(simpleFieldList, sampleFvl, isComplexColumn);
186187
}
187188
long allocatedBytes = threadBean.getThreadAllocatedBytes(threadId) - bytesBefore;
188189

‎java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSetTest.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,28 @@ public void testRangeTimestampPicosEnabled() throws SQLException {
668668
.isEqualTo("[2023-03-30 11:14:19.123456789123, 2023-03-30 11:14:20.123456789123)");
669669
}
670670

671+
@Test
672+
public void testInt64Timestamps() throws SQLException {
673+
Field rangeField =
674+
Field.newBuilder("rangeTs", StandardSQLTypeName.RANGE)
675+
.setRangeElementType(FieldElementType.newBuilder().setType("TIMESTAMP").build())
676+
.build();
677+
FieldList fields = FieldList.of(Field.of("ts", StandardSQLTypeName.TIMESTAMP), rangeField);
678+
FieldValueList row =
679+
FieldValueList.of(
680+
ImmutableList.of(
681+
FieldValue.of(Attribute.PRIMITIVE, "253402300799999999", true),
682+
FieldValue.of(Attribute.RANGE, Range.of("[-1, 253402300799999999)"), true)),
683+
fields);
684+
BigQueryJsonResultSet rs = createJsonResultSet(fields, row, false);
685+
assertThat(rs.next()).isTrue();
686+
assertThat(rs.getString("ts")).isEqualTo("9999-12-31 23:59:59.999999");
687+
assertThat(rs.getTimestamp("ts").toInstant())
688+
.isEqualTo(Instant.parse("9999-12-31T23:59:59.999999Z"));
689+
assertThat(rs.getString("rangeTs"))
690+
.isEqualTo("[1969-12-31 23:59:59.999999, 9999-12-31 23:59:59.999999)");
691+
}
692+
671693
@Test
672694
public void testRangeTimestampPicosDisabled() throws SQLException {
673695
Field rangeField =

‎java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITTimestampTimeZoneTest.java‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ public class ITTimestampTimeZoneTest extends ITBase {
8181
+ " STRUCT(TIMESTAMP '2025-01-01 12:34:56.123 UTC' AS ts) AS ts_struct,"
8282
// REST API sends these as scientific notation ("1.0E-6", "-1.0E-6").
8383
+ " TIMESTAMP '1970-01-01 00:00:00.000001 UTC' AS ts_post_epoch,"
84-
+ " TIMESTAMP '1969-12-31 23:59:59.999999 UTC' AS ts_pre_epoch";
84+
+ " TIMESTAMP '1969-12-31 23:59:59.999999 UTC' AS ts_pre_epoch,"
85+
+ " TIMESTAMP '9999-12-31 23:59:59.999999 UTC' AS ts_max";
8586

8687
// Same coercion as an INSERT into a TIMESTAMP column, for TIMESTAMP and STRING parameters.
8788
private static final String PROBE_TIMESTAMP = "SELECT UNIX_MILLIS(CAST(? AS TIMESTAMP))";
@@ -364,7 +365,12 @@ public void testGetTimestampAndGetObject_returnMoment(String zone) throws SQLExc
364365
assertEquals(
365366
Instant.parse("1969-12-31T23:59:59.999999Z"),
366367
rs.getTimestamp("ts_pre_epoch").toInstant(),
367-
label(zone, "getTimestamp just before the epoch")));
368+
label(zone, "getTimestamp just before the epoch")),
369+
() ->
370+
assertEquals(
371+
Instant.parse("9999-12-31T23:59:59.999999Z"),
372+
rs.getTimestamp("ts_max").toInstant(),
373+
label(zone, "getTimestamp at the maximum TIMESTAMP")));
368374
}
369375
}
370376

@@ -614,7 +620,12 @@ public void testGetString_returnsUtcDigits(String zone) throws SQLException {
614620
assertEquals(
615621
"1969-12-31 23:59:59.999999",
616622
rs.getString("ts_pre_epoch"),
617-
label(zone, "getString just before the epoch")));
623+
label(zone, "getString just before the epoch")),
624+
() ->
625+
assertEquals(
626+
"9999-12-31 23:59:59.999999",
627+
rs.getString("ts_max"),
628+
label(zone, "getString at the maximum TIMESTAMP")));
618629
}
619630
}
620631

0 commit comments

Comments
 (0)