diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameter.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameter.java index cb11d14e46ab..123e75dff8dc 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameter.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameter.java @@ -20,14 +20,28 @@ import com.google.cloud.bigquery.jdbc.BigQueryParameterHandler.BigQueryStatementParameterType; class BigQueryJdbcParameter { + + // The parameter can be inferred from the user via setter methods along with values and + // just the parameter types from dryRuns. Keeping track of this metadata avoids repeated dryRuns. + enum Binding { + // Type and value unknown + UNBOUND, + // Parameter Type is known; inferred from dryRun or left behind by a cleared value. No value + // known. + TYPED, + // Parameter type and value set by user; its type outranks anything a dry run reports. + BOUND + } + private int index; private Object value; private Class type; private StandardSQLTypeName sqlType; // Additional parameters needed for CallableStatement. private String paramName; - private BigQueryStatementParameterType paramType; + private BigQueryStatementParameterType paramType = BigQueryStatementParameterType.UNSPECIFIED; private int scale; + private Binding binding = Binding.UNBOUND; BigQueryJdbcParameter() {} @@ -36,6 +50,10 @@ class BigQueryJdbcParameter { this.value = parameter.value; this.type = parameter.type; this.sqlType = parameter.sqlType; + this.paramName = parameter.paramName; + this.paramType = parameter.paramType; + this.scale = parameter.scale; + this.binding = parameter.binding; } int getIndex() { @@ -58,7 +76,7 @@ Class getType() { return type; } - public void setType(Class type) { + void setType(Class type) { this.type = type; } @@ -66,10 +84,6 @@ StandardSQLTypeName getSqlType() { return sqlType; } - void setSqlType(StandardSQLTypeName sqlType) { - this.sqlType = sqlType; - } - String getParamName() { return paramName; } @@ -94,6 +108,37 @@ void setScale(int scale) { this.scale = scale; } + boolean isBound() { + return binding == Binding.BOUND; + } + + Binding getBinding() { + return binding; + } + + boolean suggestType(Class javaType, StandardSQLTypeName bqType) { + if (binding == Binding.BOUND) { + return false; + } + this.type = javaType; + this.sqlType = bqType; + this.binding = Binding.TYPED; + return true; + } + + void bindType(Class javaType, StandardSQLTypeName bqType) { + this.type = javaType; + this.sqlType = bqType; + this.binding = Binding.BOUND; + } + + void clearValue() { + this.value = null; + if (binding == Binding.BOUND) { + this.binding = Binding.TYPED; + } + } + @Override public String toString() { return "BigQueryJdbcParameter{" @@ -112,6 +157,8 @@ public String toString() { + paramType.name() + ", scale=" + scale + + ", binding=" + + binding + '}'; } } 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 70719fd91a0a..9fba3fa840ae 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 @@ -32,7 +32,6 @@ class BigQueryParameterHandler { private final int parametersArraySize; private final boolean enableTimestampPicos; final ArrayList parametersList; - private long highestIndex = 0; BigQueryParameterHandler(int parameterCount) { this(parameterCount, false); @@ -64,24 +63,28 @@ enum BigQueryStatementParameterType { QueryJobConfiguration.Builder configureParameters( QueryJobConfiguration.Builder jobConfigurationBuilder) throws SQLException { LOG.finest("++enter++"); - try { - for (int i = 1; i <= this.parametersArraySize; i++) { - - Object parameterValue = getParameter(i); - StandardSQLTypeName sqlType = getSqlType(i); - parameterValue = - formatValueForQueryParameter(parameterValue, sqlType, this.enableTimestampPicos); - LOG.finest( - "Parameter %s of type %s at index %s added to QueryJobConfiguration", - parameterValue, sqlType, i); - jobConfigurationBuilder.addPositionalParameter( - QueryParameterValue.of(parameterValue, sqlType)); - } - } catch (NullPointerException e) { - LOG.severe("Null parameter mapping encountered.", e); - if (e.getMessage().contains("Null type")) { - throw new BigQueryJdbcException("One or more parameters missing in Prepared statement.", e); + for (int i = 1; i <= this.parametersArraySize; i++) { + + int arrayIndex = i - 1; + if (this.parametersList.size() <= arrayIndex + || this.parametersList.get(arrayIndex) == null + || !this.parametersList.get(arrayIndex).isBound()) { + throw new BigQueryJdbcException( + String.format( + "One or more parameters missing in Prepared statement. No value was set for" + + " parameter %d of %d.", + i, this.parametersArraySize)); } + + Object parameterValue = getParameter(i); + StandardSQLTypeName sqlType = getSqlType(i); + parameterValue = + formatValueForQueryParameter(parameterValue, sqlType, this.enableTimestampPicos); + LOG.finest( + "Parameter %s of type %s at index %s added to QueryJobConfiguration", + parameterValue, sqlType, i); + jobConfigurationBuilder.addPositionalParameter( + QueryParameterValue.of(parameterValue, sqlType)); } return jobConfigurationBuilder; } @@ -132,27 +135,61 @@ private static String formatTimestampParameter(Timestamp ts, boolean enableTimes return copy.toString(); } - void setParameter(int parameterIndex, Object value, Class type) - throws BigQueryJdbcSqlFeatureNotSupportedException { + private BigQueryJdbcParameter getOrCreateParameter(int parameterIndex) { + int arrayIndex = parameterIndex - 1; + parametersList.ensureCapacity(parameterIndex); + while (parametersList.size() < parameterIndex) { + parametersList.add(null); + } + BigQueryJdbcParameter parameter = parametersList.get(arrayIndex); + if (parameter == null) { + parameter = new BigQueryJdbcParameter(); + parameter.setIndex(parameterIndex); + parametersList.set(arrayIndex, parameter); + } + return parameter; + } + + // A null still needs a type, since BigQuery rejects an untyped one. Preference order is the type + // the caller named, then the type a dry run inferred for this slot, then STRING. + void setNullParameter(int parameterIndex, Class declaredJavaType) { LOG.finest("++enter++"); - LOG.finest("setParameter called by : %s", type.getName()); checkValidIndex(parameterIndex); - int arrayIndex = parameterIndex - 1; - if (parameterIndex >= this.highestIndex || this.parametersList.get(arrayIndex) == null) { - parametersList.ensureCapacity(parameterIndex); - while (parametersList.size() < parameterIndex) { - parametersList.add(null); + BigQueryJdbcParameter parameter = getOrCreateParameter(parameterIndex); + Class javaType = declaredJavaType; + StandardSQLTypeName sqlType; + if (javaType != null) { + // Keep the caller's class instead of round-tripping it through BigQuery, which would report + // an Integer back as a Long. + sqlType = BigQueryTypeRegistry.toBigQueryType(javaType); + } else { + sqlType = parameter.getSqlType(); + if (sqlType == null) { + sqlType = StandardSQLTypeName.STRING; } - parametersList.set(arrayIndex, new BigQueryJdbcParameter()); + javaType = BigQueryTypeRegistry.toJavaClass(sqlType); } - this.highestIndex = Math.max(parameterIndex, highestIndex); - BigQueryJdbcParameter parameter = parametersList.get(arrayIndex); + parameter.setIndex(parameterIndex); + parameter.setValue(null); + parameter.bindType(javaType, sqlType); + parameter.setParamName(""); + parameter.setParamType(BigQueryStatementParameterType.UNSPECIFIED); + parameter.setScale(-1); + + LOG.finest("Parameter set { %s }", parameter.toString()); + } + + void setParameter(int parameterIndex, Object value, Class type) { + LOG.finest("++enter++"); + LOG.finest("setParameter called by : %s", type.getName()); + checkValidIndex(parameterIndex); + + BigQueryJdbcParameter parameter = getOrCreateParameter(parameterIndex); parameter.setIndex(parameterIndex); parameter.setValue(value); - parameter.setType(type); - parameter.setSqlType(BigQueryTypeRegistry.toBigQueryType(type)); + parameter.bindType(type, BigQueryTypeRegistry.toBigQueryType(type)); parameter.setParamName(""); parameter.setParamType(BigQueryStatementParameterType.UNSPECIFIED); parameter.setScale(-1); @@ -160,6 +197,15 @@ void setParameter(int parameterIndex, Object value, Class type) LOG.finest("Parameter set { %s }", parameter.toString()); } + // Records the type BigQuery inferred for a placeholder. Subordinate to the caller: a bound value + // keeps its own type. + boolean setInferredParameterType(int parameterIndex, StandardSQLTypeName sqlTypeName) { + checkValidIndex(parameterIndex); + BigQueryJdbcParameter parameter = getOrCreateParameter(parameterIndex); + parameter.setIndex(parameterIndex); + return parameter.suggestType(BigQueryTypeRegistry.toJavaClass(sqlTypeName), sqlTypeName); + } + private void checkValidIndex(int parameterIndex) { if (parameterIndex > this.parametersArraySize) { IndexOutOfBoundsException ex = @@ -198,8 +244,11 @@ StandardSQLTypeName getSqlType(int index) { void clearParameters() { LOG.finest("++enter++"); - parametersList.clear(); - highestIndex = 0; + for (BigQueryJdbcParameter param : this.parametersList) { + if (param != null) { + param.clearValue(); + } + } } // set parameter by name and type @@ -208,8 +257,7 @@ void setParameter( Object value, Class type, BigQueryStatementParameterType paramType, - int scale) - throws BigQueryJdbcSqlFeatureNotSupportedException { + int scale) { LOG.finest("++enter++"); LOG.finest("setParameter called by : %s", type.getName()); if (paramName == null || paramName.isEmpty()) { @@ -231,11 +279,11 @@ void setParameter( parameter.setIndex(-1); } parameter.setValue(value); - parameter.setType(type); - parameter.setSqlType(BigQueryTypeRegistry.toBigQueryType(type)); + parameter.bindType(type, BigQueryTypeRegistry.toBigQueryType(type)); parameter.setParamName(paramName); parameter.setParamType(paramType); parameter.setScale(scale); + if (parameter.getIndex() == -1) { parametersList.add(parameter); } @@ -253,21 +301,12 @@ void setParameter( LOG.finest("++enter++"); LOG.finest("setParameter called by : %s", type.getName()); checkValidIndex(parameterIndex); - int arrayIndex = parameterIndex - 1; - if (parameterIndex >= this.highestIndex || this.parametersList.get(arrayIndex) == null) { - parametersList.ensureCapacity(parameterIndex); - while (parametersList.size() < parameterIndex) { - parametersList.add(null); - } - parametersList.set(arrayIndex, new BigQueryJdbcParameter()); - } - this.highestIndex = Math.max(parameterIndex, highestIndex); - BigQueryJdbcParameter parameter = parametersList.get(arrayIndex); + + BigQueryJdbcParameter parameter = getOrCreateParameter(parameterIndex); parameter.setIndex(parameterIndex); parameter.setValue(value); - parameter.setType(type); - parameter.setSqlType(BigQueryTypeRegistry.toBigQueryType(type)); + parameter.bindType(type, BigQueryTypeRegistry.toBigQueryType(type)); parameter.setParamName(""); parameter.setParamType(paramType); parameter.setScale(scale); 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 b7dd3465b224..118161f55f44 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 @@ -17,6 +17,7 @@ package com.google.cloud.bigquery.jdbc; import com.google.api.gax.retrying.RetrySettings; +import com.google.api.services.bigquery.model.QueryParameter; import com.google.cloud.bigquery.FieldList; import com.google.cloud.bigquery.JobStatistics.QueryStatistics; import com.google.cloud.bigquery.JobStatistics.QueryStatistics.StatementType; @@ -75,27 +76,109 @@ class BigQueryPreparedStatement extends BigQueryStatement implements PreparedSta private final BigQueryJdbcCustomLogger LOG = new BigQueryJdbcCustomLogger(this.toString()); private static final char POSITIONAL_PARAMETER_CHAR = '?'; // parameterHandler is inherited from BigQueryStatement - protected int parameterCount = 0; + protected final int parameterCount; protected String currentQuery; private Queue> batchParameters = new LinkedList<>(); - Schema insertSchema = null; + // Both hold statistics.getSchema(), which reports different things per statement type: the rows + // a SELECT returns, versus the columns an INSERT writes. + Schema resultSchema = null; // Feeds getMetaData(). + Schema insertSchema = null; // Feeds the Storage Write API. private TableName insertTableName = null; BigQueryPreparedStatement(BigQueryConnection connection, String query) { super(connection); - setCurrentQuery(query); + this.currentQuery = query; + QueryStatistics queryStatistics = describeQueryQuietly(); + this.parameterCount = resolveParameterCount(query, queryStatistics); this.parameterHandler = new BigQueryParameterHandler(this.parameterCount, this.isEnableTimestampPicos()); + if (queryStatistics != null) { + applyInferredParameterTypes(queryStatistics); + captureInsertMetadata(queryStatistics); + captureResultSchema(queryStatistics); + } } - void setCurrentQuery(String currentQuery) { - this.parameterCount = getParameterCount(currentQuery); - this.currentQuery = currentQuery; + private int resolveParameterCount(String query, QueryStatistics queryStatistics) { + if (queryStatistics != null && queryStatistics.getQueryParameters() != null) { + return queryStatistics.getQueryParameters().size(); + } + return (int) query.chars().filter(ch -> ch == POSITIONAL_PARAMETER_CHAR).count(); } - private int getParameterCount(String query) { - LOG.finer("++enter++"); - return (int) query.chars().filter(ch -> ch == POSITIONAL_PARAMETER_CHAR).count(); + /** + * Failures are logged and swallowed. Nothing a dry run supplies is required for correctness: the + * types are defaults the caller's {@code setXxx} calls refine, and the count falls back to a scan + * of the query text, so a statement stays usable against a service that refuses the dry run. + */ + private QueryStatistics describeQueryQuietly() { + if (this.currentQuery.indexOf(POSITIONAL_PARAMETER_CHAR) == -1) { + return null; + } + try { + return describePositionalParameterQuery(this.currentQuery); + } catch (SQLException | RuntimeException ex) { + LOG.warning( + ex, + "Could not describe query via dry run; parameter types will be derived from the values" + + " supplied by the caller, and the parameter count from the query text."); + return null; + } + } + + private void applyInferredParameterTypes(QueryStatistics statistics) { + List undeclaredParameters = statistics.getQueryParameters(); + if (undeclaredParameters == null) { + return; + } + int index = 1; + for (QueryParameter parameter : undeclaredParameters) { + if (parameter.getParameterType() != null) { + StandardSQLTypeName sqlType = toStandardSqlType(parameter.getParameterType().getType()); + if (sqlType != null) { + this.parameterHandler.setInferredParameterType(index, sqlType); + } + } + index++; + } + } + + // Returning null costs one parameter its inferred type; letting the exception propagate would + // abandon inference for every parameter after it. + private static StandardSQLTypeName toStandardSqlType(String typeName) { + if (typeName == null) { + return null; + } + try { + return StandardSQLTypeName.valueOf(typeName); + } catch (IllegalArgumentException ex) { + return null; + } + } + + // Remembers the table and schema of single-table INSERT targets, for the Storage Write API. + // Failure is delegated to executeBatch instead of the constructor. + private void captureInsertMetadata(QueryStatistics statistics) { + if (!StatementType.INSERT.equals(statistics.getStatementType()) + || statistics.getSchema() == null + || statistics.getReferencedTables() == null + || statistics.getReferencedTables().stream().distinct().count() != 1) { + return; + } + this.insertSchema = statistics.getSchema(); + TableId tableId = statistics.getReferencedTables().get(0); + this.insertTableName = + TableName.of(tableId.getProject(), tableId.getDataset(), tableId.getTable()); + LOG.finer("insertTableName: %s, insertSchema: %s", this.insertTableName, this.insertSchema); + } + + // This populates the schema of the ResultSet that is being returned by the query. + // The column names are as they are returned from the query dryRun and may not match the actual + // table column names. + private void captureResultSchema(QueryStatistics statistics) { + if (StatementType.SELECT.equals(statistics.getStatementType())) { + this.resultSchema = statistics.getSchema(); + } } @Override @@ -136,14 +219,22 @@ public boolean execute() throws SQLException { @Override public void clearParameters() { this.parameterHandler.clearParameters(); - this.parameterCount = 0; } @Override public void setNull(int parameterIndex, int sqlType) throws SQLException { checkClosed(); - Class javaType = BigQueryTypeRegistry.toJavaClass(sqlType); - this.parameterHandler.setParameter(parameterIndex, null, javaType); + this.parameterHandler.setNullParameter(parameterIndex, declaredJavaType(sqlType)); + } + + // Types.NULL is what setObject(index, null) synthesizes when the caller named no type, and + // Types.OTHER names none either. Both defer to whatever the slot already knows. + private static Class declaredJavaType(int jdbcType) + throws BigQueryJdbcSqlFeatureNotSupportedException { + if (jdbcType == Types.NULL || jdbcType == Types.OTHER) { + return null; + } + return BigQueryTypeRegistry.toJavaClass(jdbcType); } @Override @@ -323,23 +414,33 @@ public int[] executeBatch() throws SQLException { if (this.batchParameters.isEmpty()) { return result; } + + // The constructor's dry run may have happened before the target table existed. + if (this.insertSchema == null + && this.querySettings.isUseWriteAPI() + && this.batchParameters.size() >= this.querySettings.getWriteAPIActivationRowCount()) { + try { + captureInsertMetadata( + getQueryStatistics(getWriteBatchJobConfiguration(this.batchParameters.peek()))); + } catch (SQLException | RuntimeException ex) { + LOG.warning(ex, "Could not describe INSERT target; using the standard batch path."); + } + } + if (useWriteAPI()) { try (BigQueryWriteClient writeClient = this.connection.getBigQueryWriteClient()) { LOG.info("Using Write API for bulk INSERT operation."); - ArrayList currentParameterList = this.batchParameters.peek(); - if (this.insertSchema == null && this.insertTableName == null) { - QueryStatistics insertJobQueryStatistics = - getQueryStatistics(getWriteBatchJobConfiguration(currentParameterList)); - setInsertMetadata(insertJobQueryStatistics); - } long rowCount = bulkInsertWithWriteAPI(writeClient); int[] insertArray = new int[Math.toIntExact(rowCount)]; Arrays.fill(insertArray, 1); return insertArray; - } catch (DescriptorValidationException | IOException | InterruptedException e) { - throw new BigQueryJdbcRuntimeException("Failed to execute batch with Write API", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BigQueryJdbcRuntimeException("Interrupted during Write API batch", e); + } catch (DescriptorValidationException | IOException e) { + throw new BigQueryJdbcException("Failed to execute batch with Write API", e); } } else { @@ -367,6 +468,7 @@ public int[] executeBatch() throws SQLException { } return result; } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); throw new BigQueryJdbcRuntimeException("Interrupted during individual INSERT batch", ex); } catch (SQLException e) { throw new BigQueryJdbcException("SQL error during individual INSERT batch", e); @@ -446,24 +548,6 @@ static JsonObject createJsonRow( return rowObject; } - private void setInsertMetadata(QueryStatistics statistics) throws SQLException { - LOG.finer("++enter++"); - if (!statistics.getStatementType().equals(StatementType.INSERT) - || statistics.getSchema() == null - || statistics.getReferencedTables().stream().distinct().count() > 1) { - throw new BigQueryJdbcException( - "Use java.sql.Statement.executeBatch() for heterogeneous DML batches"); - } - - this.insertSchema = statistics.getSchema(); - TableId tableID = statistics.getReferencedTables().get(0); - this.insertTableName = - TableName.of(tableID.getProject(), tableID.getDataset(), tableID.getTable()); - LOG.finer( - "this.insertTableName : %s, this.insertSchema : %s", - this.insertTableName, this.insertSchema.toString()); - } - QueryJobConfiguration getWriteBatchJobConfiguration( ArrayList currentParameterList) throws SQLException { LOG.finer("++enter++"); @@ -501,12 +585,10 @@ QueryJobConfiguration getStandardBatchJobConfiguration(String query) throws SQLE Boolean useWriteAPI() { LOG.finer("++enter++"); - if (this.querySettings.isUseWriteAPI()) { - if (this.batchParameters.size() >= this.querySettings.getWriteAPIActivationRowCount()) { - return true; - } - } - return false; + return this.querySettings.isUseWriteAPI() + // the constructor dry run already confirmed a single-table INSERT + && this.insertSchema != null + && this.batchParameters.size() >= this.querySettings.getWriteAPIActivationRowCount(); } @Override @@ -539,10 +621,11 @@ public void setArray(int parameterIndex, Array value) throws SQLException { @Override public ResultSetMetaData getMetaData() throws SQLException { checkClosed(); - if (this.insertSchema != null) { - return BigQueryResultSetMetadata.of(this.insertSchema.getFields(), this); + // Null is the spec's answer for a statement that returns no rows. + if (this.resultSchema == null) { + return null; } - return null; + return BigQueryResultSetMetadata.of(this.resultSchema.getFields(), this); } @Override 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 15563426b0b6..ca8c2656189b 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 @@ -1901,4 +1901,10 @@ private void enqueueBufferError(BlockingQueue que private void enqueueBufferEndOfStream(BlockingQueue queue) { Uninterruptibles.putUninterruptibly(queue, BigQueryFieldValueListWrapper.ofEndOfStream(null)); } + + QueryStatistics describePositionalParameterQuery(String query) + throws BigQueryJdbcException, BigQueryJdbcSqlSyntaxErrorException { + LOG.finer("++enter++"); + return getQueryStatistics(getJobConfig(query).setParameterMode("POSITIONAL").build()); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameterTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameterTest.java index 5af6077a737e..3baf2bbfe09a 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameterTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcParameterTest.java @@ -17,6 +17,9 @@ package com.google.cloud.bigquery.jdbc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.cloud.bigquery.StandardSQLTypeName; import com.google.cloud.bigquery.jdbc.BigQueryParameterHandler.BigQueryStatementParameterType; @@ -37,8 +40,7 @@ public void testSettersAndGetters() { BigQueryJdbcParameter parameter = new BigQueryJdbcParameter(); parameter.setIndex(3); parameter.setValue("String Object"); - parameter.setType(String.class); - parameter.setSqlType(StandardSQLTypeName.STRING); + parameter.bindType(String.class, StandardSQLTypeName.STRING); parameter.setParamName("StringParameter"); parameter.setParamType(BigQueryStatementParameterType.IN); parameter.setScale(-1); @@ -50,6 +52,7 @@ public void testSettersAndGetters() { assertEquals(expectedParamName, parameter.getParamName()); assertEquals(expectedParamType, parameter.getParamType()); assertEquals(expectedScale, parameter.getScale()); + assertTrue(parameter.isBound()); } @Test @@ -62,8 +65,7 @@ public void testCopyConstructor() { BigQueryJdbcParameter parameter = new BigQueryJdbcParameter(); parameter.setIndex(3); parameter.setValue("String Object"); - parameter.setType(String.class); - parameter.setSqlType(StandardSQLTypeName.STRING); + parameter.bindType(String.class, StandardSQLTypeName.STRING); BigQueryJdbcParameter copiedParameter = new BigQueryJdbcParameter(parameter); @@ -71,5 +73,42 @@ public void testCopyConstructor() { assertEquals(expectedValue, copiedParameter.getValue()); assertEquals(expectedType, copiedParameter.getType()); assertEquals(expectedSqlType, copiedParameter.getSqlType()); + // A batched copy must carry its provenance, or inference could overwrite a caller's value. + assertTrue(copiedParameter.isBound()); + } + + @Test + public void testSuggestTypeYieldsToABoundParameter() { + BigQueryJdbcParameter parameter = new BigQueryJdbcParameter(); + parameter.bindType(String.class, StandardSQLTypeName.STRING); + + assertFalse(parameter.suggestType(Long.class, StandardSQLTypeName.INT64)); + assertEquals(String.class, parameter.getType()); + assertEquals(StandardSQLTypeName.STRING, parameter.getSqlType()); + } + + @Test + public void testSuggestTypeAppliesToAnUnboundParameter() { + BigQueryJdbcParameter parameter = new BigQueryJdbcParameter(); + + assertTrue(parameter.suggestType(Long.class, StandardSQLTypeName.INT64)); + assertEquals(Long.class, parameter.getType()); + assertEquals(StandardSQLTypeName.INT64, parameter.getSqlType()); + assertFalse(parameter.isBound()); + } + + @Test + public void testClearValueKeepsTheTypeAndDemotesTheBinding() { + BigQueryJdbcParameter parameter = new BigQueryJdbcParameter(); + parameter.setValue("String Object"); + parameter.bindType(String.class, StandardSQLTypeName.STRING); + + parameter.clearValue(); + + // The type survives so a later setNull on the same slot stays typed, but the slot is no longer + // bound, so the caller must supply a value again before execution. + assertNull(parameter.getValue()); + assertEquals(StandardSQLTypeName.STRING, parameter.getSqlType()); + assertFalse(parameter.isBound()); } } 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 b9a14147a428..204b92041f7b 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 @@ -17,6 +17,7 @@ package com.google.cloud.bigquery.jdbc; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -31,6 +32,7 @@ import com.google.cloud.bigquery.QueryJobConfiguration; import com.google.cloud.bigquery.Schema; import com.google.cloud.bigquery.StandardSQLTypeName; +import com.google.cloud.bigquery.exception.BigQueryJdbcException; import com.google.gson.Gson; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -61,6 +63,7 @@ public class BigQueryPreparedStatementSettersTest { public void setUp() throws Exception { connection = mock(BigQueryConnection.class); when(connection.getQueryDialect()).thenReturn("SQL"); + when(connection.getConnectionId()).thenReturn("test-connection-id"); doReturn(BigQueryConnection.SessionState.empty()).when(connection).getSessionStateSnapshot(); preparedStatement = new BigQueryPreparedStatement(connection, "SELECT ?, ?, ?, ?, ?"); @@ -245,11 +248,10 @@ public void testSetTimestampWithCalendar() throws Exception { @Test public void testGetMetaData() throws Exception { - // Before execution/insertSchema initialization, getMetaData() returns null + // Until a dry run describes the query, there are no result columns to report. assertNull(preparedStatement.getMetaData()); - // When insertSchema is present, getMetaData() returns ResultSetMetaData - preparedStatement.insertSchema = + preparedStatement.resultSchema = Schema.of( Field.of("col1", StandardSQLTypeName.STRING), Field.of("col2", StandardSQLTypeName.INT64)); @@ -263,6 +265,15 @@ public void testGetMetaData() throws Exception { assertEquals(Types.BIGINT, metaData.getColumnType(2)); } + @Test + public void testGetMetaDataReturnsNullForInsert() throws Exception { + // An INSERT returns no ResultSet, so the columns it writes must not be reported as result + // metadata even though the Storage Write API path has captured them. + preparedStatement.insertSchema = Schema.of(Field.of("col1", StandardSQLTypeName.STRING)); + + assertNull(preparedStatement.getMetaData()); + } + @Test public void testSetObjectWithJavaTime() throws Exception { LocalDate localDate = LocalDate.of(2025, 12, 3); @@ -329,6 +340,35 @@ public void testCreateJsonRowWithSetObjectNull() throws Exception { assertEquals("42", jsonRow.get("col2").getAsString()); } + @Test + public void testInferredParameterTypeKnownBeforeSetters() throws Exception { + preparedStatement = new BigQueryPreparedStatement(connection, "SELECT ?"); + + // 1. Inferred type is known immediately without calling setInt/setString + preparedStatement.parameterHandler.setInferredParameterType(1, StandardSQLTypeName.INT64); + + ParameterMetaData pmd = preparedStatement.getParameterMetaData(); + assertEquals(Types.BIGINT, pmd.getParameterType(1)); + assertEquals("INT64", pmd.getParameterTypeName(1)); + + // 2. configureParameters fails before value is supplied + QueryJobConfiguration.Builder configBuilder = QueryJobConfiguration.newBuilder("SELECT ?"); + BigQueryJdbcException ex = + assertThrows( + BigQueryJdbcException.class, + () -> preparedStatement.parameterHandler.configureParameters(configBuilder)); + assertTrue(ex.getMessage().contains("One or more parameters missing")); + // 3. Once setter is called, configureParameters succeeds and populates QueryJobConfiguration + + preparedStatement.setLong(1, 42L); + assertDoesNotThrow(() -> preparedStatement.parameterHandler.configureParameters(configBuilder)); + + QueryJobConfiguration config = configBuilder.build(); + assertEquals(1, config.getPositionalParameters().size()); + assertEquals("42", config.getPositionalParameters().get(0).getValue()); + assertEquals(StandardSQLTypeName.INT64, config.getPositionalParameters().get(0).getType()); + } + @Test public void testSetObjectWithTimestampStringAndTypesTimestamp_picosEnabled() throws Exception { BigQueryConnection picosConnection = mock(BigQueryConnection.class); 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 ea0799e2aea3..10c24bc34b0e 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 @@ -1022,7 +1022,8 @@ public void testPreparedExecuteMethod() throws SQLException { insertStmt.setShort(10, (short) 34); insertStmt.setBytes(11, new byte[] {0x3, 0x4}); insertStmt.setObject(12, 6.28d); - insertStmt.setObject(13, false); + // calling setObject with null value without Type should now work with inferred Types. + insertStmt.setObject(13, null); insertStmt.setNull(14, Types.VARCHAR, "STRING"); boolean insertStatus = insertStmt.execute(); @@ -1048,6 +1049,91 @@ public void testPreparedExecuteMethod() throws SQLException { assertFalse(dropStatus); } + @Test + public void testPreparedQueryWithExtraPositionalParameterCharacter() throws SQLException { + String TABLE_NAME = "JDBC_PREPARED_EXTRA_PARAM_TABLE_" + randomNumber; + String createQuery = + String.format( + "CREATE OR REPLACE TABLE %s.%s (`StringField` STRING, `IntegerField` INTEGER, `ShortField` INT64, `BytesField` BYTES, `DoubleField` FLOAT64, `BooleanField` BOOL, `NullField` STRING);", + DATASET, TABLE_NAME); + String insertQuery = + String.format( + "INSERT INTO %s.%s (StringField, IntegerField, ShortField, BytesField, DoubleField, BooleanField, NullField) VALUES (?,?,?,?,?,?,?), (?,?,?,?,?,?,?);", + DATASET, TABLE_NAME); + String dropQuery = String.format("DROP TABLE %s.%s", DATASET, TABLE_NAME); + + // This query would report an incorrect parameter count if dryRun is not used to infer parameter + // count + String selectQuery = + String.format("SELECT 'Hello, ?World!' AS message, ? FROM %s.%s", DATASET, TABLE_NAME); + + boolean createStatus = bigQueryStatement.execute(createQuery); + assertFalse(createStatus); + + PreparedStatement selectStmt = bigQueryConnection.prepareStatement(selectQuery); + + // Tests that parameter Metadata is populated before query execution and setter. + ParameterMetaData parameterMetaData = selectStmt.getParameterMetaData(); + assertNotNull(parameterMetaData); + assertEquals(1, parameterMetaData.getParameterCount()); + + // Tests that ResultSet Schema is populated before query execution + ResultSetMetaData resultSetMetaData = selectStmt.getMetaData(); + assertEquals("message", resultSetMetaData.getColumnName(1)); + assertEquals(Types.NVARCHAR, resultSetMetaData.getColumnType(1)); + + selectStmt.setString(1, "StringField"); + ResultSet selectResult2 = selectStmt.executeQuery(); + assertNotNull(selectResult2); + + boolean dropStatus = bigQueryStatement.execute(dropQuery); + assertFalse(dropStatus); + } + + @Test + public void testPreparedInferredParameterTypes() throws SQLException { + + String TABLE_NAME = "JDBC_PREPARED_PARAMETER_INFER_TABLE_" + randomNumber; + String createQuery = + String.format( + "CREATE OR REPLACE TABLE %s.%s (`StringField` STRING, `IntegerField` INTEGER, `BytesField` BYTES, `DoubleField` FLOAT64, `BooleanField` BOOL, `NumericField` NUMERIC, " + + "`BigNumericField` BIGNUMERIC, `DateField` DATE, `TimeField` TIME, `DateTimeField` DATETIME, `TimestampField` TIMESTAMP, `ArrayField` ARRAY, `StructField` STRUCT, " + + "`JsonField` JSON, `GeographyField` GEOGRAPHY, `IntervalField` INTERVAL, `RangeField` RANGE);", + DATASET, TABLE_NAME); + String insertQuery = + String.format( + "INSERT INTO %s.%s (StringField, IntegerField, BytesField, DoubleField, BooleanField, NumericField, BigNumericField, " + + "DateField, TimeField, DateTimeField, TimestampField, ArrayField, StructField, JsonField, GeographyField, IntervalField, RangeField) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);", + DATASET, TABLE_NAME); + + String dropQuery = String.format("DROP TABLE %s.%s", DATASET, TABLE_NAME); + int[] expectedValues = { + -9, -5, -3, 8, 16, 2, 2, 91, 92, 93, 93, 2003, 2002, 1111, 1111, 1111, 1111 + }; + + boolean createStatus = bigQueryStatement.execute(createQuery); + assertFalse(createStatus); + + PreparedStatement insertStmt = bigQueryConnection.prepareStatement(insertQuery); + ParameterMetaData parameterMetaData = insertStmt.getParameterMetaData(); + for (int i = 0; i < parameterMetaData.getParameterCount(); i++) { + assertEquals(expectedValues[i], parameterMetaData.getParameterType(i + 1)); + } + + // Testing an Exception is thrown if not all values are set. + insertStmt.setString(1, "String1"); + insertStmt.setInt(2, 111); + insertStmt.setObject(4, 1.5); + insertStmt.setObject(6, true, Types.BOOLEAN); + insertStmt.setNull(7, Types.VARCHAR); + + assertThrows(BigQueryJdbcException.class, insertStmt::execute); + + boolean dropStatus = bigQueryStatement.execute(dropQuery); + assertFalse(dropStatus); + } + @Test public void testPreparedStatementThrowsSyntaxError() throws SQLException { String TABLE_NAME = "JDBC_PREPARED_SYNTAX_ERR_TABLE_" + randomNumber;