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 e5d89e1839e9..c187918e54d2 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 @@ -122,6 +122,9 @@ Object getCoercedValue(int index) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(this.schema)) { return BigQueryTemporalUtility.formatTimestampValue(value, true); } + if (BigQueryTemporalUtility.isRange(this.schema)) { + return BigQueryTemporalUtility.formatRange(this.schema, value, this.enableTimestampPicos); + } return BigQueryTypeRegistry.convert(value, this.schema.getType().getStandardType(), null); } 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 fdac7e8fbec8..2225d28d25d4 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 @@ -32,9 +32,7 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Timestamp; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.List; @@ -410,6 +408,9 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(arrayField)) { return BigQueryTemporalUtility.formatTimestampValue(value, true); } + if (BigQueryTemporalUtility.isRange(arrayField)) { + return BigQueryTemporalUtility.formatRange(arrayField, value, this.enableTimestampPicos); + } return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), zone); } @@ -431,29 +432,6 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { return new BigQueryArrowArray( fieldSchema, newList, this.enableTimestampPicos, this.LOG.getArrowArrayLogger()); } - if (elementTypeName == StandardSQLTypeName.RANGE) { - JsonStringArrayList newList = new JsonStringArrayList<>(); - for (Object item : originalList) { - if (item == null) { - newList.add(null); - continue; - } - JsonStringHashMap rangeMap = (JsonStringHashMap) item; - Object start = rangeMap.get("start"); - Object end = rangeMap.get("end"); - - Object representativeElement = (start != null) ? start : end; - StandardSQLTypeName rangeElementType = - getRangeElementType(fieldSchema, representativeElement); - - String formattedStart = formatRangeElement(start, rangeElementType); - String formattedEnd = formatRangeElement(end, rangeElementType); - - newList.add(String.format("[%s, %s)", formattedStart, formattedEnd)); - } - return new BigQueryArrowArray( - fieldSchema, newList, this.enableTimestampPicos, this.LOG.getArrowArrayLogger()); - } return new BigQueryArrowArray( fieldSchema, originalList, this.enableTimestampPicos, this.LOG.getArrowArrayLogger()); } @@ -466,18 +444,8 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { this.LOG.getArrowStructLogger()); } - if (fieldSchema.getType().getStandardType() == StandardSQLTypeName.RANGE) { - JsonStringHashMap rangeMap = (JsonStringHashMap) value; - Object start = rangeMap.get("start"); - Object end = rangeMap.get("end"); - - Object representativeElement = (start != null) ? start : end; - StandardSQLTypeName elementType = getRangeElementType(fieldSchema, representativeElement); - - String formattedStart = formatRangeElement(start, elementType); - String formattedEnd = formatRangeElement(end, elementType); - - return String.format("[%s, %s)", formattedStart, formattedEnd); + if (BigQueryTemporalUtility.isRange(fieldSchema)) { + return BigQueryTemporalUtility.formatRange(fieldSchema, value, this.enableTimestampPicos); } if ((fieldSchema.getType().getStandardType() == StandardSQLTypeName.NUMERIC @@ -493,59 +461,6 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), zone); } - private StandardSQLTypeName getRangeElementType(Field field, Object representativeElement) { - if (field == null - || field.getRangeElementType() == null - || field.getRangeElementType().getType() == null) { - return getElementTypeFromValue(representativeElement); - } - try { - return StandardSQLTypeName.valueOf(field.getRangeElementType().getType()); - } catch (IllegalArgumentException ignored) { - return getElementTypeFromValue(representativeElement); - } - } - - private StandardSQLTypeName getElementTypeFromValue(Object element) { - if (element == null) { - return StandardSQLTypeName.STRING; - } - if (element instanceof Integer) { - return StandardSQLTypeName.DATE; - } - if (element instanceof Long) { - return StandardSQLTypeName.TIMESTAMP; - } - if (element instanceof LocalDateTime) { - return StandardSQLTypeName.DATETIME; - } - return StandardSQLTypeName.STRING; - } - - private String formatRangeElement(Object element, StandardSQLTypeName elementType) - throws SQLException { - if (element == null) { - return "UNBOUNDED"; - } - if (element instanceof Text) { - element = element.toString(); - } - switch (elementType) { - case DATE: - // Arrow gives DATE as an Integer (days since epoch) - return LocalDate.ofEpochDay(((Integer) element).longValue()).toString(); - case DATETIME: - // Arrow gives DATETIME as a LocalDateTime - Timestamp dtTs = Timestamp.valueOf((LocalDateTime) element); - return BigQueryTypeRegistry.convert(dtTs, String.class); - case TIMESTAMP: - return BigQueryTemporalUtility.formatTimestampValue(element, this.enableTimestampPicos); - default: - // Fallback for any other unexpected type - return element.toString(); - } - } - @Override public void close() { if (this.isClosed) { 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 3a03c46ef1e1..b178517041bf 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 @@ -98,6 +98,10 @@ private Object getValue(Field currentSchema, Object currentValue) throws SQLExce if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(currentSchema)) { return BigQueryTemporalUtility.formatTimestampValue(currentValue, true); } + if (BigQueryTemporalUtility.isRange(currentSchema)) { + return BigQueryTemporalUtility.formatRange( + currentSchema, currentValue, this.enableTimestampPicos); + } return BigQueryTypeRegistry.convert( currentValue, currentSchema.getType().getStandardType(), null); } 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 7e2120eef8e3..8cb3ae1e3952 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 @@ -126,8 +126,9 @@ Object getCoercedValue(int index) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(this.schema)) { return BigQueryTemporalUtility.formatTimestampValue(fieldValue.getStringValue(), true); } - if (this.enableTimestampPicos && BigQueryJsonResultSet.isRangeTimestamp(this.schema)) { - return BigQueryJsonResultSet.formatRangeTimestamp(fieldValue); + if (BigQueryTemporalUtility.isRange(this.schema)) { + return BigQueryTemporalUtility.formatRange( + this.schema, fieldValue, this.enableTimestampPicos); } return BigQueryTypeRegistry.convert(fieldValue, this.schema.getType().getStandardType(), null); } 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 2f676cf4cb49..d608e0777944 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 @@ -24,9 +24,7 @@ import com.google.cloud.bigquery.FieldValue; import com.google.cloud.bigquery.FieldValue.Attribute; import com.google.cloud.bigquery.Job; -import com.google.cloud.bigquery.Range; import com.google.cloud.bigquery.Schema; -import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcException; import java.sql.ResultSet; import java.sql.SQLException; @@ -285,8 +283,8 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(arrayField)) { return BigQueryTemporalUtility.formatTimestampValue(value.getStringValue(), true); } - if (this.enableTimestampPicos && isRangeTimestamp(arrayField)) { - return formatRangeTimestamp(value); + if (BigQueryTemporalUtility.isRange(arrayField)) { + return BigQueryTemporalUtility.formatRange(arrayField, value, this.enableTimestampPicos); } return BigQueryTypeRegistry.convert(value, arrayField.getType().getStandardType(), zone); } @@ -303,8 +301,8 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { this.enableTimestampPicos, this.LOG.getJsonStructLogger()); } - if (this.enableTimestampPicos && isRangeTimestamp(fieldSchema)) { - return formatRangeTimestamp(value); + if (BigQueryTemporalUtility.isRange(fieldSchema)) { + return BigQueryTemporalUtility.formatRange(fieldSchema, value, this.enableTimestampPicos); } if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(fieldSchema)) { return BigQueryTemporalUtility.formatTimestampValue(value.getStringValue(), true); @@ -312,29 +310,6 @@ protected Object getObject(int columnIndex, ZoneId zone) throws SQLException { return BigQueryTypeRegistry.convert(value, fieldSchema.getType().getStandardType(), zone); } - static String formatRangeTimestamp(FieldValue value) throws SQLException { - Range range = value.getRangeValue(); - String start = - range.getStart().isNull() - ? "UNBOUNDED" - : BigQueryTemporalUtility.formatTimestampValue(range.getStart().getStringValue(), true); - String end = - range.getEnd().isNull() - ? "UNBOUNDED" - : BigQueryTemporalUtility.formatTimestampValue(range.getEnd().getStringValue(), true); - return String.format("[%s, %s)", start, end); - } - - static boolean isRangeTimestamp(Field field) { - return field != null - && field.getRangeElementType() != null - && field.getRangeElementType().getType() != null - && field - .getRangeElementType() - .getType() - .equalsIgnoreCase(StandardSQLTypeName.TIMESTAMP.name()); - } - /** * This method will be called by every other getter of this {@link java.sql.ResultSet}, including * {@link #getObject(int)} to get the value in its rawest form i.e. {@link FieldValue} to coerce 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 baff7313261c..08803c59b62f 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 @@ -90,8 +90,9 @@ private Object getValue(Field currentSchema, FieldValue currentValue) throws SQL if (this.enableTimestampPicos && BigQueryTemporalUtility.isPicosecondTimestamp(currentSchema)) { return BigQueryTemporalUtility.formatTimestampValue(currentValue.getStringValue(), true); } - if (this.enableTimestampPicos && BigQueryJsonResultSet.isRangeTimestamp(currentSchema)) { - return BigQueryJsonResultSet.formatRangeTimestamp(currentValue); + if (BigQueryTemporalUtility.isRange(currentSchema)) { + return BigQueryTemporalUtility.formatRange( + currentSchema, currentValue, this.enableTimestampPicos); } return BigQueryTypeRegistry.convert( currentValue, currentSchema.getType().getStandardType(), null); 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 4f4a173851b9..bc5817979379 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 @@ -17,6 +17,8 @@ package com.google.cloud.bigquery.jdbc; import com.google.cloud.bigquery.Field; +import com.google.cloud.bigquery.FieldValue; +import com.google.cloud.bigquery.Range; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcException; import java.math.BigDecimal; @@ -37,6 +39,7 @@ import java.time.temporal.ChronoField; import java.util.Calendar; import java.util.GregorianCalendar; +import java.util.Map; import java.util.TimeZone; /** @@ -421,6 +424,93 @@ static boolean isPicosecondTimestamp(Field field) { && field.getTimestampPrecision() > 6; } + static boolean isRange(Field field) { + return field != null + && field.getType() != null + && field.getType().getStandardType() == StandardSQLTypeName.RANGE; + } + + /** + * Formats a RANGE value (a REST {@link FieldValue} or an Arrow {@code start}/{@code end} map) as + * {@code [start, end)}. Each bound reads like {@code getString} on its element type; null bounds + * read as {@code UNBOUNDED}. + */ + static String formatRange(Field field, Object value, boolean enableTimestampPicos) + throws BigQueryJdbcException { + Object start; + Object end; + if (value instanceof FieldValue) { + Range range = ((FieldValue) value).getRangeValue(); + start = range.getStart().isNull() ? null : range.getStart().getStringValue(); + end = range.getEnd().isNull() ? null : range.getEnd().getStringValue(); + } else if (value instanceof Map) { + start = ((Map) value).get("start"); + end = ((Map) value).get("end"); + } else { + return value == null ? null : value.toString(); + } + StandardSQLTypeName elementType = getRangeElementType(field, start != null ? start : end); + return "[" + + formatRangeBound(start, elementType, enableTimestampPicos) + + ", " + + formatRangeBound(end, elementType, enableTimestampPicos) + + ")"; + } + + private static StandardSQLTypeName getRangeElementType(Field field, Object bound) { + if (field != null + && field.getRangeElementType() != null + && field.getRangeElementType().getType() != null) { + try { + return StandardSQLTypeName.valueOf(field.getRangeElementType().getType()); + } catch (IllegalArgumentException ignored) { + } + } + // Arrow bound classes: DATE -> Integer epoch days, DATETIME -> LocalDateTime, TIMESTAMP -> Long + // epoch micros. + if (bound instanceof Integer) { + return StandardSQLTypeName.DATE; + } + if (bound instanceof LocalDateTime) { + return StandardSQLTypeName.DATETIME; + } + if (bound instanceof Long) { + return StandardSQLTypeName.TIMESTAMP; + } + return StandardSQLTypeName.STRING; + } + + private static String formatRangeBound( + Object bound, StandardSQLTypeName elementType, boolean enableTimestampPicos) + throws BigQueryJdbcException { + if (bound == null) { + return "UNBOUNDED"; + } + try { + switch (elementType) { + case DATE: + return bound instanceof Integer + ? LocalDate.ofEpochDay((Integer) bound).toString() + : bound.toString(); + case DATETIME: + LocalDateTime ldt = + bound instanceof LocalDateTime + ? (LocalDateTime) bound + : LocalDateTime.parse( + truncateFractionalSeconds(bound.toString().replace(' ', 'T'), 9)); + StringBuilder sb = formatDateTimeBase(ldt, 6); + appendPadded(sb, ldt.getNano() / 1_000, 6); + return sb.toString(); + case TIMESTAMP: + return formatTimestampValue(bound, enableTimestampPicos); + default: + return bound.toString(); + } + } catch (DateTimeException e) { + throw new BigQueryJdbcException("Invalid RANGE bound: " + bound, e); + } + } + private static StringBuilder formatDateTimeBase(LocalDateTime dt, int scale) { StringBuilder sb = new StringBuilder(scale == 12 ? 32 : 26); BASE_FORMATTER.formatTo(dt, sb); 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 7d8f09fa0e1b..5fc18693a129 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 @@ -17,7 +17,6 @@ package com.google.cloud.bigquery.jdbc; import com.google.cloud.bigquery.FieldValue; -import com.google.cloud.bigquery.Range; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcException; import com.google.cloud.bigquery.exception.BigQueryJdbcSqlFeatureNotSupportedException; @@ -656,17 +655,7 @@ static TypeDescriptor createRangeDescriptor() { String.class, StandardSQLTypeName.RANGE, Arrays.asList(String.class), - (val, targetClass, zone) -> { - if (val == null) return null; - if (val instanceof Range) { - Range range = (Range) val; - String start = - range.getStart().isNull() ? "UNBOUNDED" : range.getStart().getStringValue(); - String end = range.getEnd().isNull() ? "UNBOUNDED" : range.getEnd().getStringValue(); - return String.format("[%s, %s)", start, end); - } - return String.valueOf(val); - }); + (val, targetClass, zone) -> val == null ? null : String.valueOf(val)); } private static void register(TypeDescriptor descriptor) { 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 46592e20397f..8b632d9920cb 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 @@ -29,8 +29,11 @@ import static com.google.cloud.bigquery.StandardSQLTypeName.TIME; import static com.google.cloud.bigquery.StandardSQLTypeName.TIMESTAMP; import static com.google.cloud.bigquery.jdbc.BigQueryErrorMessage.CUSTOMER_TYPE_MAPPING_NOT_SUPPORTED; +import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.arrowArrayOf; import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.arrowArraySchemaAndValue; +import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.arrowRange; import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.arrowStructOf; +import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.rangeField; import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.toArrowStruct; import static com.google.common.truth.Truth.assertThat; import static java.lang.Boolean.FALSE; @@ -42,12 +45,14 @@ import com.google.cloud.Tuple; import com.google.cloud.bigquery.Field; +import com.google.cloud.bigquery.Field.Mode; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.LegacySQLTypeName; import com.google.cloud.bigquery.jdbc.rules.TimeZoneRule; import java.math.BigDecimal; import java.sql.Array; import java.sql.Date; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.Struct; @@ -245,6 +250,32 @@ public void structOfStructs() throws SQLException { assertThat(addressStruct.getAttributes()).isEqualTo(asList("Michigan", 49086L).toArray()); } + @Test + public void structOfRanges() throws SQLException { + FieldList schema = + FieldList.of( + rangeField("ts_range", "TIMESTAMP"), + rangeField("dt_ranges", "DATETIME").toBuilder().setMode(Mode.REPEATED).build()); + JsonStringHashMap value = new JsonStringHashMap<>(); + value.put("ts_range", arrowRange(1767225600000000L, null)); + value.put( + "dt_ranges", + arrowArrayOf( + arrowRange( + LocalDateTime.parse("2026-01-01T00:00:00"), + LocalDateTime.parse("2026-12-31T23:59:59")))); + + Object[] attributes = new BigQueryArrowStruct(schema, value).getAttributes(); + + assertThat(attributes[0]).isEqualTo("[2026-01-01 00:00:00.000000, UNBOUNDED)"); + String expectedDtRange = "[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.000000)"; + Array dtArray = (Array) attributes[1]; + assertThat((Object[]) dtArray.getArray()).asList().containsExactly(expectedDtRange); + ResultSet dtResultSet = dtArray.getResultSet(); + assertThat(dtResultSet.next()).isTrue(); + assertThat(dtResultSet.getString(2)).isEqualTo(expectedDtRange); + } + @Test public void getSQLTypeNameReturnsStruct() throws SQLException { assertThat(structWithPrimitiveValues.getSQLTypeName()).isEqualTo("STRUCT"); 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 295fd8844306..9b9287e2e123 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 @@ -681,7 +681,7 @@ public void testRangeTimestampPicosDisabled() throws SQLException { BigQueryJsonResultSet rs = createJsonResultSet(fields, row, false); assertThat(rs.next()).isTrue(); assertThat(rs.getString("rangeTs")) - .isEqualTo("[1680174859.123456789123, 1680174860.123456789123)"); + .isEqualTo("[2023-03-30 11:14:19.123456, 2023-03-30 11:14:20.123456)"); } @Test 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 6c1b56c58c28..b1d232b5272f 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 @@ -33,6 +33,7 @@ import static com.google.cloud.bigquery.jdbc.BigQueryErrorMessage.CUSTOMER_TYPE_MAPPING_NOT_SUPPORTED; import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.arraySchemaAndValue; import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.primitiveSchemaAndValue; +import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.rangeField; import static com.google.common.io.BaseEncoding.base64; import static com.google.common.truth.Truth.assertThat; import static java.time.Month.MARCH; @@ -42,16 +43,19 @@ import com.google.cloud.Tuple; import com.google.cloud.bigquery.Field; +import com.google.cloud.bigquery.Field.Mode; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.FieldValue; import com.google.cloud.bigquery.FieldValue.Attribute; import com.google.cloud.bigquery.FieldValueList; import com.google.cloud.bigquery.LegacySQLTypeName; +import com.google.cloud.bigquery.Range; import com.google.cloud.bigquery.jdbc.rules.TimeZoneRule; import com.google.common.io.BaseEncoding; import java.math.BigDecimal; import java.sql.Array; import java.sql.Date; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.Struct; @@ -251,6 +255,32 @@ public void structOfStructs() throws SQLException { assertThat(addressStruct.getAttributes()).isEqualTo(asList("Michigan", 49086L).toArray()); } + @Test + public void structOfRanges() throws SQLException { + FieldList schema = + FieldList.of( + rangeField("ts_range", "TIMESTAMP"), + rangeField("dt_ranges", "DATETIME").toBuilder().setMode(Mode.REPEATED).build()); + FieldValue tsRange = FieldValue.of(Attribute.RANGE, Range.of("[1767225600.000000, UNBOUNDED)")); + FieldValue dtRanges = + FieldValue.of( + Attribute.REPEATED, + asList( + FieldValue.of( + Attribute.RANGE, Range.of("[2026-01-01T00:00:00, 2026-12-31T23:59:59)")))); + FieldValue structValue = FieldValue.of(RECORD, FieldValueList.of(asList(tsRange, dtRanges))); + + Object[] attributes = new BigQueryJsonStruct(schema, structValue).getAttributes(); + + assertThat(attributes[0]).isEqualTo("[2026-01-01 00:00:00.000000, UNBOUNDED)"); + String expectedDtRange = "[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.000000)"; + Array dtArray = (Array) attributes[1]; + assertThat((Object[]) dtArray.getArray()).asList().containsExactly(expectedDtRange); + ResultSet dtResultSet = dtArray.getResultSet(); + assertThat(dtResultSet.next()).isTrue(); + assertThat(dtResultSet.getString(2)).isEqualTo(expectedDtRange); + } + @Test public void structWithNullValue() throws SQLException { assertThat(structWithNullValue.getAttributes()) diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java index 8ec6d375c122..7a27e6112ef2 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java @@ -16,9 +16,17 @@ package com.google.cloud.bigquery.jdbc; +import static com.google.cloud.bigquery.FieldValue.Attribute.RANGE; +import static com.google.cloud.bigquery.jdbc.BigQueryTemporalUtility.formatRange; +import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.arrowRange; +import static com.google.cloud.bigquery.jdbc.utils.TestUtilities.rangeField; import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.google.cloud.bigquery.Field; +import com.google.cloud.bigquery.FieldValue; +import com.google.cloud.bigquery.Range; +import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcException; import java.sql.Date; import java.sql.Time; @@ -549,4 +557,54 @@ public void testTruncateFractionalSeconds() { "2026-04-08 10:00:00.123456789012", 9)) .isEqualTo("2026-04-08 10:00:00.123456789"); } + + @Test + public void testFormatRange() throws BigQueryJdbcException { + Field date = rangeField("r", "DATE"); + Field dateTime = rangeField("r", "DATETIME"); + Field timestamp = rangeField("r", "TIMESTAMP"); + + // REST + assertThat(formatRange(date, FieldValue.of(RANGE, Range.of("[2026-01-01, 2026-12-31)")), false)) + .isEqualTo("[2026-01-01, 2026-12-31)"); + assertThat( + formatRange( + dateTime, + FieldValue.of(RANGE, Range.of("[2026-01-01T00:00:00, 2026-12-31T23:59:59.5)")), + false)) + .isEqualTo("[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.500000)"); + FieldValue restTimestamps = + FieldValue.of(RANGE, Range.of("[1767225600.000000, 1798761599.000000)")); + assertThat(formatRange(timestamp, restTimestamps, false)) + .isEqualTo("[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.000000)"); + assertThat(formatRange(timestamp, restTimestamps, true)) + .isEqualTo("[2026-01-01 00:00:00.000000000000, 2026-12-31 23:59:59.000000000000)"); + assertThat( + formatRange( + timestamp, FieldValue.of(RANGE, Range.of("[1767225600.000000, UNBOUNDED)")), false)) + .isEqualTo("[2026-01-01 00:00:00.000000, UNBOUNDED)"); + + // Arrow + assertThat(formatRange(date, arrowRange(20454, 20818), false)) + .isEqualTo("[2026-01-01, 2026-12-31)"); + assertThat( + formatRange( + dateTime, + arrowRange( + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 12, 31, 23, 59, 59, 500_000_000)), + false)) + .isEqualTo("[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.500000)"); + assertThat(formatRange(timestamp, arrowRange(1767225600000000L, null), false)) + .isEqualTo("[2026-01-01 00:00:00.000000, UNBOUNDED)"); + // Element type inferred from the Arrow bound class when the schema lacks it. + assertThat( + formatRange( + Field.of("r", StandardSQLTypeName.RANGE), + arrowRange(1767225600000000L, 1798761599000000L), + false)) + .isEqualTo("[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.000000)"); + + assertThat(formatRange(timestamp, null, false)).isNull(); + } } 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 143b0ef5116a..a49fd970c589 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 @@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -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; @@ -2106,8 +2108,8 @@ public void testRangeDataTypeWithJsonResultSet() throws SQLException { } String expected_range_date = "[2020-01-01, 2020-01-31)"; - String expected_range_datetime = "[2020-01-01T12:00:00, 2020-01-31T12:00:00)"; - String expected_range_timestamp = "[1577851200.000000, 1580443200.000000)"; + String expected_range_datetime = "[2020-01-01 12:00:00.000000, 2020-01-31 12:00:00.000000)"; + String expected_range_timestamp = "[2020-01-01 04:00:00.000000, 2020-01-31 04:00:00.000000)"; assertThat(numRows).isEqualTo(1); assertThat(actual_range_date).isEqualTo(expected_range_date); @@ -3150,4 +3152,34 @@ public void testStructWithNestedArraysValidJson(String extraProps) throws SQLExc assertEquals(expectedJson, rs.getString("s_nested_arrays")); } } + + @ParameterizedTest + @ValueSource(strings = {"", FORCE_READ_API_PROPERTIES + "MaxResults=1;"}) + public void testRangeBoundsFormatIdenticallyOnRestAndArrow(String extraProps) + throws SQLException { + String sql = + "SELECT" + + " RANGE(DATE '2026-01-01', DATE '2026-12-31') AS r_date," + + " RANGE(DATETIME '2026-01-01 00:00:00', DATETIME '2026-12-31 23:59:59') AS r_dt," + + " RANGE(TIMESTAMP '2026-01-01 00:00:00 UTC', CAST(NULL AS TIMESTAMP)) AS r_ts," + + " [RANGE(TIMESTAMP '2026-01-01 00:00:00 UTC', TIMESTAMP '2026-12-31 23:59:59 UTC')]" + + " AS r_ts_arr," + + " STRUCT(RANGE(DATETIME '2026-01-01 00:00:00', DATETIME '2026-12-31 23:59:59') AS r)" + + " AS s" + + " FROM UNNEST([1, 2, 3])"; + + try (Connection conn = DriverManager.getConnection(connection_uri + extraProps); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + assertEquals( + !extraProps.isEmpty(), rs.getClass().getName().contains("BigQueryArrowResultSet")); + assertTrue(rs.next()); + String fullRange = "[2026-01-01 00:00:00.000000, 2026-12-31 23:59:59.000000)"; + assertEquals("[2026-01-01, 2026-12-31)", rs.getString("r_date")); + assertEquals(fullRange, rs.getString("r_dt")); + assertEquals("[2026-01-01 00:00:00.000000, UNBOUNDED)", rs.getString("r_ts")); + assertArrayEquals(new Object[] {fullRange}, (Object[]) rs.getArray("r_ts_arr").getArray()); + assertArrayEquals(new Object[] {fullRange}, ((Struct) rs.getObject("s")).getAttributes()); + } + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/TestUtilities.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/TestUtilities.java index a11e7fbb4778..4e921f103596 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/TestUtilities.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/TestUtilities.java @@ -19,6 +19,7 @@ import com.google.cloud.Tuple; import com.google.cloud.bigquery.Field; import com.google.cloud.bigquery.Field.Mode; +import com.google.cloud.bigquery.FieldElementType; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.FieldValue; import com.google.cloud.bigquery.FieldValue.Attribute; @@ -106,6 +107,20 @@ public static JsonStringArrayList arrowArrayOf(T... values) { return arrayValues; } + public static Field rangeField(String name, String elementType) { + return Field.newBuilder(name, StandardSQLTypeName.RANGE) + .setRangeElementType(FieldElementType.newBuilder().setType(elementType).build()) + .build(); + } + + /** Arrow RANGE value; a null bound is unbounded. */ + public static JsonStringHashMap arrowRange(Object start, Object end) { + JsonStringHashMap range = new JsonStringHashMap<>(); + range.put("start", start); + range.put("end", end); + return range; + } + // struct of arrays public static JsonStringHashMap toArrowStruct( Iterable>> schemaAndValues) {