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 3123b6c09b40..8c82dcf7014f 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 @@ -50,6 +50,7 @@ import org.apache.arrow.vector.util.ByteArrayReadableSeekableByteChannel; import org.apache.arrow.vector.util.JsonStringArrayList; import org.apache.arrow.vector.util.JsonStringHashMap; +import org.apache.arrow.vector.util.Text; /** {@link ResultSet} Implementation for Arrow datasource (Using Storage Read APIs) */ class BigQueryArrowResultSet extends BigQueryBaseResultSet { @@ -345,6 +346,27 @@ private Object getObjectInternal(int columnIndex) throws SQLException { return value; } + @Override + public String getString(int columnIndex) throws SQLException { + checkClosed(); + StandardSQLTypeName type = getStandardSQLTypeName(columnIndex); + if (type != StandardSQLTypeName.TIMESTAMP) { + return super.getString(columnIndex); + } + Object value = getObjectInternal(columnIndex); + if (value == null) { + return null; + } + if (value instanceof Text || value instanceof String) { + return BigQueryTemporalUtility.formatTimestampStringFromIso( + value.toString(), this.statement.isEnableTimestampPicos()); + } + if (value instanceof Long) { + return BigQueryTemporalUtility.formatTimestampStringFromMicroseconds((Long) value); + } + return super.getString(columnIndex); + } + @Override public Object getObject(int columnIndex) throws SQLException { 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 9216732b49b2..ee36184e4b5b 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 @@ -207,7 +207,7 @@ protected SQLException createCoercionException( cause)); } - private StandardSQLTypeName getStandardSQLTypeName(int columnIndex) throws SQLException { + protected StandardSQLTypeName getStandardSQLTypeName(int columnIndex) throws SQLException { checkClosed(); if (isNested) { if (columnIndex == 1) { diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java index a4aee6142f40..36043f30f9ac 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryConnection.java @@ -185,6 +185,7 @@ public class BigQueryConnection extends BigQueryNoOpsConnection { int highThroughputMinTableSize; int highThroughputActivationRatio; boolean enableSession; + boolean enableTimestampPicos; boolean enableProjectDiscovery; private List discoveredProjectsCache; boolean unsupportedHTAPIFallback; @@ -372,6 +373,7 @@ public class BigQueryConnection extends BigQueryNoOpsConnection { this.sslTrustStoreProvider, this.connectionClassName); this.enableSession = ds.getEnableSession(); + this.enableTimestampPicos = ds.getEnableTimestampPicos(); this.unsupportedHTAPIFallback = ds.getUnsupportedHTAPIFallback(); this.maxResults = ds.getMaxResults(); Map queryPropertiesMap = ds.getQueryProperties(); @@ -707,6 +709,10 @@ boolean isSessionEnabled() { return this.enableSession; } + boolean isEnableTimestampPicos() { + return this.enableTimestampPicos; + } + boolean isUnsupportedHTAPIFallback() { return this.unsupportedHTAPIFallback; } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtility.java index 928ed785a795..866a2b827c96 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtility.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcUrlUtility.java @@ -185,6 +185,8 @@ protected boolean removeEldestEntry(Map.Entry> eldes static final String SSL_TRUST_STORE_TYPE_PROPERTY_NAME = "SSLTrustStoreType"; static final String SSL_TRUST_STORE_PROVIDER_PROPERTY_NAME = "SSLTrustStoreProvider"; static final int DEFAULT_REQUEST_GOOGLE_DRIVE_SCOPE_VALUE = 0; + static final String ENABLE_TIMESTAMP_PICOS_PROPERTY_NAME = "EnableTimestampPicos"; + static final boolean DEFAULT_ENABLE_TIMESTAMP_PICOS_VALUE = false; static final String MAX_BYTES_BILLED_PROPERTY_NAME = "MaximumBytesBilled"; static final Long DEFAULT_MAX_BYTES_BILLED_VALUE = 0L; static final String LABELS_PROPERTY_NAME = "Labels"; @@ -310,6 +312,12 @@ protected boolean removeEldestEntry(Map.Entry> eldes Collections.unmodifiableSet( new HashSet<>( Arrays.asList( + BigQueryConnectionProperty.newBuilder() + .setName(ENABLE_TIMESTAMP_PICOS_PROPERTY_NAME) + .setDescription( + "Enable 12-digit picosecond precision for TIMESTAMP columns. Set to 1 to enable. Disabled (0) by default.") + .setDefaultValue(String.valueOf(DEFAULT_ENABLE_TIMESTAMP_PICOS_VALUE)) + .build(), BigQueryConnectionProperty.newBuilder() .setName(MAX_BYTES_BILLED_PROPERTY_NAME) .setDescription( 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 0dbda843d1e1..8615ed7ebade 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 @@ -25,6 +25,7 @@ import com.google.cloud.bigquery.FieldValue.Attribute; import com.google.cloud.bigquery.Job; import com.google.cloud.bigquery.Schema; +import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.exception.BigQueryJdbcRuntimeException; import java.sql.ResultSet; import java.sql.SQLException; @@ -214,6 +215,24 @@ public boolean next() throws SQLException { } } + @Override + public String getString(int columnIndex) throws SQLException { + checkClosed(); + StandardSQLTypeName type = getStandardSQLTypeName(columnIndex); + if (type != StandardSQLTypeName.TIMESTAMP) { + return super.getString(columnIndex); + } + FieldValue value = getObjectInternal(columnIndex); + if (value == null || value.isNull()) { + return null; + } + if (value.getAttribute() == Attribute.REPEATED || value.getAttribute() == Attribute.RECORD) { + return super.getString(columnIndex); + } + return BigQueryTemporalUtility.formatTimestampString( + value.getStringValue(), this.statement.isEnableTimestampPicos()); + } + @Override public Object getObject(int columnIndex) throws SQLException { // columnIndex is SQL index starting at 1 diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadata.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadata.java index c3fd8151d52c..2ecb8d998ce4 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadata.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadata.java @@ -51,6 +51,16 @@ Statement getStatement() { return this.statement; } + private boolean isTimestampPicosEnabled() { + return this.statement instanceof BigQueryStatement + && ((BigQueryStatement) this.statement).isEnableTimestampPicos(); + } + + private boolean supportsPicoseconds(int sqlColumn) { + Long timestampPrecision = getField(sqlColumn).getTimestampPrecision(); + return timestampPrecision != null && timestampPrecision > 6; + } + private Field getField(int sqlColumn) { return this.schemaFieldList.get(sqlColumn - 1); } @@ -116,7 +126,10 @@ public int getColumnDisplaySize(int column) { case Types.NUMERIC: return 14; case Types.TIMESTAMP: - return 16; + if (isTimestampPicosEnabled() && supportsPicoseconds(column)) { + return 32; + } + return 26; default: return DEFAULT_DISPLAY_SIZE; } @@ -139,6 +152,11 @@ public int getPrecision(int column) { return precision.intValue(); } StandardSQLTypeName type = getStandardSQLTypeName(column); + if (type == StandardSQLTypeName.TIMESTAMP + && isTimestampPicosEnabled() + && supportsPicoseconds(column)) { + return 32; + } BigQueryJdbcTypeMappings.ColumnTypeInfo typeInfo = BigQueryJdbcTypeMappings.STANDARD_TYPE_INFO.get(type); if (typeInfo != null && typeInfo.columnSize != null) { @@ -154,6 +172,11 @@ public int getScale(int column) { return scale.intValue(); } StandardSQLTypeName type = getStandardSQLTypeName(column); + if (type == StandardSQLTypeName.TIMESTAMP + && isTimestampPicosEnabled() + && supportsPicoseconds(column)) { + return 12; + } BigQueryJdbcTypeMappings.ColumnTypeInfo typeInfo = BigQueryJdbcTypeMappings.STANDARD_TYPE_INFO.get(type); if (typeInfo != null && typeInfo.decimalDigits != null) { diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryStatement.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryStatement.java index 30c11d13d263..fb45927c17e8 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryStatement.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryStatement.java @@ -49,12 +49,14 @@ import com.google.cloud.bigquery.exception.BigQueryJdbcSqlSyntaxErrorException; import com.google.cloud.bigquery.storage.v1.ArrowRecordBatch; import com.google.cloud.bigquery.storage.v1.ArrowSchema; +import com.google.cloud.bigquery.storage.v1.ArrowSerializationOptions; import com.google.cloud.bigquery.storage.v1.BigQueryReadClient; import com.google.cloud.bigquery.storage.v1.CreateReadSessionRequest; import com.google.cloud.bigquery.storage.v1.DataFormat; import com.google.cloud.bigquery.storage.v1.ReadRowsRequest; import com.google.cloud.bigquery.storage.v1.ReadRowsResponse; import com.google.cloud.bigquery.storage.v1.ReadSession; +import com.google.cloud.bigquery.storage.v1.ReadSession.TableReadOptions; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Uninterruptibles; @@ -846,6 +848,15 @@ ResultSet processArrowResultSet(TableResult results, Job job) throws SQLExceptio ReadSession.Builder sessionBuilder = ReadSession.newBuilder().setTable(srcTable).setDataFormat(DataFormat.ARROW); + if (this.connection.isEnableTimestampPicos()) { + TableReadOptions.Builder tableReadOptionsBuilder = TableReadOptions.newBuilder(); + tableReadOptionsBuilder + .getArrowSerializationOptionsBuilder() + .setPicosTimestampPrecision( + ArrowSerializationOptions.PicosTimestampPrecision.TIMESTAMP_PRECISION_PICOS); + sessionBuilder.setReadOptions(tableReadOptionsBuilder.build()); + } + CreateReadSessionRequest.Builder builder = CreateReadSessionRequest.newBuilder() .setParent(parent) @@ -1671,6 +1682,10 @@ public Connection getConnection() { return this.connection; } + boolean isEnableTimestampPicos() { + return this.connection.isEnableTimestampPicos(); + } + public boolean hasMoreResults() { if (this.parentJobId == null) { return false; 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 b26cf78bac0a..b7c4e127e042 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 @@ -16,6 +16,9 @@ package com.google.cloud.bigquery.jdbc; +import com.google.common.base.Strings; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; @@ -24,6 +27,8 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.Calendar; /** @@ -32,6 +37,9 @@ */ final class BigQueryTemporalUtility { + private static final DateTimeFormatter UTC_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC); + private BigQueryTemporalUtility() {} /** @@ -89,6 +97,28 @@ public static Time boxTime(String val, ZoneId zoneId) { return new Time(targetCal.getTimeInMillis()); } + /** + * Truncates a BigQuery timestamp string to 9 fractional digits (nanoseconds) because + * Instant.parse throws DateTimeParseException for >9 digits, and java.sql.Timestamp maxes out at + * nanos anyway. + */ + private static String truncateToNanoseconds(String iso) { + int dotIdx = iso.indexOf('.'); + // Fast path: if there is no dot or at most 9 fractional digits after the dot, return as-is. + if (dotIdx == -1 || iso.length() - dotIdx <= 10) { + return iso; + } + + int fractionEnd = dotIdx + 1; + while (fractionEnd < iso.length() && Character.isDigit(iso.charAt(fractionEnd))) { + fractionEnd++; + } + if (fractionEnd - dotIdx - 1 > 9) { + return iso.substring(0, dotIdx + 10) + iso.substring(fractionEnd); + } + return iso; + } + /** * Converts a BigQuery absolute TIMESTAMP string into a legacy Timestamp. Because it is absolute, * the Calendar timezone is explicitly ignored per JDBC 4.2 spec. @@ -105,11 +135,100 @@ public static Timestamp boxTimestamp(String val) { iso = iso.substring(0, 10) + 'T' + iso.substring(11); } + iso = truncateToNanoseconds(iso); + try { return Timestamp.from(Instant.parse(iso)); } catch (java.time.format.DateTimeParseException e) { // Fallback for non-standard formats - return Timestamp.valueOf(val); + return Timestamp.valueOf(truncateToNanoseconds(val)); + } + } + + /** + * Parses a numeric epoch decimal string (e.g. from BigQuery REST JSON) into a JSR-310 {@link + * Instant}. Sub-nanosecond precision is deterministically truncated (floor/down) rather than + * rounded to avoid boundary rollovers. + */ + public static Instant parseEpochDecimalToInstant(String epochDecimal) { + if (epochDecimal == null) { + return null; + } + BigDecimal bd = new BigDecimal(epochDecimal); + long seconds = bd.setScale(0, RoundingMode.FLOOR).longValue(); + long nanos = + bd.subtract(BigDecimal.valueOf(seconds)) + .movePointRight(9) + .setScale(0, RoundingMode.DOWN) + .longValue(); + return Instant.ofEpochSecond(seconds, nanos); + } + + /** + * Formats a numeric epoch decimal string into standard SQL timestamp string format ("yyyy-MM-dd + * HH:mm:ss.ffffff[ffffff]"). Sub-microsecond / sub-picosecond precision is deterministically + * truncated (down) to prevent timestamp boundary rollovers. + */ + public static String formatTimestampString(String epochDecimal, boolean enableTimestampPicos) { + if (epochDecimal == null) { + return null; } + + BigDecimal bd = new BigDecimal(epochDecimal); + long seconds = bd.setScale(0, RoundingMode.FLOOR).longValue(); + BigDecimal fractionalSeconds = bd.subtract(BigDecimal.valueOf(seconds)); + + int originalScale = bd.scale() > 0 ? bd.scale() : 0; + int scale = enableTimestampPicos ? Math.max(6, Math.min(12, originalScale)) : 6; + + String fraction = + fractionalSeconds.setScale(scale, RoundingMode.DOWN).toPlainString().substring(2); + + Instant instant = Instant.ofEpochSecond(seconds); + return UTC_FORMATTER.format(instant) + "." + fraction; + } + + public static String formatTimestampStringFromMicroseconds(long microseconds) { + long seconds = Math.floorDiv(microseconds, 1000000L); + long micros = Math.floorMod(microseconds, 1000000L); + + String fraction = Strings.padStart(Long.toString(micros), 6, '0'); + Instant instant = Instant.ofEpochSecond(seconds); + return UTC_FORMATTER.format(instant) + "." + fraction; + } + + public static String formatTimestampStringFromIso( + String isoString, boolean enableTimestampPicos) { + if (isoString == null) { + return null; + } + + String s = isoString; + if (s.endsWith(" UTC")) { + s = s.substring(0, s.length() - 4); + } else if (s.endsWith("Z")) { + s = s.substring(0, s.length() - 1); + } + + if (s.length() > 10 && s.charAt(10) == 'T') { + s = s.substring(0, 10) + ' ' + s.substring(11); + } + + int dotIdx = s.indexOf('.'); + if (dotIdx == -1) { + return s + ".000000"; + } + + String base = s.substring(0, dotIdx); + String fraction = s.substring(dotIdx + 1); + + int maxScale = enableTimestampPicos ? 12 : 6; + if (fraction.length() > maxScale) { + fraction = fraction.substring(0, maxScale); + } else if (fraction.length() < 6) { + fraction = Strings.padEnd(fraction, 6, '0'); + } + + return base + "." + fraction; } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeCoercionUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeCoercionUtility.java index aa21307db1ef..bc9a45a253bc 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeCoercionUtility.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryTypeCoercionUtility.java @@ -182,6 +182,12 @@ static Timestamp convertTimestampWithCalendar(Timestamp timestamp, Calendar cal) LocalDateTime.class, Timestamp.class) .registerTypeCoercion(Text::toString, Text.class, String.class) + .registerTypeCoercion( + text -> BigQueryTemporalUtility.boxTimestamp(text.toString()), + Text.class, + Timestamp.class) + .registerTypeCoercion( + BigQueryTemporalUtility::boxTimestamp, String.class, Timestamp.class) .registerTypeCoercion(new TextToInteger()) .registerTypeCoercion(new LongToTimestamp()) .registerTypeCoercion(new LongToTime()) @@ -438,9 +444,9 @@ public Timestamp coerce(FieldValue fieldValue) { // Timestamp.valueOf() expects "yyyy-mm-dd hh:mm:ss.fffffffff" format. return Timestamp.valueOf(rawValue.replace('T', ' ')); } else { - // It's a TIMESTAMP numeric string. - long microseconds = fieldValue.getTimestampValue(); - Instant instant = Instant.EPOCH.plus(microseconds, ChronoUnit.MICROS); + // Numeric epoch decimal string from BigQuery JSON (e.g. "1775642400.123456789123" or + // "1.6905474E9") + Instant instant = BigQueryTemporalUtility.parseEpochDecimalToInstant(rawValue); // Timezone-agnostic conversion preserving exact point in time as mandated by JDBC spec return Timestamp.from(instant); } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/DataSource.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/DataSource.java index 2c07fc483bac..c120916f3f8b 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/DataSource.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/DataSource.java @@ -58,6 +58,7 @@ public class DataSource implements javax.sql.DataSource { private Map queryProperties; private String logLevel; private Boolean enableSession; + private Boolean enableTimestampPicos; private String logPath; private String gcpTelemetryProjectId; private String gcpTelemetryCredentials; @@ -149,6 +150,12 @@ public class DataSource implements javax.sql.DataSource { .put( BigQueryJdbcUrlUtility.GCP_TELEMETRY_CREDENTIALS_PROPERTY_NAME, DataSource::setGcpTelemetryCredentials) + .put( + BigQueryJdbcUrlUtility.ENABLE_TIMESTAMP_PICOS_PROPERTY_NAME, + (ds, val) -> + ds.setEnableTimestampPicos( + BigQueryJdbcUrlUtility.convertIntToBoolean( + val, BigQueryJdbcUrlUtility.ENABLE_TIMESTAMP_PICOS_PROPERTY_NAME))) .put( BigQueryJdbcUrlUtility.ENABLE_HTAPI_PROPERTY_NAME, (ds, val) -> @@ -925,6 +932,16 @@ public Boolean getUnsupportedHTAPIFallback() { : BigQueryJdbcUrlUtility.DEFAULT_UNSUPPORTED_HTAPI_FALLBACK_VALUE; } + public Boolean getEnableTimestampPicos() { + return enableTimestampPicos != null + ? enableTimestampPicos + : BigQueryJdbcUrlUtility.DEFAULT_ENABLE_TIMESTAMP_PICOS_VALUE; + } + + public void setEnableTimestampPicos(Boolean enableTimestampPicos) { + this.enableTimestampPicos = enableTimestampPicos; + } + public Boolean getEnableSession() { return enableSession != null ? enableSession diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/ArrowFormatTypeBigQueryCoercionUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/ArrowFormatTypeBigQueryCoercionUtilityTest.java index 02f5e77c738a..5e7521294d7b 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/ArrowFormatTypeBigQueryCoercionUtilityTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/ArrowFormatTypeBigQueryCoercionUtilityTest.java @@ -29,6 +29,7 @@ import java.sql.Time; import java.sql.Timestamp; import java.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Period; @@ -158,6 +159,26 @@ public void longToTimestamp() { .isEqualTo(new Timestamp(1408452095220L)); } + @Test + public void textToTimestamp() { + Text textUtc = new Text("2026-04-08 10:00:00.123456789123 UTC"); + Timestamp expected = Timestamp.from(Instant.parse("2026-04-08T10:00:00.123456789Z")); + assertThat(INSTANCE.coerceTo(Timestamp.class, textUtc)).isEqualTo(expected); + + Text textIso = new Text("2026-04-08T10:00:00.123456789123Z"); + assertThat(INSTANCE.coerceTo(Timestamp.class, textIso)).isEqualTo(expected); + } + + @Test + public void stringToTimestamp() { + String strUtc = "2026-04-08 10:00:00.123456789123 UTC"; + Timestamp expected = Timestamp.from(Instant.parse("2026-04-08T10:00:00.123456789Z")); + assertThat(INSTANCE.coerceTo(Timestamp.class, strUtc)).isEqualTo(expected); + + String strIso = "2026-04-08T10:00:00.123456789123Z"; + assertThat(INSTANCE.coerceTo(Timestamp.class, strIso)).isEqualTo(expected); + } + @Test public void nullToTime() { assertThat(INSTANCE.coerceTo(Time.class, null)).isNull(); 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 1ffd05ff4cd6..af71a4d45f91 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 @@ -23,6 +23,7 @@ import static org.apache.arrow.vector.types.Types.MinorType.VARCHAR; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.cloud.bigquery.Field; import com.google.cloud.bigquery.Field.Mode; @@ -437,5 +438,56 @@ private int resultSetRowCount(BigQueryArrowResultSet resultSet) throws SQLExcept return rowCount; } - // TODO: Unit Test for iteration and getters + @Test + public void testPicosecondTimestampArrowVector() throws Exception { + RootAllocator allocator = new RootAllocator(); + VarCharVector timeStampPicosVector = new VarCharVector("timeStampField", allocator); + timeStampPicosVector.allocateNew(1); + timeStampPicosVector.set(0, new Text("2026-04-08T10:00:00.123456789123Z")); + timeStampPicosVector.setValueCount(1); + + VectorSchemaRoot picosRoot = new VectorSchemaRoot(ImmutableList.of(timeStampPicosVector)); + ArrowSchema arrowSchema = + ArrowSchema.newBuilder() + .setSerializedSchema(serializeSchema(picosRoot.getSchema())) + .build(); + ArrowRecordBatch recordBatch = + ArrowRecordBatch.newBuilder() + .setSerializedRecordBatch(serializeVectorSchemaRoot(picosRoot)) + .build(); + + BigQueryArrowBatchWrapper batchWrapper = BigQueryArrowBatchWrapper.of(recordBatch, false); + BlockingQueue picosBuffer = new LinkedBlockingDeque<>(2); + picosBuffer.add(batchWrapper); + picosBuffer.add(BigQueryArrowBatchWrapper.of(null, true)); + + Schema bqSchema = + Schema.of(FieldList.of(Field.of("timeStampField", StandardSQLTypeName.TIMESTAMP))); + BigQueryStatement mockStatement = mock(BigQueryStatement.class); + when(mockStatement.isEnableTimestampPicos()).thenReturn(true); + + BigQueryArrowResultSet rs = + BigQueryArrowResultSet.of( + bqSchema, arrowSchema, 1, mockStatement, picosBuffer, mock(Future.class), null); + + assertThat(rs.next()).isTrue(); + // getString returns full 12-digit picosecond string + assertThat(rs.getString("timeStampField")).isEqualTo("2026-04-08 10:00:00.123456789123"); + assertThat(rs.getString(1)).isEqualTo("2026-04-08 10:00:00.123456789123"); + + // getTimestamp returns java.sql.Timestamp with 9 digits of nanoseconds + Timestamp ts = rs.getTimestamp("timeStampField"); + assertThat(ts).isNotNull(); + assertThat(ts.getNanos()).isEqualTo(123456789); + + // getObject returns java.sql.Timestamp + Object obj = rs.getObject(1); + assertThat(obj).isInstanceOf(Timestamp.class); + assertThat(((Timestamp) obj).getNanos()).isEqualTo(123456789); + + rs.close(); + timeStampPicosVector.close(); + picosRoot.close(); + allocator.close(); + } } 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 537e20b60fea..30a66a2bf5fd 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 @@ -127,10 +127,10 @@ public static Collection data() { TIMESTAMP, arraySchemaAndValue( TIMESTAMP, - "1680174859.8202269", - "1680261259.8202269", - "1680347659.8202269", - "1680434059.8202269"), + "1680174859.820227", + "1680261259.820227", + "1680347659.820227", + "1680434059.820227"), new Timestamp[] { Timestamp.valueOf(aTimeStamp), // 2023-03-30 16:44:19.82 Timestamp.valueOf(aTimeStamp.plusDays(1)), 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 06af37010d25..eba951341c10 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 @@ -20,6 +20,7 @@ import static java.time.Month.MARCH; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.cloud.bigquery.Field; import com.google.cloud.bigquery.FieldList; @@ -247,6 +248,8 @@ public void testIteration() throws SQLException { assertThat(bigQueryJsonResultSet.getFloat(3)).isEqualTo(1.5f); assertThat(bigQueryJsonResultSet.getString("fourth")).isEqualTo(STRING_VAL); assertThat(bigQueryJsonResultSet.getString(4)).isEqualTo(STRING_VAL); + assertThat(bigQueryJsonResultSet.getString("fifth")).isEqualTo("2023-03-30 11:14:19.820000"); + assertThat(bigQueryJsonResultSet.getString(5)).isEqualTo("2023-03-30 11:14:19.820000"); assertThat(bigQueryJsonResultSet.getTimestamp("fifth")) .isEqualTo(Timestamp.valueOf(aTimeStamp)); assertThat(bigQueryJsonResultSet.getTimestamp(5)).isEqualTo(Timestamp.valueOf(aTimeStamp)); @@ -506,6 +509,25 @@ public void testGetObjectWithType_failure(Object column, Class type) throws S } } + @Test + public void testGetString_timestampWithPicoseconds() throws SQLException { + when(statement.isEnableTimestampPicos()).thenReturn(true); + assertThat(resetResultSet()).isTrue(); + bigQueryJsonResultSet.next(); + assertThat(bigQueryJsonResultSet.getString("fifth")).isEqualTo("2023-03-30 11:14:19.820000"); + assertThat(bigQueryJsonResultSet.getString(5)).isEqualTo("2023-03-30 11:14:19.820000"); + } + + @Test + public void testGetString_structAndArray() throws SQLException { + assertThat(resetResultSet()).isTrue(); + bigQueryJsonResultSet.next(); + assertThat(bigQueryJsonResultSet.getString("eight")).isEqualTo("[10, 20]"); + assertThat(bigQueryJsonResultSet.getString(8)).isEqualTo("[10, 20]"); + assertThat(bigQueryJsonResultSet.getString("ninth")).isNotNull(); + assertThat(bigQueryJsonResultSet.getString(9)).isNotNull(); + } + private int resultSetRowCount(BigQueryJsonResultSet resultSet) throws SQLException { int rowCount = 0; while (resultSet.next()) { diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadataTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadataTest.java index 8261a14dc981..7aef76229b33 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadataTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryResultSetMetadataTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.cloud.bigquery.Field; import com.google.cloud.bigquery.FieldList; @@ -196,7 +197,7 @@ public void testColumnDisplaySize() throws SQLException { assertThat(resultSetMetaData.getColumnDisplaySize(2)).isEqualTo(10); assertThat(resultSetMetaData.getColumnDisplaySize(3)).isEqualTo(14); assertThat(resultSetMetaData.getColumnDisplaySize(12)).isEqualTo(50); - assertThat(resultSetMetaData.getColumnDisplaySize(5)).isEqualTo(16); + assertThat(resultSetMetaData.getColumnDisplaySize(5)).isEqualTo(26); } // Nested Types @@ -295,4 +296,21 @@ public void testIsSearchableForAllTypes(StandardSQLTypeName type) throws SQLExce ResultSetMetaData metaData = resultSet.getMetaData(); assertThat(metaData.isSearchable(1)).isTrue(); } + + @Test + public void testTimestampPicosecondsMetadata() throws SQLException { + Field picosTimestampField = + Field.newBuilder("picosTs", StandardSQLTypeName.TIMESTAMP) + .setTimestampPrecision(12L) + .build(); + Schema schema = Schema.of(FieldList.of(picosTimestampField)); + when(statement.isEnableTimestampPicos()).thenReturn(true); + BigQueryJsonResultSet jsonRs = + BigQueryJsonResultSet.of(schema, 1L, null, statement, (Future[]) null); + ResultSetMetaData metadata = jsonRs.getMetaData(); + + assertThat(metadata.getColumnDisplaySize(1)).isEqualTo(32); + assertThat(metadata.getPrecision(1)).isEqualTo(32); + assertThat(metadata.getScale(1)).isEqualTo(12); + } } 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 new file mode 100644 index 000000000000..3c58208e2a63 --- /dev/null +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryTemporalUtilityTest.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.bigquery.jdbc; + +import static com.google.common.truth.Truth.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +public class BigQueryTemporalUtilityTest { + + @Test + public void testFormatTimestampString() { + assertThat(BigQueryTemporalUtility.formatTimestampString("1775642400.123456789", false)) + .isEqualTo("2026-04-08 10:00:00.123456"); + assertThat(BigQueryTemporalUtility.formatTimestampString("1775642400.123456789", true)) + .isEqualTo("2026-04-08 10:00:00.123456789"); + assertThat(BigQueryTemporalUtility.formatTimestampString("1775642400", false)) + .isEqualTo("2026-04-08 10:00:00.000000"); + assertThat(BigQueryTemporalUtility.formatTimestampString("0.123", false)) + .isEqualTo("1970-01-01 00:00:00.123000"); + assertThat(BigQueryTemporalUtility.formatTimestampString("-0.123456", false)) + .isEqualTo("1969-12-31 23:59:59.876544"); + assertThat(BigQueryTemporalUtility.formatTimestampString("-1.500000", false)) + .isEqualTo("1969-12-31 23:59:58.500000"); + assertThat(BigQueryTemporalUtility.formatTimestampString("-1.000000", false)) + .isEqualTo("1969-12-31 23:59:59.000000"); + assertThat(BigQueryTemporalUtility.formatTimestampString("-0.123456789123", true)) + .isEqualTo("1969-12-31 23:59:59.876543210877"); + assertThat(BigQueryTemporalUtility.formatTimestampString("1.6905474E9", false)) + .isEqualTo("2023-07-28 12:30:00.000000"); + assertThat(BigQueryTemporalUtility.formatTimestampString("1.690547400123456E9", true)) + .isEqualTo("2023-07-28 12:30:00.123456"); + } + + @Test + public void testFormatTimestampStringFromMicroseconds() { + assertThat(BigQueryTemporalUtility.formatTimestampStringFromMicroseconds(1775642400123456L)) + .isEqualTo("2026-04-08 10:00:00.123456"); + assertThat(BigQueryTemporalUtility.formatTimestampStringFromMicroseconds(-123456L)) + .isEqualTo("1969-12-31 23:59:59.876544"); + } + + @Test + public void testFormatTimestampStringFromIso() { + assertThat( + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-04-08T10:00:00.123456789123Z", false)) + .isEqualTo("2026-04-08 10:00:00.123456"); + assertThat( + BigQueryTemporalUtility.formatTimestampStringFromIso( + "2026-04-08T10:00:00.123456789123Z", true)) + .isEqualTo("2026-04-08 10:00:00.123456789123"); + assertThat(BigQueryTemporalUtility.formatTimestampStringFromIso("2026-04-08T10:00:00Z", false)) + .isEqualTo("2026-04-08 10:00:00.000000"); + } + + @Test + public void testBoxTimestamp() { + // ISO format with UTC suffix and 12-digit picoseconds + java.sql.Timestamp tsUtc = + BigQueryTemporalUtility.boxTimestamp("2026-04-08 10:00:00.123456789123 UTC"); + assertThat(tsUtc.getNanos()).isEqualTo(123456789); + + // Fallback format (no timezone) with 12-digit picoseconds triggers Timestamp.valueOf fallback + java.sql.Timestamp tsFallback = + BigQueryTemporalUtility.boxTimestamp("2026-04-08 10:00:00.123456789123"); + assertThat(tsFallback.getNanos()).isEqualTo(123456789); + } + + @Test + public void testParseEpochDecimalToInstant() { + // Standard decimal + Instant i1 = BigQueryTemporalUtility.parseEpochDecimalToInstant("1775642400.123456789123"); + assertThat(i1).isEqualTo(Instant.ofEpochSecond(1775642400, 123456789)); + + // Scientific notation + Instant i2 = BigQueryTemporalUtility.parseEpochDecimalToInstant("1.6905474E9"); + assertThat(i2).isEqualTo(Instant.parse("2023-07-28T12:30:00Z")); + + // Pre-1970 negative decimal + Instant i3 = BigQueryTemporalUtility.parseEpochDecimalToInstant("-0.123456"); + assertThat(i3).isEqualTo(Instant.ofEpochSecond(-1, 876544000)); + + // Null + assertThat(BigQueryTemporalUtility.parseEpochDecimalToInstant(null)).isNull(); + } + + @Test + public void testTimestampTruncationNotRounding() { + // Values ending in .8202269 or .9999999 must truncate towards zero (DOWN), never round up + assertThat(BigQueryTemporalUtility.formatTimestampString("1680174859.8202269", false)) + .isEqualTo("2023-03-30 11:14:19.820226"); + assertThat(BigQueryTemporalUtility.formatTimestampString("1680174859.9999999", false)) + .isEqualTo("2023-03-30 11:14:19.999999"); + + Instant truncated = + BigQueryTemporalUtility.parseEpochDecimalToInstant("1680174859.820226999999"); + assertThat(truncated).isNotNull(); + assertThat(truncated.getNano()).isEqualTo(820226999); + } +} diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/FieldValueTypeBigQueryCoercionUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/FieldValueTypeBigQueryCoercionUtilityTest.java index 7b24e389f853..60661462f7bc 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/FieldValueTypeBigQueryCoercionUtilityTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/FieldValueTypeBigQueryCoercionUtilityTest.java @@ -306,6 +306,43 @@ public void fieldValueToTimestamp() { .isEqualTo(Timestamp.from(instant)); } + @Test + public void fieldValueToTimestampWithNanos() { + FieldValue picosValue = FieldValue.of(PRIMITIVE, "1775642400.123456789123"); + Timestamp result = INSTANCE.coerceTo(Timestamp.class, picosValue); + assertThat(result).isNotNull(); + assertThat(result.getNanos()).isEqualTo(123456789); + } + + @Test + public void fieldValueToTimestampScientificNotation() { + FieldValue scientificValue = FieldValue.of(PRIMITIVE, "1.6905474E9"); + Timestamp result = INSTANCE.coerceTo(Timestamp.class, scientificValue); + assertThat(result).isNotNull(); + assertThat(result).isEqualTo(Timestamp.valueOf("2023-07-28 12:30:00")); + } + + @Test + public void fieldValueToTimestampNegativeEpoch() { + FieldValue negativeOneAndHalf = FieldValue.of(PRIMITIVE, "-1.5"); + Timestamp result = INSTANCE.coerceTo(Timestamp.class, negativeOneAndHalf); + assertThat(result).isNotNull(); + assertThat(result).isEqualTo(Timestamp.from(Instant.ofEpochSecond(-2, 500000000))); + + FieldValue pre1970Nanos = FieldValue.of(PRIMITIVE, "-0.123456789"); + Timestamp resultNanos = INSTANCE.coerceTo(Timestamp.class, pre1970Nanos); + assertThat(resultNanos).isNotNull(); + assertThat(resultNanos).isEqualTo(Timestamp.from(Instant.ofEpochSecond(-1, 876543211))); + } + + @Test + public void fieldValueToTimestampTruncation() { + FieldValue val = FieldValue.of(PRIMITIVE, "1680174859.8202269"); + Timestamp ts = INSTANCE.coerceTo(Timestamp.class, val); + assertThat(ts).isNotNull(); + assertThat(ts.getNanos()).isEqualTo(820226900); + } + @Test public void fieldValueToTimestampWhenNull() { assertThat(INSTANCE.coerceTo(Timestamp.class, null)).isNull(); diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/ArrowUtilities.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/ArrowUtilities.java index 13f3007667d3..1af7cfec6b59 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/ArrowUtilities.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/utils/ArrowUtilities.java @@ -41,8 +41,9 @@ public static ByteString serializeSchema(Schema schema) throws IOException { public static ByteString serializeVectorSchemaRoot(VectorSchemaRoot root) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); - ArrowRecordBatch recordBatch = new VectorUnloader(root).getRecordBatch(); - MessageSerializer.serialize(new WriteChannel(Channels.newChannel(out)), recordBatch); + try (ArrowRecordBatch recordBatch = new VectorUnloader(root).getRecordBatch()) { + MessageSerializer.serialize(new WriteChannel(Channels.newChannel(out)), recordBatch); + } return ByteString.readFrom(new ByteArrayInputStream(out.toByteArray())); // ArrowStreamWriter writer = new ArrowStreamWriter(root, null, Channels.newChannel(out));