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:
+ *
+ *
+ *
{@link #JdbcQueryExecutor(DataSource)} obtains one connection per
+ * operation and closes it before the operation returns. Each operation
+ * therefore runs on that connection's own transaction state, typically one
+ * auto-committed statement.
+ *
{@link #JdbcQueryExecutor(Connection)} 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
+ * application alone controls the connection's lifetime and transaction, so
+ * several generated operations can take part in one application-controlled
+ * commit or rollback.
+ *
+ *
+ *
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