diff --git a/.github/workflows/backport-bot.yml b/.github/workflows/backport-bot.yml index 8def7183d7f..aa1ab912125 100644 --- a/.github/workflows/backport-bot.yml +++ b/.github/workflows/backport-bot.yml @@ -16,6 +16,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Create Backport Issue - uses: spring-io/backport-bot@v0.0.2 + uses: spring-io/backport-bot@v0.0.3 with: token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/framework-docs/modules/ROOT/partials/web/web-data-binding-model-design.adoc b/framework-docs/modules/ROOT/partials/web/web-data-binding-model-design.adoc index b0c8803fed1..d9f2894fd35 100644 --- a/framework-docs/modules/ROOT/partials/web/web-data-binding-model-design.adoc +++ b/framework-docs/modules/ROOT/partials/web/web-data-binding-model-design.adoc @@ -42,7 +42,7 @@ wildcard; this means you can constrain binding more precisely: * `"addresses[0].city"` matches the `city` property of the element at index `0` in the `addresses` array or `List`. * `"map[key]"` matches the entry associated with `key` in the `map` property. * `"map*"` matches every entry in the `map` property, such as `"map[key1]"` and `"map[key2]"`. - the same wildcard syntax also applies to indexed elements in an array or `List`. + The same wildcard syntax also applies to indexed elements in an array or `List`. See the {spring-framework-api}/validation/DataBinder.html#setAllowedFields(java.lang.String...)[`DataBinder#setAllowedFields`] javadoc for further details on the supported pattern syntax. diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/CallMetaDataContext.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/CallMetaDataContext.java index 49ff96b0cfc..c79119aa021 100755 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/CallMetaDataContext.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/CallMetaDataContext.java @@ -374,9 +374,14 @@ public class CallMetaDataContext { if (declaredParams.containsKey(paramNameToCheck) || (meta.isReturnParameter() && returnDeclared)) { SqlParameter param; if (meta.isReturnParameter()) { - param = declaredParams.get(getFunctionReturnName()); + // Same normalization as the declaredParams keys above; the function + // return name may have been adopted from a declared out parameter + param = declaredParams.get(paramNameToCheck); + if (param == null) { + param = declaredParams.get(lowerCase(provider.parameterNameToUse(getFunctionReturnName()))); + } if (param == null && !getOutParameterNames().isEmpty()) { - param = declaredParams.get(getOutParameterNames().get(0).toLowerCase(Locale.ROOT)); + param = declaredParams.get(lowerCase(provider.parameterNameToUse(getOutParameterNames().get(0)))); } if (param == null) { throw new InvalidDataAccessApiUsageException( diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/CallMetaDataContextTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/CallMetaDataContextTests.java index fcca55103b3..db5e6c4a731 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/CallMetaDataContextTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/CallMetaDataContextTests.java @@ -18,6 +18,8 @@ package org.springframework.jdbc.core.simple; import java.sql.Connection; import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; import java.util.List; @@ -41,9 +43,10 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; /** - * Mock object based tests for CallMetaDataContext. + * Mock object based tests for {@link CallMetaDataContext}. * * @author Thomas Risberg + * @author Sam Brannen */ class CallMetaDataContextTests { @@ -103,4 +106,54 @@ class CallMetaDataContextTests { assertThat(callParameters).as("Wrong number of call parameters").hasSize(3); } + @Test // gh-37206 + void reconcileParametersMatchesFunctionReturnParameterDeclaredBeforeOutParameter() throws Exception { + initializeGetTotalFunctionMetaData(); + + List parameters = List.of( + new SqlOutParameter("RESULT", Types.INTEGER), + new SqlOutParameter("out_status", Types.INTEGER)); + + context.setFunction(true); + context.setProcedureName("GET_TOTAL"); + context.initializeMetaData(dataSource); + context.processParameters(parameters); + + assertThat(context.getCallParameters()).extracting(SqlParameter::getName) + .containsExactly("RESULT", "AMOUNT", "out_status"); + } + + @Test // gh-37206 + void reconcileParametersMatchesFunctionReturnParameterDeclaredAfterOutParameter() throws Exception { + initializeGetTotalFunctionMetaData(); + + List parameters = List.of( + new SqlOutParameter("out_status", Types.INTEGER), + new SqlOutParameter("RESULT", Types.INTEGER)); + + context.setFunction(true); + context.setProcedureName("GET_TOTAL"); + context.initializeMetaData(dataSource); + context.processParameters(parameters); + + assertThat(context.getCallParameters()).extracting(SqlParameter::getName) + .containsExactly("RESULT", "AMOUNT", "out_status"); + } + + private void initializeGetTotalFunctionMetaData() throws SQLException { + ResultSet proceduresResultSet = mock(); + ResultSet procedureColumnsResultSet = mock(); + given(databaseMetaData.getDatabaseProductName()).willReturn("Oracle"); + given(databaseMetaData.getUserName()).willReturn("ME"); + given(databaseMetaData.storesUpperCaseIdentifiers()).willReturn(true); + given(databaseMetaData.getProcedures("", "ME", "GET_TOTAL")).willReturn(proceduresResultSet); + given(databaseMetaData.getProcedureColumns("", "ME", "GET_TOTAL", null)).willReturn(procedureColumnsResultSet); + given(proceduresResultSet.next()).willReturn(true, false); + given(proceduresResultSet.getString("PROCEDURE_NAME")).willReturn("GET_TOTAL"); + given(procedureColumnsResultSet.next()).willReturn(true, true, true, false); + given(procedureColumnsResultSet.getInt("DATA_TYPE")).willReturn(Types.INTEGER); + given(procedureColumnsResultSet.getString("COLUMN_NAME")).willReturn(null, "amount", "out_status"); + given(procedureColumnsResultSet.getInt("COLUMN_TYPE")).willReturn(5, 1, 4); + } + } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcCallTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcCallTests.java index c67c6e15eaf..00910128223 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcCallTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcCallTests.java @@ -266,6 +266,61 @@ class SimpleJdbcCallTests { verify(procedureColumnsResultSet).close(); } + @Test // gh-37206 + void functionWithAdditionalOutParameterDeclaredBeforeReturn() throws Exception { + initializeGetTotalFunctionWithMetaData(); + SimpleJdbcCall function = new SimpleJdbcCall(dataSource).withFunctionName("get_total"); + function.declareParameters( + new SqlOutParameter("out_status", Types.INTEGER), + new SqlOutParameter("RESULT", Types.INTEGER)); + function.compile(); + assertThat(function.getCallParameters()).extracting(SqlParameter::getName) + .containsExactly("RESULT", "AMOUNT", "out_status"); + verifyStatement(function, "{? = call GET_TOTAL(?, ?)}"); + Integer total = function.executeFunction(Integer.class, 5); + assertThat(total).isEqualTo(42); + } + + @Test // gh-37206 + void functionWithAdditionalOutParameterDeclaredAfterReturn() throws Exception { + initializeGetTotalFunctionWithMetaData(); + SimpleJdbcCall function = new SimpleJdbcCall(dataSource).withFunctionName("get_total"); + function.declareParameters( + new SqlOutParameter("RESULT", Types.INTEGER), + new SqlOutParameter("out_status", Types.INTEGER)); + function.compile(); + assertThat(function.getCallParameters()).extracting(SqlParameter::getName) + .containsExactly("RESULT", "AMOUNT", "out_status"); + Integer total = function.executeFunction(Integer.class, 5); + assertThat(total).isEqualTo(42); + } + + @Test // gh-37206 + void sqlServerProcedureWithReturnValueDeclaredAfterOutParameter() throws Exception { + initializeSqlServerProcedureWithReturnValue(); + SimpleJdbcCall procedure = new SimpleJdbcCall(dataSource).withProcedureName("my_proc").withReturnValue(); + procedure.declareParameters( + new SqlOutParameter("@out_total", Types.INTEGER), + new SqlOutParameter("RETURN_VALUE", Types.INTEGER)); + procedure.compile(); + assertThat(procedure.getCallParameters()).extracting(SqlParameter::getName) + .containsExactly("RETURN_VALUE", "amount", "@out_total"); + verifyStatement(procedure, "{? = call my_proc(?, ?)}"); + } + + @Test // gh-37206 + void sqlServerProcedureWithReturnValueDeclaredFirst() throws Exception { + initializeSqlServerProcedureWithReturnValue(); + SimpleJdbcCall procedure = new SimpleJdbcCall(dataSource).withProcedureName("my_proc").withReturnValue(); + procedure.declareParameters( + new SqlOutParameter("RETURN_VALUE", Types.INTEGER), + new SqlOutParameter("@out_total", Types.INTEGER)); + procedure.compile(); + assertThat(procedure.getCallParameters()).extracting(SqlParameter::getName) + .containsExactly("RETURN_VALUE", "amount", "@out_total"); + verifyStatement(procedure, "{? = call my_proc(?, ?)}"); + } + private void verifyStatement(SimpleJdbcCall adder, String expected) { assertThat(adder.getCallString()).as("Incorrect call statement").isEqualTo(expected); @@ -350,6 +405,41 @@ class SimpleJdbcCallTests { verify(procedureColumnsResultSet).close(); } + private void initializeGetTotalFunctionWithMetaData() throws SQLException { + ResultSet proceduresResultSet = mock(); + ResultSet procedureColumnsResultSet = mock(); + given(databaseMetaData.getDatabaseProductName()).willReturn("Oracle"); + given(databaseMetaData.getUserName()).willReturn("ME"); + given(databaseMetaData.storesUpperCaseIdentifiers()).willReturn(true); + given(databaseMetaData.getProcedures("", "ME", "GET_TOTAL")).willReturn(proceduresResultSet); + given(databaseMetaData.getProcedureColumns("", "ME", "GET_TOTAL", null)).willReturn(procedureColumnsResultSet); + given(proceduresResultSet.next()).willReturn(true, false); + given(proceduresResultSet.getString("PROCEDURE_NAME")).willReturn("get_total"); + given(procedureColumnsResultSet.next()).willReturn(true, true, true, false); + given(procedureColumnsResultSet.getInt("DATA_TYPE")).willReturn(4); + given(procedureColumnsResultSet.getString("COLUMN_NAME")).willReturn(null, "amount", "out_status"); + given(procedureColumnsResultSet.getInt("COLUMN_TYPE")).willReturn(5, 1, 4); + given(connection.prepareCall("{? = call GET_TOTAL(?, ?)}")).willReturn(callableStatement); + given(callableStatement.execute()).willReturn(false); + given(callableStatement.getUpdateCount()).willReturn(-1); + given(callableStatement.getObject(1)).willReturn(42); + given(callableStatement.getObject(3)).willReturn(7); + } + + private void initializeSqlServerProcedureWithReturnValue() throws SQLException { + ResultSet proceduresResultSet = mock(); + ResultSet procedureColumnsResultSet = mock(); + given(databaseMetaData.getDatabaseProductName()).willReturn("Microsoft SQL Server"); + given(databaseMetaData.getProcedures(null, null, "my_proc")).willReturn(proceduresResultSet); + given(databaseMetaData.getProcedureColumns(null, null, "my_proc", null)).willReturn(procedureColumnsResultSet); + given(proceduresResultSet.next()).willReturn(true, false); + given(proceduresResultSet.getString("PROCEDURE_NAME")).willReturn("my_proc"); + given(procedureColumnsResultSet.next()).willReturn(true, true, true, false); + given(procedureColumnsResultSet.getInt("DATA_TYPE")).willReturn(4); + given(procedureColumnsResultSet.getString("COLUMN_NAME")).willReturn("@RETURN_VALUE", "@amount", "@out_total"); + given(procedureColumnsResultSet.getInt("COLUMN_TYPE")).willReturn(5, 1, 4); + } + @Test void correctSybaseFunctionStatementNamed() throws Exception { given(databaseMetaData.getDatabaseProductName()).willReturn("Sybase");