Skip to content

Commit 010a502

Browse files
authored
feat: support isolation level repeatable read (#1973)
Adds support for setting the transaction isolation level to repeatable read.
1 parent 091612b commit 010a502

File tree

7 files changed

+219
-31
lines changed

7 files changed

+219
-31
lines changed

src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper
4141
implements CloudSpannerJdbcConnection {
4242
private static final String CALLABLE_STATEMENTS_UNSUPPORTED =
4343
"Callable statements are not supported";
44-
private static final String ONLY_SERIALIZABLE =
45-
"Only isolation level TRANSACTION_SERIALIZABLE is supported";
44+
private static final String ONLY_SERIALIZABLE_OR_REPEATABLE_READ =
45+
"Only isolation levels TRANSACTION_SERIALIZABLE and TRANSACTION_REPEATABLE_READ are supported";
4646
private static final String ONLY_CLOSE_ALLOWED =
4747
"Only holdability CLOSE_CURSORS_AT_COMMIT is supported";
4848
private static final String SQLXML_UNSUPPORTED = "SQLXML is not supported";
@@ -147,13 +147,15 @@ public void setTransactionIsolation(int level) throws SQLException {
147147
|| level == TRANSACTION_READ_COMMITTED,
148148
"Not a transaction isolation level");
149149
JdbcPreconditions.checkSqlFeatureSupported(
150-
level == TRANSACTION_SERIALIZABLE, ONLY_SERIALIZABLE);
150+
JdbcDatabaseMetaData.supportsIsolationLevel(level), ONLY_SERIALIZABLE_OR_REPEATABLE_READ);
151+
spanner.setDefaultIsolationLevel(IsolationLevelConverter.convertToSpanner(level));
151152
}
152153

153154
@Override
154155
public int getTransactionIsolation() throws SQLException {
155156
checkClosed();
156-
return TRANSACTION_SERIALIZABLE;
157+
//noinspection MagicConstant
158+
return IsolationLevelConverter.convertToJdbc(spanner.getDefaultIsolationLevel());
157159
}
158160

159161
@Override
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spanner.jdbc;
18+
19+
import com.google.spanner.v1.TransactionOptions.IsolationLevel;
20+
import java.sql.Connection;
21+
import java.sql.SQLException;
22+
import java.sql.SQLFeatureNotSupportedException;
23+
24+
class IsolationLevelConverter {
25+
static IsolationLevel convertToSpanner(int jdbcIsolationLevel) throws SQLException {
26+
switch (jdbcIsolationLevel) {
27+
case Connection.TRANSACTION_SERIALIZABLE:
28+
return IsolationLevel.SERIALIZABLE;
29+
case Connection.TRANSACTION_REPEATABLE_READ:
30+
return IsolationLevel.REPEATABLE_READ;
31+
case Connection.TRANSACTION_READ_COMMITTED:
32+
case Connection.TRANSACTION_READ_UNCOMMITTED:
33+
case Connection.TRANSACTION_NONE:
34+
throw new SQLFeatureNotSupportedException(
35+
"Unsupported JDBC isolation level: " + jdbcIsolationLevel);
36+
default:
37+
throw new IllegalArgumentException("Invalid JDBC isolation level: " + jdbcIsolationLevel);
38+
}
39+
}
40+
41+
static int convertToJdbc(IsolationLevel isolationLevel) {
42+
switch (isolationLevel) {
43+
// Translate UNSPECIFIED to SERIALIZABLE as that is the default isolation level.
44+
case ISOLATION_LEVEL_UNSPECIFIED:
45+
case SERIALIZABLE:
46+
return Connection.TRANSACTION_SERIALIZABLE;
47+
case REPEATABLE_READ:
48+
return Connection.TRANSACTION_REPEATABLE_READ;
49+
default:
50+
throw new IllegalArgumentException(
51+
"Unknown or unsupported isolation level: " + isolationLevel);
52+
}
53+
}
54+
}

src/main/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaData.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,12 @@ public boolean supportsTransactions() {
664664

665665
@Override
666666
public boolean supportsTransactionIsolationLevel(int level) {
667-
return Connection.TRANSACTION_SERIALIZABLE == level;
667+
return supportsIsolationLevel(level);
668+
}
669+
670+
static boolean supportsIsolationLevel(int level) {
671+
return Connection.TRANSACTION_SERIALIZABLE == level
672+
|| Connection.TRANSACTION_REPEATABLE_READ == level;
668673
}
669674

670675
@Override
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spanner.jdbc;
18+
19+
import static com.google.cloud.spanner.jdbc.IsolationLevelConverter.convertToJdbc;
20+
import static com.google.cloud.spanner.jdbc.IsolationLevelConverter.convertToSpanner;
21+
import static org.junit.Assert.assertEquals;
22+
import static org.junit.Assert.assertThrows;
23+
24+
import com.google.spanner.v1.TransactionOptions.IsolationLevel;
25+
import java.sql.Connection;
26+
import java.sql.SQLException;
27+
import java.sql.SQLFeatureNotSupportedException;
28+
import org.junit.Test;
29+
import org.junit.runner.RunWith;
30+
import org.junit.runners.JUnit4;
31+
32+
@RunWith(JUnit4.class)
33+
public class IsolationLevelConverterTest {
34+
35+
@Test
36+
public void testConvertToSpanner() throws SQLException {
37+
assertEquals(
38+
IsolationLevel.SERIALIZABLE, convertToSpanner(Connection.TRANSACTION_SERIALIZABLE));
39+
assertEquals(
40+
IsolationLevel.REPEATABLE_READ, convertToSpanner(Connection.TRANSACTION_REPEATABLE_READ));
41+
42+
assertThrows(
43+
SQLFeatureNotSupportedException.class,
44+
() -> convertToSpanner(Connection.TRANSACTION_READ_COMMITTED));
45+
assertThrows(
46+
SQLFeatureNotSupportedException.class,
47+
() -> convertToSpanner(Connection.TRANSACTION_READ_UNCOMMITTED));
48+
assertThrows(
49+
SQLFeatureNotSupportedException.class, () -> convertToSpanner(Connection.TRANSACTION_NONE));
50+
51+
assertThrows(IllegalArgumentException.class, () -> convertToSpanner(-1));
52+
}
53+
54+
@Test
55+
public void testConvertToJdbc() {
56+
// There is no 'unspecified' isolation level in JDBC, so we convert this to the default
57+
// SERIALIZABLE isolation level in Spanner.
58+
assertEquals(
59+
Connection.TRANSACTION_SERIALIZABLE,
60+
convertToJdbc(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED));
61+
assertEquals(Connection.TRANSACTION_SERIALIZABLE, convertToJdbc(IsolationLevel.SERIALIZABLE));
62+
assertEquals(
63+
Connection.TRANSACTION_REPEATABLE_READ, convertToJdbc(IsolationLevel.REPEATABLE_READ));
64+
65+
assertThrows(IllegalArgumentException.class, () -> convertToJdbc(IsolationLevel.UNRECOGNIZED));
66+
}
67+
}

src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -366,34 +366,28 @@ private void testInvokeMethodOnClosedConnection(Method method, Object... args)
366366
public void testTransactionIsolation() throws SQLException {
367367
ConnectionOptions options = mockOptions();
368368
try (JdbcConnection connection = createConnection(options)) {
369-
assertThat(connection.getTransactionIsolation())
370-
.isEqualTo(Connection.TRANSACTION_SERIALIZABLE);
371-
// assert that setting it to this value is ok.
369+
assertEquals(Connection.TRANSACTION_SERIALIZABLE, connection.getTransactionIsolation());
370+
// assert that setting it to these values is ok.
372371
connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
372+
assertEquals(Connection.TRANSACTION_SERIALIZABLE, connection.getTransactionIsolation());
373+
connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
374+
assertEquals(Connection.TRANSACTION_REPEATABLE_READ, connection.getTransactionIsolation());
373375
// assert that setting it to something else is not ok.
374-
int[] settings =
376+
int[] invalidValues =
375377
new int[] {
376-
Connection.TRANSACTION_READ_COMMITTED,
377-
Connection.TRANSACTION_READ_UNCOMMITTED,
378-
Connection.TRANSACTION_REPEATABLE_READ,
379-
-100
378+
Connection.TRANSACTION_READ_COMMITTED, Connection.TRANSACTION_READ_UNCOMMITTED, -100
380379
};
381-
for (int setting : settings) {
382-
boolean exception = false;
383-
try {
384-
connection.setTransactionIsolation(setting);
385-
} catch (SQLException e) {
386-
if (setting == -100) {
387-
exception =
388-
(e instanceof JdbcSqlException
389-
&& ((JdbcSqlException) e).getCode() == Code.INVALID_ARGUMENT);
390-
} else {
391-
exception =
392-
(e instanceof JdbcSqlException
393-
&& ((JdbcSqlException) e).getCode() == Code.UNIMPLEMENTED);
394-
}
380+
for (int invalidValue : invalidValues) {
381+
SQLException exception =
382+
assertThrows(
383+
SQLException.class, () -> connection.setTransactionIsolation(invalidValue));
384+
assertTrue(exception instanceof JdbcSqlException);
385+
JdbcSqlException spannerException = (JdbcSqlException) exception;
386+
if (invalidValue == -100) {
387+
assertEquals(Code.INVALID_ARGUMENT, spannerException.getCode());
388+
} else {
389+
assertEquals(Code.UNIMPLEMENTED, spannerException.getCode());
395390
}
396-
assertThat(exception).isTrue();
397391
}
398392
}
399393
}

src/test/java/com/google/cloud/spanner/jdbc/JdbcDatabaseMetaDataTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,12 @@ public void testTrivialMethods() throws SQLException {
265265
assertFalse(meta.usesLocalFiles());
266266
assertFalse(meta.usesLocalFilePerTable());
267267
assertTrue(meta.supportsTransactionIsolationLevel(Connection.TRANSACTION_SERIALIZABLE));
268+
assertTrue(meta.supportsTransactionIsolationLevel(Connection.TRANSACTION_REPEATABLE_READ));
268269
for (int level :
269270
new int[] {
270271
Connection.TRANSACTION_NONE,
271272
Connection.TRANSACTION_READ_COMMITTED,
272273
Connection.TRANSACTION_READ_UNCOMMITTED,
273-
Connection.TRANSACTION_REPEATABLE_READ
274274
}) {
275275
assertFalse(meta.supportsTransactionIsolationLevel(level));
276276
}

src/test/java/com/google/cloud/spanner/jdbc/TransactionMockServerTest.java

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@
1717
package com.google.cloud.spanner.jdbc;
1818

1919
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertTrue;
2021

22+
import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
2123
import com.google.cloud.spanner.connection.AbstractMockServerTest;
2224
import com.google.cloud.spanner.connection.SpannerPool;
2325
import com.google.spanner.v1.CommitRequest;
26+
import com.google.spanner.v1.ExecuteSqlRequest;
27+
import com.google.spanner.v1.TransactionOptions.IsolationLevel;
2428
import java.sql.Connection;
2529
import java.sql.DriverManager;
2630
import java.sql.SQLException;
@@ -45,9 +49,13 @@ public void clearRequests() {
4549
}
4650

4751
private String createUrl() {
52+
return createUrl("");
53+
}
54+
55+
private String createUrl(String extraOptions) {
4856
return String.format(
49-
"jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true;autoCommit=false",
50-
getPort(), "proj", "inst", "db");
57+
"jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true;autoCommit=false%s",
58+
getPort(), "proj", "inst", "db", extraOptions);
5159
}
5260

5361
@Override
@@ -98,4 +106,62 @@ public void testRollingBackEmptyExplicitTransactionIsNoOp() throws SQLException
98106

99107
assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
100108
}
109+
110+
@Test
111+
public void testUsesDefaultIsolationLevel() throws SQLException {
112+
try (Connection connection = createJdbcConnection()) {
113+
for (IsolationLevel isolationLevel :
114+
new IsolationLevel[] {IsolationLevel.SERIALIZABLE, IsolationLevel.REPEATABLE_READ}) {
115+
//noinspection MagicConstant
116+
connection.setTransactionIsolation(IsolationLevelConverter.convertToJdbc(isolationLevel));
117+
runTestTransaction(connection, isolationLevel);
118+
}
119+
}
120+
}
121+
122+
@Test
123+
public void testUsesManualIsolationLevel() throws SQLException {
124+
try (Connection connection = createJdbcConnection()) {
125+
connection.setAutoCommit(true);
126+
for (IsolationLevel isolationLevel :
127+
new IsolationLevel[] {IsolationLevel.SERIALIZABLE, IsolationLevel.REPEATABLE_READ}) {
128+
connection
129+
.createStatement()
130+
.execute(
131+
"begin transaction isolation level " + isolationLevel.toString().replace("_", " "));
132+
runTestTransaction(connection, isolationLevel);
133+
}
134+
}
135+
}
136+
137+
@Test
138+
public void testUsesDefaultIsolationLevelInConnectionString() throws SQLException {
139+
for (IsolationLevel isolationLevel :
140+
new IsolationLevel[] {IsolationLevel.SERIALIZABLE, IsolationLevel.REPEATABLE_READ}) {
141+
try (Connection connection =
142+
DriverManager.getConnection(
143+
createUrl(";default_isolation_level=" + isolationLevel.name()))) {
144+
runTestTransaction(connection, isolationLevel);
145+
}
146+
}
147+
}
148+
149+
void runTestTransaction(Connection connection, IsolationLevel expectedIsolationLevel)
150+
throws SQLException {
151+
String sql = "insert into foo (id) values (1)";
152+
mockSpanner.putStatementResult(
153+
StatementResult.update(com.google.cloud.spanner.Statement.of(sql), 1L));
154+
155+
assertEquals(1, connection.createStatement().executeUpdate(sql));
156+
connection.commit();
157+
158+
assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
159+
ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
160+
assertTrue(request.hasTransaction());
161+
assertTrue(request.getTransaction().hasBegin());
162+
assertTrue(request.getTransaction().getBegin().hasReadWrite());
163+
assertEquals(expectedIsolationLevel, request.getTransaction().getBegin().getIsolationLevel());
164+
165+
mockSpanner.clearRequests();
166+
}
101167
}

0 commit comments

Comments
 (0)