From ec6b9251916e77a4b894a4d9a70fa684a7c7949a Mon Sep 17 00:00:00 2001 From: junhyeong9812 Date: Thu, 27 Aug 2026 22:36:03 +0900 Subject: [PATCH] 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");