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 <pickjog@gmail.com>
This commit is contained in:
junhyeong9812
2026-09-02 17:28:04 +02:00
committed by Sam Brannen
parent 751aa19671
commit ec6b925191
2 changed files with 98 additions and 2 deletions
@@ -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(
@@ -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");