Skip to content

Commit 7dd2b37

Browse files
fix(bigquery-jdbc): honor Calendar timezone and prevent ClassCastException in ResultSet temporal getters (#14657)
b/565791346 ### Summary Fixes two bugs in `ResultSet.getDate(col, Calendar)`, `ResultSet.getTime(col, Calendar)`, and `ResultSet.getTimestamp(col, Calendar)`: 1. **`Calendar` ignored on `DATE`, `TIME`, and `DATETIME` columns:** - Previously, the `Calendar` getter overloads called `getObject(columnIndex)` first with `zone = null`, which boxed raw values into `Date`, `Time`, or `Timestamp` anchored in the JVM default timezone before `BigQueryTypeRegistry.convert(..., zone)` ran. - Arrow `DATE` (`LocalDate` / `Integer`) and `DATETIME` (`LocalDateTime`) branches in `BigQueryTypeRegistry` also used `Date.valueOf(...)` and `Timestamp.valueOf(...)`, ignoring `zone`. 2. **`ClassCastException` on cross-type conversions (e.g., `getDate` / `getTime` on `DATETIME` or `TIMESTAMP` columns):** - `BigQueryTypeRegistry.convert(input, bqType, targetClass, zoneId)` dispatched solely by `bqType`, returning a `Timestamp` for `DATETIME` and `TIMESTAMP` columns even when `targetClass` was `Date.class` or `Time.class`. ### Changes - Added `getObject(int columnIndex, ZoneId zone)` to `BigQueryBaseResultSet`, `BigQueryJsonResultSet`, and `BigQueryArrowResultSet` so the `Calendar`'s `ZoneId` is applied during initial unboxing for zone-less types (`DATE`, `TIME`, `DATETIME`), while continuing to ignore `Calendar` on `TIMESTAMP` columns per the JDBC specification. - Overloaded `boxDate(LocalDate, ZoneId)`, `boxDateTime(LocalDateTime, ZoneId)`, and `boxTime(LocalTime, ZoneId)` in `BigQueryTemporalUtility` and wired them into `BigQueryTypeRegistry`. - Updated `BigQueryTypeRegistry.convert(Object, StandardSQLTypeName, Class<T>, ZoneId)` to delegate to the target class descriptor when the column descriptor returns an intermediate type. - Added unit tests in `BigQueryJsonResultSetTest`, `BigQueryArrowResultSetTest`, and `BigQueryTypeRegistryTest`.
1 parent 2106e26 commit 7dd2b37

10 files changed

Lines changed: 408 additions & 58 deletions

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.sql.Timestamp;
3636
import java.time.LocalDate;
3737
import java.time.LocalDateTime;
38+
import java.time.ZoneId;
3839
import java.util.ArrayList;
3940
import java.util.List;
4041
import java.util.concurrent.BlockingQueue;
@@ -369,6 +370,11 @@ private Object getObjectInternal(int columnIndex) throws SQLException {
369370

370371
@Override
371372
public Object getObject(int columnIndex) throws SQLException {
373+
return getObject(columnIndex, (ZoneId) null);
374+
}
375+
376+
@Override
377+
protected Object getObject(int columnIndex, ZoneId zone) throws SQLException {
372378

373379
// columnIndex is SQL index starting at 1
374380
LOG.finestTrace("getObject");
@@ -398,7 +404,7 @@ public Object getObject(int columnIndex) throws SQLException {
398404
if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(arrayField)) {
399405
return BigQueryTemporalUtility.formatTimestampValue(value, true);
400406
}
401-
return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), null);
407+
return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), zone);
402408
}
403409

404410
int fieldIndex = this.isNested ? 0 : columnIndex - 1;
@@ -478,7 +484,7 @@ public Object getObject(int columnIndex) throws SQLException {
478484
if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(fieldSchema)) {
479485
return BigQueryTemporalUtility.formatTimestampValue(value, true);
480486
}
481-
return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), null);
487+
return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), zone);
482488
}
483489

484490
private StandardSQLTypeName getRangeElementType(Field field, Object representativeElement) {

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

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,10 @@ public boolean isClosed() {
296296

297297
public abstract Object getObject(int columnIndex) throws SQLException;
298298

299+
protected Object getObject(int columnIndex, ZoneId zone) throws SQLException {
300+
return getObject(columnIndex);
301+
}
302+
299303
@Override
300304
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
301305
LOG.finestTrace("getObject");
@@ -304,6 +308,12 @@ public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
304308
if (value == null) {
305309
return null;
306310
}
311+
if (type != null && !type.isInstance(value)) {
312+
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
313+
if (bqType == StandardSQLTypeName.TIMESTAMP) {
314+
return BigQueryTypeRegistry.convert(value, bqType, type, null);
315+
}
316+
}
307317
return BigQueryTypeRegistry.convert(value, type);
308318
} catch (RuntimeException e) {
309319
throw createCoercionException(columnIndex, type, e);
@@ -454,43 +464,17 @@ public byte[] getBytes(int columnIndex) throws SQLException {
454464

455465
@Override
456466
public Date getDate(int columnIndex) throws SQLException {
457-
LOG.finestTrace("getDate");
458-
try {
459-
Object value = getObject(columnIndex);
460-
return BigQueryTypeRegistry.convert(value, Date.class);
461-
} catch (BigQueryJdbcException e) {
462-
throw createCoercionException(columnIndex, Date.class, e);
463-
}
467+
return getDate(columnIndex, null);
464468
}
465469

466470
@Override
467471
public Time getTime(int columnIndex) throws SQLException {
468-
LOG.finestTrace("getTime");
469-
StandardSQLTypeName type = getStandardSQLTypeName(columnIndex);
470-
if (type == StandardSQLTypeName.INT64) {
471-
throw createCoercionException(columnIndex, Time.class, null);
472-
}
473-
try {
474-
Object value = getObject(columnIndex);
475-
return BigQueryTypeRegistry.convert(value, Time.class);
476-
} catch (BigQueryJdbcException e) {
477-
throw createCoercionException(columnIndex, Time.class, e);
478-
}
472+
return getTime(columnIndex, null);
479473
}
480474

481475
@Override
482476
public Timestamp getTimestamp(int columnIndex) throws SQLException {
483-
LOG.finestTrace("getTimestamp");
484-
StandardSQLTypeName type = getStandardSQLTypeName(columnIndex);
485-
if (type == StandardSQLTypeName.INT64) {
486-
throw createCoercionException(columnIndex, Timestamp.class, null);
487-
}
488-
try {
489-
Object value = getObject(columnIndex);
490-
return BigQueryTypeRegistry.convert(value, Timestamp.class);
491-
} catch (BigQueryJdbcException e) {
492-
throw createCoercionException(columnIndex, Timestamp.class, e);
493-
}
477+
return getTimestamp(columnIndex, null);
494478
}
495479

496480
@Override
@@ -564,13 +548,19 @@ public InputStream getBinaryStream(int columnIndex) throws SQLException {
564548
return bytes == null ? null : new ByteArrayInputStream(bytes);
565549
}
566550

551+
private static ZoneId getEffectiveZone(StandardSQLTypeName bqType, Calendar cal) {
552+
return (cal == null || bqType == StandardSQLTypeName.TIMESTAMP)
553+
? null
554+
: cal.getTimeZone().toZoneId();
555+
}
556+
567557
@Override
568558
public Date getDate(int columnIndex, Calendar cal) throws SQLException {
569559
LOG.finestTrace("getDate");
560+
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
561+
ZoneId zone = getEffectiveZone(bqType, cal);
570562
try {
571-
Object value = getObject(columnIndex);
572-
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
573-
ZoneId zone = (cal == null) ? null : cal.getTimeZone().toZoneId();
563+
Object value = getObject(columnIndex, zone);
574564
return BigQueryTypeRegistry.convert(value, bqType, Date.class, zone);
575565
} catch (BigQueryJdbcException e) {
576566
throw createCoercionException(columnIndex, Date.class, e);
@@ -580,10 +570,13 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException {
580570
@Override
581571
public Time getTime(int columnIndex, Calendar cal) throws SQLException {
582572
LOG.finestTrace("getTime");
573+
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
574+
if (bqType == StandardSQLTypeName.INT64) {
575+
throw createCoercionException(columnIndex, Time.class, null);
576+
}
577+
ZoneId zone = getEffectiveZone(bqType, cal);
583578
try {
584-
Object value = getObject(columnIndex);
585-
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
586-
ZoneId zone = (cal == null) ? null : cal.getTimeZone().toZoneId();
579+
Object value = getObject(columnIndex, zone);
587580
return BigQueryTypeRegistry.convert(value, bqType, Time.class, zone);
588581
} catch (BigQueryJdbcException e) {
589582
throw createCoercionException(columnIndex, Time.class, e);
@@ -593,10 +586,13 @@ public Time getTime(int columnIndex, Calendar cal) throws SQLException {
593586
@Override
594587
public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException {
595588
LOG.finestTrace("getTimestamp");
589+
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
590+
if (bqType == StandardSQLTypeName.INT64) {
591+
throw createCoercionException(columnIndex, Timestamp.class, null);
592+
}
593+
ZoneId zone = getEffectiveZone(bqType, cal);
596594
try {
597-
Object value = getObject(columnIndex);
598-
StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex);
599-
ZoneId zone = (cal == null) ? null : cal.getTimeZone().toZoneId();
595+
Object value = getObject(columnIndex, zone);
600596
return BigQueryTypeRegistry.convert(value, bqType, Timestamp.class, zone);
601597
} catch (BigQueryJdbcException e) {
602598
throw createCoercionException(columnIndex, Timestamp.class, e);

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.google.cloud.bigquery.exception.BigQueryJdbcException;
3131
import java.sql.ResultSet;
3232
import java.sql.SQLException;
33+
import java.time.ZoneId;
3334
import java.util.concurrent.BlockingQueue;
3435
import java.util.concurrent.Future;
3536

@@ -250,6 +251,11 @@ public boolean next() throws SQLException {
250251

251252
@Override
252253
public Object getObject(int columnIndex) throws SQLException {
254+
return getObject(columnIndex, (ZoneId) null);
255+
}
256+
257+
@Override
258+
protected Object getObject(int columnIndex, ZoneId zone) throws SQLException {
253259
// columnIndex is SQL index starting at 1
254260
checkClosed();
255261
LOG.finestTrace("getObject");
@@ -277,7 +283,7 @@ public Object getObject(int columnIndex) throws SQLException {
277283
if (this.enableTimestampPicos && isRangeTimestamp(arrayField)) {
278284
return formatRangeTimestamp(value);
279285
}
280-
return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), null);
286+
return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), zone);
281287
}
282288

283289
Field fieldSchema = this.schemaFieldList.get(columnIndex - 1);
@@ -298,7 +304,7 @@ public Object getObject(int columnIndex) throws SQLException {
298304
if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(fieldSchema)) {
299305
return BigQueryTemporalUtility.formatTimestampValue(value.getStringValue(), true);
300306
}
301-
return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), null);
307+
return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), zone);
302308
}
303309

304310
static String formatRangeTimestamp(FieldValue value) throws SQLException {

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,46 @@ private BigQueryTemporalUtility() {}
5858
* provided timezone (or JVM default if null).
5959
*/
6060
public static Timestamp boxDateTime(String val, ZoneId zoneId) {
61-
ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault();
6261
String isoString = truncateFractionalSeconds(val.replace(' ', 'T'), 9);
63-
return Timestamp.from(LocalDateTime.parse(isoString).atZone(targetZone).toInstant());
62+
return boxDateTime(LocalDateTime.parse(isoString), zoneId);
63+
}
64+
65+
/**
66+
* Converts a civil {@link LocalDateTime} into a {@link Timestamp} by anchoring it to the provided
67+
* timezone (or JVM default if null).
68+
*/
69+
static Timestamp boxDateTime(LocalDateTime localDateTime, ZoneId zoneId) {
70+
ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault();
71+
return Timestamp.from(localDateTime.atZone(targetZone).toInstant());
6472
}
6573

6674
/**
6775
* Converts a BigQuery civil DATE string into an absolute Date by anchoring it to midnight of the
6876
* provided timezone (or JVM default if null).
6977
*/
7078
public static Date boxDate(String val, ZoneId zoneId) {
79+
return boxDate(LocalDate.parse(val), zoneId);
80+
}
81+
82+
/**
83+
* Converts a civil {@link LocalDate} into a {@link Date} by anchoring it to midnight of the
84+
* provided timezone (or JVM default if null).
85+
*/
86+
static Date boxDate(LocalDate localDate, ZoneId zoneId) {
7187
ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault();
72-
return new Date(LocalDate.parse(val).atStartOfDay(targetZone).toInstant().toEpochMilli());
88+
return new Date(localDate.atStartOfDay(targetZone).toInstant().toEpochMilli());
89+
}
90+
91+
/**
92+
* Converts a civil {@link LocalTime} into a {@link Time} anchored to 1970-01-01 in the provided
93+
* timezone (or JVM default if null).
94+
*/
95+
static Time boxTime(LocalTime localTime, ZoneId zoneId) {
96+
if (zoneId == null) {
97+
return Time.valueOf(localTime);
98+
}
99+
long millisOfDay = localTime.toNanoOfDay() / 1_000_000;
100+
return new Time(getLocalMillis(millisOfDay, zoneId));
73101
}
74102

75103
/**

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

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -253,17 +253,20 @@ static TypeDescriptor<?> createDateDescriptor() {
253253
if (val instanceof Date) {
254254
sqlDate = (Date) val;
255255
} else if (val instanceof Timestamp) {
256-
sqlDate = Date.valueOf(((Timestamp) val).toLocalDateTime().toLocalDate());
256+
ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault();
257+
LocalDate localDate = ((Timestamp) val).toInstant().atZone(targetZone).toLocalDate();
258+
sqlDate = BigQueryTemporalUtility.boxDate(localDate, zone);
257259
} else if (val instanceof java.sql.Time) {
258260
throw new BigQueryJdbcException("Cannot convert to DATE: " + val);
259261
} else if (val instanceof java.util.Date) {
260262
sqlDate = new Date(((java.util.Date) val).getTime());
261263
} else if (val instanceof LocalDate) {
262-
sqlDate = Date.valueOf((LocalDate) val);
264+
sqlDate = BigQueryTemporalUtility.boxDate((LocalDate) val, zone);
263265
} else if (val instanceof LocalDateTime) {
264-
sqlDate = Date.valueOf(((LocalDateTime) val).toLocalDate());
266+
sqlDate = BigQueryTemporalUtility.boxDate(((LocalDateTime) val).toLocalDate(), zone);
265267
} else if (val instanceof Integer) {
266-
sqlDate = Date.valueOf(LocalDate.ofEpochDay(((Integer) val).longValue()));
268+
LocalDate localDate = LocalDate.ofEpochDay(((Integer) val).longValue());
269+
sqlDate = BigQueryTemporalUtility.boxDate(localDate, zone);
267270
} else if (val instanceof String) {
268271
sqlDate = BigQueryTemporalUtility.boxDate((String) val, zone);
269272
} else {
@@ -290,7 +293,10 @@ static TypeDescriptor<?> createDatetimeDescriptor() {
290293
} else if (val instanceof java.util.Date) {
291294
return new Timestamp(((java.util.Date) val).getTime()).toLocalDateTime();
292295
} else if (val instanceof String) {
293-
return LocalDateTime.parse(((String) val).replace(' ', 'T'));
296+
String isoString =
297+
BigQueryTemporalUtility.truncateFractionalSeconds(
298+
((String) val).replace(' ', 'T'), 9);
299+
return LocalDateTime.parse(isoString);
294300
} else {
295301
throw new BigQueryJdbcException("Cannot convert to DATETIME: " + val);
296302
}
@@ -303,7 +309,7 @@ static TypeDescriptor<?> createDatetimeDescriptor() {
303309
} else if (val instanceof java.util.Date) {
304310
ts = new Timestamp(((java.util.Date) val).getTime());
305311
} else if (val instanceof LocalDateTime) {
306-
ts = Timestamp.valueOf((LocalDateTime) val);
312+
ts = BigQueryTemporalUtility.boxDateTime((LocalDateTime) val, zone);
307313
} else if (val instanceof String) {
308314
ts = BigQueryTemporalUtility.boxDateTime((String) val, zone);
309315
} else {
@@ -408,13 +414,16 @@ static TypeDescriptor<?> createTimeDescriptor() {
408414
Time sqlTime;
409415
if (val instanceof Time) sqlTime = (Time) val;
410416
else if (val instanceof Timestamp) {
411-
sqlTime = Time.valueOf(((Timestamp) val).toLocalDateTime().toLocalTime());
417+
ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault();
418+
LocalTime localTime = ((Timestamp) val).toInstant().atZone(targetZone).toLocalTime();
419+
sqlTime = BigQueryTemporalUtility.boxTime(localTime, zone);
412420
} else if (val instanceof java.sql.Date) {
413421
throw new BigQueryJdbcException("Cannot convert to TIME: " + val);
414422
} else if (val instanceof java.util.Date)
415423
sqlTime = new Time(((java.util.Date) val).getTime());
416-
else if (val instanceof LocalTime) sqlTime = Time.valueOf((LocalTime) val);
417-
else if (val instanceof LocalDateTime) {
424+
else if (val instanceof LocalTime) {
425+
sqlTime = BigQueryTemporalUtility.boxTime((LocalTime) val, zone);
426+
} else if (val instanceof LocalDateTime) {
418427
long millisOfDay = ((LocalDateTime) val).toLocalTime().toNanoOfDay() / 1_000_000;
419428
sqlTime = new Time(BigQueryTemporalUtility.getLocalMillis(millisOfDay, zone));
420429
} else if (val instanceof Long) {
@@ -772,12 +781,28 @@ public static <T> T convert(
772781
if (input == null) {
773782
return null;
774783
}
784+
if (input instanceof FieldValue) {
785+
FieldValue fv = (FieldValue) input;
786+
if (fv.isNull()) {
787+
return null;
788+
}
789+
input = fv.getValue();
790+
}
775791
int ordinal = bqType.ordinal();
776792
if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) {
777793
throw new BigQueryJdbcException("No type descriptor registered for BigQuery type: " + bqType);
778794
}
795+
ZoneId effectiveZone = (bqType == StandardSQLTypeName.TIMESTAMP) ? null : zoneId;
779796
try {
780-
return (T) DESCRIPTORS_BY_ORDINAL[ordinal].convert(input, targetClass, zoneId);
797+
Object result = DESCRIPTORS_BY_ORDINAL[ordinal].convert(input, targetClass, effectiveZone);
798+
if (!targetClass.isInstance(result)) {
799+
TypeDescriptor<?> targetDescriptor = getDescriptorForClass(targetClass);
800+
if (targetDescriptor == null) {
801+
throw new BigQueryJdbcException("Unsupported target class: " + targetClass.getName());
802+
}
803+
result = targetDescriptor.convert(result, targetClass, effectiveZone);
804+
}
805+
return (T) result;
781806
} catch (Exception e) {
782807
throw new BigQueryJdbcException(
783808
String.format(

0 commit comments

Comments
 (0)