@@ -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
0 commit comments