From a070b860218e76b6006ccabbcc79c3383818863e Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Mon, 14 Sep 2026 15:02:12 +0000 Subject: [PATCH 1/2] feat(bigquery-jdbc): support picosecond in `PreparedStatement` parameters and batching --- .../jdbc/BigQueryParameterHandler.java | 53 +++++++---- .../jdbc/BigQueryPreparedStatement.java | 12 ++- .../bigquery/jdbc/BigQueryStatement.java | 22 ++++- .../jdbc/BigQueryTemporalUtility.java | 35 ++++---- .../jdbc/BigQueryParameterHandlerTest.java | 71 +++++++++++++++ .../BigQueryPreparedStatementSettersTest.java | 88 +++++++++++++++++++ .../bigquery/jdbc/BigQueryStatementTest.java | 39 ++++++++ .../jdbc/BigQueryTemporalUtilityTest.java | 33 +++++++ 8 files changed, 316 insertions(+), 37 deletions(-) diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java index e40e3fcf28ef..6226de3b6cd9 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java @@ -23,18 +23,32 @@ import com.google.cloud.bigquery.exception.BigQueryJdbcSqlFeatureNotSupportedException; import java.math.BigInteger; import java.sql.SQLException; +import java.sql.Time; +import java.sql.Timestamp; import java.util.ArrayList; class BigQueryParameterHandler { private final BigQueryJdbcCustomLogger LOG = new BigQueryJdbcCustomLogger(this.toString()); + private final int parametersArraySize; + private final boolean enableTimestampPicos; + final ArrayList parametersList; + private long highestIndex = 0; - public BigQueryParameterHandler(int parameterCount) { - this.parametersArraySize = parameterCount; + BigQueryParameterHandler(int parameterCount, boolean enableTimestampPicos) { + this(parameterCount, new ArrayList<>(parameterCount), enableTimestampPicos); + } + + BigQueryParameterHandler(int parameterCount) { + this(parameterCount, false); } - BigQueryParameterHandler(int parameterCount, ArrayList parametersList) { + BigQueryParameterHandler( + int parameterCount, + ArrayList parametersList, + boolean enableTimestampPicos) { this.parametersArraySize = parameterCount; this.parametersList = parametersList; + this.enableTimestampPicos = enableTimestampPicos; } // Indicates whether the parameter is input, output or both @@ -47,11 +61,6 @@ enum BigQueryStatementParameterType { INOUT }; - private int parametersArraySize; - ArrayList parametersList = new ArrayList<>(parametersArraySize); - - private long highestIndex = 0; - QueryJobConfiguration.Builder configureParameters( QueryJobConfiguration.Builder jobConfigurationBuilder) throws SQLException { LOG.finest("++enter++"); @@ -60,7 +69,8 @@ QueryJobConfiguration.Builder configureParameters( Object parameterValue = getParameter(i); StandardSQLTypeName sqlType = getSqlType(i); - parameterValue = formatValueForQueryParameter(parameterValue, sqlType); + parameterValue = + formatValueForQueryParameter(parameterValue, sqlType, this.enableTimestampPicos); LOG.finest( "Parameter %s of type %s at index %s added to QueryJobConfiguration", parameterValue, sqlType, i); @@ -76,7 +86,8 @@ QueryJobConfiguration.Builder configureParameters( return jobConfigurationBuilder; } - static Object formatValueForQueryParameter(Object parameterValue, StandardSQLTypeName sqlType) { + static Object formatValueForQueryParameter( + Object parameterValue, StandardSQLTypeName sqlType, boolean enableTimestampPicos) { if (parameterValue == null) { return null; } @@ -89,13 +100,14 @@ static Object formatValueForQueryParameter(Object parameterValue, StandardSQLTyp if (sqlType == StandardSQLTypeName.FLOAT64 && parameterValue instanceof Float) { return ((Number) parameterValue).doubleValue(); } - if (parameterValue instanceof java.sql.Timestamp) { - java.sql.Timestamp ts = (java.sql.Timestamp) parameterValue; - java.sql.Timestamp copy = new java.sql.Timestamp(ts.getTime()); - copy.setNanos((ts.getNanos() / 1000) * 1000); - return copy.toString(); + if (parameterValue instanceof Timestamp) { + return formatTimestampParameter((Timestamp) parameterValue, enableTimestampPicos); + } + if (sqlType == StandardSQLTypeName.TIMESTAMP && parameterValue instanceof String) { + String str = ((String) parameterValue).trim().replace('T', ' '); + return BigQueryTemporalUtility.truncateFractionalSeconds(str, enableTimestampPicos ? 12 : 6); } - if (parameterValue instanceof java.sql.Time) { + if (parameterValue instanceof Time) { String timeStr = parameterValue.toString(); if (timeStr.length() == 8) { return timeStr + ".000000"; @@ -108,6 +120,15 @@ static Object formatValueForQueryParameter(Object parameterValue, StandardSQLTyp return parameterValue; } + private static String formatTimestampParameter(Timestamp ts, boolean enableTimestampPicos) { + if (enableTimestampPicos) { + return ts.toString(); + } + Timestamp copy = new Timestamp(ts.getTime()); + copy.setNanos((ts.getNanos() / 1000) * 1000); + return copy.toString(); + } + void setParameter(int parameterIndex, Object value, Class type) throws BigQueryJdbcSqlFeatureNotSupportedException { LOG.finest("++enter++"); diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java index 014717fd0646..b7dd3465b224 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatement.java @@ -84,7 +84,8 @@ class BigQueryPreparedStatement extends BigQueryStatement implements PreparedSta BigQueryPreparedStatement(BigQueryConnection connection, String query) { super(connection); setCurrentQuery(query); - this.parameterHandler = new BigQueryParameterHandler(this.parameterCount); + this.parameterHandler = + new BigQueryParameterHandler(this.parameterCount, this.isEnableTimestampPicos()); } void setCurrentQuery(String currentQuery) { @@ -99,6 +100,7 @@ private int getParameterCount(String query) { @Override public ResultSet executeQuery() throws SQLException { + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryPreparedStatement.executeQuery", this.connection, @@ -108,6 +110,7 @@ public ResultSet executeQuery() throws SQLException { @Override public long executeLargeUpdate() throws SQLException { + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryPreparedStatement.executeLargeUpdate", this.connection, @@ -122,6 +125,7 @@ public int executeUpdate() throws SQLException { @Override public boolean execute() throws SQLException { + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryPreparedStatement.execute", this.connection, @@ -314,6 +318,7 @@ private ArrayList deepCopyParameterList( @Override public int[] executeBatch() throws SQLException { + validateExecution(); int[] result = new int[this.batchParameters.size()]; if (this.batchParameters.isEmpty()) { return result; @@ -463,7 +468,8 @@ QueryJobConfiguration getWriteBatchJobConfiguration( ArrayList currentParameterList) throws SQLException { LOG.finer("++enter++"); BigQueryParameterHandler batchHandler = - new BigQueryParameterHandler(this.parameterCount, currentParameterList); + new BigQueryParameterHandler( + this.parameterCount, currentParameterList, this.isEnableTimestampPicos()); QueryJobConfiguration.Builder jobConfiguration = getJobConfig(this.currentQuery); jobConfiguration.setParameterMode("POSITIONAL"); jobConfiguration = batchHandler.configureParameters(jobConfiguration); @@ -482,7 +488,7 @@ QueryJobConfiguration getStandardBatchJobConfiguration(String query) throws SQLE for (BigQueryJdbcParameter parameter : parameterList) { Object parameterValue = BigQueryParameterHandler.formatValueForQueryParameter( - parameter.getValue(), parameter.getSqlType()); + parameter.getValue(), parameter.getSqlType(), this.isEnableTimestampPicos()); StandardSQLTypeName sqlType = parameter.getSqlType(); LOG.finer( "Parameter %s of type %s at index %s added to QueryJobConfiguration", 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 d1348f6d8091..2ee127a26af1 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 @@ -242,7 +242,7 @@ private BigQuerySettings generateBigQuerySettings() { */ @Override public ResultSet executeQuery(String sql) throws SQLException { - checkClosed(); + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryStatement.executeQuery", this.connection, sql, () -> executeQueryImpl(sql)); } @@ -267,7 +267,7 @@ private ResultSet executeQueryImpl(String sql) throws SQLException { @Override public long executeLargeUpdate(String sql) throws SQLException { - checkClosed(); + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryStatement.executeLargeUpdate", this.connection, @@ -308,7 +308,7 @@ int checkUpdateCount(long updateCount) { @Override public boolean execute(String sql) throws SQLException { - checkClosed(); + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryStatement.execute", this.connection, sql, () -> executeImpl(sql)); } @@ -1705,6 +1705,7 @@ public void clearBatch() { @Override public int[] executeBatch() throws SQLException { LOG.finest("++enter++"); + validateExecution(); return BigQueryJdbcOpenTelemetry.withTracing( "BigQueryStatement.executeBatch", this.connection, @@ -1866,6 +1867,21 @@ void checkClosed() throws SQLException { } } + /** + * Validates that the statement is open and that execution configuration settings are compatible. + * + * @throws SQLException if the statement is closed or settings are incompatible + */ + void validateExecution() throws SQLException { + checkClosed(); + if (isEnableTimestampPicos() && getUseLegacySql()) { + throw new BigQueryJdbcException( + "Picosecond data is incompatible with Legacy SQL. " + + "To query your Picosecond data, please set QueryDialect to SQL " + + "and restructure your query as a Standard SQL query."); + } + } + enum SqlType { SELECT, DML, 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 b1ad1917f0ce..f7d8e361e229 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 @@ -59,7 +59,7 @@ private BigQueryTemporalUtility() {} */ public static Timestamp boxDateTime(String val, ZoneId zoneId) { ZoneId targetZone = zoneId != null ? zoneId : ZoneId.systemDefault(); - String isoString = truncateIsoFractionToNanos(val.replace(' ', 'T')); + String isoString = truncateFractionalSeconds(val.replace(' ', 'T'), 9); return Timestamp.from(LocalDateTime.parse(isoString).atZone(targetZone).toInstant()); } @@ -80,7 +80,7 @@ public static Date boxDate(String val, ZoneId zoneId) { * perfectly accurate modern conversions. */ public static Time boxTime(String val, ZoneId zoneId) { - LocalTime localTime = LocalTime.parse(truncateIsoFractionToNanos(val)); + LocalTime localTime = LocalTime.parse(truncateFractionalSeconds(val, 9)); if (zoneId == null) { // JDBC 4.2 Modern API (no Calendar provided): @@ -139,7 +139,7 @@ public static Timestamp boxTimestamp(String val) { } // Truncate sub-nanosecond fraction (> 9 digits) to prevent Instant.parse failure - iso = truncateIsoFractionToNanos(iso); + iso = truncateFractionalSeconds(iso, 9); try { return Timestamp.from(Instant.parse(iso)); @@ -344,30 +344,35 @@ private static StringBuilder formatDateTimeBase(LocalDateTime dt, int scale) { } /** - * Truncates sub-second fractional digits to at most 9 digits (nanoseconds) so that standard JDK - * temporal parsers (which cap at nanosecond precision) can parse the string without throwing - * {@link java.time.format.DateTimeParseException}. Any trailing timezone offset or suffix is - * preserved intact. + * Truncates sub-second fractional digits in a timestamp string to at most {@code maxDigits}. Any + * trailing timezone offset or suffix is preserved intact. */ - private static String truncateIsoFractionToNanos(String iso) { - int dotIdx = iso.indexOf('.'); + static String truncateFractionalSeconds(String str, int maxDigits) { + if (str == null) { + return null; + } + int dotIdx = str.indexOf('.'); if (dotIdx < 0) { - return iso; + return str; } int fractionStart = dotIdx + 1; int fractionEnd = fractionStart; - while (fractionEnd < iso.length() && Character.isDigit(iso.charAt(fractionEnd))) { + int len = str.length(); + while (fractionEnd < len) { + char c = str.charAt(fractionEnd); + if (c < '0' || c > '9') { + break; + } fractionEnd++; } int fractionDigits = fractionEnd - fractionStart; - if (fractionDigits <= 9) { - return iso; + if (fractionDigits <= maxDigits) { + return str; } - // Retain the first 9 fractional digits and append any trailing suffix (e.g., 'Z' or offset) - return iso.substring(0, fractionStart + 9) + iso.substring(fractionEnd); + return str.substring(0, fractionStart + maxDigits) + str.substring(fractionEnd); } /** diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java index ab7fa7fb1c7a..c10e20d2da48 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java @@ -22,6 +22,7 @@ import com.google.cloud.bigquery.QueryJobConfiguration; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.jdbc.BigQueryParameterHandler.BigQueryStatementParameterType; +import java.sql.Timestamp; import org.junit.jupiter.api.Test; public class BigQueryParameterHandlerTest { @@ -156,4 +157,74 @@ public void testConfigureParametersWidenNumericTypes() throws Exception { assertEquals("5", config.getPositionalParameters().get(0).getValue()); assertEquals("10", config.getPositionalParameters().get(1).getValue()); } + + @Test + public void testFormatValueForQueryParameter_timestampPrecision() { + Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); + + // When enableTimestampPicos is true, full nanoseconds are preserved + Object picosFormatted = + BigQueryParameterHandler.formatValueForQueryParameter( + ts, StandardSQLTypeName.TIMESTAMP, true); + assertEquals("2024-01-01 12:34:56.123456789", picosFormatted); + + // When enableTimestampPicos is false, truncated to microseconds + Object microsFormatted = + BigQueryParameterHandler.formatValueForQueryParameter( + ts, StandardSQLTypeName.TIMESTAMP, false); + assertEquals("2024-01-01 12:34:56.123456", microsFormatted); + } + + @Test + public void testFormatValueForQueryParameter_stringTimestampPrecision() { + String picosString = "2024-01-01 12:34:56.123456789012"; + + // When enableTimestampPicos is true, up to 12 digits are preserved + Object picosFormatted = + BigQueryParameterHandler.formatValueForQueryParameter( + picosString, StandardSQLTypeName.TIMESTAMP, true); + assertEquals("2024-01-01 12:34:56.123456789012", picosFormatted); + + // 15 digits truncated to 12 digits + String excessString = "2024-01-01 12:34:56.123456789012345"; + Object truncatedPicos = + BigQueryParameterHandler.formatValueForQueryParameter( + excessString, StandardSQLTypeName.TIMESTAMP, true); + assertEquals("2024-01-01 12:34:56.123456789012", truncatedPicos); + + // 'T' is replaced with space and whitespace is trimmed + String isoString = " 2024-01-01T12:34:56.123456789012 "; + Object replacedT = + BigQueryParameterHandler.formatValueForQueryParameter( + isoString, StandardSQLTypeName.TIMESTAMP, true); + assertEquals("2024-01-01 12:34:56.123456789012", replacedT); + + // When enableTimestampPicos is false, truncated to 6 digits + Object microsFormatted = + BigQueryParameterHandler.formatValueForQueryParameter( + picosString, StandardSQLTypeName.TIMESTAMP, false); + assertEquals("2024-01-01 12:34:56.123456", microsFormatted); + } + + @Test + public void testConfigureParameters_withEnableTimestampPicos() throws Exception { + BigQueryParameterHandler paramHandler = new BigQueryParameterHandler(2, true); + Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); + String picosStr = "2024-01-01 12:34:56.123456789012"; + + paramHandler.setParameter(1, ts, Timestamp.class); + paramHandler.setParameter(2, picosStr, Timestamp.class); + + QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT ?, ?"); + paramHandler.configureParameters(builder); + + QueryJobConfiguration config = builder.build(); + assertEquals(2, config.getPositionalParameters().size()); + assertEquals( + "2024-01-01 12:34:56.123456789", config.getPositionalParameters().get(0).getValue()); + assertEquals(StandardSQLTypeName.TIMESTAMP, config.getPositionalParameters().get(0).getType()); + assertEquals( + "2024-01-01 12:34:56.123456789012", config.getPositionalParameters().get(1).getValue()); + assertEquals(StandardSQLTypeName.TIMESTAMP, config.getPositionalParameters().get(1).getType()); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java index 466d21b870d4..7e36b974b3b4 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryPreparedStatementSettersTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -325,4 +326,91 @@ public void testCreateJsonRowWithSetObjectNull() throws Exception { assertTrue(jsonRow.get("col1").isJsonNull()); assertEquals("42", jsonRow.get("col2").getAsString()); } + + @Test + public void testSetObjectWithTimestampStringAndTypesTimestamp_picosEnabled() throws Exception { + BigQueryConnection picosConnection = mock(BigQueryConnection.class); + doReturn(true).when(picosConnection).isEnableTimestampPicos(); + doReturn(BigQueryJdbcUrlUtility.DEFAULT_QUERY_DIALECT_VALUE) + .when(picosConnection) + .getQueryDialect(); + BigQueryPreparedStatement ps = + new BigQueryPreparedStatement(picosConnection, "INSERT INTO t (col) VALUES (?)"); + + ps.setObject(1, "2024-01-01 12:34:56.123456789012", Types.TIMESTAMP); + assertEquals(Timestamp.class, ps.parameterHandler.getType(1)); + assertEquals(StandardSQLTypeName.TIMESTAMP, ps.parameterHandler.getSqlType(1)); + + QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT ?"); + ps.parameterHandler.configureParameters(builder); + QueryJobConfiguration config = builder.build(); + + assertEquals(1, config.getPositionalParameters().size()); + assertEquals( + "2024-01-01 12:34:56.123456789012", config.getPositionalParameters().get(0).getValue()); + assertEquals(StandardSQLTypeName.TIMESTAMP, config.getPositionalParameters().get(0).getType()); + } + + @Test + public void testSetTimestamp_picosEnabledPreservesNanoseconds() throws Exception { + BigQueryConnection picosConnection = mock(BigQueryConnection.class); + doReturn(true).when(picosConnection).isEnableTimestampPicos(); + doReturn(BigQueryJdbcUrlUtility.DEFAULT_QUERY_DIALECT_VALUE) + .when(picosConnection) + .getQueryDialect(); + BigQueryPreparedStatement ps = + new BigQueryPreparedStatement(picosConnection, "INSERT INTO t (col) VALUES (?)"); + + Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); + ps.setTimestamp(1, ts); + + QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT ?"); + ps.parameterHandler.configureParameters(builder); + QueryJobConfiguration config = builder.build(); + + assertEquals(1, config.getPositionalParameters().size()); + assertEquals( + "2024-01-01 12:34:56.123456789", config.getPositionalParameters().get(0).getValue()); + } + + @Test + public void testSetTimestamp_picosDisabledTruncatesToMicroseconds() throws Exception { + BigQueryConnection nonPicosConnection = mock(BigQueryConnection.class); + doReturn(false).when(nonPicosConnection).isEnableTimestampPicos(); + doReturn(BigQueryJdbcUrlUtility.DEFAULT_QUERY_DIALECT_VALUE) + .when(nonPicosConnection) + .getQueryDialect(); + BigQueryPreparedStatement ps = + new BigQueryPreparedStatement(nonPicosConnection, "INSERT INTO t (col) VALUES (?)"); + + Timestamp ts = Timestamp.valueOf("2024-01-01 12:34:56.123456789"); + ps.setTimestamp(1, ts); + + QueryJobConfiguration.Builder builder = QueryJobConfiguration.newBuilder("SELECT ?"); + ps.parameterHandler.configureParameters(builder); + QueryJobConfiguration config = builder.build(); + + assertEquals(1, config.getPositionalParameters().size()); + assertEquals("2024-01-01 12:34:56.123456", config.getPositionalParameters().get(0).getValue()); + } + + @Test + public void testBatchConfiguration_withEnableTimestampPicos() throws Exception { + BigQueryConnection picosConnection = mock(BigQueryConnection.class); + doReturn(true).when(picosConnection).isEnableTimestampPicos(); + doReturn(BigQueryJdbcUrlUtility.DEFAULT_QUERY_DIALECT_VALUE) + .when(picosConnection) + .getQueryDialect(); + BigQueryPreparedStatement ps = + new BigQueryPreparedStatement(picosConnection, "INSERT INTO t (col) VALUES (?)"); + + ps.setTimestamp(1, Timestamp.valueOf("2024-01-01 12:34:56.123456789")); + ps.addBatch(); + + QueryJobConfiguration batchConfig = + ps.getStandardBatchJobConfiguration("INSERT INTO t (col) VALUES (?)"); + assertEquals(1, batchConfig.getPositionalParameters().size()); + assertEquals( + "2024-01-01 12:34:56.123456789", batchConfig.getPositionalParameters().get(0).getValue()); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java index f8dcb3ac6e67..317e0b0da921 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryStatementTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; @@ -502,6 +503,44 @@ public void testGetJobConfigWithExtraLabels() { assertTrue(Maps.difference(expectedLabels, jobConfig.getLabels()).areEqual()); } + @Test + public void testExecute_legacySqlWithEnableTimestampPicos_throwsException() { + BigQueryConnection mockConn = mock(BigQueryConnection.class); + doReturn("BIG_QUERY").when(mockConn).getQueryDialect(); + doReturn(true).when(mockConn).isEnableTimestampPicos(); + + BigQueryStatement statement = new BigQueryStatement(mockConn); + + BigQueryJdbcException ex = + assertThrows(BigQueryJdbcException.class, () -> statement.execute("select 1")); + assertTrue(ex.getMessage().contains("Picosecond data is incompatible with Legacy SQL")); + assertTrue(ex.getMessage().contains("please set QueryDialect to SQL")); + } + + @Test + public void testGetJobConfig_standardSql_setsUseLegacySqlFalse() { + BigQueryConnection mockConn = mock(BigQueryConnection.class); + doReturn("SQL").when(mockConn).getQueryDialect(); + + BigQueryStatement statement = new BigQueryStatement(mockConn); + + QueryJobConfiguration jobConfig = statement.getJobConfig("select 1").build(); + assertNotNull(jobConfig); + assertFalse(jobConfig.useLegacySql()); + } + + @Test + public void testGetJobConfig_legacySql_setsUseLegacySqlTrue() { + BigQueryConnection mockConn = mock(BigQueryConnection.class); + doReturn("BIG_QUERY").when(mockConn).getQueryDialect(); + + BigQueryStatement statement = new BigQueryStatement(mockConn); + + QueryJobConfiguration jobConfig = statement.getJobConfig("select 1").build(); + assertNotNull(jobConfig); + assertTrue(jobConfig.useLegacySql()); + } + @Test public void testJoblessQuery() throws SQLException, InterruptedException { // 1. Test JobCreationMode=2 (jobless) 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 08e92c002287..4adf052b39e0 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 @@ -417,4 +417,37 @@ public void testFormatTimestampValue() throws BigQueryJdbcException { assertThat(BigQueryTemporalUtility.formatTimestampValue("1680174859.123456789123", true)) .isEqualTo("2023-03-30 11:14:19.123456789123"); } + + @Test + public void testTruncateFractionalSeconds() { + assertThat(BigQueryTemporalUtility.truncateFractionalSeconds(null, 12)).isNull(); + assertThat(BigQueryTemporalUtility.truncateFractionalSeconds("2026-04-08 10:00:00", 12)) + .isEqualTo("2026-04-08 10:00:00"); + assertThat(BigQueryTemporalUtility.truncateFractionalSeconds("2026-04-08 10:00:00.123456", 12)) + .isEqualTo("2026-04-08 10:00:00.123456"); + assertThat( + BigQueryTemporalUtility.truncateFractionalSeconds( + "2026-04-08 10:00:00.123456789012", 12)) + .isEqualTo("2026-04-08 10:00:00.123456789012"); + assertThat( + BigQueryTemporalUtility.truncateFractionalSeconds( + "2026-04-08 10:00:00.123456789012345", 12)) + .isEqualTo("2026-04-08 10:00:00.123456789012"); + assertThat( + BigQueryTemporalUtility.truncateFractionalSeconds( + "2026-04-08 10:00:00.123456789012345+00:00", 12)) + .isEqualTo("2026-04-08 10:00:00.123456789012+00:00"); + assertThat( + BigQueryTemporalUtility.truncateFractionalSeconds( + "2026-04-08 10:00:00.123456789012345Z", 12)) + .isEqualTo("2026-04-08 10:00:00.123456789012Z"); + assertThat( + BigQueryTemporalUtility.truncateFractionalSeconds( + "2026-04-08 10:00:00.123456789012", 6)) + .isEqualTo("2026-04-08 10:00:00.123456"); + assertThat( + BigQueryTemporalUtility.truncateFractionalSeconds( + "2026-04-08 10:00:00.123456789012", 9)) + .isEqualTo("2026-04-08 10:00:00.123456789"); + } } From 6255e8c4f3d345970215ee91c5ae78186129768c Mon Sep 17 00:00:00 2001 From: Keshav Dandeva Date: Mon, 14 Sep 2026 15:25:30 +0000 Subject: [PATCH 2/2] address gemini feedback --- .../bigquery/jdbc/BigQueryParameterHandler.java | 5 ++++- .../jdbc/BigQueryParameterHandlerTest.java | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java index 6226de3b6cd9..3c2700915584 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandler.java @@ -104,7 +104,10 @@ static Object formatValueForQueryParameter( return formatTimestampParameter((Timestamp) parameterValue, enableTimestampPicos); } if (sqlType == StandardSQLTypeName.TIMESTAMP && parameterValue instanceof String) { - String str = ((String) parameterValue).trim().replace('T', ' '); + String str = ((String) parameterValue).trim(); + if (str.length() > 10 && str.charAt(10) == 'T') { + str = str.substring(0, 10) + ' ' + str.substring(11); + } return BigQueryTemporalUtility.truncateFractionalSeconds(str, enableTimestampPicos ? 12 : 6); } if (parameterValue instanceof Time) { diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java index c10e20d2da48..06cdf05c6aa2 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryParameterHandlerTest.java @@ -192,13 +192,26 @@ public void testFormatValueForQueryParameter_stringTimestampPrecision() { excessString, StandardSQLTypeName.TIMESTAMP, true); assertEquals("2024-01-01 12:34:56.123456789012", truncatedPicos); - // 'T' is replaced with space and whitespace is trimmed + // 'T' delimiter at index 10 is replaced with space and whitespace is trimmed String isoString = " 2024-01-01T12:34:56.123456789012 "; Object replacedT = BigQueryParameterHandler.formatValueForQueryParameter( isoString, StandardSQLTypeName.TIMESTAMP, true); assertEquals("2024-01-01 12:34:56.123456789012", replacedT); + // Characters matching 'T' in timezone names are not replaced + String timezoneWithT = " 2024-01-01T12:34:56.123456 America/Toronto "; + Object preservedTimezone = + BigQueryParameterHandler.formatValueForQueryParameter( + timezoneWithT, StandardSQLTypeName.TIMESTAMP, false); + assertEquals("2024-01-01 12:34:56.123456 America/Toronto", preservedTimezone); + + String utcTimezone = "2024-01-01 12:34:56.123456 UTC"; + Object preservedUtc = + BigQueryParameterHandler.formatValueForQueryParameter( + utcTimezone, StandardSQLTypeName.TIMESTAMP, false); + assertEquals("2024-01-01 12:34:56.123456 UTC", preservedUtc); + // When enableTimestampPicos is false, truncated to 6 digits Object microsFormatted = BigQueryParameterHandler.formatValueForQueryParameter(