From 35187776c58166c8e5e03b584c0924dc6df2facd Mon Sep 17 00:00:00 2001 From: Christoph Pirkl <4711730+kaklakariada@users.noreply.github.com> Date: Mon, 6 Jan 2025 20:17:15 +0100 Subject: [PATCH] Statement batching (#45) * Rename executeStatement methods to executeUpdate * Add batch() method to DbOperations * Use executeUpdate() instead of execute() * Use simple statement for queries without parameter --------- Co-authored-by: kaklakariada Co-authored-by: kaklakariada --- CHANGELOG.md | 1 + .../jdbc/BatchInsertPerformanceTest.java | 12 +- .../org/itsallcode/jdbc/ExasolTypeTest.java | 4 +- src/main/java/module-info.java | 8 +- .../itsallcode/jdbc/ConnectionWrapper.java | 55 +++++--- .../org/itsallcode/jdbc/DbOperations.java | 32 +++-- .../org/itsallcode/jdbc/SimpleConnection.java | 19 ++- .../jdbc/SimplePreparedStatement.java | 16 ++- .../org/itsallcode/jdbc/SimpleStatement.java | 90 ++++++++++++ .../java/org/itsallcode/jdbc/Transaction.java | 19 ++- .../java/org/itsallcode/jdbc/batch/Batch.java | 66 +++++++++ .../itsallcode/jdbc/batch/BatchInsert.java | 38 +---- .../itsallcode/jdbc/batch/StatementBatch.java | 38 +++++ .../jdbc/batch/StatementBatchBuilder.java | 44 ++++++ .../jdbc/ConnectionWrapperTest.java | 133 ++++++++++++++---- .../jdbc/SimpleConnectionITest.java | 49 +++++-- .../itsallcode/jdbc/SimpleConnectionTest.java | 15 +- .../jdbc/SimplePreparedStatementTest.java | 14 +- .../itsallcode/jdbc/SimpleStatementTest.java | 111 +++++++++++++++ .../org/itsallcode/jdbc/TransactionITest.java | 49 +++++-- .../org/itsallcode/jdbc/TransactionTest.java | 7 +- .../org/itsallcode/jdbc/batch/BatchTest.java | 72 ++++++++++ .../jdbc/batch/StatementBatchTest.java | 68 +++++++++ .../itsallcode/jdbc/example/ExampleTest.java | 28 +++- 24 files changed, 833 insertions(+), 155 deletions(-) create mode 100644 src/main/java/org/itsallcode/jdbc/SimpleStatement.java create mode 100644 src/main/java/org/itsallcode/jdbc/batch/Batch.java create mode 100644 src/main/java/org/itsallcode/jdbc/batch/StatementBatch.java create mode 100644 src/main/java/org/itsallcode/jdbc/batch/StatementBatchBuilder.java create mode 100644 src/test/java/org/itsallcode/jdbc/SimpleStatementTest.java create mode 100644 src/test/java/org/itsallcode/jdbc/batch/BatchTest.java create mode 100644 src/test/java/org/itsallcode/jdbc/batch/StatementBatchTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c41e538..23f26f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [PR #42](https://github.com/itsallcode/simple-jdbc/pull/42): Allow direct access to `Connection` - [PR #43](https://github.com/itsallcode/simple-jdbc/pull/43): Allow direct access to `Connection` from `DbOperations` - [PR #44](https://github.com/itsallcode/simple-jdbc/pull/44): Add `GenericDialect` for unsupported databases +- [PR #45](https://github.com/itsallcode/simple-jdbc/pull/45): Rename `executeStatement()` to `executeUpdate()` and return row count (**Breaking change**) ## [0.9.0] - 2024-12-23 diff --git a/src/integrationTest/java/org/itsallcode/jdbc/BatchInsertPerformanceTest.java b/src/integrationTest/java/org/itsallcode/jdbc/BatchInsertPerformanceTest.java index c6b9b8f..5f6405f 100644 --- a/src/integrationTest/java/org/itsallcode/jdbc/BatchInsertPerformanceTest.java +++ b/src/integrationTest/java/org/itsallcode/jdbc/BatchInsertPerformanceTest.java @@ -9,6 +9,7 @@ import java.util.stream.Stream; import org.itsallcode.jdbc.batch.*; +import org.itsallcode.jdbc.dialect.GenericDialect; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,15 +36,16 @@ void rowStatementSetter() { } private RowBatchInsertBuilder rowTestee() { - final PreparedStatement stmt = createNoopPreparedStatement(); - return new RowBatchInsertBuilder(sql -> new SimplePreparedStatement(null, null, stmt, "sql")) - .maxBatchSize(MAX_BATCH_SIZE); + return new RowBatchInsertBuilder(this::prepareStatement).maxBatchSize(MAX_BATCH_SIZE); } private BatchInsertBuilder testee() { + return new BatchInsertBuilder(this::prepareStatement).maxBatchSize(MAX_BATCH_SIZE); + } + + private SimplePreparedStatement prepareStatement(final String sql) { final PreparedStatement stmt = createNoopPreparedStatement(); - return new BatchInsertBuilder(sql -> new SimplePreparedStatement(null, null, stmt, "sql")) - .maxBatchSize(MAX_BATCH_SIZE); + return new SimplePreparedStatement(Context.builder().build(), GenericDialect.INSTANCE, stmt, sql); } private PreparedStatement createNoopPreparedStatement() { diff --git a/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java b/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java index d77a9b2..5c680f2 100644 --- a/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java +++ b/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java @@ -180,8 +180,8 @@ record TypeTest(String value, String type, Object expectedValue, JDBCType expect void batchInsert() { final LocalDate date = LocalDate.parse("2024-10-20"); try (final SimpleConnection connection = connect()) { - connection.executeStatement("create schema test"); - connection.executeStatement("create table tab(col date)"); + connection.executeUpdate("create schema test"); + connection.executeUpdate("create table tab(col date)"); connection.batchInsert(LocalDate.class).into("TAB", List.of("COL")) .mapping((row, stmt) -> stmt.setObject(1, row)).rows(Stream.of(date)).start(); try (SimpleResultSet resultSet = connection.query("select * from tab", diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 5bb8344..0e77de5 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -15,11 +15,13 @@ *
  • Execute statements *
      *
    • ... single statement: - * {@link org.itsallcode.jdbc.DbOperations#executeStatement(String)}
    • + * {@link org.itsallcode.jdbc.DbOperations#executeUpdate(String)} *
    • ... with a prepared statement and generic parameters: - * {@link org.itsallcode.jdbc.DbOperations#executeStatement(String, List)}
    • + * {@link org.itsallcode.jdbc.DbOperations#executeUpdate(String, List)} *
    • ... with a prepared statement and custom parameter setter: - * {@link org.itsallcode.jdbc.DbOperations#executeStatement(String, org.itsallcode.jdbc.PreparedStatementSetter)}
    • + * {@link org.itsallcode.jdbc.DbOperations#executeUpdate(String, org.itsallcode.jdbc.PreparedStatementSetter)} + *
    • ... multiple statements in a batch: + * {@link org.itsallcode.jdbc.DbOperations#batch()}
    • * *
    • ... semicolon separated SQL script: * {@link org.itsallcode.jdbc.DbOperations#executeScript(String)}
    • diff --git a/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java b/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java index 17c2c15..aa852c2 100644 --- a/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java +++ b/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java @@ -3,12 +3,10 @@ import static java.util.function.Predicate.not; import java.sql.*; -import java.util.Arrays; -import java.util.Objects; +import java.util.*; import java.util.logging.Logger; -import org.itsallcode.jdbc.batch.BatchInsertBuilder; -import org.itsallcode.jdbc.batch.RowBatchInsertBuilder; +import org.itsallcode.jdbc.batch.*; import org.itsallcode.jdbc.dialect.DbDialect; import org.itsallcode.jdbc.resultset.*; import org.itsallcode.jdbc.resultset.generic.Row; @@ -34,28 +32,37 @@ class ConnectionWrapper implements AutoCloseable { this.paramSetterProvider = new ParamSetterProvider(dialect); } - void executeStatement(final String sql) { - this.executeStatement(sql, ps -> { - }); + int executeUpdate(final String sql) { + try (SimpleStatement statement = createSimpleStatement()) { + return statement.executeUpdate(sql); + } } - void executeStatement(final String sql, final PreparedStatementSetter preparedStatementSetter) { + int executeUpdate(final String sql, final PreparedStatementSetter preparedStatementSetter) { try (SimplePreparedStatement statement = prepareStatement(sql)) { statement.setValues(preparedStatementSetter); - statement.execute(); + return statement.executeUpdate(); } } void executeScript(final String sqlScript) { - Arrays.stream(sqlScript.split(";")) + final List statements = Arrays.stream(sqlScript.split(";")) .map(String::trim) .filter(not(String::isEmpty)) - .forEach(this::executeStatement); + .toList(); + if (statements.isEmpty()) { + return; + } + try (StatementBatch batch = this.batch().build()) { + statements.forEach(batch::addBatch); + } } SimpleResultSet query(final String sql) { - return this.query(sql, ps -> { - }, ContextRowMapper.generic(dialect)); + LOG.finest(() -> "Executing query '" + sql + "'..."); + final SimpleStatement statement = createSimpleStatement(); + // TODO: close statement when resultset is closed + return statement.executeQuery(sql, ContextRowMapper.create(ContextRowMapper.generic(dialect))); } SimpleResultSet query(final String sql, final PreparedStatementSetter preparedStatementSetter, @@ -63,15 +70,21 @@ SimpleResultSet query(final String sql, final PreparedStatementSetter pre LOG.finest(() -> "Executing query '" + sql + "'..."); final SimplePreparedStatement statement = prepareStatement(sql); statement.setValues(preparedStatementSetter); + // TODO: close statement when resultset is closed return statement.executeQuery(ContextRowMapper.create(rowMapper)); } - SimplePreparedStatement prepareStatement(final String sql) { - return new SimplePreparedStatement(context, dialect, wrap(prepare(sql)), sql); + private SimplePreparedStatement prepareStatement(final String sql) { + return new SimplePreparedStatement(context, dialect, + new ConvertingPreparedStatement(prepare(sql), paramSetterProvider), sql); + } + + StatementBatchBuilder batch() { + return new StatementBatchBuilder(this::createSimpleStatement); } - private PreparedStatement wrap(final PreparedStatement preparedStatement) { - return new ConvertingPreparedStatement(preparedStatement, paramSetterProvider); + private SimpleStatement createSimpleStatement() { + return new SimpleStatement(context, dialect, createStatement()); } BatchInsertBuilder batchInsert() { @@ -90,6 +103,14 @@ private PreparedStatement prepare(final String sql) { } } + private Statement createStatement() { + try { + return connection.createStatement(); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error creating statement", e); + } + } + void setAutoCommit(final boolean autoCommit) { try { connection.setAutoCommit(autoCommit); diff --git a/src/main/java/org/itsallcode/jdbc/DbOperations.java b/src/main/java/org/itsallcode/jdbc/DbOperations.java index d7b305a..f1f0db7 100644 --- a/src/main/java/org/itsallcode/jdbc/DbOperations.java +++ b/src/main/java/org/itsallcode/jdbc/DbOperations.java @@ -6,8 +6,7 @@ import java.util.List; import java.util.stream.Stream; -import org.itsallcode.jdbc.batch.BatchInsertBuilder; -import org.itsallcode.jdbc.batch.RowBatchInsertBuilder; +import org.itsallcode.jdbc.batch.*; import org.itsallcode.jdbc.resultset.RowMapper; import org.itsallcode.jdbc.resultset.SimpleResultSet; import org.itsallcode.jdbc.resultset.generic.Row; @@ -29,24 +28,25 @@ public interface DbOperations extends AutoCloseable { * Execute a single SQL statement. * * @param sql SQL statement + * @return either the row count for SQL Data Manipulation Language (DML) + * statements or 0 for SQL statements that return nothing */ - default void executeStatement(final String sql) { - this.executeStatement(sql, stmt -> { - }); - } + int executeUpdate(final String sql); /** * Execute a single SQL statement as a prepared statement with placeholders. *

      * This will use {@link PreparedStatement#setObject(int, Object)} for setting * parameters. If you need more control, use - * {@link #executeStatement(String, PreparedStatementSetter)}. + * {@link #executeUpdate(String, PreparedStatementSetter)}. * * @param sql SQL statement * @param parameters parameters to set in the prepared statement + * @return either the row count for SQL Data Manipulation Language (DML) + * statements or 0 for SQL statements that return nothing */ - default void executeStatement(final String sql, final List parameters) { - this.executeStatement(sql, new GenericParameterSetter(parameters)); + default int executeUpdate(final String sql, final List parameters) { + return this.executeUpdate(sql, new GenericParameterSetter(parameters)); } /** @@ -54,8 +54,10 @@ default void executeStatement(final String sql, final List parameters) { * * @param sql SQL statement * @param preparedStatementSetter prepared statement setter + * @return either the row count for SQL Data Manipulation Language (DML) + * statements or 0 for SQL statements that return nothing */ - void executeStatement(final String sql, PreparedStatementSetter preparedStatementSetter); + int executeUpdate(final String sql, PreparedStatementSetter preparedStatementSetter); /** * Execute a SQL query and return a {@link SimpleResultSet result set} with @@ -86,7 +88,7 @@ default SimpleResultSet query(final String sql, final RowMapper rowMap *

      * This will use {@link PreparedStatement#setObject(int, Object)} for setting * parameters. If you need more control, use - * {@link #executeStatement(String, PreparedStatementSetter)}. + * {@link #executeUpdate(String, PreparedStatementSetter)}. * * @param generic row type * @param sql SQL query @@ -112,6 +114,14 @@ default SimpleResultSet query(final String sql, final List parame SimpleResultSet query(final String sql, final PreparedStatementSetter preparedStatementSetter, final RowMapper rowMapper); + /** + * Create a batch statement builder for executing multiple statements in a + * batch. + * + * @return batch statement builder + */ + StatementBatchBuilder batch(); + /** * Create a batch insert builder for inserting rows by directly setting values * of a {@link PreparedStatement}. diff --git a/src/main/java/org/itsallcode/jdbc/SimpleConnection.java b/src/main/java/org/itsallcode/jdbc/SimpleConnection.java index 09f6c65..7718b02 100644 --- a/src/main/java/org/itsallcode/jdbc/SimpleConnection.java +++ b/src/main/java/org/itsallcode/jdbc/SimpleConnection.java @@ -2,8 +2,7 @@ import java.sql.Connection; -import org.itsallcode.jdbc.batch.BatchInsertBuilder; -import org.itsallcode.jdbc.batch.RowBatchInsertBuilder; +import org.itsallcode.jdbc.batch.*; import org.itsallcode.jdbc.dialect.DbDialect; import org.itsallcode.jdbc.resultset.RowMapper; import org.itsallcode.jdbc.resultset.SimpleResultSet; @@ -69,6 +68,12 @@ private void checkOperationAllowed() { } } + @Override + public int executeUpdate(final String sql) { + checkOperationAllowed(); + return connection.executeUpdate(sql); + } + @Override public void executeScript(final String sqlScript) { checkOperationAllowed(); @@ -76,9 +81,9 @@ public void executeScript(final String sqlScript) { } @Override - public void executeStatement(final String sql, final PreparedStatementSetter preparedStatementSetter) { + public int executeUpdate(final String sql, final PreparedStatementSetter preparedStatementSetter) { checkOperationAllowed(); - connection.executeStatement(sql, preparedStatementSetter); + return connection.executeUpdate(sql, preparedStatementSetter); } @Override @@ -94,6 +99,12 @@ public SimpleResultSet query(final String sql, final PreparedStatementSet return connection.query(sql, preparedStatementSetter, rowMapper); } + @Override + public StatementBatchBuilder batch() { + checkOperationAllowed(); + return connection.batch(); + } + @Override public BatchInsertBuilder batchInsert() { checkOperationAllowed(); diff --git a/src/main/java/org/itsallcode/jdbc/SimplePreparedStatement.java b/src/main/java/org/itsallcode/jdbc/SimplePreparedStatement.java index b0ad295..36d49eb 100644 --- a/src/main/java/org/itsallcode/jdbc/SimplePreparedStatement.java +++ b/src/main/java/org/itsallcode/jdbc/SimplePreparedStatement.java @@ -1,6 +1,7 @@ package org.itsallcode.jdbc; import java.sql.*; +import java.util.Objects; import org.itsallcode.jdbc.dialect.DbDialect; import org.itsallcode.jdbc.resultset.*; @@ -16,10 +17,10 @@ public class SimplePreparedStatement implements AutoCloseable { SimplePreparedStatement(final Context context, final DbDialect dialect, final PreparedStatement statement, final String sql) { - this.context = context; - this.dialect = dialect; - this.statement = statement; - this.sql = sql; + this.context = Objects.requireNonNull(context, "context"); + this.dialect = Objects.requireNonNull(dialect, "dialect"); + this.statement = Objects.requireNonNull(statement, "statement"); + this.sql = Objects.requireNonNull(sql, "sql"); } SimpleResultSet executeQuery(final ContextRowMapper rowMapper) { @@ -36,9 +37,12 @@ private ResultSet doExecuteQuery() { } } - boolean execute() { + /** + * @see PreparedStatement#executeUpdate() + */ + int executeUpdate() { try { - return statement.execute(); + return statement.executeUpdate(); } catch (final SQLException e) { throw new UncheckedSQLException("Error executing statement '" + sql + "'", e); } diff --git a/src/main/java/org/itsallcode/jdbc/SimpleStatement.java b/src/main/java/org/itsallcode/jdbc/SimpleStatement.java new file mode 100644 index 0000000..69e9f5e --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/SimpleStatement.java @@ -0,0 +1,90 @@ +package org.itsallcode.jdbc; + +import java.sql.*; +import java.util.Objects; + +import org.itsallcode.jdbc.dialect.DbDialect; +import org.itsallcode.jdbc.resultset.*; + +/** + * Simple wrapper for a JDBC {@link Statement}. + */ +public class SimpleStatement implements AutoCloseable { + private final Context context; + private final DbDialect dialect; + private final Statement statement; + + SimpleStatement(final Context context, final DbDialect dialect, final Statement statement) { + this.context = Objects.requireNonNull(context, "context"); + this.dialect = Objects.requireNonNull(dialect, "dialect"); + this.statement = Objects.requireNonNull(statement, "statement"); + } + + SimpleResultSet executeQuery(final String sql, final ContextRowMapper rowMapper) { + final ResultSet resultSet = doExecuteQuery(sql); + final ResultSet convertingResultSet = ConvertingResultSet.create(dialect, resultSet); + return new SimpleResultSet<>(context, convertingResultSet, rowMapper); + } + + private ResultSet doExecuteQuery(final String sql) { + try { + return statement.executeQuery(sql); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error executing query '" + sql + "'", e); + } + } + + int executeUpdate(final String sql) { + try { + return statement.executeUpdate(sql); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error executing statement '" + sql + "'", e); + } + } + + /** + * Execute the batch statement. + * + * @return array of update counts + * @see Statement#executeBatch() + */ + public int[] executeBatch() { + try { + return statement.executeBatch(); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error executing batch", e); + } + } + + /** + * Add the SQL statement to the batch. + * + * @param sql SQL statement + * @see Statement#addBatch(String) + */ + public void addBatch(final String sql) { + try { + this.statement.addBatch(sql); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error adding batch", e); + } + } + + /** + * Get the underlying {@link Statement}. + * + * @return the underlying {@link Statement} + */ + public Statement getStatement() { + return statement; + } + + @Override + public void close() { + try { + statement.close(); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error closing statement", e); + } + } +} diff --git a/src/main/java/org/itsallcode/jdbc/Transaction.java b/src/main/java/org/itsallcode/jdbc/Transaction.java index 5530a7e..4b44dfb 100644 --- a/src/main/java/org/itsallcode/jdbc/Transaction.java +++ b/src/main/java/org/itsallcode/jdbc/Transaction.java @@ -3,8 +3,7 @@ import java.sql.Connection; import java.util.function.Consumer; -import org.itsallcode.jdbc.batch.BatchInsertBuilder; -import org.itsallcode.jdbc.batch.RowBatchInsertBuilder; +import org.itsallcode.jdbc.batch.*; import org.itsallcode.jdbc.resultset.RowMapper; import org.itsallcode.jdbc.resultset.SimpleResultSet; import org.itsallcode.jdbc.resultset.generic.Row; @@ -75,9 +74,15 @@ public void rollback() { } @Override - public void executeStatement(final String sql, final PreparedStatementSetter preparedStatementSetter) { + public int executeUpdate(final String sql) { checkOperationAllowed(); - connection.executeStatement(sql, preparedStatementSetter); + return connection.executeUpdate(sql); + } + + @Override + public int executeUpdate(final String sql, final PreparedStatementSetter preparedStatementSetter) { + checkOperationAllowed(); + return connection.executeUpdate(sql, preparedStatementSetter); } @Override @@ -99,6 +104,12 @@ public SimpleResultSet query(final String sql, final PreparedStatementSet return connection.query(sql, preparedStatementSetter, rowMapper); } + @Override + public StatementBatchBuilder batch() { + checkOperationAllowed(); + return connection.batch(); + } + @Override public BatchInsertBuilder batchInsert() { checkOperationAllowed(); diff --git a/src/main/java/org/itsallcode/jdbc/batch/Batch.java b/src/main/java/org/itsallcode/jdbc/batch/Batch.java new file mode 100644 index 0000000..00c5bb6 --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/batch/Batch.java @@ -0,0 +1,66 @@ +package org.itsallcode.jdbc.batch; + +import java.time.Duration; +import java.time.Instant; +import java.util.logging.Logger; + +/** + * A generic batch handler that takes care of maximum batch size and executing + * the last batch before closing. + */ +class Batch implements AutoCloseable { + private static final Logger LOG = Logger.getLogger(Batch.class.getName()); + + private final Runnable batchExecutor; + private final AutoCloseable resource; + private final int maxBatchSize; + private final Instant start; + + private int totalCount; + private int currentBatchSize; + + Batch(final int maxBatchSize, final AutoCloseable resource, final Runnable batchExecutor) { + this.maxBatchSize = maxBatchSize; + this.resource = resource; + this.batchExecutor = batchExecutor; + this.start = Instant.now(); + } + + void addBatch() { + this.currentBatchSize++; + this.totalCount++; + if (this.currentBatchSize >= this.maxBatchSize) { + this.executeBatch(); + } + } + + private void executeBatch() { + if (currentBatchSize == 0) { + LOG.finest("No items added to batch, skip"); + return; + } + final Instant batchStart = Instant.now(); + batchExecutor.run(); + final Duration duration = Duration.between(batchStart, Instant.now()); + LOG.finest(() -> "Execute batch of %d after %d took %d ms".formatted(currentBatchSize, totalCount, + duration.toMillis())); + currentBatchSize = 0; + } + + @Override + public void close() { + executeBatch(); + closeResource(); + LOG.fine(() -> "Batch processing of %d items with batch size %d completed in %s".formatted(totalCount, + maxBatchSize, + Duration.between(start, Instant.now()))); + } + + private void closeResource() { + try { + resource.close(); + } catch (final Exception e) { + throw new IllegalStateException("Failed to close resource: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/itsallcode/jdbc/batch/BatchInsert.java b/src/main/java/org/itsallcode/jdbc/batch/BatchInsert.java index 2b6f122..08ff528 100644 --- a/src/main/java/org/itsallcode/jdbc/batch/BatchInsert.java +++ b/src/main/java/org/itsallcode/jdbc/batch/BatchInsert.java @@ -1,10 +1,7 @@ package org.itsallcode.jdbc.batch; import java.sql.PreparedStatement; -import java.time.Duration; -import java.time.Instant; import java.util.Objects; -import java.util.logging.Logger; import org.itsallcode.jdbc.*; @@ -13,19 +10,12 @@ * using {@link SimpleConnection#batchInsert()}. */ public class BatchInsert implements AutoCloseable { - private static final Logger LOG = Logger.getLogger(BatchInsert.class.getName()); - - private final int maxBatchSize; + private final Batch batch; private final SimplePreparedStatement statement; - private final Instant start; - - private int rows; - private int currentBatchSize; BatchInsert(final SimplePreparedStatement statement, final int maxBatchSize) { this.statement = Objects.requireNonNull(statement, "statement"); - this.maxBatchSize = maxBatchSize; - this.start = Instant.now(); + this.batch = new Batch(maxBatchSize, statement, statement::executeBatch); } /** @@ -66,31 +56,11 @@ public PreparedStatement getStatement() { */ public void addBatch() { statement.addBatch(); - currentBatchSize++; - rows++; - if (rows % maxBatchSize == 0) { - executeBatch(); - } - } - - private void executeBatch() { - if (currentBatchSize == 0) { - LOG.finest("No rows added to batch, skip"); - return; - } - final Instant batchStart = Instant.now(); - final int[] result = statement.executeBatch(); - final Duration duration = Duration.between(batchStart, Instant.now()); - LOG.finest(() -> "Execute batch of %d after %d took %d ms, result length: %s".formatted(currentBatchSize, rows, - duration.toMillis(), result.length)); - currentBatchSize = 0; + batch.addBatch(); } @Override public void close() { - executeBatch(); - LOG.fine(() -> "Batch insert of %d rows with batch size %d completed in %s".formatted(rows, maxBatchSize, - Duration.between(start, Instant.now()))); - statement.close(); + batch.close(); } } diff --git a/src/main/java/org/itsallcode/jdbc/batch/StatementBatch.java b/src/main/java/org/itsallcode/jdbc/batch/StatementBatch.java new file mode 100644 index 0000000..84871eb --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/batch/StatementBatch.java @@ -0,0 +1,38 @@ +package org.itsallcode.jdbc.batch; + +import org.itsallcode.jdbc.SimpleStatement; + +/** + * A batch handler for SQL statements. + */ +public class StatementBatch implements AutoCloseable { + + private final Batch batch; + private final SimpleStatement statement; + + /** + * Create a new instance. + * + * @param statement the statement + * @param maxBatchSize maximum batch size + */ + public StatementBatch(final SimpleStatement statement, final int maxBatchSize) { + this.statement = statement; + this.batch = new Batch(maxBatchSize, statement, statement::executeBatch); + } + + /** + * Add a new SQL statement to the batch. + * + * @param sql SQL statement + */ + public void addBatch(final String sql) { + statement.addBatch(sql); + this.batch.addBatch(); + } + + @Override + public void close() { + this.batch.close(); + } +} diff --git a/src/main/java/org/itsallcode/jdbc/batch/StatementBatchBuilder.java b/src/main/java/org/itsallcode/jdbc/batch/StatementBatchBuilder.java new file mode 100644 index 0000000..66c9f55 --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/batch/StatementBatchBuilder.java @@ -0,0 +1,44 @@ +package org.itsallcode.jdbc.batch; + +import java.util.function.Supplier; + +import org.itsallcode.jdbc.SimpleStatement; + +/** + * A builder for {@link StatementBatch}. + */ +public class StatementBatchBuilder { + private final Supplier statementFactory; + private int maxBatchSize = BatchInsertBuilder.DEFAULT_MAX_BATCH_SIZE; + + /** + * Create a new instance. + * + * @param statementFactory factory for creating {@link SimpleStatement}. + */ + public StatementBatchBuilder(final Supplier statementFactory) { + this.statementFactory = statementFactory; + } + + /** + * Define maximum batch size, using + * {@link BatchInsertBuilder#DEFAULT_MAX_BATCH_SIZE} as default. + * + * @param maxBatchSize maximum batch size + * @return {@code this} for fluent programming + */ + public StatementBatchBuilder maxBatchSize(final int maxBatchSize) { + this.maxBatchSize = maxBatchSize; + return this; + } + + /** + * Build the batch inserter. + * + * @return the statement batch + */ + public StatementBatch build() { + final SimpleStatement statement = statementFactory.get(); + return new StatementBatch(statement, this.maxBatchSize); + } +} diff --git a/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java b/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java index e12b448..caa1152 100644 --- a/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java +++ b/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java @@ -2,12 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.sql.*; +import java.util.List; +import java.util.stream.Stream; -import org.itsallcode.jdbc.dialect.H2Dialect; +import org.itsallcode.jdbc.batch.BatchInsert; +import org.itsallcode.jdbc.dialect.GenericDialect; +import org.itsallcode.jdbc.resultset.ContextRowMapper; +import org.itsallcode.jdbc.resultset.SimpleResultSet; +import org.itsallcode.jdbc.resultset.generic.Row; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; @@ -22,69 +27,139 @@ class ConnectionWrapperTest { @Mock Connection connectionMock; @Mock + Statement statementMock; + @Mock PreparedStatement preparedStatementMock; ConnectionWrapper testee() { - return new ConnectionWrapper(connectionMock, Context.builder().build(), new H2Dialect()); + return new ConnectionWrapper(connectionMock, Context.builder().build(), GenericDialect.INSTANCE); } @Test void executeStatementPrepareFails() throws SQLException { - when(connectionMock.prepareStatement("sql")).thenThrow(new SQLException("expected")); + when(connectionMock.createStatement()).thenThrow(new SQLException("expected")); final ConnectionWrapper testee = testee(); - assertThatThrownBy(() -> testee.executeStatement("sql")) + assertThatThrownBy(() -> testee.executeUpdate("sql")) .isInstanceOf(UncheckedSQLException.class) - .hasMessage("Error preparing statement 'sql': expected"); + .hasMessage("Error creating statement: expected"); } @Test void executeStatementExecuteFails() throws SQLException { - when(connectionMock.prepareStatement("sql")).thenReturn(preparedStatementMock); - when(preparedStatementMock.execute()).thenThrow(new SQLException("expected")); + when(connectionMock.createStatement()).thenReturn(statementMock); + when(statementMock.executeUpdate("sql")).thenThrow(new SQLException("expected")); final ConnectionWrapper testee = testee(); - assertThatThrownBy(() -> testee.executeStatement("sql")) + assertThatThrownBy(() -> testee.executeUpdate("sql")) .isInstanceOf(UncheckedSQLException.class) .hasMessage("Error executing statement 'sql': expected"); } @Test void executeStatementCloseFails() throws SQLException { - when(connectionMock.prepareStatement("sql")).thenReturn(preparedStatementMock); - doThrow(new SQLException("expected")).when(preparedStatementMock).close(); + when(connectionMock.createStatement()).thenReturn(statementMock); + doThrow(new SQLException("expected")).when(statementMock).close(); final ConnectionWrapper testee = testee(); - assertThatThrownBy(() -> testee.executeStatement("sql")) + assertThatThrownBy(() -> testee.executeUpdate("sql")) .isInstanceOf(UncheckedSQLException.class) .hasMessage("Error closing statement: expected"); } @Test void executeStatement() throws SQLException { + when(connectionMock.createStatement()).thenReturn(statementMock); + testee().executeUpdate("sql"); + final InOrder inOrder = inOrder(connectionMock, statementMock); + inOrder.verify(connectionMock).createStatement(); + inOrder.verify(statementMock).executeUpdate("sql"); + inOrder.verify(statementMock).close(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void executeStatementWithPreparedStatementSetter() throws SQLException { when(connectionMock.prepareStatement("sql")).thenReturn(preparedStatementMock); - testee().executeStatement("sql"); + testee().executeUpdate("sql", ps -> { + ps.setString(1, "one"); + }); final InOrder inOrder = inOrder(connectionMock, preparedStatementMock); inOrder.verify(connectionMock).prepareStatement("sql"); - inOrder.verify(preparedStatementMock).execute(); + inOrder.verify(preparedStatementMock).setString(1, "one"); + inOrder.verify(preparedStatementMock).executeUpdate(); inOrder.verify(preparedStatementMock).close(); inOrder.verifyNoMoreInteractions(); } @Test - void executeStatementWithPreparedStatementSetter() throws SQLException { + void queryWithPreparedStatementSetterAndRowMapper() throws SQLException { when(connectionMock.prepareStatement("sql")).thenReturn(preparedStatementMock); - testee().executeStatement("sql", ps -> { + when(preparedStatementMock.executeQuery()).thenReturn(mock(ResultSet.class, RETURNS_DEEP_STUBS)); + final SimpleResultSet result = testee().query("sql", ps -> { ps.setString(1, "one"); - }); + }, ContextRowMapper.generic(GenericDialect.INSTANCE)); + assertThat(result).isNotNull(); final InOrder inOrder = inOrder(connectionMock, preparedStatementMock); inOrder.verify(connectionMock).prepareStatement("sql"); inOrder.verify(preparedStatementMock).setString(1, "one"); - inOrder.verify(preparedStatementMock).execute(); + inOrder.verify(preparedStatementMock).executeQuery(); + inOrder.verify(preparedStatementMock, never()).close(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void preparedStatementFails() throws SQLException { + when(connectionMock.prepareStatement("sql")).thenThrow(new SQLException("expected")); + final ConnectionWrapper testee = testee(); + assertThatThrownBy(() -> testee.executeUpdate("sql", ps -> { + ps.setString(1, "one"); + })).isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error preparing statement 'sql': expected"); + } + + @Test + void query() throws SQLException { + when(connectionMock.createStatement()).thenReturn(statementMock); + when(statementMock.executeQuery("sql")).thenReturn(mock(ResultSet.class, RETURNS_DEEP_STUBS)); + final SimpleResultSet result = testee().query("sql"); + assertThat(result).isNotNull(); + } + + @Test + void batchInsert() throws SQLException { + when(connectionMock.prepareStatement("insert into \"tab\" (\"c1\",\"c2\") values (?,?)")) + .thenReturn(preparedStatementMock); + final BatchInsert batch = testee().batchInsert().into("tab", List.of("c1", "c2")).maxBatchSize(4).build(); + batch.add(ps -> { + ps.setString(1, "one"); + }); + batch.close(); + final InOrder inOrder = inOrder(connectionMock, preparedStatementMock); + inOrder.verify(connectionMock).prepareStatement("insert into \"tab\" (\"c1\",\"c2\") values (?,?)"); + inOrder.verify(preparedStatementMock).setString(1, "one"); + inOrder.verify(preparedStatementMock).addBatch(); + inOrder.verify(preparedStatementMock).executeBatch(); inOrder.verify(preparedStatementMock).close(); inOrder.verifyNoMoreInteractions(); } @Test - void executeScriptEmptyString() { - testee().executeScript(""); + void rowBatchInsert() throws SQLException { + when(connectionMock.prepareStatement("insert into \"tab\" (\"c1\",\"c2\") values (?,?)")) + .thenReturn(preparedStatementMock); + testee().rowBatchInsert().into("tab", List.of("c1", "c2")).mapping(row -> new Object[] { row }) + .rows(Stream.of("one")).maxBatchSize(4).start(); + final InOrder inOrder = inOrder(connectionMock, preparedStatementMock); + inOrder.verify(connectionMock).prepareStatement("insert into \"tab\" (\"c1\",\"c2\") values (?,?)"); + inOrder.verify(preparedStatementMock).setObject(1, "one"); + inOrder.verify(preparedStatementMock).addBatch(); + inOrder.verify(preparedStatementMock).executeBatch(); + inOrder.verify(preparedStatementMock, times(2)).close(); // TODO: why twice? + inOrder.verifyNoMoreInteractions(); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", "\n", "\t", " \n\t", ";" }) + void executeScriptEmptyString(final String script) { + testee().executeScript(script); verifyNoInteractions(connectionMock); } @@ -93,20 +168,24 @@ void executeScriptEmptyString() { "sql script;", "sql script;\n", "sql script\n;", "sql script;;", "sql script;;", ";sql script", " ; ; sql script" }) void executeScriptWithoutTrailingSemicolon(final String script) throws SQLException { - when(connectionMock.prepareStatement("sql script")).thenReturn(preparedStatementMock); + when(connectionMock.createStatement()).thenReturn(statementMock); testee().executeScript(script); - verify(connectionMock).prepareStatement("sql script"); + verify(statementMock).addBatch("sql script"); + verify(statementMock).executeBatch(); verifyNoMoreInteractions(connectionMock); } @Test void executeScriptRunsMultipleCommands() throws SQLException { - when(connectionMock.prepareStatement(anyString())).thenReturn(preparedStatementMock); + when(connectionMock.createStatement()).thenReturn(statementMock); testee().executeScript("script 1; script 2; script 3"); - verify(connectionMock).prepareStatement("script 1"); - verify(connectionMock).prepareStatement("script 2"); - verify(connectionMock).prepareStatement("script 3"); - verifyNoMoreInteractions(connectionMock); + final InOrder inOrder = inOrder(connectionMock, statementMock); + inOrder.verify(statementMock).addBatch("script 1"); + inOrder.verify(statementMock).addBatch("script 2"); + inOrder.verify(statementMock).addBatch("script 3"); + inOrder.verify(statementMock).executeBatch(); + inOrder.verify(statementMock).close(); + inOrder.verifyNoMoreInteractions(); } @Test diff --git a/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java b/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java index f2051ef..6cf7222 100644 --- a/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java +++ b/src/test/java/org/itsallcode/jdbc/SimpleConnectionITest.java @@ -9,6 +9,7 @@ import java.util.*; import java.util.stream.Stream; +import org.itsallcode.jdbc.batch.StatementBatch; import org.itsallcode.jdbc.dialect.H2Dialect; import org.itsallcode.jdbc.resultset.RowMapper; import org.itsallcode.jdbc.resultset.SimpleResultSet; @@ -25,24 +26,25 @@ class SimpleConnectionITest { void wrap() throws SQLException { try (Connection existingConnection = DriverManager.getConnection(H2TestFixture.H2_MEM_JDBC_URL); SimpleConnection connection = SimpleConnection.wrap(existingConnection, new H2Dialect())) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); - assertDoesNotThrow(() -> connection.executeStatement("select count(*) from test")); + assertThat(connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))")).isZero(); + assertDoesNotThrow(() -> connection.query("select count(*) from test")); } } @Test void executeStatement() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); - assertDoesNotThrow(() -> connection.executeStatement("select count(*) from test")); + assertThat(connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))")).isZero(); + assertDoesNotThrow(() -> connection.query("select count(*) from test")); } } @Test void executeStatementWithParameter() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); - connection.executeStatement("INSERT INTO TEST VALUES (?,?), (?,?)", List.of(1, "a", 2, "b")); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + assertThat(connection.executeUpdate("INSERT INTO TEST VALUES (?,?), (?,?)", List.of(1, "a", 2, "b"))) + .isEqualTo(2); assertThat(connection.query("select count(*) from test").toList().get(0).get(0).getValue()).isEqualTo(2L); } } @@ -50,10 +52,10 @@ void executeStatementWithParameter() { @Test void executeStatementFails() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - assertThatThrownBy(() -> connection.executeStatement("select count(*) from missing_table")) + assertThatThrownBy(() -> connection.executeUpdate("select count(*) from missing_table")) .isInstanceOf(UncheckedSQLException.class) .hasMessage( - "Error preparing statement 'select count(*) from missing_table': Table \"MISSING_TABLE\" not found (this database is empty); SQL statement:\n" + "Error executing statement 'select count(*) from missing_table': Table \"MISSING_TABLE\" not found (this database is empty); SQL statement:\n" + "select count(*) from missing_table [42104-232]") .hasCauseInstanceOf(SQLException.class); } @@ -64,7 +66,7 @@ void executeScript() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { connection.executeScript("CREATE TABLE TEST(ID INT, NAME VARCHAR(255));" + "insert into test (id, name) values (1, 'test');"); - assertDoesNotThrow(() -> connection.executeStatement("select count(*) from test")); + assertDoesNotThrow(() -> connection.query("select count(*) from test")); } } @@ -74,7 +76,7 @@ void executeQueryFails() { assertThatThrownBy( () -> connection.query("select count(*) from missing_table")) .isInstanceOf(UncheckedSQLException.class).hasMessage( - "Error preparing statement 'select count(*) from missing_table': Table \"MISSING_TABLE\" not found (this database is empty); SQL statement:\n" + "Error executing query 'select count(*) from missing_table': Table \"MISSING_TABLE\" not found (this database is empty); SQL statement:\n" + "select count(*) from missing_table [42104-232]"); } } @@ -185,8 +187,8 @@ void executeQuerySingleRow() { @Test void executeQueryWithParameter() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); - connection.executeStatement("INSERT INTO TEST VALUES (?,?), (?,?)", List.of(1, "a", 2, "b")); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("INSERT INTO TEST VALUES (?,?), (?,?)", List.of(1, "a", 2, "b")); final List result = connection.query("select * from test where id=?", List.of(2), (rs, idx) -> idx + "-" + rs.getString(2) + "-" + rs.getInt(1)).toList(); assertThat(result.get(0)).isEqualTo("0-b-2"); @@ -255,18 +257,37 @@ void insert() { } } + @Test + void batchStatement() { + try (SimpleConnection connection = H2TestFixture.createMemConnection()) { + try (StatementBatch batch = connection.batch().maxBatchSize(3).build()) { + batch.addBatch("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + batch.addBatch("INSERT INTO TEST VALUES (1, 'a')"); + batch.addBatch("INSERT INTO TEST VALUES (2, 'b')"); + batch.addBatch("INSERT INTO TEST VALUES (3, 'c')"); + batch.addBatch("INSERT INTO TEST VALUES (4, 'd')"); + } + + final List result = connection.query("select count(*) from test").stream().toList(); + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).columnValues()).hasSize(1), + () -> assertThat(result.get(0).get(0).value()).isEqualTo(4L)); + } + } + @Test void multipleTransactions() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { connection.executeScript("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("INSERT INTO TEST VALUES (?,?), (?,?)", List.of(1, "a", 2, "b")); + tx.executeUpdate("INSERT INTO TEST VALUES (?,?), (?,?)", List.of(1, "a", 2, "b")); assertThat(getRowCount(tx, "test")).isEqualTo(2); tx.commit(); } assertThat(getRowCount(connection, "test")).isEqualTo(2); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("DELETE FROM TEST WHERE ID = ?", List.of(1)); + tx.executeUpdate("DELETE FROM TEST WHERE ID = ?", List.of(1)); assertThat(getRowCount(tx, "test")).isEqualTo(1); } assertThat(getRowCount(connection, "test")).isEqualTo(2); diff --git a/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java b/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java index fe3c8a1..31919a1 100644 --- a/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java +++ b/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java @@ -34,10 +34,10 @@ class SimpleConnectionTest { @Test void queryPrepareStatementFails() throws SQLException { - when(connectionMock.prepareStatement(SQL_STATEMENT)).thenThrow(new SQLException("expected")); + when(connectionMock.createStatement()).thenThrow(new SQLException("expected")); final SimpleConnection testee = testee(); assertThatThrownBy(() -> testee.query(SQL_STATEMENT)).isInstanceOf(UncheckedSQLException.class) - .hasMessage("Error preparing statement 'query': expected"); + .hasMessage("Error creating statement: expected"); } @Test @@ -64,14 +64,15 @@ static Stream operations() { return Stream.of( operation(con -> con.startTransaction()), operation(con -> con.executeScript("script")), - operation(con -> con.executeStatement("sql")), - operation(con -> con.executeStatement("sql", preparedStatementSetterMock)), - operation(con -> con.executeStatement("sql", List.of())), + operation(con -> con.executeUpdate("sql")), + operation(con -> con.executeUpdate("sql", preparedStatementSetterMock)), + operation(con -> con.executeUpdate("sql", List.of())), operation(con -> con.query("sql")), operation(con -> con.query("sql", rowMapperMock)), operation(con -> con.query("sql", preparedStatementSetterMock, rowMapperMock)), operation(con -> con.query("sql", List.of(), rowMapperMock)), operation(con -> con.getOriginalConnection()), + operation(con -> con.batch()), operation(con -> con.batchInsert()), operation(con -> con.batchInsert(null))); } @@ -86,6 +87,7 @@ void operationSucceedsForOpenConnection(final Consumer operati final SimpleConnection testee = testee(); lenient().when(connectionMock.prepareStatement(anyString())) .thenReturn(mock(PreparedStatement.class, RETURNS_DEEP_STUBS)); + lenient().when(connectionMock.createStatement()).thenReturn(mock(Statement.class, RETURNS_DEEP_STUBS)); assertDoesNotThrow(() -> operation.accept(testee)); } @@ -115,6 +117,7 @@ void operationSucceedsAfterTransactionIsClosed(final Consumer final SimpleConnection testee = testee(); lenient().when(connectionMock.prepareStatement(anyString())) .thenReturn(mock(PreparedStatement.class, RETURNS_DEEP_STUBS)); + lenient().when(connectionMock.createStatement()).thenReturn(mock(Statement.class, RETURNS_DEEP_STUBS)); testee.startTransaction().close(); assertDoesNotThrow(() -> operation.accept(testee)); } @@ -125,6 +128,7 @@ void operationSucceedsAfterTransactionIsCommitted(final Consumer operation.accept(testee)); } @@ -135,6 +139,7 @@ void operationSucceedsAfterTransactionIsRolledBack(final Consumer operation.accept(testee)); } diff --git a/src/test/java/org/itsallcode/jdbc/SimplePreparedStatementTest.java b/src/test/java/org/itsallcode/jdbc/SimplePreparedStatementTest.java index 7303de4..dd0f22e 100644 --- a/src/test/java/org/itsallcode/jdbc/SimplePreparedStatementTest.java +++ b/src/test/java/org/itsallcode/jdbc/SimplePreparedStatementTest.java @@ -7,6 +7,7 @@ import java.sql.*; +import org.itsallcode.jdbc.dialect.GenericDialect; import org.itsallcode.jdbc.resultset.ContextRowMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -48,16 +49,16 @@ void executeQueryFails() throws SQLException { @Test void executeStatement() throws SQLException { - when(statementMock.execute()).thenReturn(true); - assertThat(testee().execute()).isTrue(); - verify(statementMock).execute(); + when(statementMock.executeUpdate()).thenReturn(2); + assertThat(testee().executeUpdate()).isEqualTo(2); + verify(statementMock).executeUpdate(); } @Test void executeStatementFails() throws SQLException { - when(statementMock.execute()).thenThrow(new SQLException("expected")); + when(statementMock.executeUpdate()).thenThrow(new SQLException("expected")); final SimplePreparedStatement testee = testee(); - assertThatThrownBy(testee::execute).isInstanceOf(UncheckedSQLException.class) + assertThatThrownBy(testee::executeUpdate).isInstanceOf(UncheckedSQLException.class) .hasMessage("Error executing statement 'query': expected"); } @@ -135,7 +136,8 @@ void getStatement() { } SimplePreparedStatement testee() { - return new SimplePreparedStatement(null, null, statementMock, SQL_QUERY); + return new SimplePreparedStatement(Context.builder().build(), GenericDialect.INSTANCE, statementMock, + SQL_QUERY); } record RowType() { diff --git a/src/test/java/org/itsallcode/jdbc/SimpleStatementTest.java b/src/test/java/org/itsallcode/jdbc/SimpleStatementTest.java new file mode 100644 index 0000000..a1926d5 --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/SimpleStatementTest.java @@ -0,0 +1,111 @@ +package org.itsallcode.jdbc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import java.sql.*; + +import org.itsallcode.jdbc.dialect.GenericDialect; +import org.itsallcode.jdbc.resultset.ContextRowMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SimpleStatementTest { + @Mock + Statement stmtMock; + @Mock + ResultSet resultSetMock; + @Mock + ResultSetMetaData metaDataMock; + @Mock + ContextRowMapper rowMapperMock; + + SimpleStatement testee() { + return new SimpleStatement(Context.builder().build(), GenericDialect.INSTANCE, stmtMock); + } + + @Test + void executeQuery() throws SQLException { + when(stmtMock.executeQuery("sql")).thenReturn(resultSetMock); + when(resultSetMock.getMetaData()).thenReturn(metaDataMock); + assertThat(testee().executeQuery("sql", rowMapperMock)).isNotNull(); + verify(stmtMock).executeQuery("sql"); + } + + @Test + void executeQueryFails() throws SQLException { + when(stmtMock.executeQuery("sql")).thenThrow(new SQLException("expected")); + final SimpleStatement testee = testee(); + assertThatThrownBy(() -> testee.executeQuery("sql", rowMapperMock)) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error executing query 'sql': expected"); + } + + @Test + void executeUpdate() throws SQLException { + when(stmtMock.executeUpdate("sql")).thenReturn(2); + assertThat(testee().executeUpdate("sql")).isEqualTo(2); + } + + @Test + void executeUpdateFails() throws SQLException { + when(stmtMock.executeUpdate("sql")).thenThrow(new SQLException("expected")); + final SimpleStatement testee = testee(); + assertThatThrownBy(() -> testee.executeUpdate("sql")) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error executing statement 'sql': expected"); + } + + @Test + void addBatch() throws SQLException { + testee().addBatch("sql"); + verify(stmtMock).addBatch("sql"); + } + + @Test + void addBatchFails() throws SQLException { + doThrow(new SQLException("expected")).when(stmtMock).addBatch("sql"); + final SimpleStatement testee = testee(); + assertThatThrownBy(() -> testee.addBatch("sql")) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error adding batch: expected"); + } + + @Test + void executeBatch() throws SQLException { + when(stmtMock.executeBatch()).thenReturn(new int[] { 2 }); + assertThat(testee().executeBatch()).isEqualTo(new int[] { 2 }); + } + + @Test + void executeBatchFails() throws SQLException { + when(stmtMock.executeBatch()).thenThrow(new SQLException("expected")); + final SimpleStatement testee = testee(); + assertThatThrownBy(testee::executeBatch) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error executing batch: expected"); + } + + @Test + void getStatement() { + assertThat(testee().getStatement()).isSameAs(stmtMock); + } + + @Test + void close() throws SQLException { + testee().close(); + verify(stmtMock).close(); + } + + @Test + void closeFails() throws SQLException { + doThrow(new SQLException("expected")).when(stmtMock).close(); + final SimpleStatement testee = testee(); + assertThatThrownBy(testee::close).isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error closing statement: expected"); + } +} diff --git a/src/test/java/org/itsallcode/jdbc/TransactionITest.java b/src/test/java/org/itsallcode/jdbc/TransactionITest.java index 1481a31..8005c5e 100644 --- a/src/test/java/org/itsallcode/jdbc/TransactionITest.java +++ b/src/test/java/org/itsallcode/jdbc/TransactionITest.java @@ -1,11 +1,14 @@ package org.itsallcode.jdbc; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import java.sql.ResultSet; import java.util.List; import java.util.stream.Stream; +import org.itsallcode.jdbc.batch.StatementBatch; import org.itsallcode.jdbc.resultset.generic.Row; import org.junit.jupiter.api.Test; @@ -13,9 +16,9 @@ class TransactionITest { @Test void commit() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("insert into test (id, name) values (1, 'test')"); + tx.executeUpdate("insert into test (id, name) values (1, 'test')"); final List resultSet = tx.query("select count(*) as result from test").toList(); assertEquals(1L, resultSet.get(0).get(0).getValue(Long.class)); tx.commit(); @@ -28,9 +31,9 @@ void commit() { @Test void explicitRollback() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("insert into test (id, name) values (1, 'test')"); + tx.executeUpdate("insert into test (id, name) values (1, 'test')"); final List resultSet = tx.query("select count(*) as result from test").toList(); assertEquals(1L, resultSet.get(0).get(0).getValue(Long.class)); tx.rollback(); @@ -43,9 +46,9 @@ void explicitRollback() { @Test void implicitRollback() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("insert into test (id, name) values (1, 'test')"); + tx.executeUpdate("insert into test (id, name) values (1, 'test')"); final List resultSet = tx.query("select count(*) as result from test").toList(); assertEquals(1L, resultSet.get(0).get(0).getValue(Long.class)); } @@ -57,9 +60,9 @@ void implicitRollback() { @Test void executeStatementWithParamSetter() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("insert into test (id, name) values (?, ?)", stmt -> { + tx.executeUpdate("insert into test (id, name) values (?, ?)", stmt -> { stmt.setInt(1, 1); stmt.setString(2, "a"); }); @@ -85,9 +88,9 @@ void executeScript() { @Test void queryWithRowMapper() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (Transaction tx = connection.startTransaction()) { - tx.executeStatement("insert into test (id, name) values (1, 'test')"); + tx.executeUpdate("insert into test (id, name) values (1, 'test')"); final List resultSet = tx.query("select * from test", (final ResultSet rs, final int rowNum) -> rs.getString("name")).toList(); assertEquals(List.of("test"), resultSet); @@ -98,9 +101,9 @@ void queryWithRowMapper() { @Test void queryWithParamSetter() { try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { - connection.executeStatement("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + connection.executeUpdate("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); try (final Transaction tx = connection.startTransaction()) { - tx.executeStatement("insert into test (id, name) values (1, 'test')"); + tx.executeUpdate("insert into test (id, name) values (1, 'test')"); final List resultSet = tx.query("select * from test where id = ?", stmt -> stmt.setInt(1, 1), (final ResultSet rs, final int rowNum) -> rs.getString("name")).toList(); @@ -109,6 +112,28 @@ void queryWithParamSetter() { } } + @Test + void batchStatement() { + try (SimpleConnection connection = H2TestFixture.createMemConnection()) { + try (Transaction tx = connection.startTransaction()) { + try (StatementBatch batch = tx.batch().maxBatchSize(3).build()) { + batch.addBatch("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + batch.addBatch("INSERT INTO TEST VALUES (1, 'a')"); + batch.addBatch("INSERT INTO TEST VALUES (2, 'b')"); + batch.addBatch("INSERT INTO TEST VALUES (3, 'c')"); + batch.addBatch("INSERT INTO TEST VALUES (4, 'd')"); + } + tx.commit(); + } + + final List result = connection.query("select count(*) from test").stream().toList(); + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).columnValues()).hasSize(1), + () -> assertThat(result.get(0).get(0).value()).isEqualTo(4L)); + } + } + @Test void batchInsert() { try (SimpleConnection connection = H2TestFixture.createMemConnection()) { diff --git a/src/test/java/org/itsallcode/jdbc/TransactionTest.java b/src/test/java/org/itsallcode/jdbc/TransactionTest.java index a2e52e5..8ccf520 100644 --- a/src/test/java/org/itsallcode/jdbc/TransactionTest.java +++ b/src/test/java/org/itsallcode/jdbc/TransactionTest.java @@ -67,13 +67,14 @@ void closeRestoresAutoCommit() { static Stream operations() { return Stream.of( operation(tx -> tx.executeScript("script")), - operation(tx -> tx.executeStatement("sql")), - operation(tx -> tx.executeStatement("sql", preparedStatementSetterMock)), - operation(tx -> tx.executeStatement("sql", List.of())), + operation(tx -> tx.executeUpdate("sql")), + operation(tx -> tx.executeUpdate("sql", preparedStatementSetterMock)), + operation(tx -> tx.executeUpdate("sql", List.of())), operation(tx -> tx.query("sql")), operation(tx -> tx.query("sql", rowMapperMock)), operation(tx -> tx.query("sql", preparedStatementSetterMock, rowMapperMock)), operation(tx -> tx.query("sql", List.of(), rowMapperMock)), + operation(tx -> tx.batch()), operation(tx -> tx.batchInsert()), operation(tx -> tx.batchInsert(null)), operation(tx -> tx.getOriginalConnection()), diff --git a/src/test/java/org/itsallcode/jdbc/batch/BatchTest.java b/src/test/java/org/itsallcode/jdbc/batch/BatchTest.java new file mode 100644 index 0000000..9c8b41e --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/batch/BatchTest.java @@ -0,0 +1,72 @@ +package org.itsallcode.jdbc.batch; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BatchTest { + + @Mock + AutoCloseable resourceMock; + @Mock + Runnable batchExecutorMock; + + @Test + void addBatchDoesNotFlush() { + final Batch testee = testee(2); + testee.addBatch(); + + final InOrder inOrder = inOrder(batchExecutorMock, resourceMock); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void addBatchFlushesAfterBatchSizeReached() { + final Batch testee = testee(2); + + testee.addBatch(); + testee.addBatch(); + + final InOrder inOrder = inOrder(batchExecutorMock, resourceMock); + inOrder.verify(batchExecutorMock).run(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void closeClosesStatement() throws Exception { + final Batch testee = testee(2); + testee.close(); + verify(resourceMock).close(); + } + + @Test + void closeFlushes() throws Exception { + final Batch testee = testee(2); + + testee.addBatch(); + testee.close(); + + final InOrder inOrder = inOrder(batchExecutorMock, resourceMock); + inOrder.verify(batchExecutorMock).run(); + inOrder.verify(resourceMock).close(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void closeResourceFails() throws Exception { + final Batch testee = testee(2); + doThrow(new RuntimeException("expected")).when(resourceMock).close(); + assertThatThrownBy(testee::close).isInstanceOf(IllegalStateException.class) + .hasMessage("Failed to close resource: expected"); + } + + Batch testee(final int maxBatchSize) { + return new Batch(maxBatchSize, resourceMock, batchExecutorMock); + } +} diff --git a/src/test/java/org/itsallcode/jdbc/batch/StatementBatchTest.java b/src/test/java/org/itsallcode/jdbc/batch/StatementBatchTest.java new file mode 100644 index 0000000..108b6dc --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/batch/StatementBatchTest.java @@ -0,0 +1,68 @@ +package org.itsallcode.jdbc.batch; + +import static org.mockito.Mockito.*; + +import org.itsallcode.jdbc.SimpleStatement; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StatementBatchTest { + + @Mock + SimpleStatement stmtMock; + + @Test + void addBatchDoesNotFlush() { + final StatementBatch testee = testee(2); + testee.addBatch("stmt1"); + + final InOrder inOrder = inOrder(stmtMock); + inOrder.verify(stmtMock).addBatch("stmt1"); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void addBatchFlushesAfterBatchSizeReached() { + final StatementBatch testee = testee(2); + when(stmtMock.executeBatch()).thenReturn(new int[0]); + + testee.addBatch("stmt1"); + testee.addBatch("stmt2"); + + final InOrder inOrder = inOrder(stmtMock); + inOrder.verify(stmtMock).addBatch("stmt1"); + inOrder.verify(stmtMock).addBatch("stmt2"); + inOrder.verify(stmtMock).executeBatch(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void closeClosesStatement() { + final StatementBatch testee = testee(2); + testee.close(); + verify(stmtMock).close(); + } + + @Test + void closeFlushes() { + final StatementBatch testee = testee(2); + when(stmtMock.executeBatch()).thenReturn(new int[0]); + + testee.addBatch("stmt1"); + testee.close(); + + final InOrder inOrder = inOrder(stmtMock); + inOrder.verify(stmtMock).addBatch("stmt1"); + inOrder.verify(stmtMock).executeBatch(); + inOrder.verify(stmtMock).close(); + inOrder.verifyNoMoreInteractions(); + } + + StatementBatch testee(final int maxBatchSize) { + return new StatementBatch(stmtMock, maxBatchSize); + } +} diff --git a/src/test/java/org/itsallcode/jdbc/example/ExampleTest.java b/src/test/java/org/itsallcode/jdbc/example/ExampleTest.java index 19705c8..8f2ae34 100644 --- a/src/test/java/org/itsallcode/jdbc/example/ExampleTest.java +++ b/src/test/java/org/itsallcode/jdbc/example/ExampleTest.java @@ -1,5 +1,7 @@ package org.itsallcode.jdbc.example; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.*; @@ -12,6 +14,7 @@ import org.itsallcode.jdbc.*; import org.itsallcode.jdbc.batch.BatchInsert; +import org.itsallcode.jdbc.batch.StatementBatch; import org.itsallcode.jdbc.resultset.SimpleResultSet; import org.itsallcode.jdbc.resultset.generic.Row; import org.junit.jupiter.api.Test; @@ -30,7 +33,7 @@ void exampleInsertSelect() { .create(Context.builder().build()); try (SimpleConnection connection = connectionFactory.create("jdbc:h2:mem:", "user", "password")) { connection.executeScript(readResource("/schema.sql")); - connection.executeStatement("insert into names (id, name) values (1, 'a'), (2, 'b'), (3, 'c')"); + connection.executeUpdate("insert into names (id, name) values (1, 'a'), (2, 'b'), (3, 'c')"); try (SimpleResultSet rs = connection.query("select * from names order by id")) { final List result = rs.stream().toList(); @@ -46,7 +49,7 @@ void examplePreparedStatementWithRowMapper() { .create(Context.builder().build()); try (SimpleConnection connection = connectionFactory.create("jdbc:h2:mem:", "user", "password")) { connection.executeScript(readResource("/schema.sql")); - connection.executeStatement("insert into names (id, name) values (1, 'a'), (2, 'b'), (3, 'c')"); + connection.executeUpdate("insert into names (id, name) values (1, 'a'), (2, 'b'), (3, 'c')"); try (SimpleResultSet result = connection.query("select id, name from names where id = ?", ps -> ps.setInt(1, 2), @@ -58,6 +61,27 @@ void examplePreparedStatementWithRowMapper() { } } + @Test + void exampleBatchStatement() { + final ConnectionFactory connectionFactory = ConnectionFactory + .create(Context.builder().build()); + try (SimpleConnection connection = connectionFactory.create("jdbc:h2:mem:", "user", "password")) { + try (StatementBatch batch = connection.batch().maxBatchSize(3).build()) { + batch.addBatch("CREATE TABLE TEST(ID INT, NAME VARCHAR(255))"); + batch.addBatch("INSERT INTO TEST VALUES (1, 'a')"); + batch.addBatch("INSERT INTO TEST VALUES (2, 'b')"); + batch.addBatch("INSERT INTO TEST VALUES (3, 'c')"); + batch.addBatch("INSERT INTO TEST VALUES (4, 'd')"); + } + + final List result = connection.query("select count(*) from test").stream().toList(); + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).columnValues()).hasSize(1), + () -> assertThat(result.get(0).get(0).value()).isEqualTo(4L)); + } + } + @Test void exampleRowBatchInsert() { final ConnectionFactory connectionFactory = ConnectionFactory