@@ -46,6 +46,35 @@ public class LibsqlConnection implements Connection {
4646 private boolean readOnly = false ;
4747 private int transactionIsolation = TRANSACTION_SERIALIZABLE ;
4848 private boolean closed = false ;
49+ private TransactionMode transactionMode = TransactionMode .DEFERRED ;
50+
51+ /**
52+ * Transaction begin mode, matching xerial sqlite-jdbc's
53+ * {@code SQLiteConfig.TransactionMode}.
54+ */
55+ public enum TransactionMode {
56+ DEFERRED ("BEGIN" ),
57+ IMMEDIATE ("BEGIN IMMEDIATE" ),
58+ EXCLUSIVE ("BEGIN EXCLUSIVE" );
59+
60+ private final String beginSql ;
61+
62+ TransactionMode (String beginSql ) {
63+ this .beginSql = beginSql ;
64+ }
65+
66+ String beginSql () {
67+ return beginSql ;
68+ }
69+
70+ public static TransactionMode from (String mode ) {
71+ try {
72+ return valueOf (mode .toUpperCase ());
73+ } catch (RuntimeException e ) {
74+ throw new IllegalArgumentException ("Unknown transaction mode: " + mode , e );
75+ }
76+ }
77+ }
4978
5079 // Interactive stream state
5180 private String baton = null ;
@@ -159,15 +188,33 @@ private int[] extractBatchResults(PipelineRespBody resp, int expectedSize) throw
159188
160189 private void ensureTransactionStarted () throws SQLException {
161190 if (baton == null ) {
162- // Mitigation for Bug 2: Read-write transactions start with BEGIN IMMEDIATE
163- String beginSql = readOnly ? "BEGIN DEFERRED" : "BEGIN IMMEDIATE" ;
164- PipelineReqBody req = new PipelineReqBody (null , List .of (new StreamRequest .Execute (new Stmt (beginSql ))));
165- PipelineRespBody resp = client .sendPipeline (req , baseUrl );
166- updateStreamState (resp );
167- if (resp .results ().isEmpty () || resp .results ().get (0 ) instanceof StreamResult .Error ) {
168- closeStreamSilently ();
169- throw new SQLException ("Failed to begin transaction" );
170- }
191+ beginTransaction ();
192+ }
193+ }
194+
195+ private void beginTransaction () throws SQLException {
196+ PipelineRespBody resp ;
197+ try {
198+ PipelineReqBody req = new PipelineReqBody (null ,
199+ List .of (new StreamRequest .Execute (new Stmt (transactionMode .beginSql ()))));
200+ resp = client .sendPipeline (req , baseUrl );
201+ } catch (LibsqlException e ) {
202+ throw LibsqlErrorMapper .toSQLException (e );
203+ }
204+ updateStreamState (resp );
205+ if (resp .results ().isEmpty ()) {
206+ closeStreamSilently ();
207+ throw new SQLException ("Empty result received from server" );
208+ }
209+ StreamResult result = resp .results ().get (0 );
210+ if (result instanceof StreamResult .Error error ) {
211+ closeStreamSilently ();
212+ throw new SQLException (error .error ().message ());
213+ }
214+ if (!(result instanceof StreamResult .Ok ok )
215+ || !(ok .response () instanceof StreamResponse .Execute )) {
216+ closeStreamSilently ();
217+ throw new SQLException ("Unexpected response: " + result );
171218 }
172219 }
173220
@@ -214,15 +261,33 @@ public String nativeSQL(String sql) throws SQLException {
214261 return sql ;
215262 }
216263
264+ /**
265+ * Matches xerial sqlite-jdbc 3.53.4.0:
266+ *
267+ * <ul>
268+ * <li>Disabling auto-commit immediately begins a transaction in the configured
269+ * {@link TransactionMode}. The default is {@link TransactionMode#DEFERRED}, so an idle
270+ * non-auto-commit connection holds no lock until its first statement.
271+ * <li>{@link #commit()} and {@link #rollback()} end the current transaction and immediately
272+ * begin the next one, so a non-auto-commit connection always has a transaction open.
273+ * <li>Re-enabling auto-commit commits the open transaction and ends the Hrana transaction
274+ * stream.
275+ * </ul>
276+ *
277+ * <p>Use {@link #setTransactionMode(TransactionMode)} to select {@code IMMEDIATE} or {@code
278+ * EXCLUSIVE}, which acquire the write lock when the transaction begins.
279+ */
217280 @ Override
218281 public void setAutoCommit (boolean autoCommit ) throws SQLException {
219282 checkClosed ();
220283 if (this .autoCommit == autoCommit ) return ;
221- if (!this .autoCommit && autoCommit ) {
222- // Committing active transaction
223- commit ();
284+ if (autoCommit ) {
285+ endTransaction ("COMMIT" , false );
286+ this .autoCommit = true ;
287+ } else {
288+ this .autoCommit = false ;
289+ beginTransaction ();
224290 }
225- this .autoCommit = autoCommit ;
226291 }
227292
228293 @ Override
@@ -237,7 +302,7 @@ public void commit() throws SQLException {
237302 if (autoCommit ) {
238303 throw new SQLException ("Cannot commit when in autoCommit mode" );
239304 }
240- endTransaction ("COMMIT" );
305+ endTransaction ("COMMIT" , true );
241306 }
242307
243308 @ Override
@@ -246,18 +311,26 @@ public void rollback() throws SQLException {
246311 if (autoCommit ) {
247312 throw new SQLException ("Cannot rollback when in autoCommit mode" );
248313 }
249- endTransaction ("ROLLBACK" );
314+ endTransaction ("ROLLBACK" , true );
250315 }
251316
252- private void endTransaction (String sql ) throws SQLException {
317+ private void endTransaction (String sql , boolean rebegin ) throws SQLException {
253318 if (baton == null ) {
319+ if (rebegin ) {
320+ throw new SQLException ("No transaction is active" );
321+ }
254322 return ;
255323 }
256324
325+ List <StreamRequest > requests = new ArrayList <>(2 );
326+ requests .add (new StreamRequest .Execute (new Stmt (sql )));
327+ if (rebegin ) {
328+ requests .add (new StreamRequest .Execute (new Stmt (transactionMode .beginSql ())));
329+ }
330+
257331 PipelineRespBody resp ;
258332 try {
259- PipelineReqBody req = new PipelineReqBody (baton , List .of (new StreamRequest .Execute (new Stmt (sql ))));
260- resp = client .sendPipeline (req , baseUrl );
333+ resp = client .sendPipeline (new PipelineReqBody (baton , requests ), baseUrl );
261334 } catch (LibsqlException e ) {
262335 throw LibsqlErrorMapper .toSQLException (e );
263336 }
@@ -274,10 +347,33 @@ private void endTransaction(String sql) throws SQLException {
274347 throw new SQLException ("Unexpected response: " + result );
275348 }
276349
277- if (resp .baton () != null ) {
278- this .baton = resp .baton ();
350+ if (!rebegin ) {
351+ updateStreamState (resp );
352+ closeStreamSilently ();
353+ return ;
354+ }
355+
356+ if (resp .results ().size () < 2 ) {
357+ closeStreamSilently ();
358+ throw new SQLException ("Missing response for transaction begin" );
279359 }
280- closeStreamSilently ();
360+ StreamResult begin = resp .results ().get (1 );
361+ if (begin instanceof StreamResult .Error error ) {
362+ if (resp .baton () != null ) {
363+ this .baton = resp .baton ();
364+ }
365+ closeStreamSilently ();
366+ throw new SQLException (error .error ().message ());
367+ }
368+ if (!(begin instanceof StreamResult .Ok beginOk )
369+ || !(beginOk .response () instanceof StreamResponse .Execute )) {
370+ if (resp .baton () != null ) {
371+ this .baton = resp .baton ();
372+ }
373+ closeStreamSilently ();
374+ throw new SQLException ("Unexpected response: " + begin );
375+ }
376+ updateStreamState (resp );
281377 }
282378
283379 @ Override
@@ -319,6 +415,27 @@ public String getCatalog() throws SQLException {
319415 return null ;
320416 }
321417
418+ /**
419+ * Selects the mode used by the next transaction begin. Matches xerial sqlite-jdbc's {@code
420+ * SQLiteConfig.setTransactionMode}; the default is {@link TransactionMode#DEFERRED}.
421+ */
422+ public void setTransactionMode (TransactionMode mode ) throws SQLException {
423+ checkClosed ();
424+ if (mode == null ) {
425+ throw new IllegalArgumentException ("Transaction mode cannot be null" );
426+ }
427+ this .transactionMode = mode ;
428+ }
429+
430+ public void setTransactionMode (String mode ) throws SQLException {
431+ setTransactionMode (TransactionMode .from (mode ));
432+ }
433+
434+ public TransactionMode getTransactionMode () throws SQLException {
435+ checkClosed ();
436+ return transactionMode ;
437+ }
438+
322439 @ Override
323440 public void setTransactionIsolation (int level ) throws SQLException {
324441 checkClosed ();
0 commit comments