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 607b76bec92e..fdac7e8fbec8 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 @@ -328,7 +328,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 46b6c06e8fc1..1c1d845bacfe 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 @@ -339,7 +339,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/ITBase.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java index 80501e126712..98d7a7c014ba 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITBase.java @@ -137,9 +137,10 @@ public class ITBase extends BigQueryJdbcBaseTest { + " timestampFiled TIMESTAMP,\n" + " dateField DATE,\n" + " timeField TIME,\n" + + " timeMicrosField TIME,\n" + " dateTimeField DATETIME,\n" + " geographyField GEOGRAPHY,\n" - + " recordField STRUCT>,\n" + + " recordField STRUCT>,\n" + " rangeField RANGE,\n" + " jsonField JSON,\n" + " arrayString ARRAY,\n" @@ -163,15 +164,15 @@ public class ITBase extends BigQueryJdbcBaseTest { "INSERT INTO `%1$s.%2$s.all_bq_types`\n" + "(\n" + " stringField, bytesField, intField, floatField, numericField, bigNumericField, booleanField,\n" - + " timestampFiled, dateField, timeField, dateTimeField, geographyField, recordField, rangeField, jsonField,\n" + + " timestampFiled, dateField, timeField, timeMicrosField, dateTimeField, geographyField, recordField, rangeField, jsonField,\n" + " arrayString, arrayRecord, arrayBytes, arrayInteger, arrayNumeric, arrayBignumeric, arrayBoolean,\n" + " arrayTimestamp, arrayDate, arrayTime, arrayDatetime, arrayGeography, arrayRange, arrayJson, arrayFloat\n" + ")\n" + "VALUES\n" + "(\n" + " 'StringValue', CAST('BytesValue' AS BYTES), 123, 10.5, CAST('12345.67' AS NUMERIC), CAST('98765432109876543210.123456789' AS BIGNUMERIC), true,\n" - + " TIMESTAMP '2023-07-28 12:30:00', DATE '2023-07-28', TIME '12:30:00', DATETIME '2023-07-28 12:30:00', ST_GEOGFROMTEXT('POINT(-74.006 40.7128)'),\n" - + " STRUCT('NameValue' AS name, STRUCT('LastNameValue' AS lastName) AS recordNested),\n" + + " TIMESTAMP '2023-07-28 12:30:00', DATE '2023-07-28', TIME '12:30:00', TIME '12:30:00.123456', DATETIME '2023-07-28 12:30:00', ST_GEOGFROMTEXT('POINT(-74.006 40.7128)'),\n" + + " STRUCT('NameValue' AS name, TIME '12:30:00.123456' AS timeVal, STRUCT('LastNameValue' AS lastName) AS recordNested),\n" + " RANGE '[2023-01-01, 2023-12-01)', JSON '{\"key\":\"value\"}',\n" + " ['abc', 'def', 'ghi'],\n" + " [STRUCT('rec_val1' AS value), STRUCT('rec_val2' AS value)],\n" @@ -182,7 +183,7 @@ public class ITBase extends BigQueryJdbcBaseTest { + " [true, false],\n" + " [TIMESTAMP '2023-01-01 01:00:00', TIMESTAMP '2023-01-01 02:00:00'],\n" + " [DATE '2023-01-01', DATE '2023-01-02'],\n" - + " [TIME '01:00:00', TIME '02:00:00'],\n" + + " [TIME '01:00:00', TIME '02:00:00.123456'],\n" + " [DATETIME '2023-01-01 01:00:00', DATETIME '2023-01-01 02:00:00'],\n" + " [ST_GEOGFROMTEXT('POINT(1 1)'), ST_GEOGFROMTEXT('POINT(2 2)')],\n" + " [RANGE '[2023-01-01, 2023-01-03)', RANGE '[2023-01-04, 2023-01-06)'],\n" @@ -191,7 +192,7 @@ public class ITBase extends BigQueryJdbcBaseTest { + "),\n" + "(\n" + " NULL, NULL, NULL, NULL, NULL, NULL, NULL,\n" - + " NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,\n" + + " NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,\n" + " [], [], [], [], [], [], [],\n" + " [], [], [], [], [], [], [], []\n" + ");\n"; 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..e361ca06f6f1 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 @@ -2707,12 +2707,13 @@ 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("timeMicrosField", "12:30:00.123456") .put("dateTimeField", "2023-07-28 12:30:00.000000") .put("geographyField", "POINT(-74.006 40.7128)") .put( "recordField", - "{\"name\":\"NameValue\",\"recordNested\":{\"lastName\":\"LastNameValue\"}}") + "{\"name\":\"NameValue\",\"timeVal\":\"12:30:00.123456\",\"recordNested\":{\"lastName\":\"LastNameValue\"}}") .put("rangeField", "[2023-01-01, 2023-12-01)") .put("jsonField", "{\"key\":\"value\"}") .put("arrayString", "[abc, def, ghi]") @@ -2724,7 +2725,7 @@ public void validateGetString() throws Exception { .put("arrayBoolean", "[true, false]") .put("arrayTimestamp", "[2023-01-01 01:00:00.0, 2023-01-01 02:00:00.0]") .put("arrayDate", "[2023-01-01, 2023-01-02]") - .put("arrayTime", "[01:00:00, 02:00:00]") + .put("arrayTime", "[01:00:00, 02:00:00.123456]") .put("arrayDatetime", "[2023-01-01 01:00:00.0, 2023-01-01 02:00:00.0]") .put("arrayGeography", "[POINT(1 1), POINT(2 2)]") .put("arrayRange", "[[2023-01-01, 2023-01-03), [2023-01-04, 2023-01-06)]") @@ -2880,6 +2881,7 @@ public void validateGetTime() throws Exception { final ImmutableMap result = new ImmutableMap.Builder() .put("timeField", Time.valueOf("12:30:00")) + .put("timeMicrosField", new Time(Time.valueOf("12:30:00").getTime() + 123)) .put("dateTimeField", Time.valueOf("12:30:00")) .put("timestampFiled", Time.valueOf(expectedTimestampLocalTime)) .build(); @@ -2922,6 +2924,7 @@ public void validateGetTimestamp() throws Exception { final ImmutableMap result = new ImmutableMap.Builder() .put("timeField", Timestamp.valueOf("1970-01-01 12:30:00")) + .put("timeMicrosField", Timestamp.valueOf("1970-01-01 12:30:00.123")) .put("dateField", Timestamp.valueOf("2023-07-28 00:00:00")) .put("dateTimeField", Timestamp.valueOf("2023-07-28 12:30:00")) .put("timestampFiled", Timestamp.from(Instant.parse("2023-07-28T12:30:00Z")))