diff --git a/docs/postgresql.md b/docs/postgresql.md index fe54787..1928f83 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -12,11 +12,41 @@ blocking JDBC execution. There is no engine option and no dialect option in `sqlcj.yaml`; the full file format is documented separately. The application owns the database connection: sqlcj's runtime -`dev.sqlcj.runtime.JdbcQueryExecutor` is constructed with a -`javax.sql.DataSource` and executes each generated query as a JDBC +`dev.sqlcj.runtime.JdbcQueryExecutor` executes each generated query as a JDBC `PreparedStatement`. sqlcj does not ship a JDBC driver, so the application also supplies the PostgreSQL driver. +## Connection Ownership and Transactions + +`JdbcQueryExecutor` has two construction paths. Both share the same positional +parameter binding, row mapping, single-row and multi-row result handling, +affected-row counting, and exception translation, and both accept the same +generated query classes without regeneration: + +| Construction | Connection ownership | +| --- | --- | +| `new JdbcQueryExecutor(javax.sql.DataSource)` | The executor obtains one connection per operation and closes it before the operation returns, so each operation runs on that connection's own transaction state, typically one auto-committed statement. | +| `new JdbcQueryExecutor(java.sql.Connection)` | The executor runs every operation on the supplied connection and never closes, commits, or rolls it back, never changes its auto-commit setting, and never otherwise configures it. | + +The caller-owned connection path is how several generated operations take part +in one application-controlled transaction: the application disables auto-commit, +runs generated reads and writes through one executor, and then calls `commit` or +`rollback` itself. sqlcj provides no transaction callback or template API, no +savepoints, and no isolation configuration. + +In both paths the `PreparedStatement` and any `ResultSet` opened for an +operation are closed before that operation returns, on success and on failure. + +Every `SQLException` raised while acquiring a connection, preparing a statement, +binding parameters, executing, reading results, or closing a DataSource-acquired +connection is translated into `dev.sqlcj.runtime.QueryExecutionException` with +the message `Failed to execute query` and the `SQLException` as its cause. A +failed operation on a caller-owned connection leaves the connection open, so the +application decides whether to continue or roll back. + +The runtime is blocking and synchronous. An executor built on a caller-owned +connection inherits that connection's confinement to a single thread at a time. + Behavior is verified against PostgreSQL 16. The pipeline is executed end to end against a `postgres:16-alpine` container: the schema snapshot is run as PostgreSQL DDL, the generated Java is compiled, and the generated classes are @@ -201,3 +231,20 @@ Nulls: `DefaultSchemaParserTest.shouldParseNullabilityOfAddedColumnTypes`, and `DefaultSchemaParserTest.shouldParseSerialColumnAsNotNullable` cover parsed nullability. + +Connection ownership and transactions: + +- `JdbcQueryExecutorTest` covers both construction paths for `query`, + `queryMany`, and `execute`, including + `shouldCloseAcquiredConnectionForEachDataSourceOperation`, + `shouldCloseAcquiredConnectionWhenDataSourceOperationFails`, + `shouldLeaveCallerOwnedConnectionOpenAndItsTransactionStateUnchanged`, + `shouldCloseStatementsAndResultSetsOfCallerOwnedConnection`, + `shouldCloseStatementWhenExecutionFailsOnCallerOwnedConnection`, and + `shouldWrapSqlExceptionForCallerOwnedConnection`. +- `PostgresIntegrationTest.shouldCommitGeneratedOperationsOnCallerOwnedConnection` + and + `PostgresIntegrationTest.shouldRollBackGeneratedOperationsOnCallerOwnedConnection` + run a generated affected-row write, a generated returning write, and a + generated read on one caller-owned connection with auto-commit disabled, and + prove the application's own `commit` and `rollback`. diff --git a/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java b/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java index de6d20f..ae3a18c 100644 --- a/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java +++ b/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java @@ -7,23 +7,72 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +/** + * Executes generated queries over JDBC. + * + *

An executor is constructed either with a {@link DataSource} or with a + * caller-owned {@link Connection}. Both construction paths share the same + * positional parameter binding, row mapping, single-row and multi-row result + * handling, affected-row counting, and exception translation. Only connection + * ownership differs: + * + *

+ * + *

In both paths the {@link PreparedStatement} and any {@link ResultSet} + * opened for an operation are closed before that operation returns, on success + * and on failure. + * + *

Every {@link SQLException} raised while acquiring a connection, preparing + * a statement, binding parameters, executing, reading results, or closing a + * DataSource-acquired connection is translated into a + * {@link QueryExecutionException} with the message {@code "Failed to execute + * query"} and the {@code SQLException} as its cause. + * + *

This executor is blocking and synchronous. An instance constructed with a + * caller-owned connection inherits that connection's confinement to a single + * thread at a time. + */ public final class JdbcQueryExecutor implements QueryExecutor { private final DataSource dataSource; + private final Connection connection; + + /** + * Creates an executor that acquires and closes one connection from the + * given {@code dataSource} per operation. + */ public JdbcQueryExecutor(DataSource dataSource) { this.dataSource = dataSource; + this.connection = null; + } + + /** + * Creates an executor that runs every operation on the given caller-owned + * {@code connection}. The connection is never closed, committed, rolled + * back, or reconfigured by this executor. + */ + public JdbcQueryExecutor(Connection connection) { + this.dataSource = null; + this.connection = connection; } @Override public T query(String sql, List parameters, RowMapper mapper) { - try ( - Connection connection = dataSource.getConnection(); - PreparedStatement statement = connection.prepareStatement(sql) - ) { - bindParameters(statement, parameters); - + return onStatement(sql, parameters, statement -> { try (ResultSet resultSet = statement.executeQuery()) { if (!resultSet.next()) { return null; @@ -31,22 +80,12 @@ public T query(String sql, List parameters, RowMapper mapper) { return mapper.map(resultSet); } - } catch (SQLException e) { - throw new QueryExecutionException( - "Failed to execute query", - e - ); - } + }); } @Override public List queryMany(String sql, List parameters, RowMapper mapper) { - try ( - Connection connection = dataSource.getConnection(); - PreparedStatement statement = connection.prepareStatement(sql) - ) { - bindParameters(statement, parameters); - + return onStatement(sql, parameters, statement -> { try (ResultSet resultSet = statement.executeQuery()) { List results = new ArrayList<>(); @@ -56,6 +95,30 @@ public List queryMany(String sql, List parameters, RowMapper mapper return results; } + }); + } + + @Override + public int execute(String sql, List parameters) { + return onStatement(sql, parameters, PreparedStatement::executeUpdate); + } + + /** + * Runs the given operation on a prepared statement of the connection this + * executor owns or was given, closing the statement afterward and closing + * the connection only when this executor acquired it. + */ + private T onStatement( + String sql, + List parameters, + StatementOperation operation + ) { + if (connection != null) { + return onConnection(connection, sql, parameters, operation); + } + + try (Connection acquired = Objects.requireNonNull(dataSource).getConnection()) { + return onConnection(acquired, sql, parameters, operation); } catch (SQLException e) { throw new QueryExecutionException( "Failed to execute query", @@ -64,15 +127,16 @@ public List queryMany(String sql, List parameters, RowMapper mapper } } - @Override - public int execute(String sql, List parameters) { - try ( - Connection connection = dataSource.getConnection(); - PreparedStatement statement = connection.prepareStatement(sql) - ) { + private T onConnection( + Connection target, + String sql, + List parameters, + StatementOperation operation + ) { + try (PreparedStatement statement = target.prepareStatement(sql)) { bindParameters(statement, parameters); - return statement.executeUpdate(); + return operation.run(statement); } catch (SQLException e) { throw new QueryExecutionException( "Failed to execute query", @@ -86,4 +150,9 @@ private void bindParameters(PreparedStatement statement, List parameters) thr statement.setObject(i + 1, parameters.get(i)); } } + + private interface StatementOperation { + + T run(PreparedStatement statement) throws SQLException; + } } diff --git a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index 319cfa1..5f13572 100644 --- a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -41,6 +41,7 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -104,6 +105,26 @@ CONSTRAINT customer_orders_quantity_check CHECK (quantity > 0), ); """; + /** + * Queries used by the caller-owned transaction tests: an affected-row + * write, a returning write, and a read. + */ + private static final String TRANSACTION_QUERIES = """ + -- name: InsertUser :exec + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3); + + -- name: InsertUserReturningRow :one + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + RETURNING id, name; + + -- name: ListUserNames :many + SELECT name + FROM users + ORDER BY id; + """; + private static final UUID EXTERNAL_ID = UUID.fromString("3f2504e0-4f89-11d3-9a0c-0305e82c3301"); private static final OffsetDateTime UPDATED_AT = OffsetDateTime.of( @@ -726,6 +747,159 @@ INSERT INTO users (id, code, name, active) } } + /** + * Runs three generated operations on one caller-owned connection with + * auto-commit disabled: an affected-row write, a returning write, and a + * read that sees both uncommitted rows. The application's commit makes both + * rows durable, and the connection is neither closed nor reconfigured by the + * runtime. + */ + @Test + void shouldCommitGeneratedOperationsOnCallerOwnedConnection() throws Exception { + Path classesDirectory = generateAndCompile(TRANSACTION_QUERIES); + + try ( + URLClassLoader classLoader = classLoader(classesDirectory); + Connection connection = dataSource.getConnection() + ) { + connection.setAutoCommit(false); + + QueryExecutor transactional = new JdbcQueryExecutor(connection); + + assertEquals( + 1, + insertUser(classLoader, transactional, 1L, 42, "Alice") + ); + + Object returned = insertUserReturningRow( + classLoader, + transactional, + 2L, + 43, + "Bob" + ); + + assertNotNull(returned); + assertEquals(2L, component(returned, "id")); + assertEquals("Bob", component(returned, "name")); + + assertEquals( + List.of("Alice", "Bob"), + listUserNames(classLoader, transactional) + ); + + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + + connection.commit(); + + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + + assertEquals( + List.of("Alice", "Bob"), + listUserNames(classLoader) + ); + } + } + + /** + * Runs the same generated operations on one caller-owned connection and + * rolls the transaction back: both writes are discarded, and the connection + * remains open and usable for a further generated read. + */ + @Test + void shouldRollBackGeneratedOperationsOnCallerOwnedConnection() throws Exception { + Path classesDirectory = generateAndCompile(TRANSACTION_QUERIES); + + try ( + URLClassLoader classLoader = classLoader(classesDirectory); + Connection connection = dataSource.getConnection() + ) { + connection.setAutoCommit(false); + + QueryExecutor transactional = new JdbcQueryExecutor(connection); + + assertEquals( + 1, + insertUser(classLoader, transactional, 3L, 44, "Carol") + ); + + assertNotNull( + insertUserReturningRow(classLoader, transactional, 4L, 45, "Dora") + ); + + assertEquals( + List.of("Carol", "Dora"), + listUserNames(classLoader, transactional) + ); + + connection.rollback(); + + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + + assertEquals( + List.of(), + listUserNames(classLoader, transactional) + ); + } + } + + private Object insertUser( + URLClassLoader classLoader, + QueryExecutor executor, + Long id, + Integer code, + String name + ) throws Exception { + Object insert = newQuery(classLoader, "InsertUser", executor); + + return insert + .getClass() + .getMethod("insertUser", Long.class, Integer.class, String.class) + .invoke(insert, id, code, name); + } + + private Object insertUserReturningRow( + URLClassLoader classLoader, + QueryExecutor executor, + Long id, + Integer code, + String name + ) throws Exception { + Object insert = newQuery(classLoader, "InsertUserReturningRow", executor); + + return insert + .getClass() + .getMethod("insertUserReturningRow", Long.class, Integer.class, String.class) + .invoke(insert, id, code, name); + } + + private List listUserNames(URLClassLoader classLoader) throws Exception { + return listUserNames(classLoader, new JdbcQueryExecutor(dataSource)); + } + + private List listUserNames( + URLClassLoader classLoader, + QueryExecutor executor + ) throws Exception { + Object query = newQuery(classLoader, "ListUserNames", executor); + + List rows = (List) query + .getClass() + .getMethod("listUserNames") + .invoke(query); + + List names = new ArrayList<>(); + + for (Object row : rows) { + names.add(component(row, "name")); + } + + return names; + } + private Path generateAndCompile(String queries) throws Exception { return generateAndCompile(SCHEMA, queries); } @@ -800,6 +974,14 @@ private URLClassLoader classLoader(Path classesDirectory) throws Exception { } private Object newQuery(URLClassLoader classLoader, String queryName) throws Exception { + return newQuery(classLoader, queryName, new JdbcQueryExecutor(dataSource)); + } + + private Object newQuery( + URLClassLoader classLoader, + String queryName, + QueryExecutor executor + ) throws Exception { Class generatedClass = Class.forName( "generated." + queryName, true, @@ -808,7 +990,7 @@ private Object newQuery(URLClassLoader classLoader, String queryName) throws Exc Constructor constructor = generatedClass.getConstructor(QueryExecutor.class); - return constructor.newInstance(new JdbcQueryExecutor(dataSource)); + return constructor.newInstance(executor); } private void execute(String sql) throws Exception { diff --git a/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java b/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java index 1695bc0..34ab300 100644 --- a/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java +++ b/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java @@ -4,16 +4,24 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import javax.sql.DataSource; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.math.BigDecimal; import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -223,9 +231,405 @@ void shouldWrapSqlException() { assertInstanceOf(SQLException.class, exception.getCause()); } + @Test + void shouldQueryThroughCallerOwnedConnection() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor(connection); + + User result = connectionExecutor.query( + """ + SELECT id, name + FROM users + WHERE id = ? + AND active = ? + """, + List.of(1L, true), + resultSet -> new User( + resultSet.getLong("id"), + resultSet.getString("name") + ) + ); + + assertEquals(new User(1L, "Alice"), result); + + assertNull( + connectionExecutor.query( + "SELECT id, name FROM users WHERE id = ?", + List.of(999L), + resultSet -> new User( + resultSet.getLong("id"), + resultSet.getString("name") + ) + ) + ); + } + } + + @Test + void shouldQueryManyThroughCallerOwnedConnection() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor(connection); + + List results = connectionExecutor.queryMany( + """ + SELECT id, name + FROM users + WHERE active = ? + ORDER BY id + """, + List.of(true), + resultSet -> new User( + resultSet.getLong("id"), + resultSet.getString("name") + ) + ); + + assertEquals( + List.of( + new User(1L, "Alice"), + new User(3L, "Charlie") + ), + results + ); + + assertTrue( + connectionExecutor.queryMany( + "SELECT id, name FROM users WHERE id = ?", + List.of(999L), + resultSet -> new User( + resultSet.getLong("id"), + resultSet.getString("name") + ) + ).isEmpty() + ); + } + } + + @Test + void shouldReturnAffectedRowCountForExecuteThroughCallerOwnedConnection() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor(connection); + + int affected = connectionExecutor.execute( + """ + UPDATE users + SET name = ? + WHERE active = ? + """, + List.of("Renamed", true) + ); + + assertEquals(2, affected); + + assertEquals( + "Renamed", + connectionExecutor.query( + "SELECT name FROM users WHERE id = ?", + List.of(1L), + resultSet -> resultSet.getString("name") + ) + ); + } + } + + /** + * Two operations run on the same caller-owned connection inside one + * application-controlled transaction: the connection stays open, its + * auto-commit setting and isolation level are unchanged, and the + * application's rollback discards both operations. + */ + @Test + void shouldLeaveCallerOwnedConnectionOpenAndItsTransactionStateUnchanged() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + + int isolation = connection.getTransactionIsolation(); + + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor(connection); + + connectionExecutor.execute( + "INSERT INTO users (id, name, active) VALUES (?, ?, ?)", + List.of(4L, "Dora", true) + ); + + connectionExecutor.execute( + "UPDATE users SET name = ? WHERE id = ?", + List.of("Alicia", 1L) + ); + + assertEquals( + List.of( + new User(1L, "Alicia"), + new User(4L, "Dora") + ), + connectionExecutor.queryMany( + """ + SELECT id, name + FROM users + WHERE id IN (?, ?) + ORDER BY id + """, + List.of(1L, 4L), + resultSet -> new User( + resultSet.getLong("id"), + resultSet.getString("name") + ) + ) + ); + + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + assertEquals(isolation, connection.getTransactionIsolation()); + + connection.rollback(); + + assertEquals( + List.of(new User(1L, "Alice")), + connectionExecutor.queryMany( + """ + SELECT id, name + FROM users + WHERE id IN (?, ?) + ORDER BY id + """, + List.of(1L, 4L), + resultSet -> new User( + resultSet.getLong("id"), + resultSet.getString("name") + ) + ) + ); + } + } + + @Test + void shouldCloseStatementsAndResultSetsOfCallerOwnedConnection() throws SQLException { + JdbcResourceTracker tracker = new JdbcResourceTracker(); + + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor( + tracker.track(Connection.class, connection) + ); + + connectionExecutor.query( + "SELECT id FROM users WHERE id = ?", + List.of(1L), + resultSet -> resultSet.getLong("id") + ); + + connectionExecutor.queryMany( + "SELECT id FROM users", + List.of(), + resultSet -> resultSet.getLong("id") + ); + + connectionExecutor.execute( + "UPDATE users SET name = ? WHERE id = ?", + List.of("Alicia", 1L) + ); + + assertEquals(3, tracker.statements.size()); + assertEquals(2, tracker.resultSets.size()); + tracker.assertAllStatementsAndResultSetsClosed(); + + assertFalse(connection.isClosed()); + } + } + + @Test + void shouldCloseStatementWhenExecutionFailsOnCallerOwnedConnection() throws SQLException { + JdbcResourceTracker tracker = new JdbcResourceTracker(); + + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor( + tracker.track(Connection.class, connection) + ); + + QueryExecutionException exception = assertThrows( + QueryExecutionException.class, + () -> connectionExecutor.query( + "SELECT id FROM users WHERE id = ?", + List.of(), + resultSet -> resultSet.getLong("id") + ) + ); + + assertEquals("Failed to execute query", exception.getMessage()); + assertInstanceOf(SQLException.class, exception.getCause()); + + assertEquals(1, tracker.statements.size()); + tracker.assertAllStatementsAndResultSetsClosed(); + + assertFalse(connection.isClosed()); + } + } + + @Test + void shouldWrapSqlExceptionForCallerOwnedConnection() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + JdbcQueryExecutor connectionExecutor = new JdbcQueryExecutor(connection); + + QueryExecutionException queryException = assertThrows( + QueryExecutionException.class, + () -> connectionExecutor.query( + "SELECT * FROM missing_table", + List.of(), + resultSet -> resultSet.getLong("id") + ) + ); + + assertEquals("Failed to execute query", queryException.getMessage()); + assertInstanceOf(SQLException.class, queryException.getCause()); + + QueryExecutionException executeException = assertThrows( + QueryExecutionException.class, + () -> connectionExecutor.execute( + "DELETE FROM missing_table", + List.of() + ) + ); + + assertEquals("Failed to execute query", executeException.getMessage()); + assertInstanceOf(SQLException.class, executeException.getCause()); + + assertFalse(connection.isClosed()); + } + } + + @Test + void shouldCloseAcquiredConnectionForEachDataSourceOperation() throws SQLException { + JdbcResourceTracker tracker = new JdbcResourceTracker(); + + JdbcQueryExecutor dataSourceExecutor = new JdbcQueryExecutor( + tracker.track(DataSource.class, dataSource) + ); + + dataSourceExecutor.query( + "SELECT id FROM users WHERE id = ?", + List.of(1L), + resultSet -> resultSet.getLong("id") + ); + + dataSourceExecutor.queryMany( + "SELECT id FROM users", + List.of(), + resultSet -> resultSet.getLong("id") + ); + + dataSourceExecutor.execute( + "UPDATE users SET name = ? WHERE id = ?", + List.of("Alicia", 1L) + ); + + assertEquals(3, tracker.connections.size()); + assertEquals(3, tracker.statements.size()); + assertEquals(2, tracker.resultSets.size()); + + tracker.assertAllConnectionsClosed(); + tracker.assertAllStatementsAndResultSetsClosed(); + } + + @Test + void shouldCloseAcquiredConnectionWhenDataSourceOperationFails() throws SQLException { + JdbcResourceTracker tracker = new JdbcResourceTracker(); + + JdbcQueryExecutor dataSourceExecutor = new JdbcQueryExecutor( + tracker.track(DataSource.class, dataSource) + ); + + QueryExecutionException exception = assertThrows( + QueryExecutionException.class, + () -> dataSourceExecutor.query( + "SELECT id FROM users WHERE id = ?", + List.of(), + resultSet -> resultSet.getLong("id") + ) + ); + + assertEquals("Failed to execute query", exception.getMessage()); + assertInstanceOf(SQLException.class, exception.getCause()); + + assertEquals(1, tracker.connections.size()); + assertEquals(1, tracker.statements.size()); + + tracker.assertAllConnectionsClosed(); + tracker.assertAllStatementsAndResultSetsClosed(); + } + private record User( long id, String name ) { } + + /** + * Records the JDBC resources an executor opened, so a test can assert which + * of them were closed. A tracked object is returned as a dynamic proxy that + * delegates every call and tracks the connections, prepared statements, and + * result sets it hands out. + */ + private static final class JdbcResourceTracker { + + private final List connections = new ArrayList<>(); + + private final List statements = new ArrayList<>(); + + private final List resultSets = new ArrayList<>(); + + private T track(Class type, Object delegate) { + return type.cast( + Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] { type }, + (proxy, method, arguments) -> record(invoke(delegate, method, arguments)) + ) + ); + } + + private Object record(Object result) { + if (result instanceof Connection connection) { + connections.add(connection); + + return track(Connection.class, connection); + } + + if (result instanceof PreparedStatement statement) { + statements.add(statement); + + return track(PreparedStatement.class, statement); + } + + if (result instanceof ResultSet resultSet) { + resultSets.add(resultSet); + + return track(ResultSet.class, resultSet); + } + + return result; + } + + private Object invoke(Object delegate, Method method, Object[] arguments) throws Throwable { + try { + return method.invoke(delegate, arguments); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + private void assertAllConnectionsClosed() throws SQLException { + for (Connection connection : connections) { + assertTrue(connection.isClosed()); + } + } + + private void assertAllStatementsAndResultSetsClosed() throws SQLException { + for (PreparedStatement statement : statements) { + assertTrue(statement.isClosed()); + } + + for (ResultSet resultSet : resultSets) { + assertTrue(resultSet.isClosed()); + } + } + } }