diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArray.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArray.java index 01fb49e64476..e5d89e1839e9 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArray.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArray.java @@ -111,10 +111,7 @@ public void free() { @Override Object getCoercedValue(int index) throws SQLException { LOG.finestTrace("getCoercedValue"); - Object value = this.values.get(index); - if (value instanceof Text) { - value = value.toString(); - } + Object value = getRawElement(index); if (this.arrayOfStruct) { return new BigQueryArrowStruct( schema.getSubFields(), @@ -127,4 +124,10 @@ Object getCoercedValue(int index) throws SQLException { } return BigQueryTypeRegistry.convert(value, this.schema.getType().getStandardType(), null); } + + @Override + Object getRawElement(int index) { + Object value = this.values.get(index); + return (value instanceof Text) ? value.toString() : value; + } } 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 b6cdc393173f..52506884c7dc 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 @@ -323,7 +323,8 @@ else if (this.currentBatchRowIndex < this.vectorSchemaRoot.getRowCount()) { return false; } - private Object getObjectInternal(int columnIndex) throws SQLException { + @Override + Object getObjectInternal(int columnIndex) throws SQLException { LOG.finestTrace("getObjectInternal"); checkClosed(); Object value; diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStruct.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStruct.java index ef6c3f69294c..3a03c46ef1e1 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStruct.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStruct.java @@ -36,8 +36,7 @@ class BigQueryArrowStruct extends BigQueryBaseStruct { private final FieldList schema; - - private final JsonStringHashMap values; + private final List values; BigQueryArrowStruct(FieldList schema, JsonStringHashMap values) { this(schema, values, false, BigQueryJdbcResultSetLogger.getLogger(BigQueryArrowStruct.class)); @@ -50,7 +49,7 @@ class BigQueryArrowStruct extends BigQueryBaseStruct { BigQueryJdbcResultSetLogger log) { super(enableTimestampPicos, log); this.schema = schema; - this.values = values; + this.values = values == null ? null : new ArrayList<>(values.values()); } @Override @@ -67,12 +66,10 @@ public Object[] getAttributes() throws SQLException { if (this.values == null) { return attributes; } - List structValues = new ArrayList<>(this.values.values()); for (int index = 0; index < size; index++) { Field currentSchema = this.schema.get(index); - Object currentValue = structValues.get(index); - Object coercedValue = getValue(currentSchema, currentValue); + Object coercedValue = getValue(currentSchema, getRawAttribute(index)); Array.set(attributes, index, coercedValue); } return attributes; @@ -80,9 +77,6 @@ public Object[] getAttributes() throws SQLException { private Object getValue(Field currentSchema, Object currentValue) throws SQLException { LOG.finestTrace("getValue"); - if (currentValue instanceof Text) { - currentValue = currentValue.toString(); - } if (isArray(currentSchema)) { return new BigQueryArrowArray( currentSchema, @@ -107,4 +101,10 @@ private Object getValue(Field currentSchema, Object currentValue) throws SQLExce return BigQueryTypeRegistry.convert( currentValue, currentSchema.getType().getStandardType(), null); } + + @Override + Object getRawAttribute(int index) { + Object currentValue = this.values.get(index); + return (currentValue instanceof Text) ? currentValue.toString() : currentValue; + } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java index 290d90bc3bdc..36447684d419 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseArray.java @@ -159,6 +159,8 @@ protected Class getTargetClass() { abstract Object getCoercedValue(int index) throws SQLException; + abstract Object getRawElement(int index); + static boolean isArray(Field currentSchema) { return currentSchema.getMode() == REPEATED; } @@ -191,6 +193,21 @@ public String toString() { }) .collect(Collectors.joining(", ", "[", "]")); } + if (this.schema.getType().getStandardType() == StandardSQLTypeName.TIME) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < array.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append( + array[i] == null + ? "null" + : BigQueryTypeRegistry.convert( + getRawElement(i), StandardSQLTypeName.TIME, String.class, null)); + } + sb.append("]"); + return sb.toString(); + } return Arrays.deepToString(array); } catch (SQLException e) { LOG.warning("Error converting array to string"); 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 d409eb79724c..a0d51a64503a 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 @@ -52,6 +52,7 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; +import java.time.LocalTime; import java.time.ZoneId; import java.util.Calendar; import java.util.List; @@ -310,10 +311,18 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { return getObject(columnIndex); } + abstract Object getObjectInternal(int columnIndex) throws SQLException; + @Override public T getObject(int columnIndex, Class type) throws SQLException { LOG.finestTrace("getObject"); try { + if (type == LocalTime.class || type == String.class) { + StandardSQLTypeName bqType = getStandardSQLTypeName(columnIndex); + if (bqType == StandardSQLTypeName.TIME) { + return BigQueryTypeRegistry.convert(getObjectInternal(columnIndex), bqType, type, null); + } + } Object value = getObject(columnIndex); if (value == null) { return null; diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java index 4a5e801a6247..e9e80323299f 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryBaseStruct.java @@ -50,6 +50,8 @@ abstract class BigQueryBaseStruct implements java.sql.Struct { abstract FieldList getSchema(); + abstract Object getRawAttribute(int index); + @Override public final String getSQLTypeName() throws SQLException { return StandardSQLTypeName.STRUCT.name(); @@ -95,6 +97,13 @@ public String toString() { sb.append("\"") .append(BigQueryTemporalUtility.formatTimestampToUtcString((Timestamp) value)) .append("\""); + } else if (value instanceof Time + && field.getType().getStandardType() == StandardSQLTypeName.TIME) { + sb.append("\"") + .append( + BigQueryTypeRegistry.convert( + getRawAttribute(i), StandardSQLTypeName.TIME, String.class, null)) + .append("\""); } else if (value instanceof Timestamp || value instanceof Date || value instanceof Time) { sb.append("\"").append(value.toString()).append("\""); } else if (value instanceof byte[]) { diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArray.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArray.java index cbe237a996fa..7e2120eef8e3 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArray.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArray.java @@ -112,8 +112,8 @@ public void free() { @Override Object getCoercedValue(int index) throws SQLException { LOG.finestTrace("getCoercedValue"); - FieldValue fieldValue = this.values.get(index); - if (fieldValue == null || fieldValue.isNull()) { + FieldValue fieldValue = getRawElement(index); + if (fieldValue == null) { return null; } if (this.arrayOfStruct) { @@ -131,4 +131,10 @@ Object getCoercedValue(int index) throws SQLException { } return BigQueryTypeRegistry.convert(fieldValue, this.schema.getType().getStandardType(), null); } + + @Override + FieldValue getRawElement(int index) { + FieldValue fieldValue = this.values.get(index); + return (fieldValue == null || fieldValue.isNull()) ? null : fieldValue; + } } 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 c8a2451aeeaa..0c90ee7a55f0 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 @@ -338,7 +338,8 @@ static boolean isRangeTimestamp(Field field) { * @param columnIndex the first column is 1, the second is 2, ... * @return an instance of {@link FieldValue} represents value at columnIndex column. */ - private FieldValue getObjectInternal(int columnIndex) throws SQLException { + @Override + FieldValue getObjectInternal(int columnIndex) throws SQLException { checkClosed(); LOG.finestTrace("getObjectInternal"); FieldValue value; diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStruct.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStruct.java index 041d45650222..baff7313261c 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStruct.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStruct.java @@ -59,11 +59,13 @@ public Object[] getAttributes() throws SQLException { LOG.finestTrace("getAttributes"); int size = schema.size(); Object[] attributes = (Object[]) Array.newInstance(Object.class, size); + if (this.values == null) { + return attributes; + } for (int index = 0; index < size; index++) { Field currentSchema = schema.get(index); - FieldValue currentValue = values == null ? null : values.get(index); - Object coercedValue = getValue(currentSchema, currentValue); + Object coercedValue = getValue(currentSchema, getRawAttribute(index)); Array.set(attributes, index, coercedValue); } return attributes; @@ -71,7 +73,7 @@ public Object[] getAttributes() throws SQLException { private Object getValue(Field currentSchema, FieldValue currentValue) throws SQLException { LOG.finestTrace("getValue"); - if (currentValue == null || currentValue.isNull()) { + if (currentValue == null) { return null; } if (isArray(currentSchema)) { @@ -94,4 +96,10 @@ private Object getValue(Field currentSchema, FieldValue currentValue) throws SQL return BigQueryTypeRegistry.convert( currentValue, currentSchema.getType().getStandardType(), null); } + + @Override + FieldValue getRawAttribute(int index) { + FieldValue currentValue = this.values.get(index); + return (currentValue == null || currentValue.isNull()) ? null : currentValue; + } } 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 c5833e3922d8..b19fbe4e8014 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 @@ -57,6 +57,11 @@ final class BigQueryTemporalUtility { .appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, false) .toFormatter() .withZone(ZoneOffset.UTC); + static final DateTimeFormatter TIME_FORMATTER = + new DateTimeFormatterBuilder() + .appendPattern("HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .toFormatter(); static final String TIMESTAMP_PICOSECONDS_TYPE_NAME = "TIMESTAMP_PICOSECONDS"; private BigQueryTemporalUtility() {} 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 bb9bec5a0ac9..fa50995af79a 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 @@ -58,8 +58,6 @@ final class BigQueryTypeRegistry { private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"); - private static final DateTimeFormatter TIME_FORMATTER = - DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); static { DESCRIPTORS_BY_ORDINAL = new TypeDescriptor[StandardSQLTypeName.values().length]; @@ -122,7 +120,10 @@ static TypeDescriptor createStringDescriptor() { return TIMESTAMP_FORMATTER.format(((Timestamp) val).toLocalDateTime()); } if (val instanceof Time) { - return TIME_FORMATTER.format(((Time) val).toLocalTime()); + return BigQueryTemporalUtility.TIME_FORMATTER.format( + Instant.ofEpochMilli(((Time) val).getTime()) + .atZone(ZoneId.systemDefault()) + .toLocalTime()); } return String.valueOf(val); }); @@ -402,16 +403,31 @@ static TypeDescriptor createTimeDescriptor() { StandardSQLTypeName.TIME, Arrays.asList(Time.class, LocalTime.class), (val, targetClass, zone) -> { - if (targetClass == LocalTime.class) { + if (targetClass == LocalTime.class || targetClass == String.class) { + LocalTime localTime = null; if (val instanceof LocalTime) { - return val; + localTime = (LocalTime) val; } else if (val instanceof String) { // Fast Path: Parse directly to LocalTime to preserve microsecond precision - return LocalTime.parse((String) val); + localTime = + LocalTime.parse( + BigQueryTemporalUtility.truncateFractionalSeconds((String) val, 9)); + } else if (val instanceof Long) { + localTime = LocalTime.ofNanoOfDay((Long) val * 1_000L); } else if (val instanceof LocalDateTime) { - return ((LocalDateTime) val).toLocalTime(); + localTime = ((LocalDateTime) val).toLocalTime(); } else if (val instanceof Timestamp) { - return ((Timestamp) val).toLocalDateTime().toLocalTime(); + localTime = ((Timestamp) val).toLocalDateTime().toLocalTime(); + } else if (val instanceof Time) { + localTime = + Instant.ofEpochMilli(((Time) val).getTime()) + .atZone(ZoneId.systemDefault()) + .toLocalTime(); + } + if (localTime != null) { + return targetClass == String.class + ? BigQueryTemporalUtility.TIME_FORMATTER.format(localTime) + : localTime; } } @@ -421,7 +437,7 @@ else if (val instanceof Timestamp) { 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) { + } else if (val instanceof Date) { throw new BigQueryJdbcException("Cannot convert to TIME: " + val); } else if (val instanceof java.util.Date) sqlTime = new Time(((java.util.Date) val).getTime()); diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java index c9b71da98fe9..6bec8d3df66b 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowArrayOfPrimitivesTest.java @@ -472,4 +472,30 @@ public void testArrowArrayTimestampPicosDisabled() throws SQLException { assertThat(rs.getString(2)).isEqualTo("2026-04-08 10:00:00.123456"); assertThat(rs.getObject(2)).isEqualTo(expectedTs); } + + @Test + public void testArrowArrayTimePreservesMicroseconds() throws SQLException { + long t1Micros = (14L * 3600 + 30L * 60) * 1_000_000L + 123_456L; + long t2Micros = (23L * 3600 + 59L * 60 + 59L) * 1_000_000L + 999_999L; + long t3Micros = 3600L * 1_000_000L; // 01:00:00 + Tuple> schemaAndValue = + arrowArraySchemaAndValue(TIME, t1Micros, t2Micros, t3Micros); + BigQueryArrowArray array = new BigQueryArrowArray(schemaAndValue.x(), schemaAndValue.y()); + + assertThat(array.toString()).isEqualTo("[14:30:00.123456, 23:59:59.999999, 01:00:00]"); + + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("14:30:00.123456"); + assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(14, 30, 0, 123_456_000)); + + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("23:59:59.999999"); + assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(23, 59, 59, 999_999_000)); + + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("01:00:00"); + assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(1, 0, 0)); + } + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java index e63bfe79b397..1e431175d19b 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryArrowStructTest.java @@ -278,4 +278,17 @@ public void testArrowStructTimestampPicosDisabled() throws SQLException { Timestamp expectedTs = Timestamp.valueOf("2026-04-08 10:00:00.123456789"); assertThat(attributes).isEqualTo(new Object[] {expectedTs}); } + + @Test + public void testArrowStructTimePreservesMicroseconds() { + Field timeField = Field.of("t", TIME); + FieldList schema = FieldList.of(timeField); + long timeMicros = (14L * 3600 + 30L * 60) * 1_000_000L + 123_456L; + JsonStringHashMap values = new JsonStringHashMap<>(); + values.put("t", timeMicros); + + BigQueryArrowStruct struct = new BigQueryArrowStruct(schema, values); + + assertThat(struct.toString()).isEqualTo("{\"t\":\"14:30:00.123456\"}"); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java index f2127a552077..8c8af5657132 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonArrayOfPrimitivesTest.java @@ -458,4 +458,27 @@ public void testJsonArrayTimestampPicosDisabled() throws SQLException { assertThat(rs.getString(2)).isEqualTo("2023-03-30 11:14:19.123456"); assertThat(rs.getObject(2)).isEqualTo(expectedTs); } + + @Test + public void testJsonArrayTimePreservesMicroseconds() throws SQLException { + Tuple schemaAndValue = + arraySchemaAndValue(TIME, "14:30:00.123456", "23:59:59.999999", "01:00:00"); + BigQueryJsonArray array = new BigQueryJsonArray(schemaAndValue.x(), schemaAndValue.y()); + + assertThat(array.toString()).isEqualTo("[14:30:00.123456, 23:59:59.999999, 01:00:00]"); + + try (ResultSet rs = array.getResultSet()) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("14:30:00.123456"); + assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(14, 30, 0, 123_456_000)); + + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("23:59:59.999999"); + assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(23, 59, 59, 999_999_000)); + + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(2)).isEqualTo("01:00:00"); + assertThat(rs.getObject(2, LocalTime.class)).isEqualTo(LocalTime.of(1, 0, 0)); + } + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java index 6620cab2ca5c..a57aeacdcc19 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJsonStructTest.java @@ -295,4 +295,16 @@ public void testJsonStructTimestampPicosDisabled() throws SQLException { Timestamp expectedTs = Timestamp.valueOf("2023-03-30 11:14:19.123456789"); assertThat(attributes).isEqualTo(new Object[] {expectedTs}); } + + @Test + public void testJsonStructTimePreservesMicroseconds() { + Field timeField = Field.of("t", TIME); + FieldList schema = FieldList.of(timeField); + FieldValue val = FieldValue.of(PRIMITIVE, "14:30:00.123456"); + FieldValue structValue = FieldValue.of(RECORD, FieldValueList.of(asList(val))); + + BigQueryJsonStruct struct = new BigQueryJsonStruct(schema, structValue); + + assertThat(struct.toString()).isEqualTo("{\"t\":\"14:30:00.123456\"}"); + } } 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 442182e7f2d3..1223b0d11a12 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 @@ -263,4 +263,33 @@ public void testDatetimeAndTimestampCrossTypeConversionWithZone() throws Excepti "2025-01-01 12:34:56Z", StandardSQLTypeName.TIMESTAMP, Time.class, la); assertThat(tsTimeUtc).isEqualTo(tsTimeLa); } + + @Test + public void testTimeMicrosecondPrecision() throws BigQueryJdbcException { + String timeStr = "14:30:00.123456"; + long timeMicros = (14L * 3600 + 30L * 60) * 1_000_000L + 123_456L; + LocalTime expectedLocalTime = LocalTime.of(14, 30, 0, 123_456_000); + + // Direct conversion to LocalTime and String preserves microseconds + assertThat( + BigQueryTypeRegistry.convert(timeStr, StandardSQLTypeName.TIME, LocalTime.class, null)) + .isEqualTo(expectedLocalTime); + assertThat( + BigQueryTypeRegistry.convert( + timeMicros, StandardSQLTypeName.TIME, LocalTime.class, null)) + .isEqualTo(expectedLocalTime); + assertThat(BigQueryTypeRegistry.convert(timeStr, StandardSQLTypeName.TIME, String.class, null)) + .isEqualTo("14:30:00.123456"); + assertThat( + BigQueryTypeRegistry.convert(timeMicros, StandardSQLTypeName.TIME, String.class, null)) + .isEqualTo("14:30:00.123456"); + assertThat( + BigQueryTypeRegistry.convert("12:30:00", StandardSQLTypeName.TIME, String.class, null)) + .isEqualTo("12:30:00"); + + // Standard java.sql.Time conversion retains pure JDK Time contract + Time sqlTime = (Time) BigQueryTypeRegistry.convert(timeStr, StandardSQLTypeName.TIME, null); + assertThat(sqlTime.getClass()).isEqualTo(Time.class); + assertThat(sqlTime.toString()).isEqualTo("14:30:00"); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java index 366e95b70d46..2b572da5af69 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBigQueryJDBCTest.java @@ -42,6 +42,7 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.sql.Array; import java.sql.Connection; import java.sql.Date; import java.sql.Driver; @@ -52,6 +53,7 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Struct; import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; @@ -2707,7 +2709,7 @@ public void validateGetString() throws Exception { .put("booleanField", "true") .put("timestampFiled", expectedTimestampString) .put("dateField", "2023-07-28") - .put("timeField", "12:30:00.000") + .put("timeField", "12:30:00") .put("dateTimeField", "2023-07-28 12:30:00.000000") .put("geographyField", "POINT(-74.006 40.7128)") .put( @@ -3092,4 +3094,42 @@ public void testSessionAbortedOnConnectionClose() throws SQLException { } } } + + @Test + public void testTimeMicrosecondPrecisionInArrayAndStruct() throws SQLException { + String sql = + "SELECT " + + "TIME '14:30:00.123456' AS top_level_time, " + + "[TIME '14:30:00.123456', TIME '23:59:59.999999'] AS arr_time, " + + "STRUCT(TIME '14:30:00.123456' AS t) AS struct_time"; + + try (Connection connRegular = DriverManager.getConnection(connection_uri); + Connection connHtapi = + DriverManager.getConnection(connection_uri + FORCE_READ_API_PROPERTIES); + Statement stmtRegular = connRegular.createStatement(); + Statement stmtHtapi = connHtapi.createStatement(); + ResultSet rsRegular = stmtRegular.executeQuery(sql); + ResultSet rsHtapi = stmtHtapi.executeQuery(sql)) { + + for (ResultSet rs : new ResultSet[] {rsRegular, rsHtapi}) { + assertTrue(rs.next()); + + // Top-level TIME + assertEquals("14:30:00.123456", rs.getString("top_level_time")); + assertEquals( + LocalTime.of(14, 30, 0, 123_456_000), rs.getObject("top_level_time", LocalTime.class)); + assertEquals(Time.class, rs.getTime("top_level_time").getClass()); + + // ARRAY