Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
119 changes: 94 additions & 25 deletions src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,85 @@
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
* Executes generated queries over JDBC.
*
* <p>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:
*
* <ul>
* <li>{@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.</li>
* <li>{@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.</li>
* </ul>
*
* <p>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.
*
* <p>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.
*
* <p>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> T query(String sql, List<?> parameters, RowMapper<T> 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;
}

return mapper.map(resultSet);
}
} catch (SQLException e) {
throw new QueryExecutionException(
"Failed to execute query",
e
);
}
});
}

@Override
public <T> List<T> queryMany(String sql, List<?> parameters, RowMapper<T> 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<T> results = new ArrayList<>();

Expand All @@ -56,6 +95,30 @@ public <T> List<T> queryMany(String sql, List<?> parameters, RowMapper<T> 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> T onStatement(
String sql,
List<?> parameters,
StatementOperation<T> 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",
Expand All @@ -64,15 +127,16 @@ public <T> List<T> queryMany(String sql, List<?> parameters, RowMapper<T> mapper
}
}

@Override
public int execute(String sql, List<?> parameters) {
try (
Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)
) {
private <T> T onConnection(
Connection target,
String sql,
List<?> parameters,
StatementOperation<T> 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",
Expand All @@ -86,4 +150,9 @@ private void bindParameters(PreparedStatement statement, List<?> parameters) thr
statement.setObject(i + 1, parameters.get(i));
}
}

private interface StatementOperation<T> {

T run(PreparedStatement statement) throws SQLException;
}
}
Loading
Loading