Repository: googleapis/java-spanner-jdbc
Pull request #1973 (2025-04-01 08:31:57)

feat: support isolation level repeatable read
Adds support for setting the transaction isolation level to repeatable read.



Patch:
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
index 38430cf..00d0823 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
@@ -41,8 +41,8 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper
     implements CloudSpannerJdbcConnection {
   private static final String CALLABLE_STATEMENTS_UNSUPPORTED =
       "Callable statements are not supported";
-  private static final String ONLY_SERIALIZABLE =
-      "Only isolation level TRANSACTION_SERIALIZABLE is supported";
+  private static final String ONLY_SERIALIZABLE_OR_REPEATABLE_READ =
+      "Only isolation levels TRANSACTION_SERIALIZABLE and TRANSACTION_REPEATABLE_READ are supported";
   private static final String ONLY_CLOSE_ALLOWED =
       "Only holdability CLOSE_CURSORS_AT_COMMIT is supported";
   private static final String SQLXML_UNSUPPORTED = "SQLXML is not supported";
@@ -147,13 +147,15 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper
             || level == TRANSACTION_READ_COMMITTED,
         "Not a transaction isolation level");
     JdbcPreconditions.checkSqlFeatureSupported(
-        level == TRANSACTION_SERIALIZABLE, ONLY_SERIALIZABLE);
+        JdbcDatabaseMetaData.supportsIsolationLevel(level), ONLY_SERIALIZABLE_OR_REPEATABLE_READ);
+    spanner.setDefaultIsolationLevel(IsolationLevelConverter.convertToSpanner(level));
   }
 
   @Override
   public int getTransactionIsolation() throws SQLException {
     checkClosed();
-    return TRANSACTION_SERIALIZABLE;
+    //noinspection MagicConstant
+    return IsolationLevelConverter.convertToJdbc(spanner.getDefaultIsolationLevel());
   }
 
   @Override
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/IsolationLevelConverter.java b/src/main/java/com/google/cloud/spanner/jdbc/IsolationLevelConverter.java
new file mode 100644
index 0000000..3e3e10c
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/IsolationLevelConverter.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+
+class IsolationLevelConverter {
+  static IsolationLevel convertToSpanner(int jdbcIsolationLevel) throws SQLException {
+    switch (jdbcIsolationLevel) {
+      case Connection.TRANSACTION_SERIALIZABLE:
+        return IsolationLevel.SERIALIZABLE;
+      case Connection.TRANSACTION_REPEATABLE_READ:
+        return IsolationLevel.REPEATABLE_READ;
+      case Connection.TRANSACTION_READ_COMMITTED:
+      case Connection.TRANSACTION_READ_UNCOMMITTED:
+      case Connection.TRANSACTION_NONE:
+        throw new SQLFeatureNotSupportedException(
+            "Unsupported JDBC isolation level: " + jdbcIsolationLevel);
+      default:
+        throw new IllegalArgumentException("Invalid JDBC isolation level: " + jdbcIsolationLevel);
+    }
+  }
+
+  static int convertToJdbc(IsolationLevel isolationLevel) {
+    switch (isolationLevel) {
+        // Translate UNSPECIFIED to SERIALIZABLE as that is the default isolation level.
+      case ISOLATION_LEVEL_UNSPECIFIED:
+      case SERIALIZABLE:
+        return Connection.TRANSACTION_SERIALIZABLE;
+      case REPEATABLE_READ:
+        return Connection.TRANSACTION_REPEATABLE_READ;
+      default:
+        throw new IllegalArgumentException(
+            "Unknown or unsupported isolation level: " + isolationLevel);
+    }
+  }
+}
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java
index bdebcad..b7fe951 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java
@@ -664,7 +664,12 @@ class JdbcDatabaseMetaData extends AbstractJdbcWrapper implements DatabaseMetaDa
 
   @Override
   public boolean supportsTransactionIsolationLevel(int level) {
-    return Connection.TRANSACTION_SERIALIZABLE == level;
+    return supportsIsolationLevel(level);
+  }
+
+  static boolean supportsIsolationLevel(int level) {
+    return Connection.TRANSACTION_SERIALIZABLE == level
+        || Connection.TRANSACTION_REPEATABLE_READ == level;
   }
 
   @Override


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/IsolationLevelConverterTest.java b/src/test/java/com/google/cloud/spanner/jdbc/IsolationLevelConverterTest.java
new file mode 100644
index 0000000..2be9e5e
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/IsolationLevelConverterTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.jdbc;
+
+import static com.google.cloud.spanner.jdbc.IsolationLevelConverter.convertToJdbc;
+import static com.google.cloud.spanner.jdbc.IsolationLevelConverter.convertToSpanner;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class IsolationLevelConverterTest {
+
+  @Test
+  public void testConvertToSpanner() throws SQLException {
+    assertEquals(
+        IsolationLevel.SERIALIZABLE, convertToSpanner(Connection.TRANSACTION_SERIALIZABLE));
+    assertEquals(
+        IsolationLevel.REPEATABLE_READ, convertToSpanner(Connection.TRANSACTION_REPEATABLE_READ));
+
+    assertThrows(
+        SQLFeatureNotSupportedException.class,
+        () -> convertToSpanner(Connection.TRANSACTION_READ_COMMITTED));
+    assertThrows(
+        SQLFeatureNotSupportedException.class,
+        () -> convertToSpanner(Connection.TRANSACTION_READ_UNCOMMITTED));
+    assertThrows(
+        SQLFeatureNotSupportedException.class, () -> convertToSpanner(Connection.TRANSACTION_NONE));
+
+    assertThrows(IllegalArgumentException.class, () -> convertToSpanner(-1));
+  }
+
+  @Test
+  public void testConvertToJdbc() {
+    // There is no 'unspecified' isolation level in JDBC, so we convert this to the default
+    // SERIALIZABLE isolation level in Spanner.
+    assertEquals(
+        Connection.TRANSACTION_SERIALIZABLE,
+        convertToJdbc(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED));
+    assertEquals(Connection.TRANSACTION_SERIALIZABLE, convertToJdbc(IsolationLevel.SERIALIZABLE));
+    assertEquals(
+        Connection.TRANSACTION_REPEATABLE_READ, convertToJdbc(IsolationLevel.REPEATABLE_READ));
+
+    assertThrows(IllegalArgumentException.class, () -> convertToJdbc(IsolationLevel.UNRECOGNIZED));
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java
index e0ea49f..d4cfeb8 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java
@@ -366,34 +366,28 @@ public class JdbcConnectionTest {
   public void testTransactionIsolation() throws SQLException {
     ConnectionOptions options = mockOptions();
     try (JdbcConnection connection = createConnection(options)) {
-      assertThat(connection.getTransactionIsolation())
-          .isEqualTo(Connection.TRANSACTION_SERIALIZABLE);
-      // assert that setting it to this value is ok.
+      assertEquals(Connection.TRANSACTION_SERIALIZABLE, connection.getTransactionIsolation());
+      // assert that setting it to these values is ok.
       connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
+      assertEquals(Connection.TRANSACTION_SERIALIZABLE, connection.getTransactionIsolation());
+      connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
+      assertEquals(Connection.TRANSACTION_REPEATABLE_READ, connection.getTransactionIsolation());
       // assert that setting it to something else is not ok.
-      int[] settings =
+      int[] invalidValues =
           new int[] {
-            Connection.TRANSACTION_READ_COMMITTED,
-            Connection.TRANSACTION_READ_UNCOMMITTED,
-            Connection.TRANSACTION_REPEATABLE_READ,
-            -100
+            Connection.TRANSACTION_READ_COMMITTED, Connection.TRANSACTION_READ_UNCOMMITTED, -100
           };
-      for (int setting : settings) {
-        boolean exception = false;
-        try {
-          connection.setTransactionIsolation(setting);
-        } catch (SQLException e) {
-          if (setting == -100) {
-            exception =
-                (e instanceof JdbcSqlException
-                    && ((JdbcSqlException) e).getCode() == Code.INVALID_ARGUMENT);
-          } else {
-            exception =
-                (e instanceof JdbcSqlException
-                    && ((JdbcSqlException) e).getCode() == Code.UNIMPLEMENTED);
-          }
+      for (int invalidValue : invalidValues) {
+        SQLException exception =
+            assertThrows(
+                SQLException.class, () -> connection.setTransactionIsolation(invalidValue));
+        assertTrue(exception instanceof JdbcSqlException);
+        JdbcSqlException spannerException = (JdbcSqlException) exception;
+        if (invalidValue == -100) {
+          assertEquals(Code.INVALID_ARGUMENT, spannerException.getCode());
+        } else {
+          assertEquals(Code.UNIMPLEMENTED, spannerException.getCode());
         }
-        assertThat(exception).isTrue();
       }
     }
   }
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java
index 47ab2e5..c8970fd 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java
@@ -265,12 +265,12 @@ public class JdbcDatabaseMetaDataTest {
     assertFalse(meta.usesLocalFiles());
     assertFalse(meta.usesLocalFilePerTable());
     assertTrue(meta.supportsTransactionIsolationLevel(Connection.TRANSACTION_SERIALIZABLE));
+    assertTrue(meta.supportsTransactionIsolationLevel(Connection.TRANSACTION_REPEATABLE_READ));
     for (int level :
         new int[] {
           Connection.TRANSACTION_NONE,
           Connection.TRANSACTION_READ_COMMITTED,
           Connection.TRANSACTION_READ_UNCOMMITTED,
-          Connection.TRANSACTION_REPEATABLE_READ
         }) {
       assertFalse(meta.supportsTransactionIsolationLevel(level));
     }
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/TransactionMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/TransactionMockServerTest.java
index 954d12e..9108605 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/TransactionMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/TransactionMockServerTest.java
@@ -17,10 +17,14 @@
 package com.google.cloud.spanner.jdbc;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
 import com.google.cloud.spanner.connection.AbstractMockServerTest;
 import com.google.cloud.spanner.connection.SpannerPool;
 import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.SQLException;
@@ -45,9 +49,13 @@ public class TransactionMockServerTest extends AbstractMockServerTest {
   }
 
   private String createUrl() {
+    return createUrl("");
+  }
+
+  private String createUrl(String extraOptions) {
     return String.format(
-        "jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true;autoCommit=false",
-        getPort(), "proj", "inst", "db");
+        "jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true;autoCommit=false%s",
+        getPort(), "proj", "inst", "db", extraOptions);
   }
 
   @Override
@@ -98,4 +106,62 @@ public class TransactionMockServerTest extends AbstractMockServerTest {
 
     assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
   }
+
+  @Test
+  public void testUsesDefaultIsolationLevel() throws SQLException {
+    try (Connection connection = createJdbcConnection()) {
+      for (IsolationLevel isolationLevel :
+          new IsolationLevel[] {IsolationLevel.SERIALIZABLE, IsolationLevel.REPEATABLE_READ}) {
+        //noinspection MagicConstant
+        connection.setTransactionIsolation(IsolationLevelConverter.convertToJdbc(isolationLevel));
+        runTestTransaction(connection, isolationLevel);
+      }
+    }
+  }
+
+  @Test
+  public void testUsesManualIsolationLevel() throws SQLException {
+    try (Connection connection = createJdbcConnection()) {
+      connection.setAutoCommit(true);
+      for (IsolationLevel isolationLevel :
+          new IsolationLevel[] {IsolationLevel.SERIALIZABLE, IsolationLevel.REPEATABLE_READ}) {
+        connection
+            .createStatement()
+            .execute(
+                "begin transaction isolation level " + isolationLevel.toString().replace("_", " "));
+        runTestTransaction(connection, isolationLevel);
+      }
+    }
+  }
+
+  @Test
+  public void testUsesDefaultIsolationLevelInConnectionString() throws SQLException {
+    for (IsolationLevel isolationLevel :
+        new IsolationLevel[] {IsolationLevel.SERIALIZABLE, IsolationLevel.REPEATABLE_READ}) {
+      try (Connection connection =
+          DriverManager.getConnection(
+              createUrl(";default_isolation_level=" + isolationLevel.name()))) {
+        runTestTransaction(connection, isolationLevel);
+      }
+    }
+  }
+
+  void runTestTransaction(Connection connection, IsolationLevel expectedIsolationLevel)
+      throws SQLException {
+    String sql = "insert into foo (id) values (1)";
+    mockSpanner.putStatementResult(
+        StatementResult.update(com.google.cloud.spanner.Statement.of(sql), 1L));
+
+    assertEquals(1, connection.createStatement().executeUpdate(sql));
+    connection.commit();
+
+    assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+    assertTrue(request.hasTransaction());
+    assertTrue(request.getTransaction().hasBegin());
+    assertTrue(request.getTransaction().getBegin().hasReadWrite());
+    assertEquals(expectedIsolationLevel, request.getTransaction().getBegin().getIsolationLevel());
+
+    mockSpanner.clearRequests();
+  }
 }