Skip to content

Commit efc6990

Browse files
authored
feat(jdbc): implement savepoints and re-select the xerial SavepointTest (#184)
## Why JDBC savepoints were a hard unsupported no-op, and xerial's `SavepointTest` was rejected because of that gap. This is the same change as #179, re-issued on a branch based directly on `main`, because #179 was merged into a stacked base branch instead of `main` and so never reached `main`. ## What changed - Implemented setSavepoint, setSavepoint with a name, rollback, and release over the transaction stream. - Flipped `supportsSavepoints` to true. - Vendored and selected the xerial `SavepointTest`, and added `LibsqlSavepointTest` with 15 tests. ## Scope - Savepoint support and the conformance re-selection. No unrelated JDBC behavior changed. ## Tradeoffs - None. ## Blast Radius - Savepoint code path in the JDBC module, plus one new vendored conformance file. The upstream test opens two connections to one path, and the conformance driver maps a path to one sqld namespace, so it passes with no divergence. ## Verification - `./tools/export-upstream --verify`: verified. - `./mvnw -pl libsql-jdbc -am test`: passed. - The conformance lanes passed on the identical content in #179, with `SavepointTest` 4 of 4 under both the libSQL driver and the xerial control probe. Fixes #173
1 parent 87b5d75 commit efc6990

8 files changed

Lines changed: 782 additions & 7 deletions

File tree

‎libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java‎

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ public class LibsqlConnection implements Connection {
7171
private boolean closed = false;
7272
private TransactionMode transactionMode = TransactionMode.DEFERRED;
7373
private LibsqlDatabaseMetaData meta;
74+
private int nextSavepointId = 0;
75+
private boolean transactionStartedBySavepoint = false;
76+
private final List<String> savepointNames = new ArrayList<>();
7477

7578
/**
7679
* Transaction begin mode, matching xerial sqlite-jdbc's
@@ -439,6 +442,7 @@ private void endTransaction(String sql) throws SQLException {
439442
// The server committed or rolled back, so the stream has no state left worth holding.
440443
// Closing it lets an idle connection drop its server-side stream. On an error above the
441444
// baton stays, so the caller can retry or roll back on the same stream.
445+
clearSavepoints();
442446
closeStreamSilently();
443447
}
444448

@@ -447,6 +451,7 @@ public void close() throws SQLException {
447451
synchronized (stateLock) {
448452
if (!closed) {
449453
closed = true;
454+
clearSavepoints();
450455
closeStreamSilently();
451456
}
452457
}
@@ -592,24 +597,154 @@ private static void checkHoldability(int holdability) throws SQLException {
592597
}
593598
}
594599

600+
/**
601+
* Creates an unnamed savepoint. When the connection is in auto-commit mode this starts a
602+
* transaction, matching xerial sqlite-jdbc 3.53.4.0 and SQLite itself: a {@code SAVEPOINT}
603+
* issued outside a transaction behaves as {@code BEGIN DEFERRED}, so the savepoint is sent on
604+
* a new Hrana stream without a preceding {@code BEGIN} and releasing that outermost savepoint
605+
* later commits. Inside a transaction begun by {@link #setAutoCommit(boolean)} the savepoint
606+
* is nested in the lazily begun transaction instead.
607+
*/
595608
@Override
596609
public Savepoint setSavepoint() throws SQLException {
597-
throw new SQLException("Savepoints not supported yet");
610+
return setSavepointInternal(null);
598611
}
599612

600613
@Override
601614
public Savepoint setSavepoint(String name) throws SQLException {
602-
throw new SQLException("Savepoints not supported yet");
615+
synchronized (stateLock) {
616+
checkClosed();
617+
if (name == null || name.isBlank()) {
618+
throw new SQLException("Savepoint name cannot be null or empty");
619+
}
620+
}
621+
return setSavepointInternal(name);
622+
}
623+
624+
private Savepoint setSavepointInternal(String name) throws SQLException {
625+
synchronized (stateLock) {
626+
checkClosed();
627+
LibsqlSavepoint savepoint = new LibsqlSavepoint(++nextSavepointId, name);
628+
String sql = "SAVEPOINT " + quoteSavepointName(savepoint.sqlName());
629+
if (autoCommit) {
630+
try {
631+
executeTransactionStatement(sql);
632+
} catch (SQLException e) {
633+
// No transaction was started, so the fresh stream holds no state.
634+
closeStreamSilently();
635+
throw e;
636+
}
637+
autoCommit = false;
638+
transactionStartedBySavepoint = true;
639+
savepointNames.clear();
640+
savepointNames.add(savepoint.sqlName());
641+
} else {
642+
ensureTransactionStarted();
643+
executeTransactionStatement(sql);
644+
savepointNames.add(savepoint.sqlName());
645+
}
646+
return savepoint;
647+
}
603648
}
604649

605650
@Override
606651
public void rollback(Savepoint savepoint) throws SQLException {
607-
throw new SQLException("Savepoints not supported yet");
652+
synchronized (stateLock) {
653+
checkClosed();
654+
String sqlName = savepointSqlName(savepoint);
655+
if (autoCommit) {
656+
throw new SQLException("Cannot rollback to a savepoint when in autoCommit mode");
657+
}
658+
ensureTransactionStarted();
659+
executeTransactionStatement("ROLLBACK TO SAVEPOINT " + quoteSavepointName(sqlName));
660+
int index = savepointNames.lastIndexOf(sqlName);
661+
if (index >= 0) {
662+
// Rolling back cancels the savepoints created after the target; the target
663+
// itself stays active.
664+
savepointNames.subList(index + 1, savepointNames.size()).clear();
665+
}
666+
}
608667
}
609668

610669
@Override
611670
public void releaseSavepoint(Savepoint savepoint) throws SQLException {
612-
throw new SQLException("Savepoints not supported yet");
671+
synchronized (stateLock) {
672+
checkClosed();
673+
String sqlName = savepointSqlName(savepoint);
674+
if (autoCommit) {
675+
throw new SQLException("Cannot release a savepoint when in autoCommit mode");
676+
}
677+
ensureTransactionStarted();
678+
executeTransactionStatement("RELEASE SAVEPOINT " + quoteSavepointName(sqlName));
679+
int index = savepointNames.lastIndexOf(sqlName);
680+
if (index >= 0) {
681+
boolean outermost = index == 0;
682+
savepointNames.subList(index, savepointNames.size()).clear();
683+
if (outermost && transactionStartedBySavepoint) {
684+
// Releasing the savepoint that started the transaction commits it, so the
685+
// stream holds no transaction state and the next statement lazily begins a
686+
// new one, as after a commit.
687+
transactionStartedBySavepoint = false;
688+
savepointNames.clear();
689+
closeStreamSilently();
690+
}
691+
}
692+
}
693+
}
694+
695+
/** Callers hold {@link #stateLock}. */
696+
private void executeTransactionStatement(String sql) throws SQLException {
697+
PipelineRespBody resp;
698+
try {
699+
PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(new Stmt(sql))));
700+
resp = client.sendPipeline(req, baseUrl);
701+
} catch (LibsqlException e) {
702+
throw LibsqlErrorMapper.toSQLException(e);
703+
}
704+
updateStreamState(resp);
705+
if (resp.results().isEmpty()) {
706+
throw new SQLException("Empty result received from server");
707+
}
708+
StreamResult result = resp.results().get(0);
709+
if (result instanceof StreamResult.Error error) {
710+
throw LibsqlErrorMapper.toSQLException(error.error());
711+
}
712+
if (!(result instanceof StreamResult.Ok ok)
713+
|| !(ok.response() instanceof StreamResponse.Execute)) {
714+
throw new SQLException("Unexpected response: " + result);
715+
}
716+
}
717+
718+
private static String savepointSqlName(Savepoint savepoint) throws SQLException {
719+
if (savepoint == null) {
720+
throw new SQLException("Savepoint cannot be null");
721+
}
722+
if (savepoint instanceof LibsqlSavepoint libsqlSavepoint) {
723+
return libsqlSavepoint.sqlName();
724+
}
725+
try {
726+
String name = savepoint.getSavepointName();
727+
if (name != null && !name.isBlank()) {
728+
return name;
729+
}
730+
} catch (SQLException ignored) {
731+
// An unnamed foreign savepoint has no name; its id identifies it below.
732+
}
733+
try {
734+
return "SQLITE_SAVEPOINT_" + savepoint.getSavepointId();
735+
} catch (SQLException e) {
736+
throw new SQLException("Savepoint has no usable name or id", e);
737+
}
738+
}
739+
740+
private static String quoteSavepointName(String name) {
741+
return "\"" + name.replace("\"", "\"\"") + "\"";
742+
}
743+
744+
/** Callers hold {@link #stateLock}. */
745+
private void clearSavepoints() {
746+
savepointNames.clear();
747+
transactionStartedBySavepoint = false;
613748
}
614749

615750
@Override

‎libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ public ResultSet getTypeInfo() throws SQLException {
10121012
@Override public boolean insertsAreDetected(int type) throws SQLException { return false; }
10131013
@Override public boolean supportsBatchUpdates() throws SQLException { return true; }
10141014
@Override public ResultSet getUDTs(String catalog, String schemaPattern, String typeNamePattern, int[] types) throws SQLException { return metadataResult(UDTS_RESULT); }
1015-
@Override public boolean supportsSavepoints() throws SQLException { return false; }
1015+
@Override public boolean supportsSavepoints() throws SQLException { return true; }
10161016
@Override public boolean supportsNamedParameters() throws SQLException { return false; }
10171017
@Override public boolean supportsMultipleOpenResults() throws SQLException { return false; }
10181018
@Override public boolean supportsGetGeneratedKeys() throws SQLException { return true; }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.github.magnusp.libsql.jdbc;
2+
3+
import java.sql.SQLException;
4+
import java.sql.Savepoint;
5+
6+
/**
7+
* A savepoint created by {@link LibsqlConnection}. Unnamed savepoints carry a generated id and use
8+
* {@code SQLITE_SAVEPOINT_<id>} as their SQL name, matching xerial sqlite-jdbc; named savepoints
9+
* carry the caller's name. Following {@code java.sql.Savepoint}, reading the id of a named
10+
* savepoint and reading the name of an unnamed one both throw.
11+
*/
12+
final class LibsqlSavepoint implements Savepoint {
13+
14+
private final int id;
15+
private final String name;
16+
private final String sqlName;
17+
18+
LibsqlSavepoint(int id, String name) {
19+
this.id = id;
20+
this.name = name;
21+
this.sqlName = name == null ? "SQLITE_SAVEPOINT_" + id : name;
22+
}
23+
24+
String sqlName() {
25+
return sqlName;
26+
}
27+
28+
@Override
29+
public int getSavepointId() throws SQLException {
30+
if (name != null) {
31+
throw new SQLException("Named savepoint has no id: " + name);
32+
}
33+
return id;
34+
}
35+
36+
@Override
37+
public String getSavepointName() throws SQLException {
38+
if (name == null) {
39+
throw new SQLException("Unnamed savepoint has no name: " + id);
40+
}
41+
return name;
42+
}
43+
}

0 commit comments

Comments
 (0)