From 751aa196711da193e662e20599f38891fa56ccef Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:16:14 +0200 Subject: [PATCH 1/4] Upgrade to backport-bot v0.0.3 --- .github/workflows/backport-bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From ec6b9251916e77a4b894a4d9a70fa684a7c7949a Mon Sep 17 00:00:00 2001 From: junhyeong9812 Date: Thu, 27 Aug 2026 22:36:03 +0900 Subject: [PATCH 2/4] Normalize function return parameter lookup in CallMetaDataContext CallMetaDataContext.reconcileParameters() keys the map of declared parameters by lowerCase(provider.parameterNameToUse(name)), but the branch that matches the return parameter reported by the database metadata did not apply the same rule. It looked up the function return name as declared (original case) and fell back to the first declared OUT parameter name with a plain toLowerCase(), without the provider transformation that strips the '@' prefix on SQL Server and Sybase. The first lookup therefore always missed on Oracle, so the fallback silently used whichever OUT parameter was declared first. Declaring an additional OUT parameter before the return parameter of a function made that parameter double as the return slot: the declared return parameter was dropped from the call parameters, the wrong parameter was bound at position 1, and executeFunction() returned the value of the other out parameter. On SQL Server, a procedure compiled with withReturnValue() and an '@'-prefixed OUT parameter declared before the return parameter failed with InvalidDataAccessApiUsageException because neither lookup could find the declared parameter. The return parameter branch now looks up the metadata-derived name first and normalizes both the function return name and the first OUT parameter fallback with the same rule as the declared parameter map. Tests cover both declaration orders for an Oracle function and for a SQL Server procedure with a return value. Closes gh-37206 Signed-off-by: junhyeong9812 --- .../core/metadata/CallMetaDataContext.java | 9 +- .../jdbc/core/simple/SimpleJdbcCallTests.java | 91 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) 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/SimpleJdbcCallTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcCallTests.java index c67c6e15eaf..045cc28a68b 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 @@ -267,6 +267,62 @@ class SimpleJdbcCallTests { } + @Test + 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 + 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 + 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 + 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 +406,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"); From ee7a0d48c56ac5de3294fdceef8c20971811d896 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:39:17 +0200 Subject: [PATCH 3/4] Polish contribution This commit introduces additional unit tests for CallMetaDataContext's function return parameter matching in reconcileParameters(), verifying that the declared return parameter is correctly resolved regardless of whether it is declared before or after an additional OUT parameter. See gh-37206 --- .../core/simple/CallMetaDataContextTests.java | 55 ++++++++++++++++++- .../jdbc/core/simple/SimpleJdbcCallTests.java | 9 ++- 2 files changed, 58 insertions(+), 6 deletions(-) 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 045cc28a68b..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,8 +266,7 @@ class SimpleJdbcCallTests { verify(procedureColumnsResultSet).close(); } - - @Test + @Test // gh-37206 void functionWithAdditionalOutParameterDeclaredBeforeReturn() throws Exception { initializeGetTotalFunctionWithMetaData(); SimpleJdbcCall function = new SimpleJdbcCall(dataSource).withFunctionName("get_total"); @@ -282,7 +281,7 @@ class SimpleJdbcCallTests { assertThat(total).isEqualTo(42); } - @Test + @Test // gh-37206 void functionWithAdditionalOutParameterDeclaredAfterReturn() throws Exception { initializeGetTotalFunctionWithMetaData(); SimpleJdbcCall function = new SimpleJdbcCall(dataSource).withFunctionName("get_total"); @@ -296,7 +295,7 @@ class SimpleJdbcCallTests { assertThat(total).isEqualTo(42); } - @Test + @Test // gh-37206 void sqlServerProcedureWithReturnValueDeclaredAfterOutParameter() throws Exception { initializeSqlServerProcedureWithReturnValue(); SimpleJdbcCall procedure = new SimpleJdbcCall(dataSource).withProcedureName("my_proc").withReturnValue(); @@ -309,7 +308,7 @@ class SimpleJdbcCallTests { verifyStatement(procedure, "{? = call my_proc(?, ?)}"); } - @Test + @Test // gh-37206 void sqlServerProcedureWithReturnValueDeclaredFirst() throws Exception { initializeSqlServerProcedureWithReturnValue(); SimpleJdbcCall procedure = new SimpleJdbcCall(dataSource).withProcedureName("my_proc").withReturnValue(); From 7a0612dd4fdef99043ece56b717674a57965f777 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:42:07 +0200 Subject: [PATCH 4/4] Polishing See gh-36789 --- .../ROOT/partials/web/web-data-binding-model-design.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 48d15ae40f7..43e28280ebf 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.