From bcc05ab46bf179c54b871176bc8560f326bd2126 Mon Sep 17 00:00:00 2001 From: doxlik Date: Mon, 11 May 2026 17:32:35 +0400 Subject: [PATCH 01/14] savepoints Signed-off-by: doxlik --- pom.xml | 1 + .../vertx/pgclient/impl/PgConnectionImpl.java | 6 +- .../pgclient/impl/PgSocketConnection.java | 14 +- .../vertx/pgclient/impl/codec/PgDecoder.java | 7 +- .../pgclient/impl/codec/TxStatusEvent.java | 30 + .../java/io/vertx/pgclient/spi/PgDriver.java | 5 + .../tests/pgclient/tck/PgTransactionTest.java | 594 ++++++++++++++++++ .../java/io/vertx/sqlclient/Savepoint.java | 38 ++ .../java/io/vertx/sqlclient/Transaction.java | 10 + .../vertx/sqlclient/impl/SavepointImpl.java | 69 ++ .../vertx/sqlclient/impl/TransactionImpl.java | 63 +- .../sqlclient/impl/TransactionState.java | 13 +- .../sqlclient/internal/SqlConnectionBase.java | 2 +- .../java/io/vertx/sqlclient/spi/Driver.java | 7 + .../spi/protocol/SavepointCommand.java | 56 ++ 15 files changed, 885 insertions(+), 30 deletions(-) create mode 100644 vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxStatusEvent.java create mode 100644 vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java create mode 100644 vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java rename vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxFailedEvent.java => vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionState.java (68%) create mode 100644 vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/protocol/SavepointCommand.java diff --git a/pom.xml b/pom.xml index 37a3788d7..2474b0ad0 100644 --- a/pom.xml +++ b/pom.xml @@ -189,6 +189,7 @@ vertx-db2-client vertx-sql-client-templates vertx-oracle-client + vertx-pg-savepoints-example diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgConnectionImpl.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgConnectionImpl.java index d81dbdd02..4273efc7b 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgConnectionImpl.java +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgConnectionImpl.java @@ -23,7 +23,7 @@ import io.vertx.pgclient.PgNotice; import io.vertx.pgclient.PgNotification; import io.vertx.pgclient.impl.codec.NoticeResponse; -import io.vertx.pgclient.impl.codec.TxFailedEvent; +import io.vertx.pgclient.impl.codec.TxStatusEvent; import io.vertx.pgclient.spi.PgDriver; import io.vertx.sqlclient.codec.SocketConnectionBase; import io.vertx.sqlclient.internal.SqlConnectionBase; @@ -99,9 +99,9 @@ public void handleEvent(Object event) { } else { notice.log(SocketConnectionBase.logger); } - } else if (event instanceof TxFailedEvent) { + } else if (event instanceof TxStatusEvent) { if (tx != null) { - tx.fail(); + tx.status(((TxStatusEvent) event).status()); } } } diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgSocketConnection.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgSocketConnection.java index 24f9e3edf..ef6c518e3 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgSocketConnection.java +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgSocketConnection.java @@ -32,7 +32,7 @@ import io.vertx.pgclient.impl.codec.NoticeResponse; import io.vertx.pgclient.impl.codec.PgCodec; import io.vertx.pgclient.impl.codec.PgCommandMessage; -import io.vertx.pgclient.impl.codec.TxFailedEvent; +import io.vertx.pgclient.impl.codec.TxStatusEvent; import io.vertx.sqlclient.codec.CommandMessage; import io.vertx.sqlclient.codec.SocketConnectionBase; import io.vertx.sqlclient.spi.connection.Connection; @@ -42,6 +42,7 @@ import io.vertx.sqlclient.spi.protocol.CommandBase; import io.vertx.sqlclient.spi.protocol.ExtendedQueryCommand; import io.vertx.sqlclient.spi.protocol.InitCommand; +import io.vertx.sqlclient.spi.protocol.SavepointCommand; import io.vertx.sqlclient.spi.protocol.SimpleQueryCommand; import io.vertx.sqlclient.spi.protocol.TxCommand; @@ -117,7 +118,7 @@ Future sendCancelRequestMessage(int processId, int secretKey) { @Override protected void handleMessage(Object msg) { super.handleMessage(msg); - if (msg instanceof Notification || msg instanceof TxFailedEvent || msg instanceof NoticeResponse) { + if (msg instanceof Notification || msg instanceof TxStatusEvent || msg instanceof NoticeResponse) { handleEvent(msg); } } @@ -168,6 +169,15 @@ protected void doSchedule(CommandBase cmd, Completable handler) { SocketConnectionBase.NULL_COLLECTOR, QueryResultHandler.NOOP_HANDLER); super.doSchedule(cmd2, (res, err) -> handler.complete(tx.result(), err)); + } else if (cmd instanceof SavepointCommand) { + SavepointCommand savepoint = (SavepointCommand) cmd; + SimpleQueryCommand cmd2 = new SimpleQueryCommand<>( + savepoint.sql(), + false, + false, + SocketConnectionBase.NULL_COLLECTOR, + QueryResultHandler.NOOP_HANDLER); + super.doSchedule(cmd2, (res, err) -> handler.complete(savepoint.result(), err)); } else { super.doSchedule(cmd, handler); } diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/PgDecoder.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/PgDecoder.java index da7dd7b2d..f1666ea8d 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/PgDecoder.java +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/PgDecoder.java @@ -236,12 +236,11 @@ private void decodeRowDescription(ByteBuf in) { private void decodeReadyForQuery(ChannelHandlerContext ctx, ByteBuf in) { byte id = in.readByte(); if (id == I) { - // IDLE + ctx.fireChannelRead(TxStatusEvent.IDLE); } else if (id == T) { - // ACTIVE + ctx.fireChannelRead(TxStatusEvent.ACTIVE); } else { - // FAILED - ctx.fireChannelRead(TxFailedEvent.INSTANCE); + ctx.fireChannelRead(TxStatusEvent.FAILED); } codec.peek().handleReadyForQuery(); } diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxStatusEvent.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxStatusEvent.java new file mode 100644 index 000000000..e38543131 --- /dev/null +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxStatusEvent.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.pgclient.impl.codec; + +import io.vertx.sqlclient.impl.TransactionState; + +public class TxStatusEvent { + + public static final TxStatusEvent IDLE = new TxStatusEvent(TransactionState.IDLE); + public static final TxStatusEvent ACTIVE = new TxStatusEvent(TransactionState.ACTIVE); + public static final TxStatusEvent FAILED = new TxStatusEvent(TransactionState.FAILED); + + private final TransactionState status; + + private TxStatusEvent(TransactionState status) { + this.status = status; + } + + public TransactionState status() { + return status; + } +} diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/spi/PgDriver.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/spi/PgDriver.java index ed7d00833..3d21357a2 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/spi/PgDriver.java +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/spi/PgDriver.java @@ -76,6 +76,11 @@ public int appendQueryPlaceholder(StringBuilder queryBuilder, int index, int cur return index; } + @Override + public boolean supportsSavepoints() { + return true; + } + @Override public SqlConnectionInternal wrapConnection(ContextInternal context, ConnectionFactory factory, Connection connection) { return new PgConnectionImpl((PgConnectionFactory) factory, context, connection); diff --git a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java index b86ac3558..9ad8adc1e 100644 --- a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java +++ b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java @@ -10,6 +10,7 @@ */ package io.vertx.tests.pgclient.tck; +import io.vertx.core.Future; import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; @@ -17,12 +18,19 @@ import io.vertx.pgclient.PgException; import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.PoolOptions; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import io.vertx.sqlclient.SqlConnection; +import io.vertx.sqlclient.TransactionRollbackException; +import io.vertx.sqlclient.Tuple; import io.vertx.tests.pgclient.junit.ContainerPgRule; import io.vertx.tests.sqlclient.tck.TransactionTestBase; import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.Arrays; + @RunWith(VertxUnitRunner.class) public class PgTransactionTest extends TransactionTestBase { @@ -108,4 +116,590 @@ public void testLongTransaction(TestContext ctx) { })); })); } + + @Test + public void testRollbackToSavepointRestoresTransaction(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 2, "rolled-back") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return sp.rollback() + .compose(v -> sp.release()) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit()); + })) + .compose(v -> assertMutableIds(ctx, 1, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackInnerSavepointKeepsOuterWork(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "base") + .compose(v -> res.tx.createSavepoint()) + .compose(sp1 -> insertMutable(res.client, 2, "outer") + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 3, "inner") + .compose(v -> sp2.rollback()) + .compose(v -> insertMutable(res.client, 4, "after-inner-rollback")) + .compose(v -> sp1.release()) + .compose(v -> res.tx.commit()))) + .compose(v -> assertMutableIds(ctx, 1, 2, 4)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackInnerThenOuterSavepointKeepsOnlyWorkBeforeOuter(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before-sp1") + .compose(v -> res.tx.createSavepoint()) + .compose(sp1 -> insertMutable(res.client, 2, "between-sp1-sp2") + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 3, "after-sp2") + .compose(v -> sp2.rollback()) + .compose(v -> insertMutable(res.client, 4, "after-sp2-rollback")) + .compose(v -> sp1.rollback()) + .compose(v -> insertMutable(res.client, 5, "after-sp1-rollback")) + .compose(v -> res.tx.commit()))) + .compose(v -> assertMutableIds(ctx, 1, 5)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackToSameSavepointTwice(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "first") + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 2, "second")) + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 3, "third")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testReleaseSavepointKeepsWork(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "released-scope") + .compose(v -> sp.release()) + .compose(v -> insertMutable(res.client, 2, "after-release")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testCommitCleansUpUnreleasedSavepoint(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "unreleased") + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testReleaseOuterSavepointInvalidatesInnerSavepoint(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "base") + .compose(v -> res.tx.createSavepoint()) + .compose(sp1 -> insertMutable(res.client, 2, "outer") + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 3, "inner") + .compose(v -> sp1.release()) + .compose(v -> sp2.rollback()) + .compose(v -> Future.failedFuture("Expected inner savepoint to be invalidated")) + .recover(err -> { + assertSqlState(ctx, err, "3B001"); + return res.tx.commit(); + }))) + .onComplete(ctx.asyncAssertFailure(err -> { + assertTransactionRollback(ctx, err); + assertMutableIds(ctx).onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + } + + @Test + public void testRollbackOuterSavepointInvalidatesInnerSavepointAndCanRecover(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "base") + .compose(v -> res.tx.createSavepoint()) + .compose(sp1 -> insertMutable(res.client, 2, "outer") + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 3, "inner") + .compose(v -> sp1.rollback()) + .compose(v -> sp2.release()) + .compose(v -> Future.failedFuture("Expected inner savepoint to be invalidated")) + .recover(err -> { + assertSqlState(ctx, err, "3B001"); + return sp1.rollback() + .compose(v -> insertMutable(res.client, 4, "recovered")) + .compose(v -> res.tx.commit()); + }))) + .compose(v -> assertMutableIds(ctx, 1, 4)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackReleasedSavepointFailsButTransactionCanCommit(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> sp.release() + .compose(v -> sp.rollback()) + .compose(v -> Future.failedFuture("Expected rollback on released savepoint to fail")) + .recover(err -> { + ctx.assertEquals("Savepoint already released", err.getMessage()); + return insertMutable(res.client, 1, "still-usable") + .compose(v -> res.tx.commit()); + })) + .compose(v -> assertMutableIds(ctx, 1)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testReleaseReleasedSavepointFailsButTransactionCanCommit(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> sp.release() + .compose(v -> sp.release()) + .compose(v -> Future.failedFuture("Expected second release to fail")) + .recover(err -> { + ctx.assertEquals("Savepoint already released", err.getMessage()); + return insertMutable(res.client, 1, "still-usable") + .compose(v -> res.tx.commit()); + })) + .compose(v -> assertMutableIds(ctx, 1)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testCreateSavepointFailsWhileTransactionIsFailed(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "before-failure") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return res.tx.createSavepoint() + .compose(v -> Future.failedFuture("Expected create savepoint to fail in failed transaction")) + .recover(err2 -> { + assertSqlState(ctx, err2, "25P02"); + return sp.rollback() + .compose(v -> insertMutable(res.client, 2, "after-recovery")) + .compose(v -> res.tx.commit()); + }); + })) + .compose(v -> assertMutableIds(ctx, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testReleaseSavepointFailsWhileTransactionIsFailed(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "before-failure") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return sp.release() + .compose(v -> Future.failedFuture("Expected release to fail in failed transaction")) + .recover(err2 -> { + assertSqlState(ctx, err2, "25P02"); + return sp.rollback() + .compose(v -> insertMutable(res.client, 2, "after-recovery")) + .compose(v -> res.tx.commit()); + }); + })) + .compose(v -> assertMutableIds(ctx, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testCanCreateNewSavepointAfterRollbackToSavepoint(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp1 -> insertMutable(res.client, 1, "before-failure") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return sp1.rollback() + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 2, "after-recovery") + .compose(x -> sp2.release())) + .compose(v -> res.tx.commit()); + })) + .compose(v -> assertMutableIds(ctx, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackToSavepointAfterRepeatedFailedTransactionStatusRestoresTransaction(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 1, "duplicate") + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return res.client.query("SELECT 1") + .execute() + .compose(v -> Future.failedFuture("Expected failed transaction error")) + .recover(err2 -> { + assertSqlState(ctx, err2, "25P02"); + return sp.rollback() + .compose(v -> insertMutable(res.client, 2, "after-recovery")) + .compose(v -> res.tx.commit()); + }); + })) + .compose(v -> assertMutableIds(ctx, 1, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackToSavepointAfterPreparedQueryFailureRestoresTransaction(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> res.client.preparedQuery("INSERT INTO mutable (id, val) VALUES ($1, $2)") + .execute(Tuple.of(1, "duplicate")) + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return sp.rollback() + .compose(v -> insertMutable(res.client, 2, "after-recovery")) + .compose(v -> res.tx.commit()); + })) + .compose(v -> assertMutableIds(ctx, 1, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackToSavepointAfterPreparedBatchFailureRestoresTransaction(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> res.client.preparedQuery("INSERT INTO mutable (id, val) VALUES ($1, $2)") + .executeBatch(Arrays.asList( + Tuple.of(2, "batch-before-error"), + Tuple.of(1, "batch-duplicate"), + Tuple.of(3, "batch-after-error") + )) + .compose(v -> Future.failedFuture("Expected duplicate key failure")) + .recover(err -> { + assertSqlState(ctx, err, "23505"); + return sp.rollback() + .compose(v -> insertMutable(res.client, 4, "after-recovery")) + .compose(v -> res.tx.commit()); + })) + .compose(v -> assertMutableIds(ctx, 1, 4)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testSavepointCommandAlreadyInProgress(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + Future first = sp.release(); + Future second = sp.release(); + + second.onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Savepoint command already in progress", err.getMessage()); + first + .compose(v -> res.tx.commit()) + .compose(v -> assertMutableIds(ctx)) + .onComplete(ctx.asyncAssertSuccess(x -> async.complete())); + })); + })); + })); + } + + @Test + public void testCreateSavepointAfterCommitRequestedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.client.query("SELECT pg_sleep(0.2)") + .execute(); + + Future commit = res.tx.commit(); + + res.tx.createSavepoint() + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + commit.onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + } + + @Test + public void testCreateSavepointAfterRollbackRequestedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.client.query("SELECT pg_sleep(0.2)") + .execute(); + + Future rollback = res.tx.rollback(); + + res.tx.createSavepoint() + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + rollback.onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + } + + @Test + public void testRollbackSavepointAfterCommitRequestedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.client.query("SELECT pg_sleep(0.2)") + .execute(); + + Future commit = res.tx.commit(); + + sp.rollback().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + commit.onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + })); + } + + @Test + public void testRollbackSavepointAfterRollbackRequestedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.client.query("SELECT pg_sleep(0.2)") + .execute(); + + Future rollback = res.tx.rollback(); + + sp.rollback().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + rollback.onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + })); + } + + @Test + public void testReleaseSavepointAfterCommitRequestedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.client.query("SELECT pg_sleep(0.2)") + .execute(); + + Future commit = res.tx.commit(); + + sp.release().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + commit.onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + })); + } + + @Test + public void testReleaseSavepointAfterRollbackRequestedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.client.query("SELECT pg_sleep(0.2)") + .execute(); + + Future rollback = res.tx.rollback(); + + sp.release().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + rollback.onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + })); + } + + @Test + public void testWholeTransactionRollbackWithSavepoint(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "before-whole-rollback") + .compose(v -> insertMutable(res.client, 2, "still-rolled-back")) + .compose(v -> res.tx.rollback())) + .compose(v -> assertMutableIds(ctx)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testCreateSavepointAfterRollbackCompletedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.rollback().onComplete(ctx.asyncAssertSuccess(v -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + async.complete(); + })); + })); + })); + } + + @Test + public void testCreateSavepointAfterCommitCompletedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.commit().onComplete(ctx.asyncAssertSuccess(v -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + async.complete(); + })); + })); + })); + } + + @Test + public void testRollbackSavepointAfterCommitCompletedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.tx.commit().onComplete(ctx.asyncAssertSuccess(v -> { + sp.rollback().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + async.complete(); + })); + })); + })); + })); + } + + @Test + public void testRollbackSavepointAfterRollbackCompletedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.tx.rollback().onComplete(ctx.asyncAssertSuccess(v -> { + sp.rollback().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + async.complete(); + })); + })); + })); + })); + } + + @Test + public void testReleaseSavepointAfterCommitCompletedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.tx.commit().onComplete(ctx.asyncAssertSuccess(v -> { + sp.release().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + async.complete(); + })); + })); + })); + })); + } + + @Test + public void testReleaseSavepointAfterRollbackCompletedFails(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint().onComplete(ctx.asyncAssertSuccess(sp -> { + res.tx.rollback().onComplete(ctx.asyncAssertSuccess(v -> { + sp.release().onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertEquals("Transaction already completed", err.getMessage()); + async.complete(); + })); + })); + })); + })); + } + + @Test + public void testReleaseAfterRollbackToSameSavepoint(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "one") + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 2, "two")) + .compose(v -> sp.release()) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + private Future> insertMutable(SqlConnection client, int id, String val) { + return client.query("INSERT INTO mutable (id, val) VALUES (" + id + ", '" + val + "')").execute(); + } + + private Future assertMutableIds(TestContext ctx, int... expectedIds) { + return getPool() + .query("SELECT id FROM mutable ORDER BY id") + .execute() + .map(rows -> { + ctx.assertEquals(expectedIds.length, rows.size()); + int index = 0; + for (Row row : rows) { + ctx.assertEquals(expectedIds[index++], row.getInteger("id").intValue()); + } + return null; + }); + } + + private void assertSqlState(TestContext ctx, Throwable err, String sqlState) { + ctx.assertTrue(err instanceof PgException); + ctx.assertEquals(sqlState, ((PgException) err).getSqlState()); + } + + private void assertTransactionRollback(TestContext ctx, Throwable err) { + ctx.assertTrue(err instanceof TransactionRollbackException); + } } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java new file mode 100644 index 000000000..b2e930832 --- /dev/null +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.sqlclient; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Future; + +/** + * A savepoint created from a {@link Transaction}. + * + *

A savepoint marks a position inside the current transaction that can later + * be rolled back to, or released when no longer needed. + */ +@VertxGen +public interface Savepoint { + + /** + * Roll back the current transaction to this savepoint. + * + *

The transaction remains active after a successful rollback. + */ + Future rollback(); + + /** + * Release this savepoint. + * + *

After release, this savepoint can no longer be used. + */ + Future release(); +} diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java index ccd008011..4a4b2c7f7 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java @@ -25,6 +25,16 @@ @VertxGen public interface Transaction { + /** + * Create a savepoint in this transaction. + * + *

Fails with {@link UnsupportedOperationException} when the driver does not + * support savepoints. + * + * @return a future notified with the created savepoint + */ + Future createSavepoint(); + /** * Commit the current transaction. */ diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java new file mode 100644 index 000000000..a7539bf6f --- /dev/null +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.sqlclient.impl; + +import io.vertx.core.Future; +import io.vertx.sqlclient.Savepoint; + +public class SavepointImpl implements Savepoint { + + private enum State { + ACTIVE, + PENDING, + RELEASED + } + + private final TransactionImpl transaction; + private final String name; + private State state; + + public SavepointImpl(TransactionImpl transaction, String name) { + this.transaction = transaction; + this.name = name; + this.state = State.ACTIVE; + } + + @Override + public Future rollback() { + return execute(false, () -> transaction.rollbackToSavepoint(name)); + } + + @Override + public Future release() { + return execute(true, () -> transaction.releaseSavepoint(name)); + } + + private Future execute(boolean release, Action action) { + synchronized (this) { + if (state == State.RELEASED) { + return transaction.failedFuture("Savepoint already released"); + } + if (state == State.PENDING) { + return transaction.failedFuture("Savepoint command already in progress"); + } + state = State.PENDING; + } + return action.execute().andThen(ar -> { + synchronized (SavepointImpl.this) { + if (ar.succeeded()) { + state = release ? State.RELEASED : State.ACTIVE; + } else { + state = State.ACTIVE; + } + } + }); + } + + @FunctionalInterface + private interface Action { + Future execute(); + } +} diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index 154155ac2..b608ff24b 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -19,46 +19,85 @@ import io.vertx.core.*; import io.vertx.core.internal.ContextInternal; import io.vertx.core.internal.PromiseInternal; +import io.vertx.sqlclient.Savepoint; import io.vertx.sqlclient.Transaction; import io.vertx.sqlclient.TransactionRollbackException; +import io.vertx.sqlclient.spi.Driver; import io.vertx.sqlclient.spi.connection.Connection; import io.vertx.sqlclient.spi.protocol.CommandBase; +import io.vertx.sqlclient.spi.protocol.SavepointCommand; import io.vertx.sqlclient.spi.protocol.TxCommand; public class TransactionImpl implements Transaction { private final ContextInternal context; private final Connection connection; + private final Driver driver; private final Promise completion; private final Handler endHandler; private int pendingQueries; private boolean ended; - private boolean failed; + private boolean rollbackRequested; + private long savepointSeq; private TxCommand endCommand; + private TransactionState state = TransactionState.ACTIVE; - public TransactionImpl(ContextInternal context, Handler endHandler, Connection connection) { + public TransactionImpl(ContextInternal context, Handler endHandler, Connection connection, Driver driver) { this.context = context; this.connection = connection; + this.driver = driver; this.completion = context.promise(); this.endHandler = endHandler; } public Future begin() { - PromiseInternal promise = context.promise(); - TxCommand begin = new TxCommand<>(TxCommand.Kind.BEGIN, this); - scheduleInternal(begin, wrap(begin, promise)); - return promise.future(); + return submit(new TxCommand<>(TxCommand.Kind.BEGIN, this)); + } + + public void status(TransactionState state) { + this.state = state; + } + + Future failedFuture(String message) { + return context.failedFuture(message); + } + + @Override + public Future createSavepoint() { + if (!driver.supportsSavepoints()) { + return context.failedFuture(new UnsupportedOperationException( + "Savepoints are not supported by this driver")); + } + + String name; + synchronized (this) { + name = "__vx_sp_" + (++savepointSeq); + } + SavepointImpl savepoint = new SavepointImpl(this, name); + return submit(new SavepointCommand<>(SavepointCommand.Kind.CREATE, name, savepoint)); + } + + Future rollbackToSavepoint(String name) { + return submit(new SavepointCommand<>(SavepointCommand.Kind.ROLLBACK_TO, name, null)); } - public void fail() { - failed = true; + Future releaseSavepoint(String name) { + return submit(new SavepointCommand<>(SavepointCommand.Kind.RELEASE, name, null)); + } + + private Future submit(CommandBase cmd) { + PromiseInternal promise = context.promise(); + if (!scheduleInternal(cmd, wrap(promise))) { + promise.fail("Transaction already completed"); + } + return promise.future(); } private void execute(CommandBase cmd, Completable handler) { connection.schedule(cmd, handler); } - private Completable wrap(CommandBase cmd, Completable handler) { + private Completable wrap(Completable handler) { return (res, err) -> { synchronized (TransactionImpl.this) { pendingQueries--; @@ -69,7 +108,7 @@ private Completable wrap(CommandBase cmd, Completable handler) { } public void schedule(CommandBase cmd, Completable handler) { - if (!scheduleInternal(cmd, wrap(cmd, handler))) { + if (!scheduleInternal(cmd, wrap(handler))) { handler.fail("Transaction already completed"); } } @@ -92,7 +131,7 @@ private void checkEnd() { if (pendingQueries > 0 || !ended || endCommand != null) { return; } - TxCommand.Kind kind = failed ? TxCommand.Kind.ROLLBACK : TxCommand.Kind.COMMIT; + TxCommand.Kind kind = rollbackRequested || state == TransactionState.FAILED ? TxCommand.Kind.ROLLBACK : TxCommand.Kind.COMMIT; cmd = new TxCommand<>(kind, null); handler = (res, err) -> { if (err == null) { @@ -113,7 +152,7 @@ private Future end(boolean rollback) { return context.failedFuture("Transaction already complete"); } ended = true; - failed |= rollback; + rollbackRequested |= rollback; } checkEnd(); return completion.future(); diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxFailedEvent.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionState.java similarity index 68% rename from vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxFailedEvent.java rename to vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionState.java index 283c1cb43..78e459a00 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/TxFailedEvent.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionState.java @@ -8,13 +8,10 @@ * * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 */ -package io.vertx.pgclient.impl.codec; - -/** - * Event to signal a transaction is failed. - */ -public class TxFailedEvent { - - public static final TxFailedEvent INSTANCE = new TxFailedEvent(); +package io.vertx.sqlclient.impl; +public enum TransactionState { + IDLE, + ACTIVE, + FAILED } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/internal/SqlConnectionBase.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/internal/SqlConnectionBase.java index b68418703..6ac4b11be 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/internal/SqlConnectionBase.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/internal/SqlConnectionBase.java @@ -213,7 +213,7 @@ public Future begin() { if (tx != null) { throw new IllegalStateException(); } - tx = new TransactionImpl(context, v -> tx = null, conn); + tx = new TransactionImpl(context, v -> tx = null, conn, driver()); return tx.begin(); } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java index d9c13658b..02a25c235 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java @@ -144,4 +144,11 @@ default int appendQueryPlaceholder(StringBuilder queryBuilder, int index, int cu queryBuilder.append("?"); return current; } + + /** + * @return {@code true} when the driver supports savepoints. + */ + default boolean supportsSavepoints() { + return false; + } } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/protocol/SavepointCommand.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/protocol/SavepointCommand.java new file mode 100644 index 000000000..d890d1693 --- /dev/null +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/protocol/SavepointCommand.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.sqlclient.spi.protocol; + +public class SavepointCommand extends CommandBase { + + public enum Kind { + CREATE("SAVEPOINT "), + ROLLBACK_TO("ROLLBACK TO SAVEPOINT "), + RELEASE("RELEASE SAVEPOINT "); + + private final String sqlPrefix; + + Kind(String sqlPrefix) { + this.sqlPrefix = sqlPrefix; + } + + public String sql(String name) { + return sqlPrefix + name; + } + } + + private final Kind kind; + private final String name; + private final R result; + + public SavepointCommand(Kind kind, String name, R result) { + this.kind = kind; + this.name = name; + this.result = result; + } + + public Kind kind() { + return kind; + } + + public String name() { + return name; + } + + public String sql() { + return kind.sql(name); + } + + public R result() { + return result; + } +} From e8b36d04e9fc23d83be96441cdf5c80711b9230e Mon Sep 17 00:00:00 2001 From: doxlik Date: Mon, 11 May 2026 17:37:00 +0400 Subject: [PATCH 02/14] savepoints Signed-off-by: doxlik --- pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2474b0ad0..37a3788d7 100644 --- a/pom.xml +++ b/pom.xml @@ -189,7 +189,6 @@ vertx-db2-client vertx-sql-client-templates vertx-oracle-client - vertx-pg-savepoints-example From 190be2cdb5a9a1313f9a5c4ee7bcb05b43ebe230 Mon Sep 17 00:00:00 2001 From: doxlik Date: Mon, 11 May 2026 18:01:15 +0400 Subject: [PATCH 03/14] savepoints Signed-off-by: doxlik --- .../main/java/io/vertx/sqlclient/impl/TransactionImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index b608ff24b..a8376ab01 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -55,7 +55,9 @@ public Future begin() { } public void status(TransactionState state) { - this.state = state; + synchronized (this) { + this.state = state; + } } Future failedFuture(String message) { From 64c10d43cb239012ff6e7fda6ad5b566496504fa Mon Sep 17 00:00:00 2001 From: doxlik Date: Sat, 12 Sep 2026 17:31:50 +0400 Subject: [PATCH 04/14] Support savepoints on MySQL, DB2, SQL Server and Oracle Savepoints were implemented for PostgreSQL only. The command itself is plain SQL, so the remaining drivers mostly need to route SavepointCommand to a simple query, but they do not all agree on the syntax and two of them cannot release a savepoint at all. Driver gains supportsSavepointRelease(). Microsoft SQL Server and Oracle create savepoints but have no statement that discards one, so releasing on those drivers fails with an UnsupportedOperationException rather than pretending to succeed, and the savepoint stays usable for a rollback. MySQL uses the standard syntax. DB2 requires the mandatory ON ROLLBACK RETAIN CURSORS clause when a savepoint is created. Transact-SQL names the statements SAVE TRANSACTION and ROLLBACK TRANSACTION. Oracle runs the statement on its JDBC connection through a new OracleSavepointCommand. Generated savepoint names are now VX_SP_: an unquoted Oracle identifier cannot start with an underscore, so the previous __vx_sp_ was invalid there. The behaviour every database agrees on moved to TransactionTestBase, gated on the two capabilities, so each driver inherits it. What the databases do not agree on stays in the driver tests: PostgreSQL fails the whole transaction when a statement fails, whereas MySQL leaves the transaction usable, and MySQLTransactionTest now covers that difference. Verified against PostgreSQL, MySQL and Oracle. SQL Server and DB2 could not be started locally, both for reasons unrelated to this change. Signed-off-by: doxlik --- .../db2client/impl/DB2SocketConnection.java | 17 ++ .../io/vertx/db2client/spi/DB2Driver.java | 5 + .../db2client/tck/DB2TransactionTest.java | 5 + .../impl/MSSQLSocketConnection.java | 31 +++ .../io/vertx/mssqlclient/spi/MSSQLDriver.java | 13 + .../mssqlclient/tck/MSSQLTransactionTest.java | 10 + .../impl/MySQLSocketConnection.java | 10 + .../io/vertx/mysqlclient/spi/MySQLDriver.java | 5 + .../mysqlclient/tck/MySQLTransactionTest.java | 54 +++++ .../impl/OracleJdbcConnection.java | 2 + .../impl/commands/OracleSavepointCommand.java | 52 ++++ .../vertx/oracleclient/spi/OracleDriver.java | 13 + .../tck/OracleTransactionTest.java | 10 + .../tests/pgclient/tck/PgTransactionTest.java | 87 +------ .../java/io/vertx/sqlclient/Savepoint.java | 4 + .../vertx/sqlclient/impl/TransactionImpl.java | 6 +- .../java/io/vertx/sqlclient/spi/Driver.java | 15 ++ .../sqlclient/tck/TransactionTestBase.java | 225 ++++++++++++++++++ 18 files changed, 481 insertions(+), 83 deletions(-) create mode 100644 vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/commands/OracleSavepointCommand.java diff --git a/vertx-db2-client/src/main/java/io/vertx/db2client/impl/DB2SocketConnection.java b/vertx-db2-client/src/main/java/io/vertx/db2client/impl/DB2SocketConnection.java index 69171735e..95292847d 100644 --- a/vertx-db2-client/src/main/java/io/vertx/db2client/impl/DB2SocketConnection.java +++ b/vertx-db2-client/src/main/java/io/vertx/db2client/impl/DB2SocketConnection.java @@ -32,6 +32,7 @@ import io.vertx.sqlclient.spi.connection.Connection; import io.vertx.sqlclient.spi.protocol.CommandBase; import io.vertx.sqlclient.spi.protocol.ExtendedQueryCommand; +import io.vertx.sqlclient.spi.protocol.SavepointCommand; import io.vertx.sqlclient.spi.protocol.SimpleQueryCommand; import io.vertx.sqlclient.spi.protocol.TxCommand; @@ -121,11 +122,27 @@ protected void doSchedule(CommandBase cmd, Completable handler) { super.doSchedule(cmd2, (res, err) -> handler.complete(txCmd.result(), err)); } + } else if (cmd instanceof SavepointCommand) { + SavepointCommand savepoint = (SavepointCommand) cmd; + SimpleQueryCommand cmd2 = new SimpleQueryCommand<>(savepointSql(savepoint), false, false, + SocketConnectionBase.NULL_COLLECTOR, QueryResultHandler.NOOP_HANDLER); + super.doSchedule(cmd2, (res, err) -> handler.complete(savepoint.result(), err)); } else { super.doSchedule(cmd, handler); } } + /** + * DB2 requires the {@code ON ROLLBACK RETAIN CURSORS} clause when a savepoint is + * created, the other statements follow the standard syntax. + */ + private static String savepointSql(SavepointCommand savepoint) { + if (savepoint.kind() == SavepointCommand.Kind.CREATE) { + return "SAVEPOINT " + savepoint.name() + " ON ROLLBACK RETAIN CURSORS"; + } + return savepoint.sql(); + } + @Override public String system() { return "db2"; diff --git a/vertx-db2-client/src/main/java/io/vertx/db2client/spi/DB2Driver.java b/vertx-db2-client/src/main/java/io/vertx/db2client/spi/DB2Driver.java index e679cece9..350417246 100644 --- a/vertx-db2-client/src/main/java/io/vertx/db2client/spi/DB2Driver.java +++ b/vertx-db2-client/src/main/java/io/vertx/db2client/spi/DB2Driver.java @@ -82,6 +82,11 @@ public ConnectionFactory createConnectionFactory(Vertx vertx, return new DB2ConnectionFactory((VertxInternal) vertx, transportOptions); } + @Override + public boolean supportsSavepoints() { + return true; + } + @Override public SqlConnectionInternal wrapConnection(ContextInternal context, ConnectionFactory factory, Connection connection) { return new DB2ConnectionImpl(context, factory, connection); diff --git a/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java b/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java index 5383511db..23c34a440 100644 --- a/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java +++ b/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java @@ -73,4 +73,9 @@ protected void cleanTestTable(TestContext ctx) { protected String statement(String... parts) { return String.join("?", parts); } + + @Override + protected boolean supportsSavepoints() { + return true; + } } diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLSocketConnection.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLSocketConnection.java index 945deee1a..f85bef478 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLSocketConnection.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/impl/MSSQLSocketConnection.java @@ -42,6 +42,8 @@ import java.util.Map; import java.util.function.Predicate; +import io.vertx.sqlclient.spi.protocol.SavepointCommand; + import static io.vertx.sqlclient.spi.protocol.TxCommand.Kind.BEGIN; public class MSSQLSocketConnection extends SocketConnectionBase { @@ -158,6 +160,20 @@ public void init() { return MSSQLCommandMessage.wrap(command); } + /** + * Transact-SQL names the savepoint statements differently and has no statement to + * release one, {@link io.vertx.sqlclient.spi.Driver#supportsSavepointRelease()} + * reports that. + */ + private static String savepointSql(SavepointCommand savepoint) { + switch (savepoint.kind()) { + case CREATE: + return "SAVE TRANSACTION " + savepoint.name(); + default: + return "ROLLBACK TRANSACTION " + savepoint.name(); + } + } + @Override protected void doSchedule(CommandBase cmd, Completable handler) { if (cmd instanceof TxCommand) { @@ -170,6 +186,21 @@ protected void doSchedule(CommandBase cmd, Completable handler) { SocketConnectionBase.NULL_COLLECTOR, QueryResultHandler.NOOP_HANDLER); super.doSchedule(cmd2, (res, err) -> handler.complete(tx.result(), err)); + } else if (cmd instanceof SavepointCommand) { + SavepointCommand savepoint = (SavepointCommand) cmd; + if (savepoint.kind() == SavepointCommand.Kind.RELEASE) { + // Guarded by MSSQLDriver#supportsSavepointRelease, fail rather than throw on the event loop + handler.fail(new UnsupportedOperationException( + "Releasing a savepoint is not supported by Microsoft SQL Server")); + return; + } + SimpleQueryCommand cmd2 = new SimpleQueryCommand<>( + savepointSql(savepoint), + false, + false, + SocketConnectionBase.NULL_COLLECTOR, + QueryResultHandler.NOOP_HANDLER); + super.doSchedule(cmd2, (res, err) -> handler.complete(savepoint.result(), err)); } else { super.doSchedule(cmd, handler); } diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java index 7901059c5..7858b5c96 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java @@ -67,6 +67,19 @@ public int appendQueryPlaceholder(StringBuilder queryBuilder, int index, int cur return index; } + @Override + public boolean supportsSavepoints() { + return true; + } + + /** + * Transact-SQL has no statement that discards a savepoint without rolling back to it. + */ + @Override + public boolean supportsSavepointRelease() { + return false; + } + @Override public SqlConnectionInternal wrapConnection(ContextInternal context, ConnectionFactory factory, Connection connection) { return new MSSQLConnectionImpl(context, factory, connection); diff --git a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java index f08608821..23cb3cbde 100644 --- a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java +++ b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java @@ -55,4 +55,14 @@ protected String statement(String... parts) { public void testDelayedCommit(TestContext ctx) { throw new AssumptionViolatedException("MSSQL holds write locks on inserted row with isolation level = 2"); } + + @Override + protected boolean supportsSavepoints() { + return true; + } + + @Override + protected boolean supportsSavepointRelease() { + return false; + } } diff --git a/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/impl/MySQLSocketConnection.java b/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/impl/MySQLSocketConnection.java index 60c102add..c01c6c649 100644 --- a/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/impl/MySQLSocketConnection.java +++ b/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/impl/MySQLSocketConnection.java @@ -43,6 +43,7 @@ import io.vertx.sqlclient.codec.SocketConnectionBase; import io.vertx.sqlclient.spi.protocol.CommandBase; import io.vertx.sqlclient.spi.protocol.ExtendedQueryCommand; +import io.vertx.sqlclient.spi.protocol.SavepointCommand; import io.vertx.sqlclient.spi.protocol.SimpleQueryCommand; import io.vertx.sqlclient.spi.protocol.TxCommand; import io.vertx.sqlclient.spi.DatabaseMetadata; @@ -127,6 +128,15 @@ protected void doSchedule(CommandBase cmd, Completable handler) { SocketConnectionBase.NULL_COLLECTOR, QueryResultHandler.NOOP_HANDLER); super.doSchedule(cmd2, (res, err) -> handler.complete(tx.result(), err)); + } else if (cmd instanceof SavepointCommand) { + SavepointCommand savepoint = (SavepointCommand) cmd; + SimpleQueryCommand cmd2 = new SimpleQueryCommand<>( + savepoint.sql(), + false, + false, + SocketConnectionBase.NULL_COLLECTOR, + QueryResultHandler.NOOP_HANDLER); + super.doSchedule(cmd2, (res, err) -> handler.complete(savepoint.result(), err)); } else { super.doSchedule(cmd, handler); } diff --git a/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java b/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java index f326ae353..c9fe487f2 100644 --- a/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java +++ b/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java @@ -85,6 +85,11 @@ public ConnectionFactory createConnectionFactory(Vertx vert return new MySQLConnectionFactory((VertxInternal) vertx, transportOptions); } + @Override + public boolean supportsSavepoints() { + return true; + } + @Override public SqlConnectionInternal wrapConnection(ContextInternal context, ConnectionFactory factory, Connection connection) { return new MySQLConnectionImpl(context, factory, connection); diff --git a/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java b/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java index ccef02cfd..095c249cc 100644 --- a/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java +++ b/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java @@ -15,6 +15,9 @@ */ package io.vertx.tests.mysqlclient.tck; +import io.vertx.core.Future; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; import io.vertx.mysqlclient.MySQLBuilder; import io.vertx.tests.mysqlclient.junit.MySQLRule; @@ -22,6 +25,7 @@ import io.vertx.sqlclient.PoolOptions; import io.vertx.tests.sqlclient.tck.TransactionTestBase; import org.junit.ClassRule; +import org.junit.Test; import org.junit.runner.RunWith; @RunWith(VertxUnitRunner.class) @@ -44,4 +48,54 @@ protected Pool nonTxPool() { protected String statement(String... parts) { return String.join("?", parts); } + + @Override + protected boolean supportsSavepoints() { + return true; + } + + /** + * MySQL does not put a transaction into a failed state when a statement fails, so + * the transaction stays usable and the work before the failure is still committed. + * PostgreSQL fails the whole transaction instead, {@code PgTransactionTest} covers that. + */ + @Test + public void testStatementErrorLeavesTransactionUsable(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); + // no rollback to a savepoint needed, the transaction is still alive + return insertMutable(res.client, 2, "after"); + }) + .compose(v -> res.tx.commit()) + .compose(v -> assertMutableIds(ctx, 1, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + /** + * Rolling back to a savepoint after a failed statement still discards the work that + * followed the savepoint, even though the transaction was never in a failed state. + */ + @Test + public void testRollbackToSavepointAfterStatementError(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 2, "rolled-back") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); + return sp.rollback(); + }) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } } diff --git a/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/OracleJdbcConnection.java b/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/OracleJdbcConnection.java index e0790416b..a77e6494d 100644 --- a/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/OracleJdbcConnection.java +++ b/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/OracleJdbcConnection.java @@ -206,6 +206,8 @@ private OracleCommand wrap(CommandBase cmd) { action = forExtendedQuery((ExtendedQueryCommand) cmd); } else if (cmd instanceof TxCommand) { action = OracleTransactionCommand.create(connection, context, ((TxCommand) cmd)); + } else if (cmd instanceof SavepointCommand) { + action = OracleSavepointCommand.create(connection, context, ((SavepointCommand) cmd)); } else if (cmd instanceof CloseStatementCommand) { action = new OracleCloseStatementCommand(connection, context); } else if (cmd instanceof CloseCursorCommand) { diff --git a/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/commands/OracleSavepointCommand.java b/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/commands/OracleSavepointCommand.java new file mode 100644 index 000000000..da9c0edaa --- /dev/null +++ b/vertx-oracle-client/src/main/java/io/vertx/oracleclient/impl/commands/OracleSavepointCommand.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.oracleclient.impl.commands; + +import io.vertx.core.Future; +import io.vertx.core.internal.ContextInternal; +import io.vertx.sqlclient.spi.protocol.SavepointCommand; +import oracle.jdbc.OracleConnection; + +import java.sql.Statement; + +/** + * Runs a savepoint statement on the JDBC connection. + * + *

Oracle names savepoints with the standard syntax, but has no statement that + * releases one, so {@link SavepointCommand.Kind#RELEASE} never reaches this command. + */ +public class OracleSavepointCommand extends OracleCommand { + + private final SavepointCommand op; + + private OracleSavepointCommand(OracleConnection oracleConnection, ContextInternal connectionContext, SavepointCommand op) { + super(oracleConnection, connectionContext); + this.op = op; + } + + public static OracleSavepointCommand create(OracleConnection oracleConnection, ContextInternal connectionContext, SavepointCommand cmd) { + return new OracleSavepointCommand<>(oracleConnection, connectionContext, cmd); + } + + @Override + protected Future execute() { + if (op.kind() == SavepointCommand.Kind.RELEASE) { + return connectionContext.failedFuture(new UnsupportedOperationException( + "Releasing a savepoint is not supported by Oracle")); + } + String sql = op.sql(); + return executeBlocking(() -> { + try (Statement statement = oracleConnection.createStatement()) { + statement.execute(sql); + } + }).map(op.result()); + } +} diff --git a/vertx-oracle-client/src/main/java/io/vertx/oracleclient/spi/OracleDriver.java b/vertx-oracle-client/src/main/java/io/vertx/oracleclient/spi/OracleDriver.java index d7494ec48..6c7541f09 100644 --- a/vertx-oracle-client/src/main/java/io/vertx/oracleclient/spi/OracleDriver.java +++ b/vertx-oracle-client/src/main/java/io/vertx/oracleclient/spi/OracleDriver.java @@ -62,6 +62,19 @@ public ConnectionFactory createConnectionFactory(Vertx ver return new OracleConnectionFactory(); } + @Override + public boolean supportsSavepoints() { + return true; + } + + /** + * Oracle has no statement that discards a savepoint without rolling back to it. + */ + @Override + public boolean supportsSavepointRelease() { + return false; + } + @Override public SqlConnectionInternal wrapConnection(ContextInternal context, ConnectionFactory factory, Connection connection) { return new OracleConnectionImpl(context, factory, connection); diff --git a/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java b/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java index 9f089d355..bf84c6e61 100644 --- a/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java +++ b/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java @@ -81,4 +81,14 @@ public void testConstraintViolationIsReported(TestContext ctx) { return conn.preparedQuery(sql, new OraclePrepareOptions().setAutoGeneratedKeys(true)).execute(); }).onComplete(ctx.asyncAssertFailure()); } + + @Override + protected boolean supportsSavepoints() { + return true; + } + + @Override + protected boolean supportsSavepointRelease() { + return false; + } } diff --git a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java index 9ad8adc1e..bdd0ccf88 100644 --- a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java +++ b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java @@ -156,54 +156,8 @@ public void testRollbackInnerSavepointKeepsOuterWork(TestContext ctx) { })); } - @Test - public void testRollbackInnerThenOuterSavepointKeepsOnlyWorkBeforeOuter(TestContext ctx) { - Async async = ctx.async(); - connector.accept(ctx.asyncAssertSuccess(res -> { - insertMutable(res.client, 1, "before-sp1") - .compose(v -> res.tx.createSavepoint()) - .compose(sp1 -> insertMutable(res.client, 2, "between-sp1-sp2") - .compose(v -> res.tx.createSavepoint()) - .compose(sp2 -> insertMutable(res.client, 3, "after-sp2") - .compose(v -> sp2.rollback()) - .compose(v -> insertMutable(res.client, 4, "after-sp2-rollback")) - .compose(v -> sp1.rollback()) - .compose(v -> insertMutable(res.client, 5, "after-sp1-rollback")) - .compose(v -> res.tx.commit()))) - .compose(v -> assertMutableIds(ctx, 1, 5)) - .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); - })); - } - @Test - public void testRollbackToSameSavepointTwice(TestContext ctx) { - Async async = ctx.async(); - connector.accept(ctx.asyncAssertSuccess(res -> { - res.tx.createSavepoint() - .compose(sp -> insertMutable(res.client, 1, "first") - .compose(v -> sp.rollback()) - .compose(v -> insertMutable(res.client, 2, "second")) - .compose(v -> sp.rollback()) - .compose(v -> insertMutable(res.client, 3, "third")) - .compose(v -> res.tx.commit())) - .compose(v -> assertMutableIds(ctx, 3)) - .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); - })); - } - @Test - public void testReleaseSavepointKeepsWork(TestContext ctx) { - Async async = ctx.async(); - connector.accept(ctx.asyncAssertSuccess(res -> { - res.tx.createSavepoint() - .compose(sp -> insertMutable(res.client, 1, "released-scope") - .compose(v -> sp.release()) - .compose(v -> insertMutable(res.client, 2, "after-release")) - .compose(v -> res.tx.commit())) - .compose(v -> assertMutableIds(ctx, 1, 2)) - .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); - })); - } @Test public void testCommitCleansUpUnreleasedSavepoint(TestContext ctx) { @@ -347,26 +301,6 @@ public void testReleaseSavepointFailsWhileTransactionIsFailed(TestContext ctx) { })); } - @Test - public void testCanCreateNewSavepointAfterRollbackToSavepoint(TestContext ctx) { - Async async = ctx.async(); - connector.accept(ctx.asyncAssertSuccess(res -> { - res.tx.createSavepoint() - .compose(sp1 -> insertMutable(res.client, 1, "before-failure") - .compose(v -> insertMutable(res.client, 1, "duplicate")) - .compose(v -> Future.failedFuture("Expected duplicate key failure")) - .recover(err -> { - assertSqlState(ctx, err, "23505"); - return sp1.rollback() - .compose(v -> res.tx.createSavepoint()) - .compose(sp2 -> insertMutable(res.client, 2, "after-recovery") - .compose(x -> sp2.release())) - .compose(v -> res.tx.commit()); - })) - .compose(v -> assertMutableIds(ctx, 2)) - .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); - })); - } @Test public void testRollbackToSavepointAfterRepeatedFailedTransactionStatusRestoresTransaction(TestContext ctx) { @@ -676,23 +610,7 @@ public void testReleaseAfterRollbackToSameSavepoint(TestContext ctx) { })); } - private Future> insertMutable(SqlConnection client, int id, String val) { - return client.query("INSERT INTO mutable (id, val) VALUES (" + id + ", '" + val + "')").execute(); - } - private Future assertMutableIds(TestContext ctx, int... expectedIds) { - return getPool() - .query("SELECT id FROM mutable ORDER BY id") - .execute() - .map(rows -> { - ctx.assertEquals(expectedIds.length, rows.size()); - int index = 0; - for (Row row : rows) { - ctx.assertEquals(expectedIds[index++], row.getInteger("id").intValue()); - } - return null; - }); - } private void assertSqlState(TestContext ctx, Throwable err, String sqlState) { ctx.assertTrue(err instanceof PgException); @@ -702,4 +620,9 @@ private void assertSqlState(TestContext ctx, Throwable err, String sqlState) { private void assertTransactionRollback(TestContext ctx, Throwable err) { ctx.assertTrue(err instanceof TransactionRollbackException); } + + @Override + protected boolean supportsSavepoints() { + return true; + } } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java index b2e930832..8296191f5 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Savepoint.java @@ -33,6 +33,10 @@ public interface Savepoint { * Release this savepoint. * *

After release, this savepoint can no longer be used. + * + *

Fails with {@link UnsupportedOperationException} when the driver creates + * savepoints but has no statement that releases one, such as Microsoft SQL Server + * and Oracle. The savepoint remains usable for a {@link #rollback()} in that case. */ Future release(); } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index a8376ab01..0077fb4e0 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -73,7 +73,7 @@ public Future createSavepoint() { String name; synchronized (this) { - name = "__vx_sp_" + (++savepointSeq); + name = "VX_SP_" + (++savepointSeq); } SavepointImpl savepoint = new SavepointImpl(this, name); return submit(new SavepointCommand<>(SavepointCommand.Kind.CREATE, name, savepoint)); @@ -84,6 +84,10 @@ Future rollbackToSavepoint(String name) { } Future releaseSavepoint(String name) { + if (!driver.supportsSavepointRelease()) { + return context.failedFuture(new UnsupportedOperationException( + "Releasing a savepoint is not supported by this driver")); + } return submit(new SavepointCommand<>(SavepointCommand.Kind.RELEASE, name, null)); } diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java index 02a25c235..f78dad1e6 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java @@ -151,4 +151,19 @@ default int appendQueryPlaceholder(StringBuilder queryBuilder, int index, int cu default boolean supportsSavepoints() { return false; } + + /** + * Whether a savepoint can be released without rolling back to it. + * + *

Some databases, such as Microsoft SQL Server and Oracle, create savepoints + * but offer no statement to discard one. Releasing a savepoint on those drivers + * fails with an {@link UnsupportedOperationException}. + * + *

Only meaningful when {@link #supportsSavepoints()} returns {@code true}. + * + * @return {@code true} when the driver supports releasing a savepoint. + */ + default boolean supportsSavepointRelease() { + return true; + } } diff --git a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java index 886dffe52..7703ebfe0 100644 --- a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java +++ b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java @@ -19,6 +19,7 @@ import io.vertx.ext.unit.TestContext; import io.vertx.sqlclient.*; import org.junit.After; +import org.junit.Assume; import org.junit.Before; import org.junit.Test; @@ -412,4 +413,228 @@ public void testWithPropagatableConnectionTransactionRollback(TestContext ctx) { })))); }); } + + // --------------------------------------------------------------------------- + // Savepoints + // + // Only the behaviour every database agrees on lives here. Whether a failed + // statement also fails the surrounding transaction is database specific and is + // covered by the driver test classes. + // --------------------------------------------------------------------------- + + /** + * Overridden by the drivers that implement savepoints. + */ + protected boolean supportsSavepoints() { + return false; + } + + /** + * Overridden by the drivers that create savepoints but cannot release one, + * such as Microsoft SQL Server and Oracle. + */ + protected boolean supportsSavepointRelease() { + return true; + } + + private void assumeSavepoints() { + Assume.assumeTrue("driver does not support savepoints", supportsSavepoints()); + } + + private void assumeSavepointRelease() { + assumeSavepoints(); + Assume.assumeTrue("driver cannot release a savepoint", supportsSavepointRelease()); + } + + protected Future> insertMutable(SqlConnection client, int id, String val) { + return client.query("INSERT INTO mutable (id, val) VALUES (" + id + ", '" + val + "')").execute(); + } + + protected Future assertMutableIds(TestContext ctx, int... expectedIds) { + return getPool() + .query("SELECT id FROM mutable ORDER BY id") + .execute() + .map(rows -> { + ctx.assertEquals(expectedIds.length, rows.size()); + int index = 0; + for (Row row : rows) { + ctx.assertEquals(expectedIds[index++], row.getInteger("id").intValue()); + } + return null; + }); + } + + @Test + public void testRollbackToSavepointUndoesWorkAfterIt(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 2, "rolled-back") + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackInnerThenOuterSavepointKeepsOnlyWorkBeforeOuter(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before-sp1") + .compose(v -> res.tx.createSavepoint()) + .compose(sp1 -> insertMutable(res.client, 2, "between-sp1-sp2") + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 3, "after-sp2") + .compose(v -> sp2.rollback()) + .compose(v -> insertMutable(res.client, 4, "after-sp2-rollback")) + .compose(v -> sp1.rollback()) + .compose(v -> insertMutable(res.client, 5, "after-sp1-rollback")) + .compose(v -> res.tx.commit()))) + .compose(v -> assertMutableIds(ctx, 1, 5)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testRollbackToSameSavepointTwice(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "first") + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 2, "second")) + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 3, "third")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testCanCreateNewSavepointAfterRollbackToSavepoint(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp1 -> insertMutable(res.client, 1, "first") + .compose(v -> sp1.rollback()) + .compose(v -> res.tx.createSavepoint()) + .compose(sp2 -> insertMutable(res.client, 2, "second") + .compose(v -> sp2.rollback()) + .compose(v -> insertMutable(res.client, 3, "third")) + .compose(v -> res.tx.commit()))) + .compose(v -> assertMutableIds(ctx, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testWholeTransactionRollbackDiscardsSavepointWork(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 2, "after") + .compose(v -> res.tx.rollback())) + .compose(v -> assertMutableIds(ctx)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testReleaseSavepointKeepsWork(TestContext ctx) { + assumeSavepointRelease(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "released-scope") + .compose(v -> sp.release()) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testReleaseReleasedSavepointFails(TestContext ctx) { + assumeSavepointRelease(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> sp.release().compose(v -> sp.release())) + .onComplete(ctx.asyncAssertFailure(err -> { + res.tx.commit().onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + })); + } + + /** + * A driver that cannot release a savepoint rejects the call rather than pretending + * it worked, and the savepoint stays usable for a rollback. + */ + @Test + public void testReleaseIsRejectedWhenUnsupported(TestContext ctx) { + assumeSavepoints(); + Assume.assumeFalse("driver releases savepoints", supportsSavepointRelease()); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> insertMutable(res.client, 1, "kept") + .compose(v -> sp.release()) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "release should have been rejected"); + ctx.assertTrue(ar.cause() instanceof UnsupportedOperationException, + "expected an UnsupportedOperationException but got " + ar.cause()); + return insertMutable(res.client, 2, "also-kept").compose(v -> sp.rollback()); + }) + .compose(v -> insertMutable(res.client, 3, "after-rollback")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + @Test + public void testCreateSavepointIsRejectedWhenUnsupported(TestContext ctx) { + Assume.assumeFalse("driver supports savepoints", supportsSavepoints()); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .onComplete(ctx.asyncAssertFailure(err -> { + ctx.assertTrue(err instanceof UnsupportedOperationException, + "expected an UnsupportedOperationException but got " + err); + async.complete(); + })); + })); + } + + @Test + public void testCreateSavepointAfterCommitFails(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.commit() + .compose(v -> res.tx.createSavepoint()) + .onComplete(ctx.asyncAssertFailure(err -> async.complete())); + })); + } + + @Test + public void testRollbackSavepointAfterCommitFails(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + res.tx.createSavepoint() + .compose(sp -> res.tx.commit().compose(v -> sp.rollback())) + .onComplete(ctx.asyncAssertFailure(err -> async.complete())); + })); + } } From 1603b86ca97b07490c856ffc049b9a38b6a7fba6 Mon Sep 17 00:00:00 2001 From: doxlik Date: Sat, 12 Sep 2026 19:02:53 +0400 Subject: [PATCH 05/14] Fix the savepoint build on SQL Server and restore the formatting Removing the duplicated helpers from PgTransactionTest left three imports behind, and MySQLTransactionTest imported Future without using it, both of which fail spotless:check. SQL Server drops a savepoint once the transaction has been rolled back to it and answers a second rollback with "No transaction or savepoint of that name was found", so rolling back twice to the same savepoint is now a capability the drivers report, and SQL Server opts out. Signed-off-by: doxlik --- .../tests/mssqlclient/tck/MSSQLTransactionTest.java | 8 ++++++++ .../tests/mysqlclient/tck/MySQLTransactionTest.java | 1 - .../io/vertx/tests/pgclient/tck/PgTransactionTest.java | 3 --- .../vertx/tests/sqlclient/tck/TransactionTestBase.java | 10 ++++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java index 23cb3cbde..6fdfcf4e4 100644 --- a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java +++ b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java @@ -65,4 +65,12 @@ protected boolean supportsSavepoints() { protected boolean supportsSavepointRelease() { return false; } + + /** + * SQL Server drops the savepoint once the transaction has been rolled back to it. + */ + @Override + protected boolean supportsRepeatedRollbackToSavepoint() { + return false; + } } diff --git a/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java b/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java index 095c249cc..2cb3fe4de 100644 --- a/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java +++ b/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java @@ -15,7 +15,6 @@ */ package io.vertx.tests.mysqlclient.tck; -import io.vertx.core.Future; import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; diff --git a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java index bdd0ccf88..dd25cd243 100644 --- a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java +++ b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java @@ -18,9 +18,6 @@ import io.vertx.pgclient.PgException; import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.PoolOptions; -import io.vertx.sqlclient.Row; -import io.vertx.sqlclient.RowSet; -import io.vertx.sqlclient.SqlConnection; import io.vertx.sqlclient.TransactionRollbackException; import io.vertx.sqlclient.Tuple; import io.vertx.tests.pgclient.junit.ContainerPgRule; diff --git a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java index 7703ebfe0..f61f27a96 100644 --- a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java +++ b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java @@ -437,6 +437,15 @@ protected boolean supportsSavepointRelease() { return true; } + /** + * Overridden by the drivers that drop a savepoint once it has been rolled back to. + * Microsoft SQL Server reports "No transaction or savepoint of that name was found" + * on the second rollback. + */ + protected boolean supportsRepeatedRollbackToSavepoint() { + return true; + } + private void assumeSavepoints() { Assume.assumeTrue("driver does not support savepoints", supportsSavepoints()); } @@ -503,6 +512,7 @@ public void testRollbackInnerThenOuterSavepointKeepsOnlyWorkBeforeOuter(TestCont @Test public void testRollbackToSameSavepointTwice(TestContext ctx) { assumeSavepoints(); + Assume.assumeTrue("driver drops the savepoint after a rollback", supportsRepeatedRollbackToSavepoint()); Async async = ctx.async(); connector.accept(ctx.asyncAssertSuccess(res -> { res.tx.createSavepoint() From 49945abd983bdffb56073d92d998d575fbe0aaa4 Mon Sep 17 00:00:00 2001 From: doxlik Date: Sat, 12 Sep 2026 20:01:00 +0400 Subject: [PATCH 06/14] Cover the savepoint behaviour that differs between databases Whether a failed statement also fails the surrounding transaction was only covered for PostgreSQL and MySQL, and only MySQL stated it. It is now a capability the drivers report, statementErrorFailsTransaction(), with a test for each answer and a third test showing that rolling back to a savepoint recovers the transaction either way. The drivers that had no savepoint test of their own get one for the part that is specific to them: SQL Server that a savepoint does not nest the transaction, so @@TRANCOUNT stays at one across SAVE TRANSACTION and the rollback; DB2 that a cursor opened before the savepoint survives the rollback, which is what ON ROLLBACK RETAIN CURSORS is for; Oracle that a savepoint leaves the autocommit handling around commit and rollback alone. Signed-off-by: doxlik --- .../db2client/tck/DB2TransactionTest.java | 37 +++++++++ .../mssqlclient/tck/MSSQLTransactionTest.java | 37 +++++++++ .../mysqlclient/tck/MySQLTransactionTest.java | 46 ----------- .../tck/OracleTransactionTest.java | 23 ++++++ .../tests/pgclient/tck/PgTransactionTest.java | 8 ++ .../sqlclient/tck/TransactionTestBase.java | 82 +++++++++++++++++++ 6 files changed, 187 insertions(+), 46 deletions(-) diff --git a/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java b/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java index 23c34a440..e6369bf78 100644 --- a/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java +++ b/vertx-db2-client/src/test/java/io/vertx/tests/db2client/tck/DB2TransactionTest.java @@ -12,6 +12,9 @@ import io.vertx.db2client.DB2Builder; import io.vertx.db2client.DB2ConnectOptions; +import io.vertx.ext.unit.Async; +import io.vertx.sqlclient.Cursor; +import org.junit.Test; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; import io.vertx.sqlclient.Pool; @@ -78,4 +81,38 @@ protected String statement(String... parts) { protected boolean supportsSavepoints() { return true; } + + /** + * DB2 creates savepoints with ON ROLLBACK RETAIN CURSORS, so a cursor opened before the + * rollback keeps returning the rows it was already positioned on. + */ + @Test + public void testCursorSurvivesRollbackToSavepoint(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "one") + .compose(v -> insertMutable(res.client, 2, "two")) + .compose(v -> res.client.prepare("SELECT id FROM mutable ORDER BY id")) + .compose(ps -> { + Cursor cursor = ps.cursor(); + return cursor.read(1) + .compose(first -> { + ctx.assertEquals(1, first.size()); + ctx.assertEquals(1, first.iterator().next().getInteger("id")); + return res.tx.createSavepoint(); + }) + .compose(sp -> insertMutable(res.client, 3, "three").compose(v -> sp.rollback())) + // the cursor was opened before the savepoint, it must still be readable + .compose(v -> cursor.read(1)) + .compose(second -> { + ctx.assertEquals(1, second.size()); + ctx.assertEquals(2, second.iterator().next().getInteger("id")); + return cursor.close(); + }); + }) + .compose(v -> res.tx.commit()) + .compose(v -> assertMutableIds(ctx, 1, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } } diff --git a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java index 6fdfcf4e4..0e0cd09c0 100644 --- a/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java +++ b/vertx-mssql-client/src/test/java/io/vertx/tests/mssqlclient/tck/MSSQLTransactionTest.java @@ -10,7 +10,10 @@ */ package io.vertx.tests.mssqlclient.tck; +import io.vertx.core.Future; +import io.vertx.sqlclient.SqlConnection; import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.Async; import io.vertx.ext.unit.junit.VertxUnitRunner; import io.vertx.mssqlclient.MSSQLBuilder; import io.vertx.mssqlclient.MSSQLConnectOptions; @@ -73,4 +76,38 @@ protected boolean supportsSavepointRelease() { protected boolean supportsRepeatedRollbackToSavepoint() { return false; } + + /** + * A savepoint is a mark inside the current transaction, it must not open a nested one: + * @@TRANCOUNT stays at 1 after SAVE TRANSACTION and after rolling back to it. + */ + @Test + public void testSavepointDoesNotNestTheTransaction(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + trancount(res.client) + .compose(before -> { + ctx.assertEquals(1, before, "the transaction should be the only one open"); + return res.tx.createSavepoint(); + }) + .compose(sp -> trancount(res.client) + .compose(afterSave -> { + ctx.assertEquals(1, afterSave, "SAVE TRANSACTION must not nest a transaction"); + return insertMutable(res.client, 1, "rolled-back"); + }) + .compose(v -> sp.rollback()) + .compose(v -> trancount(res.client)) + .compose(afterRollback -> { + ctx.assertEquals(1, afterRollback, "rolling back to a savepoint must keep the transaction open"); + return insertMutable(res.client, 2, "kept"); + }) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + private Future trancount(SqlConnection client) { + return client.query("SELECT @@TRANCOUNT AS c").execute().map(rows -> rows.iterator().next().getInteger("c")); + } } diff --git a/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java b/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java index 2cb3fe4de..c8003a84c 100644 --- a/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java +++ b/vertx-mysql-client/src/test/java/io/vertx/tests/mysqlclient/tck/MySQLTransactionTest.java @@ -15,8 +15,6 @@ */ package io.vertx.tests.mysqlclient.tck; -import io.vertx.ext.unit.Async; -import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; import io.vertx.mysqlclient.MySQLBuilder; import io.vertx.tests.mysqlclient.junit.MySQLRule; @@ -24,7 +22,6 @@ import io.vertx.sqlclient.PoolOptions; import io.vertx.tests.sqlclient.tck.TransactionTestBase; import org.junit.ClassRule; -import org.junit.Test; import org.junit.runner.RunWith; @RunWith(VertxUnitRunner.class) @@ -53,48 +50,5 @@ protected boolean supportsSavepoints() { return true; } - /** - * MySQL does not put a transaction into a failed state when a statement fails, so - * the transaction stays usable and the work before the failure is still committed. - * PostgreSQL fails the whole transaction instead, {@code PgTransactionTest} covers that. - */ - @Test - public void testStatementErrorLeavesTransactionUsable(TestContext ctx) { - Async async = ctx.async(); - connector.accept(ctx.asyncAssertSuccess(res -> { - insertMutable(res.client, 1, "before") - .compose(v -> insertMutable(res.client, 1, "duplicate")) - .transform(ar -> { - ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); - // no rollback to a savepoint needed, the transaction is still alive - return insertMutable(res.client, 2, "after"); - }) - .compose(v -> res.tx.commit()) - .compose(v -> assertMutableIds(ctx, 1, 2)) - .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); - })); - } - /** - * Rolling back to a savepoint after a failed statement still discards the work that - * followed the savepoint, even though the transaction was never in a failed state. - */ - @Test - public void testRollbackToSavepointAfterStatementError(TestContext ctx) { - Async async = ctx.async(); - connector.accept(ctx.asyncAssertSuccess(res -> { - insertMutable(res.client, 1, "before") - .compose(v -> res.tx.createSavepoint()) - .compose(sp -> insertMutable(res.client, 2, "rolled-back") - .compose(v -> insertMutable(res.client, 1, "duplicate")) - .transform(ar -> { - ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); - return sp.rollback(); - }) - .compose(v -> insertMutable(res.client, 3, "after")) - .compose(v -> res.tx.commit())) - .compose(v -> assertMutableIds(ctx, 1, 3)) - .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); - })); - } } diff --git a/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java b/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java index bf84c6e61..8f586b2b0 100644 --- a/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java +++ b/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java @@ -91,4 +91,27 @@ protected boolean supportsSavepoints() { protected boolean supportsSavepointRelease() { return false; } + + /** + * OracleTransactionCommand turns autocommit off to begin and back on when the + * transaction ends. A savepoint runs its own statement on the same JDBC connection, so + * check it leaves that handling alone: the connection is reusable and back on autocommit + * once the transaction has committed. + */ + @Test + public void testSavepointLeavesAutoCommitHandlingIntact(TestContext ctx) { + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 2, "rolled-back").compose(v -> sp.rollback())) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit()) + // autocommit is back on, this statement stands on its own and is durable + .compose(v -> insertMutable(res.client, 4, "after-commit")) + .compose(v -> res.client.close()) + .compose(v -> assertMutableIds(ctx, 1, 3, 4)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } } diff --git a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java index dd25cd243..aa6d1ce99 100644 --- a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java +++ b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/tck/PgTransactionTest.java @@ -622,4 +622,12 @@ private void assertTransactionRollback(TestContext ctx, Throwable err) { protected boolean supportsSavepoints() { return true; } + + /** + * PostgreSQL fails the whole transaction when a statement fails. + */ + @Override + protected boolean statementErrorFailsTransaction() { + return true; + } } diff --git a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java index f61f27a96..d3f6f5f6b 100644 --- a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java +++ b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java @@ -437,6 +437,18 @@ protected boolean supportsSavepointRelease() { return true; } + /** + * Whether a failed statement also fails the surrounding transaction. + * + *

PostgreSQL puts the transaction in a failed state, every later statement is + * rejected until the transaction is rolled back or rolled back to a savepoint. + * The other databases roll back the failed statement only and leave the + * transaction usable. + */ + protected boolean statementErrorFailsTransaction() { + return false; + } + /** * Overridden by the drivers that drop a savepoint once it has been rolled back to. * Microsoft SQL Server reports "No transaction or savepoint of that name was found" @@ -647,4 +659,74 @@ public void testRollbackSavepointAfterCommitFails(TestContext ctx) { .onComplete(ctx.asyncAssertFailure(err -> async.complete())); })); } + + /** + * A statement that fails rolls back that statement only, the transaction carries on + * and the work around the failure is committed. + */ + @Test + public void testStatementErrorLeavesTransactionUsable(TestContext ctx) { + Assume.assumeFalse("driver fails the transaction", statementErrorFailsTransaction()); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); + return insertMutable(res.client, 2, "after"); + }) + .compose(v -> res.tx.commit()) + .compose(v -> assertMutableIds(ctx, 1, 2)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + /** + * The counterpart: a failed statement leaves the transaction unusable, so the next + * statement is rejected too and nothing is committed. + */ + @Test + public void testStatementErrorFailsTransaction(TestContext ctx) { + Assume.assumeTrue("driver keeps the transaction usable", statementErrorFailsTransaction()); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); + return insertMutable(res.client, 2, "after"); + }) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "the transaction should have rejected the next statement"); + return res.tx.rollback(); + }) + .compose(v -> assertMutableIds(ctx)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + /** + * Rolling back to a savepoint after a failed statement discards the work that followed + * the savepoint and lets the transaction commit, whichever of the two behaviours above + * the database has. + */ + @Test + public void testRollbackToSavepointAfterStatementError(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint()) + .compose(sp -> insertMutable(res.client, 2, "rolled-back") + .compose(v -> insertMutable(res.client, 1, "duplicate")) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "the duplicate key should have failed"); + return sp.rollback(); + }) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } } From a01b60eab2b0de30f6cc54a881130c2581b1f5db Mon Sep 17 00:00:00 2001 From: doxlik Date: Sat, 12 Sep 2026 20:06:08 +0400 Subject: [PATCH 07/14] Do not close the pooled connection in the Oracle savepoint test The connector hands out a pooled connection that is released when the transaction ends, so closing it again failed the test with "Connection released twice". Acquire the connection from the pool instead, which is what proves it went back, and assert on the rows the transaction committed. Signed-off-by: doxlik --- .../oracleclient/tck/OracleTransactionTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java b/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java index 8f586b2b0..a5f5cdca1 100644 --- a/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java +++ b/vertx-oracle-client/src/test/java/tests/oracleclient/tck/OracleTransactionTest.java @@ -11,6 +11,7 @@ package tests.oracleclient.tck; import io.vertx.ext.unit.Async; +import io.vertx.sqlclient.SqlConnection; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; import io.vertx.oracleclient.OracleBuilder; @@ -95,8 +96,8 @@ protected boolean supportsSavepointRelease() { /** * OracleTransactionCommand turns autocommit off to begin and back on when the * transaction ends. A savepoint runs its own statement on the same JDBC connection, so - * check it leaves that handling alone: the connection is reusable and back on autocommit - * once the transaction has committed. + * check it leaves that handling alone: the transaction still commits the right rows and + * the connection goes back to the pool once it has. */ @Test public void testSavepointLeavesAutoCommitHandlingIntact(TestContext ctx) { @@ -107,10 +108,9 @@ public void testSavepointLeavesAutoCommitHandlingIntact(TestContext ctx) { .compose(sp -> insertMutable(res.client, 2, "rolled-back").compose(v -> sp.rollback())) .compose(v -> insertMutable(res.client, 3, "after")) .compose(v -> res.tx.commit()) - // autocommit is back on, this statement stands on its own and is durable - .compose(v -> insertMutable(res.client, 4, "after-commit")) - .compose(v -> res.client.close()) - .compose(v -> assertMutableIds(ctx, 1, 3, 4)) + // the connection went back to the pool, so the single pooled connection is free again + .compose(v -> getPool().getConnection().compose(SqlConnection::close)) + .compose(v -> assertMutableIds(ctx, 1, 3)) .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); })); } From 331cbf725a4dfca520d805cb492f315256d9c3eb Mon Sep 17 00:00:00 2001 From: doxlik Date: Fri, 18 Sep 2026 13:10:34 +0400 Subject: [PATCH 08/14] Document savepoints and simplify SavepointImpl The transactions documentation now has a savepoint section covering rollback, release and the databases that cannot release a savepoint, with an example for each of the two operations in every client module. SavepointImpl carried a private functional interface only to defer the two transaction calls. A BiFunction does the same, so rollback and release are method references now. Signed-off-by: doxlik --- .../main/java/examples/SqlClientExamples.java | 30 +++++++++++++++++++ .../main/java/examples/SqlClientExamples.java | 30 +++++++++++++++++++ .../main/java/examples/SqlClientExamples.java | 30 +++++++++++++++++++ .../main/java/examples/SqlClientExamples.java | 30 +++++++++++++++++++ .../main/java/examples/SqlClientExamples.java | 30 +++++++++++++++++++ .../src/main/asciidoc/transactions.adoc | 29 ++++++++++++++++++ .../vertx/sqlclient/impl/SavepointImpl.java | 14 ++++----- 7 files changed, 185 insertions(+), 8 deletions(-) diff --git a/vertx-db2-client/src/main/java/examples/SqlClientExamples.java b/vertx-db2-client/src/main/java/examples/SqlClientExamples.java index f69bda0f5..64e1a0a9b 100644 --- a/vertx-db2-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-db2-client/src/main/java/examples/SqlClientExamples.java @@ -267,6 +267,36 @@ public void transaction03(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint01(Pool pool) { + + pool.withTransaction(client -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> client.transaction().createSavepoint()) + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Emad','Alblueshi')") + .execute() + // Undo the second insert, the first one is still part of the transaction + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed with a single user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void savepoint02(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + .createSavepoint() + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + // The insert is kept, the savepoint cannot be used anymore + .flatMap(v -> savepoint.release()))) + .onSuccess(v -> System.out.println("Transaction committed")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection .prepare("SELECT * FROM users WHERE first_name LIKE ?") diff --git a/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java b/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java index 449b9a01c..0ce1bef3d 100644 --- a/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java @@ -296,6 +296,36 @@ public void transaction03(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint01(Pool pool) { + + pool.withTransaction(client -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> client.transaction().createSavepoint()) + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Emad','Alblueshi')") + .execute() + // Undo the second insert, the first one is still part of the transaction + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed with a single user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void savepoint02(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + .createSavepoint() + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + // The insert is kept, the savepoint cannot be used anymore + .flatMap(v -> savepoint.release()))) + .onSuccess(v -> System.out.println("Transaction committed")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection.prepare("SELECT * FROM users WHERE age > @p1") .onComplete(ar1 -> { diff --git a/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java b/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java index a435115bd..460126764 100644 --- a/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java @@ -266,6 +266,36 @@ public void transaction03(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint01(Pool pool) { + + pool.withTransaction(client -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> client.transaction().createSavepoint()) + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Emad','Alblueshi')") + .execute() + // Undo the second insert, the first one is still part of the transaction + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed with a single user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void savepoint02(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + .createSavepoint() + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + // The insert is kept, the savepoint cannot be used anymore + .flatMap(v -> savepoint.release()))) + .onSuccess(v -> System.out.println("Transaction committed")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection .prepare("SELECT * FROM users WHERE age > ?") diff --git a/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java b/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java index edf35fa4b..6af5d6a54 100644 --- a/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java @@ -267,6 +267,36 @@ public void transaction03(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint01(Pool pool) { + + pool.withTransaction(client -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> client.transaction().createSavepoint()) + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Emad','Alblueshi')") + .execute() + // Undo the second insert, the first one is still part of the transaction + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed with a single user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void savepoint02(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + .createSavepoint() + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + // The insert is kept, the savepoint cannot be used anymore + .flatMap(v -> savepoint.release()))) + .onSuccess(v -> System.out.println("Transaction committed")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection .prepare("SELECT * FROM users WHERE age > ?") diff --git a/vertx-pg-client/src/main/java/examples/SqlClientExamples.java b/vertx-pg-client/src/main/java/examples/SqlClientExamples.java index 7f7177e05..087d9e4c2 100644 --- a/vertx-pg-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-pg-client/src/main/java/examples/SqlClientExamples.java @@ -266,6 +266,36 @@ public void transaction03(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint01(Pool pool) { + + pool.withTransaction(client -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> client.transaction().createSavepoint()) + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Emad','Alblueshi')") + .execute() + // Undo the second insert, the first one is still part of the transaction + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed with a single user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void savepoint02(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + .createSavepoint() + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + // The insert is kept, the savepoint cannot be used anymore + .flatMap(v -> savepoint.release()))) + .onSuccess(v -> System.out.println("Transaction committed")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection .prepare("SELECT * FROM users WHERE first_name LIKE $1") diff --git a/vertx-sql-client/src/main/asciidoc/transactions.adoc b/vertx-sql-client/src/main/asciidoc/transactions.adoc index 05f6b853c..9c2a45504 100644 --- a/vertx-sql-client/src/main/asciidoc/transactions.adoc +++ b/vertx-sql-client/src/main/asciidoc/transactions.adoc @@ -40,3 +40,32 @@ After the transaction completes, the connection is returned to the pool and the ---- {@link examples.SqlClientExamples#transaction03(io.vertx.sqlclient.Pool)} ---- + +=== Savepoints + +Within a transaction you can set a savepoint with {@link io.vertx.sqlclient.Transaction#createSavepoint}, +a mark you can later come back to without giving up the whole transaction. + +{@link io.vertx.sqlclient.Savepoint#rollback} undoes everything done since the savepoint was created and +leaves the transaction open, so it can carry on and still commit the work done before: + +[source,$lang] +---- +{@link examples.SqlClientExamples#savepoint01(io.vertx.sqlclient.Pool)} +---- + +On PostgreSQL a failed statement also fails the transaction, every later statement is rejected until the +transaction ends. Rolling back to a savepoint taken before the failure recovers it. The other databases +roll back the failed statement only, and there the savepoint simply discards the work that followed it. + +{@link io.vertx.sqlclient.Savepoint#release} drops a savepoint you no longer need, keeping the work done +since it was created. The savepoint cannot be used afterwards: + +[source,$lang] +---- +{@link examples.SqlClientExamples#savepoint02(io.vertx.sqlclient.Pool)} +---- + +Savepoints are not supported by every database, and Microsoft SQL Server and Oracle create them but have +no statement that releases one. Both calls report that: they fail with an `UnsupportedOperationException` +rather than pretending to succeed, and a savepoint that could not be released stays usable for a rollback. diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java index a7539bf6f..a3e8dc1dd 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/SavepointImpl.java @@ -13,6 +13,8 @@ import io.vertx.core.Future; import io.vertx.sqlclient.Savepoint; +import java.util.function.BiFunction; + public class SavepointImpl implements Savepoint { private enum State { @@ -33,15 +35,15 @@ public SavepointImpl(TransactionImpl transaction, String name) { @Override public Future rollback() { - return execute(false, () -> transaction.rollbackToSavepoint(name)); + return execute(false, TransactionImpl::rollbackToSavepoint); } @Override public Future release() { - return execute(true, () -> transaction.releaseSavepoint(name)); + return execute(true, TransactionImpl::releaseSavepoint); } - private Future execute(boolean release, Action action) { + private Future execute(boolean release, BiFunction> action) { synchronized (this) { if (state == State.RELEASED) { return transaction.failedFuture("Savepoint already released"); @@ -51,7 +53,7 @@ private Future execute(boolean release, Action action) { } state = State.PENDING; } - return action.execute().andThen(ar -> { + return action.apply(transaction, name).andThen(ar -> { synchronized (SavepointImpl.this) { if (ar.succeeded()) { state = release ? State.RELEASED : State.ACTIVE; @@ -62,8 +64,4 @@ private Future execute(boolean release, Action action) { }); } - @FunctionalInterface - private interface Action { - Future execute(); - } } From e3b2c7325567374dc2bdbb68d0ac8018d8b51ad3 Mon Sep 17 00:00:00 2001 From: doxlik Date: Fri, 18 Sep 2026 13:14:01 +0400 Subject: [PATCH 09/14] Keep the database specific savepoint notes out of the shared page The transactions documentation is included by every client, so the section describing what PostgreSQL, SQL Server and Oracle do would have appeared on all of the pages. The shared section only states what the API does now, and each client contributes its own savepoint_note.adoc, the way cursor.adoc already lets MySQL add its ProxySQL warning. Signed-off-by: doxlik --- .../src/main/asciidoc/savepoint_note.adoc | 4 ++++ .../src/main/asciidoc/savepoint_note.adoc | 6 ++++++ .../src/main/asciidoc/savepoint_note.adoc | 4 ++++ .../src/main/asciidoc/savepoint_note.adoc | 2 ++ vertx-pg-client/src/main/asciidoc/savepoint_note.adoc | 5 +++++ vertx-sql-client/src/main/asciidoc/transactions.adoc | 11 ++++------- 6 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 vertx-db2-client/src/main/asciidoc/savepoint_note.adoc create mode 100644 vertx-mssql-client/src/main/asciidoc/savepoint_note.adoc create mode 100644 vertx-mysql-client/src/main/asciidoc/savepoint_note.adoc create mode 100644 vertx-oracle-client/src/main/asciidoc/savepoint_note.adoc create mode 100644 vertx-pg-client/src/main/asciidoc/savepoint_note.adoc diff --git a/vertx-db2-client/src/main/asciidoc/savepoint_note.adoc b/vertx-db2-client/src/main/asciidoc/savepoint_note.adoc new file mode 100644 index 000000000..fbc1cf176 --- /dev/null +++ b/vertx-db2-client/src/main/asciidoc/savepoint_note.adoc @@ -0,0 +1,4 @@ +DB2 supports savepoints fully. + +Savepoints are created with the `ON ROLLBACK RETAIN CURSORS` clause, so a cursor opened before the +savepoint stays usable after a rollback. diff --git a/vertx-mssql-client/src/main/asciidoc/savepoint_note.adoc b/vertx-mssql-client/src/main/asciidoc/savepoint_note.adoc new file mode 100644 index 000000000..97de53f65 --- /dev/null +++ b/vertx-mssql-client/src/main/asciidoc/savepoint_note.adoc @@ -0,0 +1,6 @@ +Microsoft SQL Server has no statement that releases a savepoint, so +{@link io.vertx.sqlclient.Savepoint#release} fails with an `UnsupportedOperationException`. The savepoint +remains usable for a rollback. + +A savepoint is also dropped once the transaction has been rolled back to it, so it cannot be rolled back +to a second time. diff --git a/vertx-mysql-client/src/main/asciidoc/savepoint_note.adoc b/vertx-mysql-client/src/main/asciidoc/savepoint_note.adoc new file mode 100644 index 000000000..34a0aed9d --- /dev/null +++ b/vertx-mysql-client/src/main/asciidoc/savepoint_note.adoc @@ -0,0 +1,4 @@ +MySQL supports savepoints fully. + +A failed statement does not fail the transaction, only the statement is rolled back and the transaction +stays usable. A savepoint is still useful to discard a group of statements as a whole. diff --git a/vertx-oracle-client/src/main/asciidoc/savepoint_note.adoc b/vertx-oracle-client/src/main/asciidoc/savepoint_note.adoc new file mode 100644 index 000000000..d9ed38245 --- /dev/null +++ b/vertx-oracle-client/src/main/asciidoc/savepoint_note.adoc @@ -0,0 +1,2 @@ +Oracle has no statement that releases a savepoint, so {@link io.vertx.sqlclient.Savepoint#release} fails +with an `UnsupportedOperationException`. The savepoint remains usable for a rollback. diff --git a/vertx-pg-client/src/main/asciidoc/savepoint_note.adoc b/vertx-pg-client/src/main/asciidoc/savepoint_note.adoc new file mode 100644 index 000000000..ec61e535f --- /dev/null +++ b/vertx-pg-client/src/main/asciidoc/savepoint_note.adoc @@ -0,0 +1,5 @@ +PostgreSQL supports savepoints fully. + +A failed statement also fails the transaction: every later statement is rejected with _current transaction +is aborted, commands ignored until end of transaction block_ until the transaction ends. Rolling back to a +savepoint taken before the failure clears that state and lets the transaction carry on. diff --git a/vertx-sql-client/src/main/asciidoc/transactions.adoc b/vertx-sql-client/src/main/asciidoc/transactions.adoc index 9c2a45504..d0c55f373 100644 --- a/vertx-sql-client/src/main/asciidoc/transactions.adoc +++ b/vertx-sql-client/src/main/asciidoc/transactions.adoc @@ -54,10 +54,6 @@ leaves the transaction open, so it can carry on and still commit the work done b {@link examples.SqlClientExamples#savepoint01(io.vertx.sqlclient.Pool)} ---- -On PostgreSQL a failed statement also fails the transaction, every later statement is rejected until the -transaction ends. Rolling back to a savepoint taken before the failure recovers it. The other databases -roll back the failed statement only, and there the savepoint simply discards the work that followed it. - {@link io.vertx.sqlclient.Savepoint#release} drops a savepoint you no longer need, keeping the work done since it was created. The savepoint cannot be used afterwards: @@ -66,6 +62,7 @@ since it was created. The savepoint cannot be used afterwards: {@link examples.SqlClientExamples#savepoint02(io.vertx.sqlclient.Pool)} ---- -Savepoints are not supported by every database, and Microsoft SQL Server and Oracle create them but have -no statement that releases one. Both calls report that: they fail with an `UnsupportedOperationException` -rather than pretending to succeed, and a savepoint that could not be released stays usable for a rollback. +Not every database supports savepoints, and some support only part of the API. When an operation is not +supported the call fails with an `UnsupportedOperationException` rather than pretending to succeed. + +include::savepoint_note.adoc[opts=optional] From 9c5198efa944505bd517ea1b55f98aff92f2a246 Mon Sep 17 00:00:00 2001 From: doxlik Date: Fri, 18 Sep 2026 13:19:03 +0400 Subject: [PATCH 10/14] Let the application name a savepoint createSavepoint(String) creates a savepoint under a name the application chooses, which is what shows up when the database mentions the savepoint in an error message. The no-argument method keeps generating VX_SP_. The name is written to the statement as an unquoted identifier. Quoting it would have to be done per database and would make the name case sensitive, so a name is instead restricted to what all the supported databases accept unquoted: a letter followed by letters, digits or underscores. Anything else is rejected with an IllegalArgumentException before the statement is built, leaving the transaction untouched. Signed-off-by: doxlik --- .../main/java/examples/SqlClientExamples.java | 15 ++++++ .../main/java/examples/SqlClientExamples.java | 15 ++++++ .../main/java/examples/SqlClientExamples.java | 15 ++++++ .../main/java/examples/SqlClientExamples.java | 15 ++++++ .../main/java/examples/SqlClientExamples.java | 15 ++++++ .../src/main/asciidoc/transactions.adoc | 9 ++++ .../java/io/vertx/sqlclient/Transaction.java | 16 +++++++ .../vertx/sqlclient/impl/TransactionImpl.java | 42 +++++++++++++++-- .../sqlclient/tck/TransactionTestBase.java | 46 +++++++++++++++++++ 9 files changed, 184 insertions(+), 4 deletions(-) diff --git a/vertx-db2-client/src/main/java/examples/SqlClientExamples.java b/vertx-db2-client/src/main/java/examples/SqlClientExamples.java index 64e1a0a9b..9187e3fca 100644 --- a/vertx-db2-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-db2-client/src/main/java/examples/SqlClientExamples.java @@ -296,6 +296,21 @@ public void savepoint02(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint03(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + // The name shows up in database error messages + .createSavepoint("before_users") + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed without the user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection diff --git a/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java b/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java index 0ce1bef3d..995148a6a 100644 --- a/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-mssql-client/src/main/java/examples/SqlClientExamples.java @@ -325,6 +325,21 @@ public void savepoint02(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint03(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + // The name shows up in database error messages + .createSavepoint("before_users") + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed without the user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection.prepare("SELECT * FROM users WHERE age > @p1") diff --git a/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java b/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java index 460126764..18d346fbe 100644 --- a/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-mysql-client/src/main/java/examples/SqlClientExamples.java @@ -295,6 +295,21 @@ public void savepoint02(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint03(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + // The name shows up in database error messages + .createSavepoint("before_users") + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed without the user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection diff --git a/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java b/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java index 6af5d6a54..1a8f9bec6 100644 --- a/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-oracle-client/src/main/java/examples/SqlClientExamples.java @@ -296,6 +296,21 @@ public void savepoint02(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint03(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + // The name shows up in database error messages + .createSavepoint("before_users") + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed without the user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection diff --git a/vertx-pg-client/src/main/java/examples/SqlClientExamples.java b/vertx-pg-client/src/main/java/examples/SqlClientExamples.java index 087d9e4c2..dda403989 100644 --- a/vertx-pg-client/src/main/java/examples/SqlClientExamples.java +++ b/vertx-pg-client/src/main/java/examples/SqlClientExamples.java @@ -295,6 +295,21 @@ public void savepoint02(Pool pool) { .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); } + public void savepoint03(Pool pool) { + + pool.withTransaction(client -> client + .transaction() + // The name shows up in database error messages + .createSavepoint("before_users") + .flatMap(savepoint -> client + .query("INSERT INTO Users (first_name,last_name) VALUES ('Julien','Viet')") + .execute() + .flatMap(v -> savepoint.rollback()))) + .onSuccess(v -> System.out.println("Transaction committed without the user")) + .onFailure(err -> System.out.println("Transaction failed: " + err.getMessage())); + } + + public void usingCursors01(SqlConnection connection) { connection diff --git a/vertx-sql-client/src/main/asciidoc/transactions.adoc b/vertx-sql-client/src/main/asciidoc/transactions.adoc index d0c55f373..12f225fa2 100644 --- a/vertx-sql-client/src/main/asciidoc/transactions.adoc +++ b/vertx-sql-client/src/main/asciidoc/transactions.adoc @@ -62,6 +62,15 @@ since it was created. The savepoint cannot be used afterwards: {@link examples.SqlClientExamples#savepoint02(io.vertx.sqlclient.Pool)} ---- +You can also name a savepoint yourself, which makes it easier to recognise in a database error message. +The name is written to the statement unquoted, so it must start with a letter and continue with letters, +digits or underscores, anything else is rejected with an `IllegalArgumentException`: + +[source,$lang] +---- +{@link examples.SqlClientExamples#savepoint03(io.vertx.sqlclient.Pool)} +---- + Not every database supports savepoints, and some support only part of the API. When an operation is not supported the call fails with an `UnsupportedOperationException` rather than pretending to succeed. diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java index 4a4b2c7f7..4bd8c3c96 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java @@ -35,6 +35,22 @@ public interface Transaction { */ Future createSavepoint(); + /** + * Create a savepoint named {@code name} in this transaction. + * + *

The name is written to the statement as an unquoted identifier, so it must start + * with a letter and continue with letters, digits or underscores. Any other name is + * rejected with an {@link IllegalArgumentException}. Names are scoped to the + * transaction, creating a savepoint with the name of an existing one replaces it. + * + *

Fails with {@link UnsupportedOperationException} when the driver does not + * support savepoints. + * + * @param name the savepoint name + * @return a future notified with the created savepoint + */ + Future createSavepoint(String name); + /** * Commit the current transaction. */ diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index 0077fb4e0..cb56c1e39 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -66,19 +66,53 @@ Future failedFuture(String message) { @Override public Future createSavepoint() { + String name; + synchronized (this) { + name = "VX_SP_" + (++savepointSeq); + } + return createSavepoint(name, false); + } + + @Override + public Future createSavepoint(String name) { + return createSavepoint(name, true); + } + + private Future createSavepoint(String name, boolean validate) { if (!driver.supportsSavepoints()) { return context.failedFuture(new UnsupportedOperationException( "Savepoints are not supported by this driver")); } - - String name; - synchronized (this) { - name = "VX_SP_" + (++savepointSeq); + if (validate && !isValidSavepointName(name)) { + return context.failedFuture(new IllegalArgumentException( + "Invalid savepoint name: " + name + + ", a name must start with a letter and continue with letters, digits or underscores")); } SavepointImpl savepoint = new SavepointImpl(this, name); return submit(new SavepointCommand<>(SavepointCommand.Kind.CREATE, name, savepoint)); } + /** + * The name goes into the statement as an unquoted identifier. Quoting would have to be + * done per database and would make the name case sensitive, so names are restricted to + * what every supported database accepts unquoted instead. + */ + private static boolean isValidSavepointName(String name) { + if (name == null || name.isEmpty()) { + return false; + } + if (!Character.isLetter(name.charAt(0)) || name.charAt(0) > 127) { + return false; + } + for (int i = 1; i < name.length(); i++) { + char c = name.charAt(i); + if (c > 127 || (!Character.isLetterOrDigit(c) && c != '_')) { + return false; + } + } + return true; + } + Future rollbackToSavepoint(String name) { return submit(new SavepointCommand<>(SavepointCommand.Kind.ROLLBACK_TO, name, null)); } diff --git a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java index d3f6f5f6b..f0f798452 100644 --- a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java +++ b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java @@ -729,4 +729,50 @@ public void testRollbackToSavepointAfterStatementError(TestContext ctx) { .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); })); } + + @Test + public void testNamedSavepointRollsBack(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint("before_batch")) + .compose(sp -> insertMutable(res.client, 2, "rolled-back") + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + /** + * The name is written to the statement unquoted, so anything that is not a plain + * identifier is rejected before the statement is built and the transaction is untouched. + */ + @Test + public void testInvalidSavepointNameIsRejected(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + String[] invalid = {"", "1_starts_with_a_digit", "_starts_with_underscore", "has space", + "has-dash", "quo'te", "drop; DROP TABLE mutable", "café"}; + connector.accept(ctx.asyncAssertSuccess(res -> { + Future chain = Future.succeededFuture(); + for (String name : invalid) { + chain = chain.compose(v -> res.tx.createSavepoint(name) + .transform(ar -> { + ctx.assertTrue(ar.failed(), "savepoint name should have been rejected: " + name); + ctx.assertTrue(ar.cause() instanceof IllegalArgumentException, + "expected an IllegalArgumentException for " + name + " but got " + ar.cause()); + return Future.succeededFuture(); + })); + } + // the transaction was never touched, it still works + chain + .compose(v -> insertMutable(res.client, 1, "still-usable")) + .compose(v -> res.tx.commit()) + .compose(v -> assertMutableIds(ctx, 1)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } } From 044cea6ffc86aa8cf347a8d9306ee91edca2520d Mon Sep 17 00:00:00 2001 From: doxlik Date: Fri, 18 Sep 2026 14:50:37 +0400 Subject: [PATCH 11/14] Quote savepoint names through the driver instead of restricting them A savepoint name was restricted to a letter followed by letters, digits or underscores so it could be written to the statement unquoted. The driver already knows its dialect, it decides how to write a parameter placeholder, so let it write a delimited identifier too: appendQuotedIdentifier uses the standard double quote by default, MySQL overrides it with backticks and Transact-SQL with square brackets, each escaping its own delimiter. The name is quoted once when the savepoint is created and the rollback and release statements reuse it. What a name may hold is no longer restricted, only its length: SQL Server keeps the first 32 characters of a savepoint name, so longer names would silently collide there. Signed-off-by: doxlik --- .../io/vertx/mssqlclient/spi/MSSQLDriver.java | 8 ++++ .../io/vertx/mysqlclient/spi/MySQLDriver.java | 8 ++++ .../src/main/asciidoc/transactions.adoc | 6 ++- .../java/io/vertx/sqlclient/Transaction.java | 10 ++-- .../vertx/sqlclient/impl/TransactionImpl.java | 48 +++++++------------ .../java/io/vertx/sqlclient/spi/Driver.java | 18 +++++++ .../sqlclient/tck/TransactionTestBase.java | 30 ++++++++++-- 7 files changed, 85 insertions(+), 43 deletions(-) diff --git a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java index 7858b5c96..89105cc8a 100644 --- a/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java +++ b/vertx-mssql-client/src/main/java/io/vertx/mssqlclient/spi/MSSQLDriver.java @@ -67,6 +67,14 @@ public int appendQueryPlaceholder(StringBuilder queryBuilder, int index, int cur return index; } + /** + * Transact-SQL delimits identifiers with square brackets. + */ + @Override + public StringBuilder appendQuotedIdentifier(StringBuilder sql, String identifier) { + return sql.append('[').append(identifier.replace("]", "]]")).append(']'); + } + @Override public boolean supportsSavepoints() { return true; diff --git a/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java b/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java index c9fe487f2..c343b89a2 100644 --- a/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java +++ b/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/spi/MySQLDriver.java @@ -85,6 +85,14 @@ public ConnectionFactory createConnectionFactory(Vertx vert return new MySQLConnectionFactory((VertxInternal) vertx, transportOptions); } + /** + * MySQL delimits identifiers with backticks. + */ + @Override + public StringBuilder appendQuotedIdentifier(StringBuilder sql, String identifier) { + return sql.append('`').append(identifier.replace("`", "``")).append('`'); + } + @Override public boolean supportsSavepoints() { return true; diff --git a/vertx-sql-client/src/main/asciidoc/transactions.adoc b/vertx-sql-client/src/main/asciidoc/transactions.adoc index 12f225fa2..436bb6fb6 100644 --- a/vertx-sql-client/src/main/asciidoc/transactions.adoc +++ b/vertx-sql-client/src/main/asciidoc/transactions.adoc @@ -63,8 +63,10 @@ since it was created. The savepoint cannot be used afterwards: ---- You can also name a savepoint yourself, which makes it easier to recognise in a database error message. -The name is written to the statement unquoted, so it must start with a letter and continue with letters, -digits or underscores, anything else is rejected with an `IllegalArgumentException`: +The name is written to the statement as a delimited identifier, so it is taken literally and may hold +characters an ordinary identifier could not. It must not be empty and cannot be longer than 32 characters, +the shortest limit across the supported databases, otherwise it is rejected with an +`IllegalArgumentException`: [source,$lang] ---- diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java index 4bd8c3c96..e960be1de 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java @@ -38,10 +38,12 @@ public interface Transaction { /** * Create a savepoint named {@code name} in this transaction. * - *

The name is written to the statement as an unquoted identifier, so it must start - * with a letter and continue with letters, digits or underscores. Any other name is - * rejected with an {@link IllegalArgumentException}. Names are scoped to the - * transaction, creating a savepoint with the name of an existing one replaces it. + *

The name is written to the statement as a delimited identifier, so it is taken + * literally, it is not folded to upper or lower case and may hold characters an ordinary + * identifier could not. An empty name, or one longer than 32 characters, the shortest + * limit across the supported databases, is rejected with an + * {@link IllegalArgumentException}. Names are scoped to the transaction, creating a + * savepoint with the name of an existing one replaces it. * *

Fails with {@link UnsupportedOperationException} when the driver does not * support savepoints. diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index cb56c1e39..733c729e4 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -38,6 +38,8 @@ public class TransactionImpl implements Transaction { private int pendingQueries; private boolean ended; private boolean rollbackRequested; + private static final int MAX_SAVEPOINT_NAME_LENGTH = 32; + private long savepointSeq; private TxCommand endCommand; private TransactionState state = TransactionState.ACTIVE; @@ -66,51 +68,33 @@ Future failedFuture(String message) { @Override public Future createSavepoint() { - String name; + long seq; synchronized (this) { - name = "VX_SP_" + (++savepointSeq); + seq = ++savepointSeq; } - return createSavepoint(name, false); + return createSavepoint("VX_SP_" + seq); } @Override public Future createSavepoint(String name) { - return createSavepoint(name, true); - } - - private Future createSavepoint(String name, boolean validate) { if (!driver.supportsSavepoints()) { return context.failedFuture(new UnsupportedOperationException( "Savepoints are not supported by this driver")); } - if (validate && !isValidSavepointName(name)) { - return context.failedFuture(new IllegalArgumentException( - "Invalid savepoint name: " + name - + ", a name must start with a letter and continue with letters, digits or underscores")); - } - SavepointImpl savepoint = new SavepointImpl(this, name); - return submit(new SavepointCommand<>(SavepointCommand.Kind.CREATE, name, savepoint)); - } - - /** - * The name goes into the statement as an unquoted identifier. Quoting would have to be - * done per database and would make the name case sensitive, so names are restricted to - * what every supported database accepts unquoted instead. - */ - private static boolean isValidSavepointName(String name) { if (name == null || name.isEmpty()) { - return false; - } - if (!Character.isLetter(name.charAt(0)) || name.charAt(0) > 127) { - return false; + return context.failedFuture(new IllegalArgumentException("Savepoint name cannot be null or empty")); } - for (int i = 1; i < name.length(); i++) { - char c = name.charAt(i); - if (c > 127 || (!Character.isLetterOrDigit(c) && c != '_')) { - return false; - } + if (name.length() > MAX_SAVEPOINT_NAME_LENGTH) { + // Microsoft SQL Server keeps the first 32 characters of a savepoint name, longer names + // would silently collide there + return context.failedFuture(new IllegalArgumentException( + "Savepoint name cannot be longer than " + MAX_SAVEPOINT_NAME_LENGTH + " characters: " + name)); } - return true; + // The name is written to the statement as a delimited identifier, so it is taken literally + // and needs no restriction beyond the length + String quoted = driver.appendQuotedIdentifier(new StringBuilder(), name).toString(); + SavepointImpl savepoint = new SavepointImpl(this, quoted); + return submit(new SavepointCommand<>(SavepointCommand.Kind.CREATE, quoted, savepoint)); } Future rollbackToSavepoint(String name) { diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java index f78dad1e6..7dcfb961e 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/spi/Driver.java @@ -145,6 +145,24 @@ default int appendQueryPlaceholder(StringBuilder queryBuilder, int index, int cu return current; } + /** + * Append {@code identifier} to {@code sql} as a delimited identifier. + * + *

The default implementation uses the SQL standard double quote, which PostgreSQL, + * Oracle and DB2 accept. MySQL and Microsoft SQL Server delimit identifiers differently + * and override this. + * + *

A delimited identifier is taken literally: it is not folded to upper or lower case + * and may hold characters an ordinary identifier cannot. + * + * @param sql the builder to append to + * @param identifier the identifier to append + * @return the builder + */ + default StringBuilder appendQuotedIdentifier(StringBuilder sql, String identifier) { + return sql.append('"').append(identifier.replace("\"", "\"\"")).append('"'); + } + /** * @return {@code true} when the driver supports savepoints. */ diff --git a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java index f0f798452..22762f671 100644 --- a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java +++ b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java @@ -747,15 +747,35 @@ public void testNamedSavepointRollsBack(TestContext ctx) { } /** - * The name is written to the statement unquoted, so anything that is not a plain - * identifier is rejected before the statement is built and the transaction is untouched. + * The name is written as a delimited identifier, so characters an ordinary identifier + * could not hold are fine, including the delimiter of every supported database. + */ + @Test + public void testSavepointNameWithSpecialCharacters(TestContext ctx) { + assumeSavepoints(); + Async async = ctx.async(); + connector.accept(ctx.asyncAssertSuccess(res -> { + insertMutable(res.client, 1, "before") + .compose(v -> res.tx.createSavepoint("sp \"a\" `b` [c]")) + .compose(sp -> insertMutable(res.client, 2, "rolled-back") + .compose(v -> sp.rollback()) + .compose(v -> insertMutable(res.client, 3, "after")) + .compose(v -> res.tx.commit())) + .compose(v -> assertMutableIds(ctx, 1, 3)) + .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); + })); + } + + /** + * Only an empty name and one longer than the shortest limit across the supported + * databases are rejected, and the transaction is untouched when they are. */ @Test public void testInvalidSavepointNameIsRejected(TestContext ctx) { assumeSavepoints(); Async async = ctx.async(); - String[] invalid = {"", "1_starts_with_a_digit", "_starts_with_underscore", "has space", - "has-dash", "quo'te", "drop; DROP TABLE mutable", "café"}; + String tooLong = "s".repeat(33); + String[] invalid = {"", tooLong}; connector.accept(ctx.asyncAssertSuccess(res -> { Future chain = Future.succeededFuture(); for (String name : invalid) { @@ -767,7 +787,6 @@ public void testInvalidSavepointNameIsRejected(TestContext ctx) { return Future.succeededFuture(); })); } - // the transaction was never touched, it still works chain .compose(v -> insertMutable(res.client, 1, "still-usable")) .compose(v -> res.tx.commit()) @@ -775,4 +794,5 @@ public void testInvalidSavepointNameIsRejected(TestContext ctx) { .onComplete(ctx.asyncAssertSuccess(v -> async.complete())); })); } + } From 17e6f27b4b5ddc9b15b0e6fde4c122bf2b0116a5 Mon Sep 17 00:00:00 2001 From: doxlik Date: Fri, 18 Sep 2026 14:56:22 +0400 Subject: [PATCH 12/14] Refuse a savepoint name holding a double quote Oracle rejects a savepoint identifier that holds a double quote even when it is escaped, ORA-25716, while the other databases accept it. A name that works on one database should work on all of them, so the double quote is refused everywhere rather than on Oracle alone. Signed-off-by: doxlik --- vertx-sql-client/src/main/asciidoc/transactions.adoc | 6 +++--- .../src/main/java/io/vertx/sqlclient/Transaction.java | 7 ++++--- .../main/java/io/vertx/sqlclient/impl/TransactionImpl.java | 6 ++++++ .../io/vertx/tests/sqlclient/tck/TransactionTestBase.java | 6 +++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/vertx-sql-client/src/main/asciidoc/transactions.adoc b/vertx-sql-client/src/main/asciidoc/transactions.adoc index 436bb6fb6..dbe3d7dfc 100644 --- a/vertx-sql-client/src/main/asciidoc/transactions.adoc +++ b/vertx-sql-client/src/main/asciidoc/transactions.adoc @@ -64,9 +64,9 @@ since it was created. The savepoint cannot be used afterwards: You can also name a savepoint yourself, which makes it easier to recognise in a database error message. The name is written to the statement as a delimited identifier, so it is taken literally and may hold -characters an ordinary identifier could not. It must not be empty and cannot be longer than 32 characters, -the shortest limit across the supported databases, otherwise it is rejected with an -`IllegalArgumentException`: +characters an ordinary identifier could not. It is rejected with an `IllegalArgumentException` when it is +empty, when it holds a double quote, which Oracle refuses inside a savepoint identifier, or when it is +longer than 32 characters, the shortest limit across the supported databases: [source,$lang] ---- diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java index e960be1de..206bf4c31 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/Transaction.java @@ -40,9 +40,10 @@ public interface Transaction { * *

The name is written to the statement as a delimited identifier, so it is taken * literally, it is not folded to upper or lower case and may hold characters an ordinary - * identifier could not. An empty name, or one longer than 32 characters, the shortest - * limit across the supported databases, is rejected with an - * {@link IllegalArgumentException}. Names are scoped to the transaction, creating a + * identifier could not. A name is rejected with an {@link IllegalArgumentException} when + * it is empty, when it holds a double quote, which Oracle refuses inside a savepoint + * identifier, or when it is longer than 32 characters, the shortest limit across the + * supported databases. Names are scoped to the transaction, creating a * savepoint with the name of an existing one replaces it. * *

Fails with {@link UnsupportedOperationException} when the driver does not diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index 733c729e4..c9a3128d1 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -84,6 +84,12 @@ public Future createSavepoint(String name) { if (name == null || name.isEmpty()) { return context.failedFuture(new IllegalArgumentException("Savepoint name cannot be null or empty")); } + if (name.indexOf('"') >= 0) { + // Oracle rejects a savepoint identifier holding a double quote even when it is escaped, + // ORA-25716, so the name is refused everywhere rather than on Oracle alone + return context.failedFuture(new IllegalArgumentException( + "Savepoint name cannot contain a double quote: " + name)); + } if (name.length() > MAX_SAVEPOINT_NAME_LENGTH) { // Microsoft SQL Server keeps the first 32 characters of a savepoint name, longer names // would silently collide there diff --git a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java index 22762f671..2da581e2f 100644 --- a/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java +++ b/vertx-sql-client/src/test/java/io/vertx/tests/sqlclient/tck/TransactionTestBase.java @@ -748,7 +748,7 @@ public void testNamedSavepointRollsBack(TestContext ctx) { /** * The name is written as a delimited identifier, so characters an ordinary identifier - * could not hold are fine, including the delimiter of every supported database. + * could not hold are fine, including the delimiters MySQL and SQL Server use. */ @Test public void testSavepointNameWithSpecialCharacters(TestContext ctx) { @@ -756,7 +756,7 @@ public void testSavepointNameWithSpecialCharacters(TestContext ctx) { Async async = ctx.async(); connector.accept(ctx.asyncAssertSuccess(res -> { insertMutable(res.client, 1, "before") - .compose(v -> res.tx.createSavepoint("sp \"a\" `b` [c]")) + .compose(v -> res.tx.createSavepoint("sp 'a' `b` [c]")) .compose(sp -> insertMutable(res.client, 2, "rolled-back") .compose(v -> sp.rollback()) .compose(v -> insertMutable(res.client, 3, "after")) @@ -775,7 +775,7 @@ public void testInvalidSavepointNameIsRejected(TestContext ctx) { assumeSavepoints(); Async async = ctx.async(); String tooLong = "s".repeat(33); - String[] invalid = {"", tooLong}; + String[] invalid = {"", tooLong, "has a \" quote"}; connector.accept(ctx.asyncAssertSuccess(res -> { Future chain = Future.succeededFuture(); for (String name : invalid) { From 198e6f0308ccceb619756d043691f09d73426f3e Mon Sep 17 00:00:00 2001 From: doxlik Date: Fri, 18 Sep 2026 15:04:18 +0400 Subject: [PATCH 13/14] Correct a stale comment on savepoint name validation The comment claimed length was the only restriction, which stopped being true when the double quote was refused. Signed-off-by: doxlik --- .../src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java index c9a3128d1..a7c8749e1 100644 --- a/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java +++ b/vertx-sql-client/src/main/java/io/vertx/sqlclient/impl/TransactionImpl.java @@ -96,8 +96,7 @@ public Future createSavepoint(String name) { return context.failedFuture(new IllegalArgumentException( "Savepoint name cannot be longer than " + MAX_SAVEPOINT_NAME_LENGTH + " characters: " + name)); } - // The name is written to the statement as a delimited identifier, so it is taken literally - // and needs no restriction beyond the length + // Delimited, so the name is taken literally and the checks above are all it needs String quoted = driver.appendQuotedIdentifier(new StringBuilder(), name).toString(); SavepointImpl savepoint = new SavepointImpl(this, quoted); return submit(new SavepointCommand<>(SavepointCommand.Kind.CREATE, quoted, savepoint)); From 91235d1d4104f9288d0424f08a13e9284f0443b9 Mon Sep 17 00:00:00 2001 From: doxlik Date: Tue, 22 Sep 2026 19:01:37 +0400 Subject: [PATCH 14/14] Drop the generic savepoint support paragraph Every client contributes a savepoint note saying what it supports, so the shared page no longer needs to say that support varies. Signed-off-by: doxlik --- vertx-sql-client/src/main/asciidoc/transactions.adoc | 3 --- 1 file changed, 3 deletions(-) diff --git a/vertx-sql-client/src/main/asciidoc/transactions.adoc b/vertx-sql-client/src/main/asciidoc/transactions.adoc index dbe3d7dfc..3de5234b9 100644 --- a/vertx-sql-client/src/main/asciidoc/transactions.adoc +++ b/vertx-sql-client/src/main/asciidoc/transactions.adoc @@ -73,7 +73,4 @@ longer than 32 characters, the shortest limit across the supported databases: {@link examples.SqlClientExamples#savepoint03(io.vertx.sqlclient.Pool)} ---- -Not every database supports savepoints, and some support only part of the API. When an operation is not -supported the call fails with an `UnsupportedOperationException` rather than pretending to succeed. - include::savepoint_note.adoc[opts=optional]