Skip to content

Commit 53bbde0

Browse files
fix(bigquery-jdbc): preserve civil digits for DATETIME/DATE (#14910)
b/577457022 ### Summary Prevents JVM default timezone DST gaps and historical calendar shifts from corrupting timezone-less civil temporal types (`DATETIME`, `DATE`, `TIME`) when accessed as `java.sql.Date`, `java.sql.Time`, or formatted inside `ARRAY` and `STRUCT`. ### Root Cause 1. **`Date` / `Time` extraction from `DATETIME`**: `BigQueryBaseResultSet` (`getDate`, `getTime`, `getObject(int, Class)`) and `BigQueryTypeRegistry` (`createDatetimeDescriptor`) boxed `DATETIME` values into an intermediate `java.sql.Timestamp` in the JVM default timezone before extracting `Date` or `Time`. When a `DATETIME` value fell inside a local DST spring-forward gap (e.g., `2025-03-09 02:30:00` in `America/New_York`), the intermediate `Timestamp` shifted the wall-clock hour forward by +1 hour (`03:30:00`). 2. **`ARRAY` / `STRUCT` string formatting**: `BigQueryBaseArray.toString()` and `BigQueryBaseStruct.appendJsonValue()` called `.toString()` on boxed `java.sql.Timestamp` / `java.sql.Date` elements, corrupting `ARRAY<DATETIME>` / `STRUCT<DATETIME>` values in DST gaps and `ARRAY<DATE>` / `STRUCT<DATE>` values prior to the 1582 Julian–Gregorian cutover. ### Changes - **`BigQueryTemporalUtility`**: Added `isCivilTemporalType` and `formatCivilTemporalElement` to centralize civil type checks and format raw `DATE`, `TIME`, and `DATETIME` elements directly from their unshifted civil representation. - **`BigQueryTypeRegistry`**: Unified `createDatetimeDescriptor` to parse `DATETIME` into `LocalDateTime` once and project directly to `Date`, `Time`, `LocalDate`, `LocalTime`, `LocalDateTime`, `String`, or instant views without intermediate `Timestamp` boxing. - **`BigQueryBaseResultSet`**: Updated `getObject`, `getDate`, `getTime`, and `getTimestamp` to pass raw unboxed values for civil temporal types (`DATE`, `TIME`, `DATETIME`) to `BigQueryTypeRegistry`. - **`BigQueryBaseArray` & `BigQueryBaseStruct`**: Updated string/JSON formatting to format civil temporal elements directly from raw values via `BigQueryTemporalUtility.formatCivilTemporalElement`.
1 parent 0c20c1b commit 53bbde0

9 files changed

Lines changed: 169 additions & 109 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,15 @@ public String toString() {
172172
if (array == null) {
173173
return "null";
174174
}
175-
if (this.schema.getType().getStandardType() == StandardSQLTypeName.BYTES) {
175+
StandardSQLTypeName type = this.schema.getType().getStandardType();
176+
if (type == StandardSQLTypeName.BYTES) {
176177
return Arrays.stream(array)
177178
.map(
178179
element ->
179180
element == null ? "null" : Base64.getEncoder().encodeToString((byte[]) element))
180181
.collect(Collectors.joining(", ", "[", "]"));
181182
}
182-
if (this.schema.getType().getStandardType() == StandardSQLTypeName.TIMESTAMP) {
183+
if (type == StandardSQLTypeName.TIMESTAMP) {
183184
return Arrays.stream(array)
184185
.map(
185186
element -> {
@@ -193,7 +194,7 @@ public String toString() {
193194
})
194195
.collect(Collectors.joining(", ", "[", "]"));
195196
}
196-
if (this.schema.getType().getStandardType() == StandardSQLTypeName.TIME) {
197+
if (BigQueryTemporalUtility.isCivilTemporalType(type)) {
197198
StringBuilder sb = new StringBuilder("[");
198199
for (int i = 0; i < array.length; i++) {
199200
if (i > 0) {
@@ -202,8 +203,7 @@ public String toString() {
202203
sb.append(
203204
array[i] == null
204205
? "null"
205-
: BigQueryTypeRegistry.convert(
206-
getRawElement(i), StandardSQLTypeName.TIME, String.class, null));
206+
: BigQueryTypeRegistry.formatCivilTemporalElement(type, getRawElement(i)));
207207
}
208208
sb.append("]");
209209
return sb.toString();

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

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,7 @@
5252
import java.sql.Statement;
5353
import java.sql.Time;
5454
import java.sql.Timestamp;
55-
import java.time.Instant;
56-
import java.time.LocalDate;
57-
import java.time.LocalDateTime;
58-
import java.time.LocalTime;
59-
import java.time.OffsetDateTime;
6055
import java.time.ZoneId;
61-
import java.time.ZonedDateTime;
6256
import java.util.Calendar;
6357
import java.util.List;
6458
import javax.sql.rowset.serial.SerialBlob;
@@ -318,45 +312,34 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException {
318312

319313
abstract Object getObjectInternal(int columnIndex) throws SQLException;
320314

315+
private Object getTemporalValue(int columnIndex, StandardSQLTypeName bqType, ZoneId zone)
316+
throws SQLException {
317+
return BigQueryTemporalUtility.isCivilTemporalType(bqType)
318+
? getObjectInternal(columnIndex)
319+
: getObject(columnIndex, zone);
320+
}
321+
321322
@Override
322323
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
323324
LOG.finestTrace("getObject");
324325
try {
325-
if (isRawViewTarget(type)) {
326-
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
327-
// java.sql types can't hold every value of these (sub-ms TIME, pre-1582 DATE/DATETIME).
328-
if (bqType == StandardSQLTypeName.TIME
329-
|| bqType == StandardSQLTypeName.DATE
330-
|| bqType == StandardSQLTypeName.DATETIME) {
331-
return BigQueryTypeRegistry.convert(getObjectInternal(columnIndex), bqType, type, null);
332-
}
326+
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
327+
if (type != null && BigQueryTemporalUtility.isCivilTemporalType(bqType)) {
328+
return BigQueryTypeRegistry.convert(getObjectInternal(columnIndex), bqType, type, null);
333329
}
334330
Object value = getObject(columnIndex);
335331
if (value == null) {
336332
return null;
337333
}
338-
if (type != null && !type.isInstance(value)) {
339-
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
340-
if (bqType == StandardSQLTypeName.TIMESTAMP) {
341-
return BigQueryTypeRegistry.convert(value, bqType, type, null);
342-
}
334+
if (type != null && !type.isInstance(value) && bqType == StandardSQLTypeName.TIMESTAMP) {
335+
return BigQueryTypeRegistry.convert(value, bqType, type, null);
343336
}
344337
return BigQueryTypeRegistry.convert(value, type);
345338
} catch (RuntimeException e) {
346339
throw createCoercionException(columnIndex, type, e);
347340
}
348341
}
349342

350-
private static boolean isRawViewTarget(Class<?> type) {
351-
return type == String.class
352-
|| type == LocalDate.class
353-
|| type == LocalDateTime.class
354-
|| type == LocalTime.class
355-
|| type == Instant.class
356-
|| type == OffsetDateTime.class
357-
|| type == ZonedDateTime.class;
358-
}
359-
360343
@Override
361344
public <T> T getObject(String columnLabel, Class<T> type) throws SQLException {
362345
return getObject(getColumnIndex(columnLabel), type);
@@ -592,7 +575,7 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException {
592575
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
593576
ZoneId zone = getEffectiveZone(bqType, cal);
594577
try {
595-
Object value = getObject(columnIndex, zone);
578+
Object value = getTemporalValue(columnIndex, bqType, zone);
596579
return BigQueryTypeRegistry.convert(value, bqType, Date.class, zone);
597580
} catch (BigQueryJdbcException e) {
598581
throw createCoercionException(columnIndex, Date.class, e);
@@ -608,7 +591,7 @@ public Time getTime(int columnIndex, Calendar cal) throws SQLException {
608591
}
609592
ZoneId zone = getEffectiveZone(bqType, cal);
610593
try {
611-
Object value = getObject(columnIndex, zone);
594+
Object value = getTemporalValue(columnIndex, bqType, zone);
612595
return BigQueryTypeRegistry.convert(value, bqType, Time.class, zone);
613596
} catch (BigQueryJdbcException e) {
614597
throw createCoercionException(columnIndex, Time.class, e);
@@ -624,7 +607,7 @@ public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException
624607
}
625608
ZoneId zone = getEffectiveZone(bqType, cal);
626609
try {
627-
Object value = getObject(columnIndex, zone);
610+
Object value = getTemporalValue(columnIndex, bqType, zone);
628611
return BigQueryTypeRegistry.convert(value, bqType, Timestamp.class, zone);
629612
} catch (BigQueryJdbcException e) {
630613
throw createCoercionException(columnIndex, Timestamp.class, e);

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

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,23 +117,21 @@ public String toString() {
117117

118118
private static void appendJsonValue(StringBuilder sb, Field field, Object value, Object rawValue)
119119
throws SQLException {
120+
StandardSQLTypeName type = field.getType().getStandardType();
120121
if (value == null) {
121122
sb.append("null");
122-
} else if (field.getType().getStandardType() == StandardSQLTypeName.JSON) {
123+
} else if (type == StandardSQLTypeName.JSON) {
123124
sb.append(value);
124125
} else if (value instanceof String || value instanceof Text) {
125126
String stringValue = value.toString().replace("\"", "\\\"");
126127
sb.append("\"").append(stringValue).append("\"");
127-
} else if (value instanceof Timestamp
128-
&& field.getType().getStandardType() == StandardSQLTypeName.TIMESTAMP) {
128+
} else if (value instanceof Timestamp && type == StandardSQLTypeName.TIMESTAMP) {
129129
sb.append("\"")
130130
.append(BigQueryTemporalUtility.formatTimestampToUtcString((Timestamp) value))
131131
.append("\"");
132-
} else if (value instanceof Time
133-
&& field.getType().getStandardType() == StandardSQLTypeName.TIME) {
132+
} else if (BigQueryTemporalUtility.isCivilTemporalType(type)) {
134133
sb.append("\"")
135-
.append(
136-
BigQueryTypeRegistry.convert(rawValue, StandardSQLTypeName.TIME, String.class, null))
134+
.append(BigQueryTypeRegistry.formatCivilTemporalElement(type, rawValue))
137135
.append("\"");
138136
} else if (value instanceof Timestamp || value instanceof Date || value instanceof Time) {
139137
sb.append("\"").append(value).append("\"");

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ final class BigQueryTemporalUtility {
5454
private static final BigDecimal MICROS_PER_SECOND = new BigDecimal("1000000");
5555
private static final DateTimeFormatter BASE_FORMATTER =
5656
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.");
57-
private static final DateTimeFormatter UTC_TIMESTAMP_TO_STRING_FORMATTER =
57+
static final DateTimeFormatter UTC_TIMESTAMP_TO_STRING_FORMATTER =
5858
new DateTimeFormatterBuilder()
5959
.appendPattern("uuuu-MM-dd HH:mm:ss.")
6060
.appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, false)
@@ -392,6 +392,12 @@ static String formatTimestampToUtcString(Timestamp ts) {
392392
return UTC_TIMESTAMP_TO_STRING_FORMATTER.format(ts.toInstant());
393393
}
394394

395+
static boolean isCivilTemporalType(StandardSQLTypeName type) {
396+
return type == StandardSQLTypeName.DATE
397+
|| type == StandardSQLTypeName.TIME
398+
|| type == StandardSQLTypeName.DATETIME;
399+
}
400+
395401
/**
396402
* Formats a timestamp value (which may be a {@link Long} epoch microsecond, an ISO-8601 string, a
397403
* {@link java.sql.Timestamp}, or an epoch decimal string) into a standard UTC JDBC timestamp

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

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -298,57 +298,56 @@ static TypeDescriptor<?> createDatetimeDescriptor() {
298298
StandardSQLTypeName.DATETIME,
299299
Arrays.asList(Timestamp.class, LocalDateTime.class),
300300
(val, targetClass, zone) -> {
301-
// Modern fast-path: Bypass intermediate object creation for JSR-310 targets
302-
if (targetClass == LocalDateTime.class
303-
|| targetClass == String.class
304-
|| targetClass == LocalDate.class
305-
|| targetClass == LocalTime.class
306-
|| isInstantView(targetClass)) {
307-
LocalDateTime localDateTime;
308-
if (val instanceof LocalDateTime) {
309-
localDateTime = (LocalDateTime) val;
310-
} else if (val instanceof Timestamp) {
311-
localDateTime = ((Timestamp) val).toLocalDateTime();
312-
} else if (val instanceof java.util.Date) {
313-
localDateTime = new Timestamp(((java.util.Date) val).getTime()).toLocalDateTime();
314-
} else if (val instanceof String) {
315-
String isoString =
316-
BigQueryTemporalUtility.truncateFractionalSeconds(
317-
((String) val).replace(' ', 'T'), 9);
318-
localDateTime = LocalDateTime.parse(isoString);
319-
} else {
320-
throw new BigQueryJdbcException("Cannot convert to DATETIME: " + val);
321-
}
322-
if (targetClass == LocalDateTime.class) {
323-
return localDateTime;
324-
}
325-
if (targetClass == String.class) {
326-
return TIMESTAMP_FORMATTER.format(localDateTime);
327-
}
328-
if (targetClass == LocalDate.class) {
329-
return localDateTime.toLocalDate();
330-
}
331-
if (targetClass == LocalTime.class) {
332-
return localDateTime.toLocalTime();
333-
}
334-
return anchorCivil(localDateTime, targetClass, zone);
301+
if (val instanceof Timestamp && targetClass == Timestamp.class && zone == null) {
302+
return val;
303+
}
304+
if (val instanceof java.util.Date
305+
&& !(val instanceof Timestamp)
306+
&& targetClass == Timestamp.class
307+
&& zone == null) {
308+
return new Timestamp(((java.util.Date) val).getTime());
335309
}
336310

337-
// Legacy path: Box values into java.sql.Timestamp
338-
Timestamp ts;
339-
if (val instanceof Timestamp) {
340-
ts = (Timestamp) val;
311+
LocalDateTime localDateTime;
312+
if (val instanceof LocalDateTime) {
313+
localDateTime = (LocalDateTime) val;
314+
} else if (val instanceof Timestamp) {
315+
localDateTime = BigQueryTemporalUtility.unboxDateTime((Timestamp) val, zone);
341316
} else if (val instanceof java.util.Date) {
342-
ts = new Timestamp(((java.util.Date) val).getTime());
343-
} else if (val instanceof LocalDateTime) {
344-
ts = BigQueryTemporalUtility.boxDateTime((LocalDateTime) val, zone);
317+
localDateTime =
318+
BigQueryTemporalUtility.unboxDateTime(
319+
new Timestamp(((java.util.Date) val).getTime()), zone);
345320
} else if (val instanceof String) {
346-
ts = BigQueryTemporalUtility.boxDateTime((String) val, zone);
321+
String isoString =
322+
BigQueryTemporalUtility.truncateFractionalSeconds(
323+
((String) val).replace(' ', 'T'), 9);
324+
localDateTime = LocalDateTime.parse(isoString);
347325
} else {
348326
throw new BigQueryJdbcException("Cannot convert to DATETIME: " + val);
349327
}
350328

351-
return ts;
329+
if (targetClass == LocalDateTime.class) {
330+
return localDateTime;
331+
}
332+
if (targetClass == String.class) {
333+
return TIMESTAMP_FORMATTER.format(localDateTime);
334+
}
335+
if (targetClass == LocalDate.class) {
336+
return localDateTime.toLocalDate();
337+
}
338+
if (targetClass == LocalTime.class) {
339+
return localDateTime.toLocalTime();
340+
}
341+
if (targetClass == Date.class) {
342+
return BigQueryTemporalUtility.boxDate(localDateTime.toLocalDate(), zone);
343+
}
344+
if (targetClass == Time.class) {
345+
return BigQueryTemporalUtility.boxTime(localDateTime.toLocalTime(), zone);
346+
}
347+
if (isInstantView(targetClass)) {
348+
return anchorCivil(localDateTime, targetClass, zone);
349+
}
350+
return BigQueryTemporalUtility.boxDateTime(localDateTime, zone);
352351
});
353352
}
354353

@@ -886,6 +885,23 @@ public static <T> T convert(
886885
}
887886
}
888887

888+
/**
889+
* Formats a raw civil temporal value ({@code DATE}, {@code TIME}, or {@code DATETIME}) as a
890+
* string for complex types ({@code ARRAY} and {@code STRUCT}) without boxing through {@code
891+
* java.sql} types.
892+
*/
893+
static String formatCivilTemporalElement(StandardSQLTypeName type, Object rawValue)
894+
throws BigQueryJdbcException {
895+
if (type == StandardSQLTypeName.DATETIME) {
896+
LocalDateTime ldt =
897+
convert(rawValue, StandardSQLTypeName.DATETIME, LocalDateTime.class, null);
898+
return ldt == null
899+
? null
900+
: BigQueryTemporalUtility.UTC_TIMESTAMP_TO_STRING_FORMATTER.format(ldt);
901+
}
902+
return convert(rawValue, type, String.class, null);
903+
}
904+
889905
/** Returns int64-encoded TIMESTAMP values as epoch micros, as the Arrow path delivers them. */
890906
private static Object unwrap(FieldValue fv, StandardSQLTypeName bqType)
891907
throws BigQueryJdbcException {

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -700,16 +700,20 @@ public void testGetStringOnTimestampAndDatetimeAcrossJvmZones(String jvmZone)
700700
TimeZone originalDefault = TimeZone.getDefault();
701701
try {
702702
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone)));
703-
Instant ref = Instant.parse("2025-01-01T12:34:56.123Z");
703+
Instant ref = Instant.parse("2025-03-09T02:34:56.123Z");
704704
BigQueryArrowResultSet rs = createTemporalArrowResultSet(ref);
705705
assertThat(rs.next()).isTrue();
706706

707-
assertThat(rs.getString("ts")).isEqualTo("2025-01-01 12:34:56.123000");
708-
assertThat(rs.getObject("ts", String.class)).isEqualTo("2025-01-01 12:34:56.123000");
709-
assertThat(rs.getString("dt")).isEqualTo("2025-01-01 12:34:56.123000");
707+
assertThat(rs.getString("ts")).isEqualTo("2025-03-09 02:34:56.123000");
708+
assertThat(rs.getObject("ts", String.class)).isEqualTo("2025-03-09 02:34:56.123000");
709+
assertThat(rs.getString("dt")).isEqualTo("2025-03-09 02:34:56.123000");
710+
assertThat(rs.getDate("dt").toLocalDate()).isEqualTo(LocalDate.of(2025, 3, 9));
711+
assertThat(rs.getObject("dt", Date.class).toLocalDate()).isEqualTo(LocalDate.of(2025, 3, 9));
712+
assertThat(rs.getTime("dt").toLocalTime()).isEqualTo(LocalTime.of(2, 34, 56));
713+
assertThat(rs.getObject("dt", Time.class).toLocalTime()).isEqualTo(LocalTime.of(2, 34, 56));
710714

711715
long tsMicros = ref.getEpochSecond() * 1_000_000L + ref.getNano() / 1_000L;
712-
LocalDateTime dtLocal = LocalDateTime.of(2025, 1, 1, 12, 34, 56, 123_000_000);
716+
LocalDateTime dtLocal = LocalDateTime.of(2025, 3, 9, 2, 34, 56, 123_000_000);
713717

714718
Field tsArraySchema =
715719
Field.newBuilder("ts_array", StandardSQLTypeName.TIMESTAMP)
@@ -718,10 +722,10 @@ public void testGetStringOnTimestampAndDatetimeAcrossJvmZones(String jvmZone)
718722
JsonStringArrayList<Long> tsList = new JsonStringArrayList<>();
719723
tsList.add(tsMicros);
720724
BigQueryArrowArray tsArrowArray = new BigQueryArrowArray(tsArraySchema, tsList);
721-
assertThat(tsArrowArray.toString()).isEqualTo("[2025-01-01 12:34:56.123]");
725+
assertThat(tsArrowArray.toString()).isEqualTo("[2025-03-09 02:34:56.123]");
722726
try (ResultSet nestedTsRs = tsArrowArray.getResultSet()) {
723727
assertThat(nestedTsRs.next()).isTrue();
724-
assertThat(nestedTsRs.getString(2)).isEqualTo("2025-01-01 12:34:56.123000");
728+
assertThat(nestedTsRs.getString(2)).isEqualTo("2025-03-09 02:34:56.123000");
725729
}
726730

727731
Field dtArraySchema =
@@ -731,7 +735,12 @@ public void testGetStringOnTimestampAndDatetimeAcrossJvmZones(String jvmZone)
731735
JsonStringArrayList<LocalDateTime> dtList = new JsonStringArrayList<>();
732736
dtList.add(dtLocal);
733737
BigQueryArrowArray dtArrowArray = new BigQueryArrowArray(dtArraySchema, dtList);
734-
assertThat(dtArrowArray.toString()).isEqualTo("[2025-01-01 12:34:56.123]");
738+
assertThat(dtArrowArray.toString()).isEqualTo("[2025-03-09 02:34:56.123]");
739+
try (ResultSet nestedDtRs = dtArrowArray.getResultSet()) {
740+
assertThat(nestedDtRs.next()).isTrue();
741+
assertThat(nestedDtRs.getString(2)).isEqualTo("2025-03-09 02:34:56.123000");
742+
assertThat(nestedDtRs.getTime(2).toLocalTime()).isEqualTo(LocalTime.of(2, 34, 56));
743+
}
735744

736745
FieldList structSchema =
737746
FieldList.of(
@@ -742,7 +751,7 @@ public void testGetStringOnTimestampAndDatetimeAcrossJvmZones(String jvmZone)
742751
structValues.put("dt", dtLocal);
743752
BigQueryArrowStruct arrowStruct = new BigQueryArrowStruct(structSchema, structValues);
744753
assertThat(arrowStruct.toString())
745-
.isEqualTo("{\"ts\":\"2025-01-01 12:34:56.123\",\"dt\":\"2025-01-01 12:34:56.123\"}");
754+
.isEqualTo("{\"ts\":\"2025-03-09 02:34:56.123\",\"dt\":\"2025-03-09 02:34:56.123\"}");
746755
} finally {
747756
TimeZone.setDefault(originalDefault);
748757
}

0 commit comments

Comments
 (0)