diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSet.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSet.java index 625e6de5a9d5..b6cdc393173f 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSet.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowResultSet.java @@ -35,6 +35,7 @@ import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.concurrent.BlockingQueue; @@ -369,6 +370,11 @@ private Object getObjectInternal(int columnIndex) throws SQLException { @Override public Object getObject(int columnIndex) throws SQLException { + return getObject(columnIndex, (ZoneId) null); + } + + @Override + protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { // columnIndex is SQL index starting at 1 LOG.finestTrace("getObject"); @@ -398,7 +404,7 @@ public Object getObject(int columnIndex) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(arrayField)) { return BigQueryTemporalUtility.formatTimestampValue(value, true); } - return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), null); + return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), zone); } int fieldIndex = this.isNested ? 0 : columnIndex - 1; @@ -478,7 +484,7 @@ public Object getObject(int columnIndex) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(fieldSchema)) { return BigQueryTemporalUtility.formatTimestampValue(value, true); } - return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), null); + return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), zone); } private StandardSQLTypeName getRangeElementType(Field field, Object representativeElement) { 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 31ef128f3dd0..6a3499b2a0ca 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 @@ -296,6 +296,10 @@ public boolean isClosed() { public abstract Object getObject(int columnIndex) throws SQLException; + protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { + return getObject(columnIndex); + } + @Override public T getObject(int columnIndex, Class type) throws SQLException { LOG.finestTrace("getObject"); @@ -304,6 +308,12 @@ public T getObject(int columnIndex, Class type) throws SQLException { if (value == null) { return null; } + if (type != null && !type.isInstance(value)) { + StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); + if (bqType == StandardSQLTypeName.TIMESTAMP) { + return BigQueryTypeRegistry.convert(value, bqType, type, null); + } + } return BigQueryTypeRegistry.convert(value, type); } catch (RuntimeException e) { throw createCoercionException(columnIndex, type, e); @@ -454,43 +464,17 @@ public byte[] getBytes(int columnIndex) throws SQLException { @Override public Date getDate(int columnIndex) throws SQLException { - LOG.finestTrace("getDate"); - try { - Object value = getObject(columnIndex); - return BigQueryTypeRegistry.convert(value, Date.class); - } catch (BigQueryJdbcException e) { - throw createCoercionException(columnIndex, Date.class, e); - } + return getDate(columnIndex, null); } @Override public Time getTime(int columnIndex) throws SQLException { - LOG.finestTrace("getTime"); - StandardSQLTypeName type = getStandardSQLTypeName(columnIndex); - if (type == StandardSQLTypeName.INT64) { - throw createCoercionException(columnIndex, Time.class, null); - } - try { - Object value = getObject(columnIndex); - return BigQueryTypeRegistry.convert(value, Time.class); - } catch (BigQueryJdbcException e) { - throw createCoercionException(columnIndex, Time.class, e); - } + return getTime(columnIndex, null); } @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - LOG.finestTrace("getTimestamp"); - StandardSQLTypeName type = getStandardSQLTypeName(columnIndex); - if (type == StandardSQLTypeName.INT64) { - throw createCoercionException(columnIndex, Timestamp.class, null); - } - try { - Object value = getObject(columnIndex); - return BigQueryTypeRegistry.convert(value, Timestamp.class); - } catch (BigQueryJdbcException e) { - throw createCoercionException(columnIndex, Timestamp.class, e); - } + return getTimestamp(columnIndex, null); } @Override @@ -564,13 +548,19 @@ public InputStream getBinaryStream(int columnIndex) throws SQLException { return bytes == null ? null : new ByteArrayInputStream(bytes); } + private static ZoneId getEffectiveZone(StandardSQLTypeName bqType, Calendar cal) { + return (cal == null || bqType == StandardSQLTypeName.TIMESTAMP) + ? null + : cal.getTimeZone().toZoneId(); + } + @Override public Date getDate(int columnIndex, Calendar cal) throws SQLException { LOG.finestTrace("getDate"); + StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); + ZoneId zone = getEffectiveZone(bqType, cal); try { - Object value = getObject(columnIndex); - StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); - ZoneId zone = (cal == null) ? null : cal.getTimeZone().toZoneId(); + Object value = getObject(columnIndex, zone); return BigQueryTypeRegistry.convert(value, bqType, Date.class, zone); } catch (BigQueryJdbcException e) { throw createCoercionException(columnIndex, Date.class, e); @@ -580,10 +570,13 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException { @Override public Time getTime(int columnIndex, Calendar cal) throws SQLException { LOG.finestTrace("getTime"); + StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); + if (bqType == StandardSQLTypeName.INT64) { + throw createCoercionException(columnIndex, Time.class, null); + } + ZoneId zone = getEffectiveZone(bqType, cal); try { - Object value = getObject(columnIndex); - StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); - ZoneId zone = (cal == null) ? null : cal.getTimeZone().toZoneId(); + Object value = getObject(columnIndex, zone); return BigQueryTypeRegistry.convert(value, bqType, Time.class, zone); } catch (BigQueryJdbcException e) { throw createCoercionException(columnIndex, Time.class, e); @@ -593,10 +586,13 @@ public Time getTime(int columnIndex, Calendar cal) throws SQLException { @Override public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { LOG.finestTrace("getTimestamp"); + StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); + if (bqType == StandardSQLTypeName.INT64) { + throw createCoercionException(columnIndex, Timestamp.class, null); + } + ZoneId zone = getEffectiveZone(bqType, cal); try { - Object value = getObject(columnIndex); - StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); - ZoneId zone = (cal == null) ? null : cal.getTimeZone().toZoneId(); + Object value = getObject(columnIndex, zone); return BigQueryTypeRegistry.convert(value, bqType, Timestamp.class, zone); } catch (BigQueryJdbcException e) { throw createCoercionException(columnIndex, Timestamp.class, e); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSet.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSet.java index f7cd6266813f..c8a2451aeeaa 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSet.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonResultSet.java @@ -30,6 +30,7 @@ import com.google.cloud.bigquery.exception.BigQueryJdbcException; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.ZoneId; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; @@ -250,6 +251,11 @@ public boolean next() throws SQLException { @Override public Object getObject(int columnIndex) throws SQLException { + return getObject(columnIndex, (ZoneId) null); + } + + @Override + protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { // columnIndex is SQL index starting at 1 checkClosed(); LOG.finestTrace("getObject"); @@ -277,7 +283,7 @@ public Object getObject(int columnIndex) throws SQLException { if (this.enableTimestampPicos && isRangeTimestamp(arrayField)) { return formatRangeTimestamp(value); } - return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), null); + return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), zone); } Field fieldSchema = this.schemaFieldList.get(columnIndex - 1); @@ -298,7 +304,7 @@ public Object getObject(int columnIndex) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(fieldSchema)) { return BigQueryTemporalUtility.formatTimestampValue(value.getStringValue(), true); } - return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), null); + return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), zone); } static String formatRangeTimestamp(FieldValue value) throws SQLException { 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 2d3dddd3d38e..07c5362ff0e9 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 @@ -58,9 +58,17 @@ private BigQueryTemporalUtility() {} * provided timezone (or JVM default if null). */ public static Timestamp boxDateTime(String val, ZoneId zoneId) { - ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); String isoString = truncateFractionalSeconds(val.replace(' ', 'T'), 9); - return Timestamp.from(LocalDateTime.parse(isoString).atZone(targetZone).toInstant()); + return boxDateTime(LocalDateTime.parse(isoString), zoneId); + } + + /** + * Converts a civil {@link LocalDateTime} into a {@link Timestamp} by anchoring it to the provided + * timezone (or JVM default if null). + */ + static Timestamp boxDateTime(LocalDateTime localDateTime, ZoneId zoneId) { + ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); + return Timestamp.from(localDateTime.atZone(targetZone).toInstant()); } /** @@ -68,8 +76,28 @@ public static Timestamp boxDateTime(String val, ZoneId zoneId) { * provided timezone (or JVM default if null). */ public static Date boxDate(String val, ZoneId zoneId) { + return boxDate(LocalDate.parse(val), zoneId); + } + + /** + * Converts a civil {@link LocalDate} into a {@link Date} by anchoring it to midnight of the + * provided timezone (or JVM default if null). + */ + static Date boxDate(LocalDate localDate, ZoneId zoneId) { ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); - return new Date(LocalDate.parse(val).atStartOfDay(targetZone).toInstant().toEpochMilli()); + return new Date(localDate.atStartOfDay(targetZone).toInstant().toEpochMilli()); + } + + /** + * Converts a civil {@link LocalTime} into a {@link Time} anchored to 1970-01-01 in the provided + * timezone (or JVM default if null). + */ + static Time boxTime(LocalTime localTime, ZoneId zoneId) { + if (zoneId == null) { + return Time.valueOf(localTime); + } + long millisOfDay = localTime.toNanoOfDay() / 1_000_000; + return new Time(getLocalMillis(millisOfDay, zoneId)); } /** 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 7ecaefe7d6db..4d09e3febd0a 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 @@ -253,17 +253,20 @@ static TypeDescriptor createDateDescriptor() { if (val instanceof Date) { sqlDate = (Date) val; } else if (val instanceof Timestamp) { - sqlDate = Date.valueOf(((Timestamp) val).toLocalDateTime().toLocalDate()); + ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault(); + LocalDate localDate = ((Timestamp) val).toInstant().atZone(targetZone).toLocalDate(); + sqlDate = BigQueryTemporalUtility.boxDate(localDate, zone); } else if (val instanceof java.sql.Time) { throw new BigQueryJdbcException("Cannot convert to DATE: " + val); } else if (val instanceof java.util.Date) { sqlDate = new Date(((java.util.Date) val).getTime()); } else if (val instanceof LocalDate) { - sqlDate = Date.valueOf((LocalDate) val); + sqlDate = BigQueryTemporalUtility.boxDate((LocalDate) val, zone); } else if (val instanceof LocalDateTime) { - sqlDate = Date.valueOf(((LocalDateTime) val).toLocalDate()); + sqlDate = BigQueryTemporalUtility.boxDate(((LocalDateTime) val).toLocalDate(), zone); } else if (val instanceof Integer) { - sqlDate = Date.valueOf(LocalDate.ofEpochDay(((Integer) val).longValue())); + LocalDate localDate = LocalDate.ofEpochDay(((Integer) val).longValue()); + sqlDate = BigQueryTemporalUtility.boxDate(localDate, zone); } else if (val instanceof String) { sqlDate = BigQueryTemporalUtility.boxDate((String) val, zone); } else { @@ -290,7 +293,10 @@ static TypeDescriptor createDatetimeDescriptor() { } else if (val instanceof java.util.Date) { return new Timestamp(((java.util.Date) val).getTime()).toLocalDateTime(); } else if (val instanceof String) { - return LocalDateTime.parse(((String) val).replace(' ', 'T')); + String isoString = + BigQueryTemporalUtility.truncateFractionalSeconds( + ((String) val).replace(' ', 'T'), 9); + return LocalDateTime.parse(isoString); } else { throw new BigQueryJdbcException("Cannot convert to DATETIME: " + val); } @@ -303,7 +309,7 @@ static TypeDescriptor createDatetimeDescriptor() { } else if (val instanceof java.util.Date) { ts = new Timestamp(((java.util.Date) val).getTime()); } else if (val instanceof LocalDateTime) { - ts = Timestamp.valueOf((LocalDateTime) val); + ts = BigQueryTemporalUtility.boxDateTime((LocalDateTime) val, zone); } else if (val instanceof String) { ts = BigQueryTemporalUtility.boxDateTime((String) val, zone); } else { @@ -408,13 +414,16 @@ static TypeDescriptor createTimeDescriptor() { Time sqlTime; if (val instanceof Time) sqlTime = (Time) val; else if (val instanceof Timestamp) { - sqlTime = Time.valueOf(((Timestamp) val).toLocalDateTime().toLocalTime()); + ZoneId targetZone = zone != null ? zone : ZoneId.systemDefault(); + LocalTime localTime = ((Timestamp) val).toInstant().atZone(targetZone).toLocalTime(); + sqlTime = BigQueryTemporalUtility.boxTime(localTime, zone); } else if (val instanceof java.sql.Date) { throw new BigQueryJdbcException("Cannot convert to TIME: " + val); } else if (val instanceof java.util.Date) sqlTime = new Time(((java.util.Date) val).getTime()); - else if (val instanceof LocalTime) sqlTime = Time.valueOf((LocalTime) val); - else if (val instanceof LocalDateTime) { + else if (val instanceof LocalTime) { + sqlTime = BigQueryTemporalUtility.boxTime((LocalTime) val, zone); + } else if (val instanceof LocalDateTime) { long millisOfDay = ((LocalDateTime) val).toLocalTime().toNanoOfDay() / 1_000_000; sqlTime = new Time(BigQueryTemporalUtility.getLocalMillis(millisOfDay, zone)); } else if (val instanceof Long) { @@ -772,12 +781,28 @@ public static T convert( if (input == null) { return null; } + if (input instanceof FieldValue) { + FieldValue fv = (FieldValue) input; + if (fv.isNull()) { + return null; + } + input = fv.getValue(); + } int ordinal = bqType.ordinal(); if (ordinal >= DESCRIPTORS_BY_ORDINAL.length || DESCRIPTORS_BY_ORDINAL[ordinal] == null) { throw new BigQueryJdbcException("No type descriptor registered for BigQuery type: " + bqType); } + ZoneId effectiveZone = (bqType == StandardSQLTypeName.TIMESTAMP) ? null : zoneId; try { - return (T) DESCRIPTORS_BY_ORDINAL[ordinal].convert(input, targetClass, zoneId); + Object result = DESCRIPTORS_BY_ORDINAL[ordinal].convert(input, targetClass, effectiveZone); + if (!targetClass.isInstance(result)) { + TypeDescriptor targetDescriptor = getDescriptorForClass(targetClass); + if (targetDescriptor == null) { + throw new BigQueryJdbcException("Unsupported target class: " + targetClass.getName()); + } + result = targetDescriptor.convert(result, targetClass, effectiveZone); + } + return (T) result; } catch (Exception e) { throw new BigQueryJdbcException( String.format( 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 08887854f363..d1b72fce1605 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 @@ -43,10 +43,14 @@ import java.sql.Struct; import java.sql.Time; import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Arrays; +import java.util.Calendar; import java.util.List; import java.util.TimeZone; import java.util.concurrent.BlockingQueue; @@ -55,10 +59,12 @@ import java.util.stream.Stream; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.BitVector; +import org.apache.arrow.vector.DateDayVector; import org.apache.arrow.vector.DateMilliVector; import org.apache.arrow.vector.FieldVector; import org.apache.arrow.vector.Float8Vector; import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.TimeMicroVector; import org.apache.arrow.vector.TimeMilliVector; import org.apache.arrow.vector.TimeStampMicroVector; import org.apache.arrow.vector.VarBinaryVector; @@ -76,6 +82,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; public class BigQueryArrowResultSetTest { @@ -546,4 +553,109 @@ public void testStandardMicrosecondTimestampWithPicosEnabled() throws SQLExcepti assertThat(rs.getString("timeStampField")).isEqualTo("1970-01-01 00:00:00.010000"); assertThat(rs.getObject("timeStampField")).isEqualTo(new Timestamp(10L)); } + + private BigQueryArrowResultSet createTemporalArrowResultSet(Instant ref) + throws SQLException, IOException { + RootAllocator allocator = new RootAllocator(); + DateDayVector dateVec = new DateDayVector("d", allocator); + dateVec.allocateNew(1); + dateVec.set(0, (int) LocalDate.of(2000, 2, 16).toEpochDay()); + dateVec.setValueCount(1); + + TimeMicroVector timeVec = new TimeMicroVector("t", allocator); + timeVec.allocateNew(1); + timeVec.set(0, LocalTime.of(10, 30, 46).toNanoOfDay() / 1_000L); + timeVec.setValueCount(1); + + long refMicros = ref.getEpochSecond() * 1_000_000L + ref.getNano() / 1_000L; + TimeStampMicroVector dtVec = new TimeStampMicroVector("dt", allocator); + dtVec.allocateNew(1); + dtVec.set(0, refMicros); + dtVec.setValueCount(1); + + TimeStampMicroVector tsVec = new TimeStampMicroVector("ts", allocator); + tsVec.allocateNew(1); + tsVec.set(0, refMicros); + tsVec.setValueCount(1); + + VectorSchemaRoot root = new VectorSchemaRoot(ImmutableList.of(dateVec, timeVec, dtVec, tsVec)); + ArrowRecordBatch batch = + ArrowRecordBatch.newBuilder() + .setSerializedRecordBatch(serializeVectorSchemaRoot(root)) + .build(); + BlockingQueue arrowBuffer = new LinkedBlockingDeque<>(); + arrowBuffer.add(BigQueryArrowBatchWrapper.of(batch)); + arrowBuffer.add(BigQueryArrowBatchWrapper.of(null, true)); + + Schema schema = + Schema.of( + Field.of("d", StandardSQLTypeName.DATE), + Field.of("t", StandardSQLTypeName.TIME), + Field.of("dt", StandardSQLTypeName.DATETIME), + Field.of("ts", StandardSQLTypeName.TIMESTAMP)); + ArrowSchema arrowSchema = + ArrowSchema.newBuilder().setSerializedSchema(serializeSchema(root.getSchema())).build(); + + return BigQueryArrowResultSet.of( + schema, arrowSchema, 1, statement, arrowBuffer, mock(Future.class), null); + } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testCalendarGettersOnWallClockTypesUseCalendarZone(String jvmZone) + throws SQLException, IOException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + BigQueryArrowResultSet rs = + createTemporalArrowResultSet(Instant.parse("2025-01-01T12:34:56.123Z")); + assertThat(rs.next()).isTrue(); + + Calendar utcCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + Calendar laCal = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")); + ZoneId laZone = ZoneId.of("America/Los_Angeles"); + + assertThat(rs.getDate("d", utcCal).getTime()).isEqualTo(950659200000L); + assertThat(rs.getDate("d", laCal).getTime()).isEqualTo(950688000000L); + assertThat(rs.getTime("t", utcCal).getTime()).isEqualTo(37846000L); + assertThat(rs.getTime("t", laCal).getTime()).isEqualTo(66646000L); + + LocalDateTime dtDigits = LocalDateTime.of(2025, 1, 1, 12, 34, 56, 123_000_000); + assertThat(rs.getTimestamp("dt", laCal).toInstant()) + .isEqualTo(dtDigits.atZone(laZone).toInstant()); + assertThat( + Instant.ofEpochMilli(rs.getDate("dt", laCal).getTime()).atZone(laZone).toLocalDate()) + .isEqualTo(LocalDate.of(2025, 1, 1)); + assertThat( + Instant.ofEpochMilli(rs.getTime("dt", laCal).getTime()) + .atZone(laZone) + .toLocalTime() + .withNano(0)) + .isEqualTo(LocalTime.of(12, 34, 56)); + } finally { + TimeZone.setDefault(originalDefault); + } + } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testCalendarGettersOnTimestampIgnoreCalendar(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(); + + Calendar laCal = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")); + ZonedDateTime local = ref.atZone(ZoneId.of(jvmZone)); + + assertThat(rs.getTimestamp("ts", laCal).toInstant()).isEqualTo(ref); + assertThat(rs.getDate("ts", laCal).toLocalDate()).isEqualTo(local.toLocalDate()); + assertThat(rs.getTime("ts", laCal).toLocalTime()).isEqualTo(local.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 f44e42595a1b..00cab3fdf0fd 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 @@ -51,9 +51,13 @@ import java.sql.Struct; import java.sql.Time; import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Calendar; import java.util.TimeZone; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; @@ -66,6 +70,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; public class BigQueryJsonResultSetTest { @@ -550,9 +555,15 @@ public void testTimestampPicosEnabledWithDecimalEpoch() throws SQLException { assertThat(rs.getObject(1)).isEqualTo("2023-03-30 11:14:19.123456789123"); assertThat(rs.getObject("picosTs", String.class)).isEqualTo("2023-03-30 11:14:19.123456789123"); - Timestamp expectedTs = Timestamp.valueOf("2023-03-30 11:14:19.123456789"); + Timestamp expectedTs = Timestamp.from(Instant.parse("2023-03-30T11:14:19.123456789Z")); + ZonedDateTime expectedLocal = expectedTs.toInstant().atZone(ZoneId.systemDefault()); assertThat(rs.getTimestamp("picosTs")).isEqualTo(expectedTs); assertThat(rs.getObject("picosTs", Timestamp.class)).isEqualTo(expectedTs); + assertThat(rs.getObject("picosTs", LocalDateTime.class)) + .isEqualTo(expectedLocal.toLocalDateTime()); + assertThat(rs.getDate("picosTs").toLocalDate()).isEqualTo(expectedLocal.toLocalDate()); + assertThat(rs.getTime("picosTs").toLocalTime()) + .isEqualTo(expectedLocal.toLocalTime().withNano(0)); } @Test @@ -700,4 +711,77 @@ public void testRangeDate() throws SQLException { assertThat(rs.next()).isTrue(); assertThat(rs.getString("rangeDate")).isEqualTo("[2023-01-01, 2023-01-31)"); } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testCalendarGettersOnWallClockTypesUseCalendarZone(String jvmZone) + throws SQLException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + FieldList fields = + FieldList.of( + Field.of("d", StandardSQLTypeName.DATE), + Field.of("t", StandardSQLTypeName.TIME), + Field.of("dt", StandardSQLTypeName.DATETIME)); + FieldValueList row = + FieldValueList.of( + ImmutableList.of( + FieldValue.of(Attribute.PRIMITIVE, "2000-02-15"), + FieldValue.of(Attribute.PRIMITIVE, "10:30:45"), + FieldValue.of(Attribute.PRIMITIVE, "2025-01-01 12:34:56.123000")), + fields); + BigQueryJsonResultSet rs = createJsonResultSet(fields, row, false); + assertThat(rs.next()).isTrue(); + + Calendar utcCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + Calendar laCal = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")); + ZoneId laZone = ZoneId.of("America/Los_Angeles"); + + assertThat(rs.getDate("d", utcCal).getTime()).isEqualTo(950572800000L); + assertThat(rs.getDate("d", laCal).getTime()).isEqualTo(950601600000L); + assertThat(rs.getTime("t", utcCal).getTime()).isEqualTo(37845000L); + assertThat(rs.getTime("t", laCal).getTime()).isEqualTo(66645000L); + + LocalDateTime dtDigits = LocalDateTime.of(2025, 1, 1, 12, 34, 56, 123_000_000); + assertThat(rs.getTimestamp("dt", laCal).toInstant()) + .isEqualTo(dtDigits.atZone(laZone).toInstant()); + assertThat( + Instant.ofEpochMilli(rs.getDate("dt", laCal).getTime()).atZone(laZone).toLocalDate()) + .isEqualTo(LocalDate.of(2025, 1, 1)); + assertThat( + Instant.ofEpochMilli(rs.getTime("dt", laCal).getTime()) + .atZone(laZone) + .toLocalTime() + .withNano(0)) + .isEqualTo(LocalTime.of(12, 34, 56)); + } finally { + TimeZone.setDefault(originalDefault); + } + } + + @ParameterizedTest + @ValueSource(strings = {"UTC", "America/New_York", "Asia/Tokyo"}) + public void testCalendarGettersOnTimestampIgnoreCalendar(String jvmZone) throws SQLException { + TimeZone originalDefault = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of(jvmZone))); + FieldList fields = FieldList.of(Field.of("ts", StandardSQLTypeName.TIMESTAMP)); + Instant ref = Instant.parse("2025-01-01T12:34:56.123Z"); + FieldValueList row = + FieldValueList.of( + ImmutableList.of(FieldValue.of(Attribute.PRIMITIVE, "1735734896.123000")), fields); + BigQueryJsonResultSet rs = createJsonResultSet(fields, row, false); + assertThat(rs.next()).isTrue(); + + Calendar laCal = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")); + ZonedDateTime local = ref.atZone(ZoneId.of(jvmZone)); + + assertThat(rs.getTimestamp("ts", laCal).toInstant()).isEqualTo(ref); + assertThat(rs.getDate("ts", laCal).toLocalDate()).isEqualTo(local.toLocalDate()); + assertThat(rs.getTime("ts", laCal).toLocalTime()).isEqualTo(local.toLocalTime().withNano(0)); + } finally { + TimeZone.setDefault(originalDefault); + } + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java index cf2bd9abe6f8..442182e7f2d3 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTypeRegistryTest.java @@ -223,4 +223,44 @@ public void testTimestampReadCoercionsAreJvmZoneInvariant(String zone) throws Ex epochMicros, StandardSQLTypeName.TIMESTAMP, Instant.class, null)) .isEqualTo(microsInstant); } + + @Test + public void testDatetimeAndTimestampCrossTypeConversionWithZone() throws Exception { + ZoneId utc = ZoneId.of("UTC"); + ZoneId la = ZoneId.of("America/Los_Angeles"); + + Date dtDateUtc = + BigQueryTypeRegistry.convert( + "2000-02-15 10:30:45", StandardSQLTypeName.DATETIME, Date.class, utc); + Date dtDateLa = + BigQueryTypeRegistry.convert( + "2000-02-15 10:30:45", StandardSQLTypeName.DATETIME, Date.class, la); + assertThat(dtDateUtc.getTime()).isEqualTo(950572800000L); + assertThat(dtDateLa.getTime()).isEqualTo(950601600000L); + + Time dtTimeUtc = + BigQueryTypeRegistry.convert( + "2000-02-15 10:30:45", StandardSQLTypeName.DATETIME, Time.class, utc); + Time dtTimeLa = + BigQueryTypeRegistry.convert( + "2000-02-15 10:30:45", StandardSQLTypeName.DATETIME, Time.class, la); + assertThat(dtTimeUtc.getTime()).isEqualTo(37845000L); + assertThat(dtTimeLa.getTime()).isEqualTo(66645000L); + + Date tsDateUtc = + BigQueryTypeRegistry.convert( + "2025-01-01 12:34:56Z", StandardSQLTypeName.TIMESTAMP, Date.class, utc); + Date tsDateLa = + BigQueryTypeRegistry.convert( + "2025-01-01 12:34:56Z", StandardSQLTypeName.TIMESTAMP, Date.class, la); + assertThat(tsDateUtc).isEqualTo(tsDateLa); + + Time tsTimeUtc = + BigQueryTypeRegistry.convert( + "2025-01-01 12:34:56Z", StandardSQLTypeName.TIMESTAMP, Time.class, utc); + Time tsTimeLa = + BigQueryTypeRegistry.convert( + "2025-01-01 12:34:56Z", StandardSQLTypeName.TIMESTAMP, Time.class, la); + assertThat(tsTimeUtc).isEqualTo(tsTimeLa); + } } 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 993f508ea9a2..5955248b27c1 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 @@ -16,6 +16,7 @@ package com.google.cloud.bigquery.jdbc.it; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -37,9 +38,12 @@ import java.sql.Types; import java.time.Instant; 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; import java.util.concurrent.atomic.AtomicInteger; @@ -615,6 +619,57 @@ 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 + @Tag("advanced") + public void testTemporalAccessors_picosEnabled_truncateToJavaPrecision() throws SQLException { + String url = getPicosConnectionUrl(true, false); + String query = + String.format( + "SELECT ts_picos FROM `%s.%s.%s` WHERE id = 1", 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")); + + try (Connection conn = DriverManager.getConnection(url); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query)) { + + 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.getObject("ts_picos", OffsetDateTime.class).toInstant(), + "getObject(OffsetDateTime)"), + () -> + assertEquals( + local.toLocalDateTime(), + rs.getObject("ts_picos", LocalDateTime.class), + "getObject(LocalDateTime)"), + () -> assertEquals(local.toLocalDate(), rs.getDate("ts_picos").toLocalDate(), "getDate"), + () -> + assertEquals( + local.toLocalTime().withNano(0), rs.getTime("ts_picos").toLocalTime(), "getTime"), + () -> + assertEquals( + moment, rs.getTimestamp("ts_picos", cal).toInstant(), "getTimestamp(cal)"), + () -> + assertEquals( + local.toLocalDate(), rs.getDate("ts_picos", cal).toLocalDate(), "getDate(cal)"), + () -> + assertEquals( + local.toLocalTime().withNano(0), + rs.getTime("ts_picos", cal).toLocalTime(), + "getTime(cal)")); + } + } + @Test public void testLegacySqlRejection_throwsSqlException() throws SQLException { String url = ITBase.connectionUrl + "EnableTimestampPicos=1;QueryDialect=BIG_QUERY;"; 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 9e71c6a8b055..09353dafeffb 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 @@ -413,7 +413,6 @@ public void testWallClockTypes_returnDigits(String zone) throws SQLException { } /** For DATETIME, DATE and TIME the Calendar is the zone the digits are interpreted in. */ - @Disabled @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") public void testCalendarGettersOnWallClockTypes_useCalendarZone(String zone) throws SQLException { @@ -452,7 +451,6 @@ public void testCalendarGettersOnWallClockTypes_useCalendarZone(String zone) thr } /** A TIMESTAMP is already a moment, so the Calendar does not change the result. */ - @Disabled @ParameterizedTest(name = "JVM zone {0}") @MethodSource("jvmZones") public void testCalendarGettersOnTimestamp_matchNoCalendarGetters(String zone)