Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;

/**
* Provides CRUD (Create, Read, Update, Delete) operations for entities.
Expand Down Expand Up @@ -132,4 +133,77 @@ public interface Repository<ID, E extends Identifiable<ID>> {
* @throws RuntimeException if the query operation fails
*/
List<E> findAll();

/**
* Begins a new database transaction.
* <p>
* This method starts a transaction that allows multiple operations to be
* grouped together atomically. All operations performed after calling this
* method will be part of the transaction until either {@link #commit()} or
* {@link #rollback()} is called.
* </p>
* <p>
* Transaction management may not be supported by all database implementations.
* Check {@link fr.robotv2.anchor.api.database.Database#supports(fr.robotv2.anchor.api.database.SupportType)}
* with {@link fr.robotv2.anchor.api.database.SupportType#TRANSACTION} before
* using transaction methods.
* </p>
*
* @throws UnsupportedOperationException if transactions are not supported by this database
* @throws RuntimeException if the transaction cannot be started
*/
void beginTransaction();

/**
* Commits the current transaction.
* <p>
* This method persists all changes made during the current transaction to
* the database. After a successful commit, the transaction is complete and
* a new transaction must be started for subsequent grouped operations.
* </p>
*
* @throws IllegalStateException if no transaction is active
* @throws UnsupportedOperationException if transactions are not supported by this database
* @throws RuntimeException if the commit operation fails
*/
void commit();

/**
* Rolls back the current transaction.
* <p>
* This method discards all changes made during the current transaction,
* returning the database to its state before the transaction began.
* After a rollback, the transaction is complete and a new transaction
* must be started for subsequent grouped operations.
* </p>
*
* @throws IllegalStateException if no transaction is active
* @throws UnsupportedOperationException if transactions are not supported by this database
* @throws RuntimeException if the rollback operation fails
*/
void rollback();

/**
* Executes a series of operations within a transaction.
* <p>
* This method provides a convenient way to perform multiple operations
* atomically within a transaction. The provided consumer receives this
* repository and can perform any operations. If the consumer completes
* successfully, the transaction is committed. If an exception is thrown,
* the transaction is rolled back automatically.
* </p>
* Example usage:
* <pre>{@code
* repository.executeInTransaction(repo -> {
* repo.save(entity1);
* repo.save(entity2);
* repo.deleteById(oldId);
* });
* }</pre>
*
* @param operations the operations to execute within the transaction, must not be {@code null}
* @throws UnsupportedOperationException if transactions are not supported by this database
* @throws RuntimeException if any operation fails (transaction will be rolled back)
*/
void executeInTransaction(Consumer<Repository<ID, E>> operations);
}
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,116 @@ public void testBlob() {
Assertions.assertEquals(blobUser.getBlob().getEnumValue(), retrieved.getBlob().getEnumValue());
Assertions.assertEquals(blobUser.getBlob().getMap(), retrieved.getBlob().getMap());
}

@Test
public void testTransactionCommit() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

repository.beginTransaction();
UserLong newUser = new UserLong(4L, "David", 28, true, "user", null);
repository.save(newUser);
repository.commit();

UserLong retrieved = repository.findById(4L).orElse(null);
Assertions.assertNotNull(retrieved);
Assertions.assertEquals("David", retrieved.getName());
}

@Test
public void testTransactionRollback() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

repository.beginTransaction();
UserLong newUser = new UserLong(5L, "Eve", 22, false, "user", null);
repository.save(newUser);
repository.rollback();

UserLong retrieved = repository.findById(5L).orElse(null);
Assertions.assertNull(retrieved);
}

@Test
public void testExecuteInTransactionSuccess() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

repository.executeInTransaction(repo -> {
repo.save(new UserLong(6L, "Frank", 40, true, "admin", "Frankie"));
repo.save(new UserLong(7L, "Grace", 32, false, "user", null));
});

Assertions.assertNotNull(repository.findById(6L).orElse(null));
Assertions.assertNotNull(repository.findById(7L).orElse(null));
Assertions.assertEquals(5, repository.findAll().size());
}

@Test
public void testExecuteInTransactionRollbackOnError() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

Assertions.assertThrows(RuntimeException.class, () -> {
repository.executeInTransaction(repo -> {
repo.save(new UserLong(8L, "Henry", 45, true, "admin", null));
throw new RuntimeException("Simulated error");
});
});

UserLong retrieved = repository.findById(8L).orElse(null);
Assertions.assertNull(retrieved);
Assertions.assertEquals(3, repository.findAll().size());
}

@Test
public void testTransactionIsolation() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

repository.beginTransaction();
repository.save(new UserLong(9L, "Iris", 27, true, "user", null));

// Update within transaction
UserLong user = repository.findById(1L).orElse(null);
Assertions.assertNotNull(user);
user.setAge(31);
repository.save(user);

repository.commit();

UserLong retrieved = repository.findById(9L).orElse(null);
Assertions.assertNotNull(retrieved);
Assertions.assertEquals("Iris", retrieved.getName());

UserLong updated = repository.findById(1L).orElse(null);
Assertions.assertNotNull(updated);
Assertions.assertEquals(31, updated.getAge());
}

@Test
public void testNoActiveTransactionCommit() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

Assertions.assertThrows(IllegalStateException.class, () -> repository.commit());
}

@Test
public void testNoActiveTransactionRollback() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

Assertions.assertThrows(IllegalStateException.class, () -> repository.rollback());
}

@Test
public void testDoubleBeginTransaction() {
Assumptions.assumeTrue(database.supports(fr.robotv2.anchor.api.database.SupportType.TRANSACTION),
"Database does not support transactions");

repository.beginTransaction();
Assertions.assertThrows(IllegalStateException.class, () -> repository.beginTransaction());
repository.rollback(); // Clean up
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.stream.Stream;

public class JsonRepository<ID, T extends Identifiable<ID>> implements Repository<ID, T> {
Expand Down Expand Up @@ -186,4 +187,24 @@ private T readEntityFromFile(Path path) {
throw new RuntimeException("Failed to read entity from file: " + path.getFileName(), exception);
}
}

@Override
public void beginTransaction() {
throw new UnsupportedOperationException("Transactions are not supported by JSON database");
}

@Override
public void commit() {
throw new UnsupportedOperationException("Transactions are not supported by JSON database");
}

@Override
public void rollback() {
throw new UnsupportedOperationException("Transactions are not supported by JSON database");
}

@Override
public void executeInTransaction(Consumer<Repository<ID, T>> operations) {
throw new UnsupportedOperationException("Transactions are not supported by JSON database");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public abstract class HikariDatabase implements SQLDatabase {

protected final HikariDataSource source;
protected final SQLDialect dialect;
private final ThreadLocal<Connection> transactionConnection = new ThreadLocal<>();

@ApiStatus.Internal
protected HikariDatabase(DataSource source, SQLDialect dialect) {
Expand Down Expand Up @@ -49,38 +50,72 @@ public boolean isConnected() {

@Override
public Connection getConnection() throws SQLException {
Connection txConnection = transactionConnection.get();
if (txConnection != null) {
return txConnection;
}
return this.source.getConnection();
}

/**
* Sets the transaction connection for the current thread.
* @param connection the connection to use for the transaction, or null to clear
*/
public void setTransactionConnection(Connection connection) {
transactionConnection.set(connection);
}

/**
* Gets the active transaction connection for the current thread.
* @return the transaction connection, or null if no transaction is active
*/
public Connection getTransactionConnection() {
return transactionConnection.get();
}

@Override
public SQLDialect getDialect() {
return dialect;
}

@Override
public boolean execute(String sql) throws SQLException {
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
Connection connection = getConnection();
boolean isTransactional = transactionConnection.get() != null;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
return statement.execute();
} finally {
if (!isTransactional) {
connection.close();
}
}
}

@Override
public int executeUpdate(String sql, Collection<Object> parameters) throws SQLException {
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
Connection connection = getConnection();
boolean isTransactional = transactionConnection.get() != null;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for (Object param : parameters) {
statement.setObject(index++, dialect.toDatabaseValue(param));
}
return statement.executeUpdate();
} finally {
if (!isTransactional) {
connection.close();
}
}
}

@Override
public int executeBatchUpdate(String sql, Collection<Collection<Object>> parameters) throws SQLException {
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
Connection connection = getConnection();
boolean isTransactional = transactionConnection.get() != null;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (Collection<Object> params : parameters) {
int index = 1;
for (Object param : params) {
Expand All @@ -90,13 +125,19 @@ public int executeBatchUpdate(String sql, Collection<Collection<Object>> paramet
}
final int[] results = statement.executeBatch();
return Arrays.stream(results).sum();
} finally {
if (!isTransactional) {
connection.close();
}
}
}

@Override
public <R> List<R> query(String sql, Collection<Object> parameters, RowMapper<R> mapper) throws SQLException {
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
Connection connection = getConnection();
boolean isTransactional = transactionConnection.get() != null;

try (PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for (Object param : parameters) {
statement.setObject(index++, dialect.toDatabaseValue(param));
Expand All @@ -108,21 +149,30 @@ public <R> List<R> query(String sql, Collection<Object> parameters, RowMapper<R>
}
}
return results;
} finally {
if (!isTransactional) {
connection.close();
}
}
}

@Override
public <R> List<R> queryRaw(String sql, RowMapper<R> mapper) throws SQLException {
try (Connection connection = getConnection();
Statement statement = connection.createStatement()) {

Connection connection = getConnection();
boolean isTransactional = transactionConnection.get() != null;

try (Statement statement = connection.createStatement()) {
List<R> results = new ArrayList<>();
try (ResultSet rs = statement.executeQuery(sql)) {
while (rs.next()) {
results.add(mapper.map(rs));
}
}
return results;
} finally {
if (!isTransactional) {
connection.close();
}
}
}

Expand Down
Loading