Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -147,13 +147,15 @@ public void setTransactionIsolation(int level) throws SQLException {
|| 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,12 @@ public boolean supportsTransactions() {

@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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason we're defaulting to the Serialization isolation level when the Spanner isolation level isn't specified?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the reason is the following:

  1. JDBC requires us to always return an actual isolation level (so for example serializable). JDBC does not have anything like an 'unspecified isolation level'.
  2. Spanner supports ISOLATION_LEVEL_UNSPECIFIED and this is the default that we are using in the Java client library. It basically means 'Spanner should use its own default'. At the moment, as a user, you cannot configure a default. So this means the default is always serializable.
  3. We use this method to convert the isolation level specified in the Spanner client library to the JDBC spec. As the Spanner client library can return ISOLATION_LEVEL_UNSPECIFIED and JDBC does not have an equivalent, we have to convert it to one of the actual isolation levels. And the only logical choice is then to return serializable, as that is the isolation level that Spanner will be using when you use ISOLATION_LEVEL_UNSPECIFIED.

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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -366,34 +366,28 @@ private void testInvokeMethodOnClosedConnection(Method method, Object... args)
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();
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,12 @@ public void testTrivialMethods() throws SQLException {
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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,9 +49,13 @@ public void clearRequests() {
}

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
Expand Down Expand Up @@ -98,4 +106,62 @@ public void testRollingBackEmptyExplicitTransactionIsNoOp() throws SQLException

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();
}
}