Repository: isoos/postgresql-dart

Issue:
How to set Isolation level to transaction?
Is it possible to set a transaction isolation level?

```dart
Future transaction(Future queryBlock( PostgreSQLExecutionContext connection), {int? commitTimeoutInSeconds})
```

There is no isolation level setting.


Discussion: This is another good candidate for v3 API (see #74). Until then, I think setting the isolation level inside the transaction should work, but I haven't tried it:
https://www.postgresql.org/docs/current/sql-set-transaction.html

@simolus3 I've seen the latest PRs and it looks like you're working on v3 of this library. The community really appreciates your open source work, it keeps the Dart ecosystem going!

Given this issue is it possible to change `Future<R> runTx<R>(Future<R> Function(PgSession session) fn);` to `Future<R> runTx<R>(Future<R> Function(PgSession session) fn, {IsolationLevel isolationLevel});`. `IsolationLevel` could be a enum like this:

```dart
enum IsolationLevel {
  readUncommitted('READ UNCOMMITTED'),
  readCommitted('READ COMMITTED'),
  repeatableRead('REPEATABLE READ'),
  serializable('SERIALIZABLE');

  const IsolationLevel(this.value);
  final String value;

  // Optional, we could just build the string with `value`
  String setTransactionIsolationLevel() =>
      'SET TRANSACTION ISOLATION LEVEL $value;';
  String beginTransactionIsolationLevel() =>
      'BEGIN TRANSACTION ISOLATION LEVEL $value;';
}

Future<R> runTx<R>(Future<R> Function(PgSession session) fn, {IsolationLevel isolationLevel});
```
Thanks! I think adding an isolation level parameter to `runTx` would be a good addition to the v3 API, but `runTx`  isn't implemented yet either way. 

I wanted to implement the `runTx` API with the isolation level, but I ran into two design issues:

1. Do we want to support nested transactions in the v3 API through `runTx`? I think we can, but then there's a question of what should happen if inner transactions are started with a different isolation level. Do we just sent a  `SET TRANSACTION` before the savepoint? Is that sound?
2. Unlike in the v2 API, we now have a method to explicitly prepare a statement. Should it be possible to prepare a statement outside of a transaction and then use it in a transaction? I think this would be good, but we can't really do it in the current API since there is no way to pass a `PgSession` to a prepared statement.

Maybe we can just take care of them later, but maybe that's something to think about.
**Disclaimer:  I don't know if this is possible to implement or even correct, just giving some ideas to discuss.**
1. By nested transactions you mean `savepoints`? If yes I think we should.

> Do we just sent a SET TRANSACTION before the savepoint? Is that sound?

If I understood correctly I think you can't do that because of this:

> The transaction isolation level cannot be changed after the **first query or data-modification statement** (SELECT, INSERT, DELETE, UPDATE, FETCH, or COPY) of a transaction has been executed.

I don't think there are much gains to set a isolation level after a transaction begins. I would say that allowing to set a isolation level within a transaction does more harm than good.

I am not an expert in building database libraries but is it a bad idea to introduce a `PgTransaction` to replace `PgSession` on `fn`, this maybe also allow a more open, fine-grained API? If we declare `PgTransaction extends PgSession`, for example, what should it do on `close`? Commit or throw an exception?

Would rather be an option to use composition like this?
```dart
abstract class PgSessionExecutor {
  Future<R> run<R>(Future<R> Function(PgSession session) fn);

  // Begin transaction with isolationLevel, Commit after `fn` ends.
  Future<R> runTx<R>(Future<R> Function(PgTransaction transaction) fn, {IsolationLevel isolationLevel}); 

  // More open, fine-grained transaction API. Begin transaction with isolationLevel. Commit is developer responsibility.
  Future<PgTransaction> startTransaction({IsolationLevel isolationLevel}); 
}

abstract class PgTransaction {
    PgTransaction(this._session);
    final PgSession _session;
    Future<...> prepare(...) => session.prepare(...);
    Future<...> execute(...) => session.execute(...);
    Future<...> savepoint(String savepoint);
    Future<...> rollbackToSavepoint(String savepoint);
    Future<...> releaseSavepoint(String savepoint);
    Future<...> commit(); // maybe remove commit for `runTx`? Name this PgManagedTransaction extends PgTransaction?
    Future<...> rollback(); // maybe remove rollback for `runTx`? Name this PgManagedTransaction extends PgTransaction?

    // Take this with a huge grain of salt :)
    // Future<...> runStmt(PgStatement stmt, ...) => _session == stmt._session ? stmt.run(...) : (throw Exception("Not same session."));
    // Future<...> bindStmt(PgStatement stmt, ...) => _session == stmt._session ? stmt.bind(...) : (throw Exception("Not same session."));
}
```

2. Correct me if I am wrong, but couldn't we use composition like above and use a prepared statement on a transaction created with the same `PgSession`, since they share the same session? Developers can create a prepared statement and start a transaction with different `PgSession`s but I assume they would expect that to give an error.

**Usage examples:**

```dart
final num = await db.runTx((tx) {
    final rows = await tx.execute('Select 1;');
    await tx.savepoint('savepoint1');
    try {
      await tx.execute('INSERT...');
    } catch (err) {
      await tx.rollbackToSavepoint('savepoint1'); //Duplicate error
    }
    return rows[0][0] as int;
}, isolationLevel: IsolationLevel.serializable);

```
or
```dart
try {
  const tx= await db.startTransaction(isolationLevel: IsolationLevel.serializable);
  await tx.execute('Select 1;');
  await tx.savepoint('savepoint1');
  try {
    await tx.execute('INSERT...');
  } catch (err) {
    await tx.rollbackToSavepoint('savepoint1'); //Duplicate error
  }
  await tx.commit();
} catch (err) {
  await tx.rollback();
}
```

Fix patch:
diff --git a/lib/postgres.dart b/lib/postgres.dart
index 35b564fb..14eb78bc 100644
--- a/lib/postgres.dart
+++ b/lib/postgres.dart
@@ -2,6 +2,7 @@ import 'dart:async';
 import 'dart:convert';
 
 import 'package:collection/collection.dart';
+import 'package:meta/meta.dart';
 import 'package:stream_channel/stream_channel.dart';
 
 import 'src/replication.dart';
@@ -145,7 +146,10 @@ abstract class SessionExecutor {
   ///
   /// Note that other invocations on a [Connection] are blocked while a
   /// transaction is active.
-  Future<R> runTx<R>(Future<R> Function(Session session) fn);
+  Future<R> runTx<R>(
+    Future<R> Function(Session session) fn, {
+    TransactionMode? transactionMode,
+  });
 
   /// Closes this session, cleaning up resources and forbiding further calls to
   /// [prepare] and [execute].
@@ -438,3 +442,106 @@ enum QueryMode {
   /// Simple Query Protocol
   simple,
 }
+
+/// The isolation level of a transaction determines what data the transaction
+/// can see when other transactions are running concurrently.
+enum IsolationLevel {
+  /// A statement can only see rows committed before it began.
+  /// This is the default.
+  readCommitted._('READ COMMITTED'),
+
+  /// All statements of the current transaction can only see rows committed
+  /// before the first query or data-modification statement was executed in
+  /// this transaction.
+  repeatableRead._('REPEATABLE READ'),
+
+  /// All statements of the current transaction can only see rows committed
+  /// before the first query or data-modification statement was executed in
+  /// this transaction. If a pattern of reads and writes among concurrent
+  /// serializable transactions would create a situation which could not have
+  /// occurred for any serial (one-at-a-time) execution of those transactions,
+  /// one of them will be rolled back with a serialization_failure error.
+  serializable._('SERIALIZABLE'),
+
+  /// One transaction may see uncommitted changes made by some other transaction.
+  /// In PostgreSQL READ UNCOMMITTED is treated as READ COMMITTED.
+  readUncommitted._('READ UNCOMMITTED'),
+  ;
+
+  /// The SQL identifier of the isolation level including "ISOLATION LEVEL" prefix
+  /// and leading space.
+  @internal
+  final String queryPart;
+
+  const IsolationLevel._(String value) : queryPart = ' ISOLATION LEVEL $value';
+}
+
+/// The transaction access mode determines whether the transaction is read/write
+/// or read-only.
+enum AccessMode {
+  /// Read/write is the default.
+  readWrite._('READ WRITE'),
+
+  /// When a transaction is read-only, the following SQL commands are disallowed:
+  /// INSERT, UPDATE, DELETE, MERGE, and COPY FROM if the table they would write
+  /// to is not a temporary table; all CREATE, ALTER, and DROP commands; COMMENT,
+  /// GRANT, REVOKE, TRUNCATE; and EXPLAIN ANALYZE and EXECUTE if the command
+  /// they would execute is among those listed. This is a high-level notion of
+  /// read-only that does not prevent all writes to disk.
+  readOnly._('READ ONLY'),
+  ;
+
+  /// The SQL identifier of the access mode including leading space.
+  @internal
+  final String queryPart;
+
+  const AccessMode._(String value) : queryPart = ' $value';
+}
+
+/// The deferrable mode of the transaction.
+enum DeferrableMode {
+  /// The DEFERRABLE transaction property has no effect unless the transaction
+  /// is also SERIALIZABLE and READ ONLY. When all three of these properties
+  /// are selected for a transaction, the transaction may block when first
+  /// acquiring its snapshot, after which it is able to run without the normal
+  /// overhead of a SERIALIZABLE transaction and without any risk of contributing
+  /// to or being canceled by a serialization failure. This mode is well suited
+  /// for long-running reports or backups.
+  deferrable._('DEFERRABLE'),
+
+  /// The default mode.
+  notDeferrable._('NOT DEFERRABLE'),
+  ;
+
+  /// The SQL identifier of the deferrable mode including leading space.
+  @internal
+  final String queryPart;
+
+  const DeferrableMode._(String value) : queryPart = ' $value';
+}
+
+/// The characteristics of the current transaction.
+class TransactionMode {
+  /// The isolation level of a transaction determines what data the transaction
+  /// can see when other transactions are running concurrently.
+  final IsolationLevel? isolationLevel;
+
+  /// The transaction access mode determines whether the transaction is read/write
+  /// or read-only.
+  final AccessMode? accessMode;
+
+  /// The DEFERRABLE transaction property has no effect unless the transaction
+  /// is also SERIALIZABLE and READ ONLY. When all three of these properties
+  /// are selected for a transaction, the transaction may block when first
+  /// acquiring its snapshot, after which it is able to run without the normal
+  /// overhead of a SERIALIZABLE transaction and without any risk of contributing
+  /// to or being canceled by a serialization failure. This mode is well suited
+  /// for long-running reports or backups.
+  final DeferrableMode? deferrable;
+
+  TransactionMode({
+    this.isolationLevel,
+    this.accessMode,
+    this.deferrable,
+  });
+}
diff --git a/lib/src/pool/pool_api.dart b/lib/src/pool/pool_api.dart
index 75aa0493..a97ca8c0 100644
--- a/lib/src/pool/pool_api.dart
+++ b/lib/src/pool/pool_api.dart
@@ -69,6 +69,7 @@ abstract class Pool<L> implements Session, SessionExecutor {
   @override
   Future<R> runTx<R>(
     Future<R> Function(Session session) fn, {
+    TransactionMode? transactionMode,
     L? locality,
   });
 
diff --git a/lib/src/pool/pool_impl.dart b/lib/src/pool/pool_impl.dart
index e02e5863..528d8f29 100644
--- a/lib/src/pool/pool_impl.dart
+++ b/lib/src/pool/pool_impl.dart
@@ -100,10 +100,14 @@ class PoolImplementation<L> implements Pool<L> {
   @override
   Future<R> runTx<R>(
     Future<R> Function(Session session) fn, {
+    TransactionMode? transactionMode,
     L? locality,
   }) {
     return withConnection(
-      (connection) => connection.runTx(fn),
+      (connection) => connection.runTx(
+        fn,
+        transactionMode: transactionMode,
+      ),
       locality: locality,
     );
   }
@@ -212,8 +216,14 @@ class _PoolConnection implements Connection {
   }
 
   @override
-  Future<R> runTx<R>(Future<R> Function(Session session) fn) {
-    return _connection.runTx(fn);
+  Future<R> runTx<R>(
+    Future<R> Function(Session session) fn, {
+    TransactionMode? transactionMode,
+  }) {
+    return _connection.runTx(
+      fn,
+      transactionMode: transactionMode,
+    );
   }
 }
 
diff --git a/lib/src/v3/connection.dart b/lib/src/v3/connection.dart
index b727ae9f..cad4921c 100644
--- a/lib/src/v3/connection.dart
+++ b/lib/src/v3/connection.dart
@@ -397,7 +397,10 @@ class PgConnectionImplementation extends _PgSessionBase implements Connection {
   }
 
   @override
-  Future<R> runTx<R>(Future<R> Function(Session session) fn) {
+  Future<R> runTx<R>(
+    Future<R> Function(Session session) fn, {
+    TransactionMode? transactionMode,
+  }) {
     // Keep this database is locked while the transaction is active. We do that
     // because on a protocol level, the entire connection is in a transaction.
     // From a Dart point of view, methods called outside of the transaction
@@ -409,7 +412,21 @@ class PgConnectionImplementation extends _PgSessionBase implements Connection {
       // only it) can be used to run statements while it's active.
       final transaction =
           _connection._activeTransaction = _TransactionSession(this);
-      await transaction.execute(Sql('BEGIN;'), queryMode: QueryMode.simple);
+
+      late String beginQuery;
+      if (transactionMode != null && transactionMode.isNotEmpty) {
+        final sb = StringBuffer('BEGIN');
+        transactionMode.appendToStringBuffer(sb);
+        sb.write(';');
+        beginQuery = sb.toString();
+      } else {
+        beginQuery = 'BEGIN;';
+      }
+
+      await transaction.execute(
+        Sql(beginQuery),
+        queryMode: QueryMode.simple,
+      );
 
       try {
         final result = await fn(transaction);
@@ -1004,3 +1021,22 @@ extension FutureExt<R> on Future<R> {
     return timeout(duration);
   }
 }
+
+extension on TransactionMode {
+  bool get isEmpty =>
+      isolationLevel == null && accessMode == null && deferrable == null;
+
+  bool get isNotEmpty => !isEmpty;
+
+  void appendToStringBuffer(StringBuffer sb) {
+    if (isolationLevel != null) {
+      sb.write(isolationLevel!.queryPart);
+    }
+    if (accessMode != null) {
+      sb.write(accessMode!.queryPart);
+    }
+    if (deferrable != null) {
+      sb.write(deferrable!.queryPart);
+    }
+  }
+}


Test patch:
diff --git a/test/transaction_test.dart b/test/transaction_test.dart
index 7b420835..a2784710 100644
--- a/test/transaction_test.dart
+++ b/test/transaction_test.dart
@@ -689,4 +689,96 @@ void main() {
       );
     }, skip: !server.useV3);
   });
+
+  withPostgresServer('transaction isolations', (server) {
+    late Connection conn1;
+    late Connection conn2;
+
+    setUp(() async {
+      conn1 = await server.newConnection();
+      conn2 = await server.newConnection();
+      await conn1.execute('CREATE TABLE t (id INT PRIMARY KEY, counter INT)');
+      await conn1.execute('INSERT INTO t VALUES (1, 0)');
+    });
+
+    tearDown(() async {
+      await conn1.execute('DROP TABLE t;');
+      await conn1.close();
+      await conn2.close();
+    });
+
+    test('read committed works as expected', () async {
+      final c1 = Completer();
+      final c2 = Completer();
+      final c3 = Completer();
+      final f1 = Future.microtask(
+        () => conn1.runTx(
+          transactionMode: TransactionMode(
+            isolationLevel: IsolationLevel.readCommitted,
+          ),
+          (session) async {
+            await c2.future;
+            await session
+                .execute('UPDATE t SET counter = counter + 1 WHERE id=1');
+            c1.complete();
+            // await c3.future;
+          },
+        ),
+      );
+      final f2 = Future.microtask(
+        () => conn2.runTx(
+          transactionMode: TransactionMode(
+            isolationLevel: IsolationLevel.readCommitted,
+          ),
+          (session) async {
+            c2.complete();
+            await c1.future;
+            await session
+                .execute('UPDATE t SET counter = counter + 1 WHERE id=1');
+            c3.complete();
+          },
+        ),
+      );
+      await Future.wait([f1, f2]);
+      final rs = await conn1.execute('SELECT * from t WHERE id=1');
+      expect(rs.single, [1, 2]);
+    });
+
+    test('forced serialization failure', () async {
+      final c1 = Completer();
+      final c2 = Completer();
+      final c3 = Completer();
+      final f1 = Future.microtask(
+        () => conn1.runTx(
+          transactionMode: TransactionMode(
+            isolationLevel: IsolationLevel.serializable,
+          ),
+          (session) async {
+            await c2.future;
+            await session
+                .execute('UPDATE t SET counter = counter + 1 WHERE id=1');
+            c1.complete();
+            // await c3.future;
+          },
+        ),
+      );
+      final f2 = Future.microtask(
+        () => conn2.runTx(
+          transactionMode: TransactionMode(
+            isolationLevel: IsolationLevel.serializable,
+          ),
+          (session) async {
+            c2.complete();
+            await c1.future;
+            await session
+                .execute('UPDATE t SET counter = counter + 1 WHERE id=1');
+            c3.complete();
+          },
+        ),
+      );
+      expectLater(() => Future.wait([f1, f2]), throwsA(isA<ServerException>()));
+      final rs = await conn1.execute('SELECT * from t WHERE id=1');
+      expect(rs.single, [1, 1]);
+    });
+  });
 }