From db1e3588001dee30c05cb045337b800cc5ec1a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Fri, 2 Oct 2026 23:38:17 +0300 Subject: [PATCH 1/2] Fix ClassCastException with pooled connections in DefaultOutputBuffer Fixes #110 Connection pools such as HikariCP return proxy statements that are not instances of OracleCallableStatement, so fetching reporter output with a pooled connection failed in DefaultOutputBuffer.getLinesCursorStatement. The cast was never needed, because only standard CallableStatement methods are used. It is replaced with a plain CallableStatement, the same way CompatibilityOutputBufferPre310 already does it. Also close the statement created in Reporter.initDbReporter. It was never closed, so every Reporter.init leaked an open cursor. On long-lived pooled connections this can lead to ORA-01000. Add unit tests for both cases using mocked, non-Oracle JDBC objects. --- .../api/outputBuffer/DefaultOutputBuffer.java | 3 +- .../org/utplsql/api/reporter/Reporter.java | 11 ++-- .../outputBuffer/DefaultOutputBufferTest.java | 66 +++++++++++++++++++ .../api/reporter/ReporterInitTest.java | 39 +++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java create mode 100644 src/test/java/org/utplsql/api/reporter/ReporterInitTest.java diff --git a/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java b/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java index 2c31e5e..543a240 100644 --- a/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java +++ b/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java @@ -1,6 +1,5 @@ package org.utplsql.api.outputBuffer; -import oracle.jdbc.OracleCallableStatement; import oracle.jdbc.OracleTypes; import org.utplsql.api.reporter.Reporter; @@ -34,7 +33,7 @@ protected CallableStatement getLinesCursorStatement(Connection conn) throws SQLE " l_rep.set_reporter_id(:reporter_id); " + " :lines_cursor := l_rep.get_lines_cursor(); " + "end;"; - OracleCallableStatement cstmt = (OracleCallableStatement) conn.prepareCall(plsql); + CallableStatement cstmt = conn.prepareCall(plsql); cstmt.setString("reporter_id", reporter.getId()); cstmt.registerOutParameter("lines_cursor", OracleTypes.CURSOR); return cstmt; diff --git a/src/main/java/org/utplsql/api/reporter/Reporter.java b/src/main/java/org/utplsql/api/reporter/Reporter.java index 6e91062..f48b3bf 100644 --- a/src/main/java/org/utplsql/api/reporter/Reporter.java +++ b/src/main/java/org/utplsql/api/reporter/Reporter.java @@ -67,13 +67,14 @@ public Reporter init(Connection con) throws SQLException { * @throws SQLException if there are problems with the database access */ private void initDbReporter(OracleConnection oraConn, ReporterFactory reporterFactory) throws SQLException { - OracleCallableStatement callableStatement = (OracleCallableStatement) oraConn.prepareCall("{? = call " + selfType + "()}"); - callableStatement.registerOutParameter(1, OracleTypes.STRUCT, "UT_REPORTER_BASE"); - callableStatement.execute(); + try (OracleCallableStatement callableStatement = (OracleCallableStatement) oraConn.prepareCall("{? = call " + selfType + "()}")) { + callableStatement.registerOutParameter(1, OracleTypes.STRUCT, "UT_REPORTER_BASE"); + callableStatement.execute(); - Reporter obj = (Reporter) callableStatement.getORAData(1, reporterFactory); + Reporter obj = (Reporter) callableStatement.getORAData(1, reporterFactory); - setAttributes(obj.getAttributes()); + setAttributes(obj.getAttributes()); + } logger.debug("Database-reporter initialized, Type: {}, ID: {}", selfType, id); } diff --git a/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java b/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java new file mode 100644 index 0000000..c665a6f --- /dev/null +++ b/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java @@ -0,0 +1,66 @@ +package org.utplsql.api.outputBuffer; + +import oracle.jdbc.OracleTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.utplsql.api.reporter.Reporter; + +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Connection pools (e.g. HikariCP) return their own proxy statements, which are not + * {@link oracle.jdbc.OracleCallableStatement} instances. The output buffer must work with plain JDBC statements. + */ +class DefaultOutputBufferTest { + + private Reporter reporter; + private Connection connection; + private CallableStatement callableStatement; + + @BeforeEach + void setUp() throws SQLException { + reporter = mock(Reporter.class); + when(reporter.isInit()).thenReturn(true); + when(reporter.getTypeName()).thenReturn("UT_DOCUMENTATION_REPORTER"); + when(reporter.getId()).thenReturn("ABC123"); + + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getString("text")).thenReturn("line 1", "line 2"); + + // A plain CallableStatement, as returned by a connection pool proxy + callableStatement = mock(CallableStatement.class); + when(callableStatement.getObject("lines_cursor")).thenReturn(resultSet); + + connection = mock(Connection.class); + when(connection.prepareCall(anyString())).thenReturn(callableStatement); + } + + @Test + void fetchAllWorksWithNonOracleCallableStatement() throws SQLException { + List lines = new DefaultOutputBuffer(reporter).fetchAll(connection); + + assertThat(lines, contains("line 1", "line 2")); + verify(callableStatement).setString("reporter_id", "ABC123"); + verify(callableStatement).registerOutParameter("lines_cursor", OracleTypes.CURSOR); + verify(callableStatement).close(); + } + + @Test + void printAvailableWorksWithNonOracleCallableStatement() throws SQLException { + new DefaultOutputBuffer(reporter).setFetchSize(1).printAvailable(connection, mock(java.io.PrintStream.class)); + + verify(callableStatement).setFetchSize(1); + verify(callableStatement).execute(); + verify(callableStatement).close(); + } +} diff --git a/src/test/java/org/utplsql/api/reporter/ReporterInitTest.java b/src/test/java/org/utplsql/api/reporter/ReporterInitTest.java new file mode 100644 index 0000000..9b2ffce --- /dev/null +++ b/src/test/java/org/utplsql/api/reporter/ReporterInitTest.java @@ -0,0 +1,39 @@ +package org.utplsql.api.reporter; + +import oracle.jdbc.OracleCallableStatement; +import oracle.jdbc.OracleConnection; +import org.junit.jupiter.api.Test; +import org.utplsql.api.compatibility.CompatibilityProxy; + +import java.sql.Connection; +import java.sql.SQLException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ReporterInitTest { + + @Test + void initClosesReporterStatement() throws SQLException { + OracleCallableStatement callableStatement = mock(OracleCallableStatement.class); + Reporter dbReporter = new DefaultReporter("UT_DOCUMENTATION_REPORTER", + new Object[]{"UT_DOCUMENTATION_REPORTER", new byte[]{0x0A, 0x0B}}); + when(callableStatement.getORAData(eq(1), any())).thenReturn(dbReporter); + + OracleConnection oracleConnection = mock(OracleConnection.class); + when(oracleConnection.prepareCall(anyString())).thenReturn(callableStatement); + + // Connection as handed out by a connection pool + Connection connection = mock(Connection.class); + when(connection.unwrap(OracleConnection.class)).thenReturn(oracleConnection); + + Reporter reporter = new DefaultReporter("UT_DOCUMENTATION_REPORTER", null) + .init(connection, mock(CompatibilityProxy.class), new ReporterFactory()); + + assertThat(reporter.isInit(), equalTo(true)); + assertThat(reporter.getId(), equalTo("0A0B")); + verify(callableStatement).close(); + } +} From d623cffe5cf641425337846686c7069982c2cb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20G=C4=99bal?= Date: Sat, 3 Oct 2026 00:18:56 +0300 Subject: [PATCH 2/2] The statement preparation and binding for both output buffers now live in a shared helper in AbstractOutputBuffer. The helper closes the statement if binding fails, so it no longer leaks on that error path. --- .../outputBuffer/AbstractOutputBuffer.java | 22 +++++++++++++++++++ .../CompatibilityOutputBufferPre310.java | 6 +---- .../api/outputBuffer/DefaultOutputBuffer.java | 6 +---- .../outputBuffer/DefaultOutputBufferTest.java | 14 ++++++++++++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/utplsql/api/outputBuffer/AbstractOutputBuffer.java b/src/main/java/org/utplsql/api/outputBuffer/AbstractOutputBuffer.java index 86f8e68..13608ff 100644 --- a/src/main/java/org/utplsql/api/outputBuffer/AbstractOutputBuffer.java +++ b/src/main/java/org/utplsql/api/outputBuffer/AbstractOutputBuffer.java @@ -1,5 +1,6 @@ package org.utplsql.api.outputBuffer; +import oracle.jdbc.OracleTypes; import org.utplsql.api.reporter.Reporter; import java.io.PrintStream; @@ -79,6 +80,27 @@ public void printAvailable(Connection conn, List printStreams) thro protected abstract CallableStatement getLinesCursorStatement(Connection conn) throws SQLException; + /** + * Prepares the given PL/SQL block and binds the :reporter_id and :lines_cursor parameters. + * The returned statement must be closed by the caller. It is closed here if binding fails. + * + * @param conn DB connection + * @param plsql PL/SQL block using the :reporter_id and :lines_cursor bind variables + * @return the prepared statement + * @throws SQLException any sql errors + */ + protected CallableStatement prepareLinesCursorStatement(Connection conn, String plsql) throws SQLException { + CallableStatement cstmt = conn.prepareCall(plsql); + try { + cstmt.setString("reporter_id", reporter.getId()); + cstmt.registerOutParameter("lines_cursor", OracleTypes.CURSOR); + return cstmt; + } catch (SQLException | RuntimeException e) { + cstmt.close(); + throw e; + } + } + /** * Print the lines as soon as they are produced and call the callback passing the new line. * diff --git a/src/main/java/org/utplsql/api/outputBuffer/CompatibilityOutputBufferPre310.java b/src/main/java/org/utplsql/api/outputBuffer/CompatibilityOutputBufferPre310.java index ad080e3..6fd6025 100644 --- a/src/main/java/org/utplsql/api/outputBuffer/CompatibilityOutputBufferPre310.java +++ b/src/main/java/org/utplsql/api/outputBuffer/CompatibilityOutputBufferPre310.java @@ -1,6 +1,5 @@ package org.utplsql.api.outputBuffer; -import oracle.jdbc.OracleTypes; import org.utplsql.api.reporter.Reporter; import java.sql.CallableStatement; @@ -20,9 +19,6 @@ class CompatibilityOutputBufferPre310 extends AbstractOutputBuffer { @Override protected CallableStatement getLinesCursorStatement(Connection conn) throws SQLException { - CallableStatement cstmt = conn.prepareCall("begin :lines_cursor := ut_output_buffer.get_lines_cursor(:reporter_id); end;"); - cstmt.registerOutParameter("lines_cursor", OracleTypes.CURSOR); - cstmt.setString("reporter_id", getReporter().getId()); - return cstmt; + return prepareLinesCursorStatement(conn, "begin :lines_cursor := ut_output_buffer.get_lines_cursor(:reporter_id); end;"); } } diff --git a/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java b/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java index 543a240..9bb212e 100644 --- a/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java +++ b/src/main/java/org/utplsql/api/outputBuffer/DefaultOutputBuffer.java @@ -1,6 +1,5 @@ package org.utplsql.api.outputBuffer; -import oracle.jdbc.OracleTypes; import org.utplsql.api.reporter.Reporter; import java.sql.CallableStatement; @@ -33,9 +32,6 @@ protected CallableStatement getLinesCursorStatement(Connection conn) throws SQLE " l_rep.set_reporter_id(:reporter_id); " + " :lines_cursor := l_rep.get_lines_cursor(); " + "end;"; - CallableStatement cstmt = conn.prepareCall(plsql); - cstmt.setString("reporter_id", reporter.getId()); - cstmt.registerOutParameter("lines_cursor", OracleTypes.CURSOR); - return cstmt; + return prepareLinesCursorStatement(conn, plsql); } } diff --git a/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java b/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java index c665a6f..2e9bf8b 100644 --- a/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java +++ b/src/test/java/org/utplsql/api/outputBuffer/DefaultOutputBufferTest.java @@ -13,6 +13,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -63,4 +65,16 @@ void printAvailableWorksWithNonOracleCallableStatement() throws SQLException { verify(callableStatement).execute(); verify(callableStatement).close(); } + + @Test + void statementIsClosedWhenBindingFails() throws SQLException { + SQLException bindError = new SQLException("bind failed"); + doThrow(bindError).when(callableStatement).setString(anyString(), anyString()); + + SQLException thrown = assertThrows(SQLException.class, () -> new DefaultOutputBuffer(reporter).fetchAll(connection)); + + assertThat(thrown, sameInstance(bindError)); + verify(callableStatement).close(); + verify(callableStatement, never()).execute(); + } }