This is an automated email from the ASF dual-hosted git repository.

rmaucher pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git

commit ace9fff8d5d91f7e1c31543e7ecdb9c5f73a32a3
Author: opencode <[email protected]>
AuthorDate: Fri Oct 30 14:19:28 2026 +0100

    Keep the abandoned timer alive while a getObject REF cursor streams
    
    The timer interceptor decided which result sets to wrap by method name,
    so a cursor returned through CallableStatement.getObject reached the
    application unwrapped and reads on it never refreshed the connection
    timestamp - the removeAbandoned sweep could destroy the session
    mid-iteration, the exact problem the streaming keep-alive fixed for
    executeQuery, getResultSet and getGeneratedKeys. Decide on the return
    type instead, mirroring the rule and the java.sql.Wrapper carve-out the
    statement facade already uses, so every result set a pooled statement
    hands out resets the timer on read while objects reached through
    unwrap() stay the driver's own.
---
 .../jdbc/pool/interceptor/ResetAbandonedTimer.java |  41 +-
 .../TestResetAbandonedTimerCursorOutParameter.java | 550 +++++++++++++++++++++
 2 files changed, 575 insertions(+), 16 deletions(-)

diff --git 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
index b998a0f927..5da165526c 100644
--- 
a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
+++ 
b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/ResetAbandonedTimer.java
@@ -24,6 +24,7 @@ import java.sql.CallableStatement;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.Statement;
+import java.sql.Wrapper;
 
 import javax.management.ObjectName;
 
@@ -165,7 +166,9 @@ public class ResetAbandonedTimer extends 
AbstractQueryReport implements ResetAba
 
     /**
      * Proxy for a statement that turns the result sets the statement hands 
out into {@link TimerResultSetProxy}
-     * instances. Every other call is passed straight through to the wrapped 
statement.
+     * instances, whatever the method that returned one was called. Every 
other call is passed straight through to the
+     * wrapped statement, and so is what the {@link java.sql.Wrapper} methods 
hand back: a driver object reached through
+     * {@code unwrap()} stays the driver's own object, even one that 
implements {@link java.sql.ResultSet}.
      */
     private final class TimerStatementProxy implements InvocationHandler {
         /**
@@ -204,21 +207,27 @@ public class ResetAbandonedTimer extends 
AbstractQueryReport implements ResetAba
                 }
                 throw cause;
             }
-            // Wrap the same three result set producing calls that the
-            // statement facade wraps: executeQuery, getResultSet and
-            // getGeneratedKeys
-            if (result instanceof ResultSet) {
-                String name = method.getName();
-                if (compare(EXECUTE_QUERY, name) || compare(GET_RESULTSET, 
name) || compare(GET_GENERATED_KEYS, name)) {
-                    try {
-                        return getConstructor(RESULTSET_IDX, ResultSet.class)
-                                .newInstance(new Object[] { new 
TimerResultSetProxy(result, proxy) });
-                    } catch (Exception x) {
-                        // The application just does not get the timer reset
-                        // while reading this result set
-                        log.warn("Unable to create timer result set proxy for 
" +
-                                ResetAbandonedTimer.this.getClass().getName() 
+ ".", x);
-                    }
+            // Wrap every result set the call hands back, whatever the method
+            // that returned it was called, on the same rule the statement
+            // facade uses: a decision made on the method name only sees the
+            // three calls the standard API names, and misses the result sets
+            // declared to come back as an Object - a cursor OUT-parameter
+            // reaches the application through 
CallableStatement.getObject(...),
+            // and iterating it keeps a cursor open on this connection just as
+            // an executeQuery one does. The Wrapper methods are kept out of
+            // this for the facade's reason: unwrap() is the documented way to
+            // reach the driver's own object, and a driver's cursor type is
+            // such an object, so what it hands back stays the driver's object
+            // even where that object implements ResultSet.
+            if (result instanceof ResultSet && method.getDeclaringClass() != 
Wrapper.class) {
+                try {
+                    return getConstructor(RESULTSET_IDX, ResultSet.class)
+                            .newInstance(new Object[] { new 
TimerResultSetProxy(result, proxy) });
+                } catch (Exception x) {
+                    // The application just does not get the timer reset
+                    // while reading this result set
+                    log.warn("Unable to create timer result set proxy for " +
+                            ResetAbandonedTimer.this.getClass().getName() + 
".", x);
                 }
             }
             return result;
diff --git 
a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestResetAbandonedTimerCursorOutParameter.java
 
b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestResetAbandonedTimerCursorOutParameter.java
new file mode 100644
index 0000000000..31808fcd66
--- /dev/null
+++ 
b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestResetAbandonedTimerCursorOutParameter.java
@@ -0,0 +1,550 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.apache.tomcat.jdbc.pool.interceptor;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.sql.CallableStatement;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.logging.Logger;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tomcat.jdbc.JdbcPoolBaseTest;
+import org.apache.tomcat.jdbc.pool.DataSource;
+
+/**
+ * A result set that reaches the application as a plain {@link Object} - a
+ * cursor OUT-parameter read through {@link CallableStatement#getObject(int)}
+ * or one of its overloads, the way a REF cursor arrives on the drivers that
+ * have them - is read row by row over the same physical session as the ones
+ * {@code executeQuery}, {@code getResultSet} and {@code getGeneratedKeys}
+ * hand back, so the {@code ResetAbandonedTimer} interceptor must keep the
+ * abandoned timer alive while it is read. Deciding what to wrap by the name
+ * of the invoked method sees only the three calls the standard API names and
+ * misses this one, and the application iterating the cursor was abandoned
+ * mid-iteration exactly as before the streaming keep-alive existed. The pins
+ * below are the interceptor counterpart of the facade's return-type rule
+ * (see {@code TestCursorOutParameterSealing}): whatever call hands back a
+ * {@code java.sql.ResultSet} gets the interceptor's handle, exactly one layer
+ * of it beneath the facade's own, while what the {@code Wrapper} methods hand
+ * back stays the driver's object and a plain OUT-parameter value stays itself.
+ *
+ * <p>H2 has no cursor OUT-parameter feature, so the pool here is fed by a
+ * small delegating provider: its callable statements answer
+ * {@code getObject(...)} for a designated parameter with a real H2 result set
+ * opened on the very physical session the pool borrowed, the way an Oracle REF
+ * cursor or a PostgreSQL refcursor reaches the application.</p>
+ */
+class TestResetAbandonedTimerCursorOutParameter extends JdbcPoolBaseTest {
+
+    /*
+     * Time the busy connection is kept streaming rows off the cursor. It
+     * must stay well above removeAbandonedTimeout plus one sweep interval so
+     * that the idle control connection - and, while the cursor reads did not
+     * reset the timer, the streaming one too - is certain to expire while the
+     * iteration is still running.
+     */
+    private static final long STREAM_MILLIS = 2500;
+
+    /*
+     * Lower bound on streamed rows. An app iterating rows between the
+     * per-row pauses makes far more than this; the bound only catches an
+     * iteration that never really ran.
+     */
+    private static final int MIN_STREAM_ROWS = 20;
+
+    /*
+     * The parameter this provider's callable statements answer with a cursor,
+     * and the one they answer with a plain value, mirroring what a driver with
+     * a REF cursor OUT-parameter and an ordinary OUT-parameter does for
+     * CallableStatement.getObject(...).
+     */
+    private static final int CURSOR_PARAMETER = 1;
+    private static final int VALUE_PARAMETER = 2;
+    private static final Integer OUT_VALUE = Integer.valueOf(1234);
+
+    /*
+     * The call the provider's statements are prepared with, and the cursor it
+     * hands out. The row count and the fetch size keep the reads pulling from
+     * the server across the whole iteration, so the iteration stands for
+     * streaming a genuine server-side cursor.
+     */
+    private static final String CURSOR_CALL = "CALL 1";
+    private static final String CURSOR_SQL = "SELECT X FROM 
SYSTEM_RANGE(1,100000)";
+    private static final int CURSOR_FETCH_SIZE = 10;
+
+    /* The batch processing between the reads, as any per-row work stands for. 
*/
+    private static final long READ_PAUSE_MILLIS = 10;
+
+    /*
+     * Stands for the driver's own cursor interface. A real driver's cursor
+     * class implements such an interface and implements ResultSet, and an
+     * application that needs the driver-only cursor methods reaches it with
+     * unwrap(); a pooled handle must hand that object back untouched rather
+     * than a proxy of its own, whatever rule decides the wrapping.
+     */
+    public interface DriverCursor {
+    }
+
+    /*
+     * A connection provider shaped like a driver with cursor
+     * OUT-parameters: its callable statements hand the cursor - a real H2
+     * result set on the very physical session the pool holds - to
+     * getObject(...) and to unwrap(DriverCursor.class), and forward every
+     * other call straight to the H2 objects beneath.
+     */
+    private static final class CursorProvider implements javax.sql.DataSource {
+
+        private final org.h2.jdbcx.JdbcDataSource delegate;
+
+        CursorProvider(String url) {
+            delegate = new org.h2.jdbcx.JdbcDataSource();
+            delegate.setUrl(url);
+            delegate.setUser("sa");
+            delegate.setPassword("");
+        }
+
+        @Override
+        public Connection getConnection() throws SQLException {
+            return (Connection) Proxy.newProxyInstance(
+                    CursorProvider.class.getClassLoader(),
+                    new Class<?>[] { Connection.class },
+                    new ConnectionHandler(delegate.getConnection()));
+        }
+
+        @Override
+        public Connection getConnection(String username, String password) 
throws SQLException {
+            // The credentials are the pool's configuration, not the
+            // database's; the physical provider is the fixed H2 one. (Same
+            // posture as the provider in TestCursorOutParameterSealing.)
+            return getConnection();
+        }
+
+        @Override
+        public java.io.PrintWriter getLogWriter() {
+            return null;
+        }
+
+        @Override
+        public void setLogWriter(java.io.PrintWriter out) {
+            // no-op
+        }
+
+        @Override
+        public void setLoginTimeout(int seconds) {
+            // no-op
+        }
+
+        @Override
+        public int getLoginTimeout() {
+            return 0;
+        }
+
+        @Override
+        public Logger getParentLogger() {
+            return Logger.getLogger(CursorProvider.class.getName());
+        }
+
+        // javax.sql.DataSource is a Wrapper: answer it honestly, from the
+        // H2 provider beneath, as a real driver's data source does.
+        @Override
+        public <T> T unwrap(Class<T> iface) throws SQLException {
+            return delegate.unwrap(iface);
+        }
+
+        @Override
+        public boolean isWrapperFor(Class<?> iface) throws SQLException {
+            return delegate.isWrapperFor(iface);
+        }
+    }
+
+    /*
+     * Intercepts prepareCall to wrap the H2 callable statement in the cursor
+     * behaviour below. Everything else - createStatement, prepareStatement,
+     * close, the pool's own setup calls and the session marker probes - goes
+     * to the H2 connection unchanged.
+     */
+    private static final class ConnectionHandler implements InvocationHandler {
+
+        private final Connection physical;
+
+        ConnectionHandler(Connection physical) {
+            this.physical = physical;
+        }
+
+        @Override
+        public Object invoke(Object proxy, Method method, Object[] args) 
throws Throwable {
+            try {
+                Object result = method.invoke(physical, args);
+                if ("prepareCall".equals(method.getName())) {
+                    return Proxy.newProxyInstance(
+                            CursorProvider.class.getClassLoader(),
+                            new Class<?>[] { CallableStatement.class },
+                            new CallableStatementHandler(physical, 
(CallableStatement) result));
+                }
+                return result;
+            } catch (InvocationTargetException e) {
+                throw e.getCause();
+            }
+        }
+    }
+
+    /*
+     * The fictional cursor behaviour, and nothing else: getObject(...) for the
+     * cursor parameter returns the session's H2 result set - the same object
+     * every request and an unwrap(DriverCursor.class) hands back - and
+     * getObject(...) for the value parameter returns the OUT-parameter value.
+     * Every other call is forwarded to the H2 callable statement.
+     */
+    private static final class CallableStatementHandler implements 
InvocationHandler {
+
+        private final Connection physical;
+        private final CallableStatement delegate;
+        /* Kept open with the cursor: closing the owner would close it. */
+        private Statement cursorOwner;
+        private ResultSet cursor;
+
+        CallableStatementHandler(Connection physical, CallableStatement 
delegate) {
+            this.physical = physical;
+            this.delegate = delegate;
+        }
+
+        /* The session's cursor, opened on the physical session once. */
+        private ResultSet cursor() throws SQLException {
+            if (cursor == null) {
+                cursorOwner = physical.createStatement();
+                cursorOwner.setFetchSize(CURSOR_FETCH_SIZE);
+                cursor = cursorOwner.executeQuery(CURSOR_SQL);
+            }
+            return cursor;
+        }
+
+        @Override
+        public Object invoke(Object proxy, Method method, Object[] args) 
throws Throwable {
+            String name = method.getName();
+            try {
+                if ("getObject".equals(name) && args != null && args.length >= 
1
+                        && args[0] instanceof Integer) {
+                    int parameter = ((Integer) args[0]).intValue();
+                    if (parameter == CURSOR_PARAMETER) {
+                        if (args.length == 1) {
+                            return cursor();
+                        }
+                        if (args[1] instanceof Class && ((Class<?>) 
args[1]).isInstance(cursor())) {
+                            return cursor();
+                        }
+                    } else if (parameter == VALUE_PARAMETER
+                            && (args.length == 1 || ((Class<?>) 
args[1]).isInstance(OUT_VALUE))) {
+                        return OUT_VALUE;
+                    }
+                } else if ("unwrap".equals(name)) {
+                    Class<?> iface = (Class<?>) args[0];
+                    if (DriverCursor.class.equals(iface)) {
+                        // A driver's own cursor method hands back its cursor
+                        // object, which implements ResultSet; this provider
+                        // answers such a reach the same way, and the
+                        // interceptor must not put a proxy of its own in
+                        // front of it.
+                        return cursor();
+                    }
+                    return delegate.unwrap(iface);
+                } else if ("isWrapperFor".equals(name)) {
+                    Class<?> iface = (Class<?>) args[0];
+                    return Boolean.valueOf(DriverCursor.class.equals(iface) || 
delegate.isWrapperFor(iface));
+                }
+                return method.invoke(delegate, args);
+            } catch (InvocationTargetException e) {
+                throw e.getCause();
+            }
+        }
+    }
+
+    /*
+     * A pool that serves its connections through the cursor provider and
+     * abandons an idle one after a second, swept every 100ms - the same
+     * setting the other abandoned-timer tests drive.
+     */
+    private DataSource cursorTimerPool() throws SQLException {
+        DataSource ds = newDataSource();
+        ds.setDataSource(new CursorProvider(getUrl()));
+        ds.getPoolProperties().setJdbcInterceptors(
+                "org.apache.tomcat.jdbc.pool.interceptor.ResetAbandonedTimer");
+        ds.getPoolProperties().setRemoveAbandoned(true);
+        ds.getPoolProperties().setRemoveAbandonedTimeout(1); // seconds
+        ds.getPoolProperties().setTimeBetweenEvictionRunsMillis(100);
+        ds.createPool();
+        pool = ds;
+        return ds;
+    }
+
+    /* The pool under test, for forced teardown. */
+    private DataSource pool;
+
+    /*
+     * These tests deliberately end with connections still borrowed. The
+     * regular data source teardown only disconnects idle connections, so the
+     * sessions of the borrowed ones would linger until the garbage collector
+     * finalizes them and leak their session counts into the starting point of
+     * the next test; force-close disconnects the busy ones as well, right
+     * away.
+     */
+    @AfterEach
+    void disconnectLeakedBusyConnections() {
+        if (pool != null) {
+            pool.close(true);
+        }
+    }
+
+    /*
+     * Borrows an idle control connection - left borrowed and untouched, it
+     * exists only to prove the abandoned sweep is running - and a busy
+     * connection, each identified by a session marker, and pins the session
+     * count to two while both are still live. The caller must have waited out
+     * any sessions dying from a previous test first: with a one-second
+     * abandoned timeout there is no time to absorb stale sessions once these
+     * connections have started their countdown. Returns the busy connection.
+     */
+    private Connection borrowBusyWithIdleControl(DataSource ds) throws 
SQLException {
+        Connection idle = ds.getConnection();
+        setSessionMarker(idle, 2);
+        Connection busy = ds.getConnection();
+        setSessionMarker(busy, 1);
+        Assertions.assertEquals(2, poolSessions(),
+                "both borrowed connections must start on live physical 
sessions");
+        return busy;
+    }
+
+    /*
+     * The busy connection ends with one live physical session carrying the
+     * marker it was given before the iteration, and the idle control
+     * connection has lost its session to the abandoned sweep.
+     */
+    private void assertBusySurvivedAndIdleAbandoned(Connection busy) throws 
SQLException {
+        Integer marker = sessionMarker(busy);
+        Assertions.assertNotNull(marker, "the session of the busy connection 
must still be alive");
+        Assertions.assertEquals(1, marker.intValue(), "the busy connection 
must still own its original session");
+        awaitSessions(1);
+    }
+
+    /*
+     * Iterates the cursor this pool's callable statements hand out through
+     * getObject(int) with a pause between the reads - the batch processing
+     * between fetches that the abandoned timeout is meant to look over - for
+     * STREAM_MILLIS, and returns the number of rows read.
+     */
+    private int streamCursorSlowly(Connection busy) throws SQLException {
+        int rows = 0;
+        try (CallableStatement cs = busy.prepareCall(CURSOR_CALL)) {
+            cs.execute();
+            try (ResultSet cursor = (ResultSet) 
cs.getObject(CURSOR_PARAMETER)) {
+                long deadline = System.currentTimeMillis() + STREAM_MILLIS;
+                while (System.currentTimeMillis() < deadline && cursor.next()) 
{
+                    rows++;
+                    Assertions.assertEquals(rows, cursor.getInt(1),
+                            "the cursor read through getObject must hand out 
its own rows");
+                    sleep(READ_PAUSE_MILLIS);
+                }
+            }
+        }
+        return rows;
+    }
+
+    /*
+     * An application streaming a REF cursor is active for as long as it reads
+     * it, exactly as it is when it iterates a result set that
+     * {@code executeQuery} returned. The abandoned timer must be reset by the
+     * reads made through the cursor OUT-parameter - through the always
+     * installed statement facade, over the interceptor's own handle, over the
+     * driver's cursor - and not only until {@code getObject} returned.
+     */
+    @Test
+    void streamingACursorOutParameterKeepsConnectionAlive() throws Exception {
+        awaitSessions(0);
+        DataSource ds = cursorTimerPool();
+        Connection busy = borrowBusyWithIdleControl(ds);
+
+        int rows = streamCursorSlowly(busy);
+        Assertions.assertTrue(rows >= MIN_STREAM_ROWS,
+                "the streaming loop must have run, only " + rows + " rows");
+
+        assertBusySurvivedAndIdleAbandoned(busy);
+    }
+
+    /*
+     * The composition the keep-alive above depends on, pinned without waiting
+     * on a sweep: the cursor handed back by getObject(...) must reach the
+     * application as the facade's handle over exactly one handle of this
+     * interceptor over the driver's own cursor - the same three-layer shape 
the
+     * three named calls have, and no layer of it doubled.
+     */
+    private static void assertTimerHandleBeneathTheFacade(ResultSet handle, 
Object driverCursor,
+            String path) throws ReflectiveOperationException {
+        Assertions.assertTrue(Proxy.isProxyClass(handle.getClass()),
+                path + ": the application's result set must be a handle, was " 
+ handle.getClass().getName());
+        
Assertions.assertTrue(Proxy.getInvocationHandler(handle).getClass().getName()
+                .contains("StatementFacade"),
+                path + ": the outermost handle must be the facade's, found "
+                        + 
Proxy.getInvocationHandler(handle).getClass().getName());
+        Object interceptorHandle = proxiedObject(handle);
+        Assertions.assertTrue(Proxy.isProxyClass(interceptorHandle.getClass()),
+                path + ": the facade must sit over this interceptor's own 
handle, found "
+                        + interceptorHandle.getClass().getName());
+        
Assertions.assertTrue(Proxy.getInvocationHandler(interceptorHandle).getClass().getName()
+                .contains(ResetAbandonedTimer.class.getSimpleName()),
+                path + ": the handle beneath the facade must be this 
interceptor's, found "
+                        + 
Proxy.getInvocationHandler(interceptorHandle).getClass().getName());
+        Object driver = proxiedObject(interceptorHandle);
+        Assertions.assertFalse(Proxy.isProxyClass(driver.getClass()),
+                path + ": this interceptor must wrap the driver's cursor once, 
not its own handle again");
+        Assertions.assertSame(driverCursor, driver,
+                path + ": the handle must wrap exactly this session's driver 
cursor");
+    }
+
+    /**
+     * The cursor OUT-parameter arrives through {@code getObject(int)} and
+     * through the typed {@code getObject(int, Class)} of JDBC 4.0, and both
+     * must produce the interceptor's handle rather than the driver's raw
+     * result set, so that reading it keeps this borrow alive. It must still
+     * behave like every other handle while it is read: the application's own
+     * statement as its statement, and its own rows coming through. The three
+     * calls the name rule did wrap keep exactly the same composition, which is
+     * to say the type rule adds a layer to the cursor without doubling one
+     * anywhere.
+     */
+    @Test
+    void aCursorOutParameterArrivesAsATimerHandleBeneathTheFacade() throws 
Exception {
+        DataSource ds = cursorTimerPool();
+        try (Connection pooled = ds.getConnection()) {
+            try (CallableStatement cs = pooled.prepareCall(CURSOR_CALL)) {
+                cs.execute();
+                Object driverCursor = cs.unwrap(DriverCursor.class);
+                Assertions.assertTrue(driverCursor instanceof ResultSet,
+                        "precondition: the driver's cursor object implements 
ResultSet");
+
+                ResultSet cursor = (ResultSet) cs.getObject(CURSOR_PARAMETER);
+                assertTimerHandleBeneathTheFacade(cursor, driverCursor, 
"getObject(int)");
+                Assertions.assertSame(cs, cursor.getStatement(),
+                        "the handle must report the application's own 
statement");
+                for (int expected = 1; expected <= 3; expected++) {
+                    Assertions.assertTrue(cursor.next(), "the cursor is live 
during the borrow");
+                    Assertions.assertEquals(expected, cursor.getInt(1),
+                            "the cursor's own rows must come through the 
handle");
+                }
+            }
+
+            try (CallableStatement cs = pooled.prepareCall(CURSOR_CALL)) {
+                cs.execute();
+                Object driverCursor = cs.unwrap(DriverCursor.class);
+
+                ResultSet cursor = cs.getObject(CURSOR_PARAMETER, 
ResultSet.class);
+                assertTimerHandleBeneathTheFacade(cursor, driverCursor, 
"getObject(int, Class)");
+                Assertions.assertTrue(cursor.next(), "the cursor is live 
during the borrow");
+            }
+
+            try (Statement st = pooled.createStatement()) {
+                try (ResultSet rs = st.executeQuery(CURSOR_SQL)) {
+                    assertTimerHandleBeneathTheFacade(rs, 
rs.unwrap(ResultSet.class), "executeQuery");
+                    Assertions.assertTrue(rs.next());
+                    Assertions.assertEquals(1, rs.getInt(1));
+                }
+
+                Assertions.assertTrue(st.execute(CURSOR_SQL));
+                try (ResultSet rs = st.getResultSet()) {
+                    Assertions.assertNotNull(rs, "the query result must be 
reachable through getResultSet");
+                    assertTimerHandleBeneathTheFacade(rs, 
rs.unwrap(ResultSet.class), "getResultSet");
+                    Assertions.assertTrue(rs.next());
+                }
+            }
+        }
+    }
+
+    /**
+     * The documented escape hatch, pinned at exactly the point where a
+     * wrap-by-return-type rule could break it: this driver's cursor type
+     * implements ResultSet, so a blanket rule would silently wrap what
+     * {@code unwrap()} hands back - and with the interceptor installed the
+     * driver's object passes through this layer, not only through the 
facade's.
+     * {@code unwrap()} must still arrive at the driver's very own cursor
+     * object: never a proxy, the same object on repeated calls, still usable.
+     */
+    @Test
+    void unwrapStillHandsBackTheDriversOwnCursorThroughTheInterceptor() throws 
Exception {
+        DataSource ds = cursorTimerPool();
+        try (Connection pooled = ds.getConnection();
+                CallableStatement cs = pooled.prepareCall(CURSOR_CALL)) {
+            cs.execute();
+            Assertions.assertTrue(cs.isWrapperFor(DriverCursor.class),
+                    "the provider must answer its own cursor interface 
honestly, as a driver does");
+
+            Object cursor = cs.unwrap(DriverCursor.class);
+
+            Assertions.assertTrue(cursor instanceof ResultSet,
+                    "precondition: the object reached by unwrapping implements 
ResultSet");
+            Assertions.assertFalse(Proxy.isProxyClass(cursor.getClass()),
+                    "unwrap() must hand back the driver's object, not a 
handle: " + cursor.getClass().getName());
+            
Assertions.assertTrue(cursor.getClass().getName().startsWith("org.h2."),
+                    "unwrap() must arrive at the driver object through every 
layer: "
+                            + cursor.getClass().getName());
+            Assertions.assertSame(cursor, cs.unwrap(DriverCursor.class),
+                    "a repeated unwrap() must return the same object, not a 
new wrapper");
+
+            try (ResultSet raw = (ResultSet) cursor) {
+                Assertions.assertTrue(raw.next(),
+                        "what the escape hatch hands back must still work");
+            }
+            
Assertions.assertInstanceOf(org.h2.jdbc.JdbcCallableStatement.class, 
cs.unwrap(Statement.class),
+                    "the ordinary statement unwrap must stay unaffected");
+        }
+    }
+
+    /**
+     * The rule fires on the return type, so it must leave everything the same
+     * {@code getObject(...)} family hands back alone: an ordinary
+     * OUT-parameter value passes through untouched, in the plain and the typed
+     * overload alike, and an ordinary column read through a handle is not
+     * touched either.
+     */
+    @Test
+    void aPlainOutParameterPassesThroughTheInterceptorUnchanged() throws 
Exception {
+        DataSource ds = cursorTimerPool();
+        try (Connection pooled = ds.getConnection();
+                CallableStatement cs = pooled.prepareCall(CURSOR_CALL)) {
+            cs.execute();
+            Assertions.assertSame(OUT_VALUE, cs.getObject(VALUE_PARAMETER),
+                    "a plain OUT-parameter value must reach the application 
untouched");
+            Assertions.assertSame(OUT_VALUE, cs.getObject(VALUE_PARAMETER, 
Object.class),
+                    "the typed overload must pass the plain value through 
unchanged too");
+
+            try (Statement st = pooled.createStatement();
+                    ResultSet rs = st.executeQuery("SELECT 7")) {
+                Assertions.assertTrue(rs.next());
+                Assertions.assertEquals(7, rs.getInt(1), "an ordinary column 
read must be untouched");
+                Object column = rs.getObject(1);
+                Assertions.assertFalse(Proxy.isProxyClass(column.getClass()),
+                        "an ordinary column value must not become a handle: " 
+ column.getClass().getName());
+            }
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to