diff --git a/google-cloud-spanner/clirr-ignored-differences.xml b/google-cloud-spanner/clirr-ignored-differences.xml
index 31e5e7b3fa9..ec4e21fe594 100644
--- a/google-cloud-spanner/clirr-ignored-differences.xml
+++ b/google-cloud-spanner/clirr-ignored-differences.xml
@@ -850,6 +850,18 @@
com/google/cloud/spanner/connection/Connection
java.lang.String getDefaultSequenceKind()
+
+
+
+ 7012
+ com/google/cloud/spanner/connection/Connection
+ void setDefaultIsolationLevel(com.google.spanner.v1.TransactionOptions$IsolationLevel)
+
+
+ 7012
+ com/google/cloud/spanner/connection/Connection
+ com.google.spanner.v1.TransactionOptions$IsolationLevel getDefaultIsolationLevel()
+
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementValueConverters.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementValueConverters.java
index 09525d4fa29..89a33c8eca8 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementValueConverters.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementValueConverters.java
@@ -33,6 +33,7 @@
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.spanner.v1.DirectedReadOptions;
+import com.google.spanner.v1.TransactionOptions;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.time.Duration;
@@ -382,6 +383,33 @@ public DirectedReadOptions convert(String value) {
}
}
+ /**
+ * Converter for converting strings to {@link
+ * com.google.spanner.v1.TransactionOptions.IsolationLevel} values.
+ */
+ static class IsolationLevelConverter
+ implements ClientSideStatementValueConverter {
+ static final IsolationLevelConverter INSTANCE = new IsolationLevelConverter();
+
+ private final CaseInsensitiveEnumMap values =
+ new CaseInsensitiveEnumMap<>(TransactionOptions.IsolationLevel.class);
+
+ private IsolationLevelConverter() {}
+
+ /** Constructor needed for reflection. */
+ public IsolationLevelConverter(String allowedValues) {}
+
+ @Override
+ public Class getParameterClass() {
+ return TransactionOptions.IsolationLevel.class;
+ }
+
+ @Override
+ public TransactionOptions.IsolationLevel convert(String value) {
+ return values.get(value);
+ }
+ }
+
/** Converter for converting strings to {@link AutocommitDmlMode} values. */
static class AutocommitDmlModeConverter
implements ClientSideStatementValueConverter {
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/Connection.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/Connection.java
index 7bf4e47bd9a..b0c63e347a9 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/Connection.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/Connection.java
@@ -42,6 +42,7 @@
import com.google.spanner.v1.DirectedReadOptions;
import com.google.spanner.v1.ExecuteBatchDmlRequest;
import com.google.spanner.v1.ResultSetStats;
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import java.time.Duration;
import java.util.Iterator;
import java.util.Set;
@@ -219,6 +220,12 @@ public interface Connection extends AutoCloseable {
/** @return true if this connection is in read-only mode */
boolean isReadOnly();
+ /** Sets the default isolation level for read/write transactions for this connection. */
+ void setDefaultIsolationLevel(IsolationLevel isolationLevel);
+
+ /** Returns the default isolation level for read/write transactions for this connection. */
+ IsolationLevel getDefaultIsolationLevel();
+
/**
* Sets the duration the connection should wait before automatically aborting the execution of a
* statement. The default is no timeout. Statement timeouts are applied all types of statements,
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionImpl.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionImpl.java
index 4c0c95a91a5..24dc4d2303c 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionImpl.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionImpl.java
@@ -27,6 +27,7 @@
import static com.google.cloud.spanner.connection.ConnectionProperties.AUTO_PARTITION_MODE;
import static com.google.cloud.spanner.connection.ConnectionProperties.DATA_BOOST_ENABLED;
import static com.google.cloud.spanner.connection.ConnectionProperties.DDL_IN_TRANSACTION_MODE;
+import static com.google.cloud.spanner.connection.ConnectionProperties.DEFAULT_ISOLATION_LEVEL;
import static com.google.cloud.spanner.connection.ConnectionProperties.DEFAULT_SEQUENCE_KIND;
import static com.google.cloud.spanner.connection.ConnectionProperties.DELAY_TRANSACTION_START_UNTIL_FIRST_WRITE;
import static com.google.cloud.spanner.connection.ConnectionProperties.DIRECTED_READ;
@@ -90,6 +91,7 @@
import com.google.spanner.v1.DirectedReadOptions;
import com.google.spanner.v1.ExecuteSqlRequest.QueryOptions;
import com.google.spanner.v1.ResultSetStats;
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
@@ -478,6 +480,7 @@ private void reset(Context context, boolean inTransaction) {
this.connectionState.resetValue(RETRY_ABORTS_INTERNALLY, context, inTransaction);
this.connectionState.resetValue(AUTOCOMMIT, context, inTransaction);
this.connectionState.resetValue(READONLY, context, inTransaction);
+ this.connectionState.resetValue(DEFAULT_ISOLATION_LEVEL, context, inTransaction);
this.connectionState.resetValue(READ_ONLY_STALENESS, context, inTransaction);
this.connectionState.resetValue(OPTIMIZER_VERSION, context, inTransaction);
this.connectionState.resetValue(OPTIMIZER_STATISTICS_PACKAGE, context, inTransaction);
@@ -635,6 +638,24 @@ public boolean isReadOnly() {
return getConnectionPropertyValue(READONLY);
}
+ @Override
+ public void setDefaultIsolationLevel(IsolationLevel isolationLevel) {
+ ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG);
+ ConnectionPreconditions.checkState(
+ !isBatchActive(), "Cannot default isolation level while in a batch");
+ ConnectionPreconditions.checkState(
+ !isTransactionStarted(),
+ "Cannot set default isolation level while a transaction is active");
+ setConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL, isolationLevel);
+ clearLastTransactionAndSetDefaultTransactionOptions();
+ }
+
+ @Override
+ public IsolationLevel getDefaultIsolationLevel() {
+ ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG);
+ return getConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL);
+ }
+
private void clearLastTransactionAndSetDefaultTransactionOptions() {
setDefaultTransactionOptions();
this.currentUnitOfWork = null;
@@ -2196,6 +2217,7 @@ UnitOfWork createNewUnitOfWork(
.setUsesEmulator(options.usesEmulator())
.setUseAutoSavepointsForEmulator(options.useAutoSavepointsForEmulator())
.setDatabaseClient(dbClient)
+ .setIsolationLevel(getConnectionPropertyValue(DEFAULT_ISOLATION_LEVEL))
.setDelayTransactionStartUntilFirstWrite(
getConnectionPropertyValue(DELAY_TRANSACTION_START_UNTIL_FIRST_WRITE))
.setKeepTransactionAlive(getConnectionPropertyValue(KEEP_TRANSACTION_ALIVE))
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java
index d31f14f6586..54d3461b787 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionProperties.java
@@ -112,6 +112,7 @@
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.DdlInTransactionModeConverter;
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.DialectConverter;
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.DurationConverter;
+import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.IsolationLevelConverter;
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.LongConverter;
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.NonNegativeIntegerConverter;
import com.google.cloud.spanner.connection.ClientSideStatementValueConverters.ReadOnlyStalenessConverter;
@@ -123,7 +124,9 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.spanner.v1.DirectedReadOptions;
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import java.time.Duration;
+import java.util.Arrays;
/** Utility class that defines all known connection properties. */
public class ConnectionProperties {
@@ -397,13 +400,28 @@ public class ConnectionProperties {
BOOLEANS,
BooleanConverter.INSTANCE,
Context.USER);
+ static final ConnectionProperty DEFAULT_ISOLATION_LEVEL =
+ create(
+ "default_isolation_level",
+ "The transaction isolation level that is used by default for read/write transactions. "
+ + "The default is isolation_level_unspecified, which means that the connection will use the "
+ + "default isolation level of the database that it is connected to.",
+ IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED,
+ new IsolationLevel[] {
+ IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED,
+ IsolationLevel.SERIALIZABLE,
+ IsolationLevel.REPEATABLE_READ
+ },
+ IsolationLevelConverter.INSTANCE,
+ Context.USER);
static final ConnectionProperty AUTOCOMMIT_DML_MODE =
create(
"autocommit_dml_mode",
"Determines the transaction type that is used to execute "
+ "DML statements when the connection is in auto-commit mode.",
AutocommitDmlMode.TRANSACTIONAL,
- AutocommitDmlMode.values(),
+ // Add 'null' as a valid value.
+ Arrays.copyOf(AutocommitDmlMode.values(), AutocommitDmlMode.values().length + 1),
AutocommitDmlModeConverter.INSTANCE,
Context.USER);
static final ConnectionProperty RETRY_ABORTS_INTERNALLY =
@@ -519,7 +537,8 @@ public class ConnectionProperties {
RPC_PRIORITY_NAME,
"Sets the priority for all RPC invocations from this connection (HIGH/MEDIUM/LOW). The default is HIGH.",
DEFAULT_RPC_PRIORITY,
- RpcPriority.values(),
+ // Add 'null' as a valid value.
+ Arrays.copyOf(RpcPriority.values(), RpcPriority.values().length + 1),
RpcPriorityConverter.INSTANCE,
Context.USER);
static final ConnectionProperty SAVEPOINT_SUPPORT =
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionState.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionState.java
index b732d617c22..4a6d2cf98f9 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionState.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionState.java
@@ -27,10 +27,12 @@
import com.google.cloud.spanner.connection.ConnectionProperty.Context;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Objects;
import java.util.function.Supplier;
import javax.annotation.Nullable;
@@ -233,6 +235,7 @@ private void internalSetValue(
T value,
Map> currentProperties,
Context context) {
+ checkValidValue(property, value);
ConnectionPropertyValue newValue = cast(currentProperties.get(property.getKey()));
if (newValue == null) {
ConnectionPropertyValue existingValue = cast(properties.get(property.getKey()));
@@ -249,6 +252,23 @@ private void internalSetValue(
currentProperties.put(property.getKey(), newValue);
}
+ static void checkValidValue(ConnectionProperty property, T value) {
+ if (property.getValidValues() == null || property.getValidValues().length == 0) {
+ return;
+ }
+ if (Arrays.stream(property.getValidValues())
+ .noneMatch(validValue -> Objects.equals(validValue, value))) {
+ throw invalidParamValueError(property, value);
+ }
+ }
+
+ /** Creates an exception for an invalid value for a connection property. */
+ static SpannerException invalidParamValueError(ConnectionProperty property, T value) {
+ return SpannerExceptionFactory.newSpannerException(
+ ErrorCode.INVALID_ARGUMENT,
+ String.format("invalid value \"%s\" for configuration property \"%s\"", value, property));
+ }
+
/** Creates an exception for an unknown connection property. */
static SpannerException unknownParamError(ConnectionProperty> property) {
return SpannerExceptionFactory.newSpannerException(
diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReadWriteTransaction.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReadWriteTransaction.java
index 1f6ab6bf0c6..3e4e98b16c0 100644
--- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReadWriteTransaction.java
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ReadWriteTransaction.java
@@ -60,6 +60,7 @@
import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.spanner.v1.SpannerGrpc;
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.context.Scope;
import java.time.Duration;
@@ -151,6 +152,7 @@ class ReadWriteTransaction extends AbstractMultiUseTransaction {
private final long keepAliveIntervalMillis;
private final ReentrantLock keepAliveLock;
private final SavepointSupport savepointSupport;
+ @Nonnull private final IsolationLevel isolationLevel;
private int transactionRetryAttempts;
private int successfulRetries;
private volatile ApiFuture txContextFuture;
@@ -202,6 +204,7 @@ static class Builder extends AbstractMultiUseTransaction.Builder {
- assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
- assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
- return null;
- });
+ for (IsolationLevel isolationLevel : DEFAULT_ISOLATION_LEVEL.getValidValues()) {
+ try (Connection connection = createConnection()) {
+ connection.setDefaultIsolationLevel(isolationLevel);
+ connection.runTransaction(
+ transaction -> {
+ assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
+ assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
+ return null;
+ });
+ }
+ assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ TransactionOptions transactionOptions =
+ mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0).getTransaction().getBegin();
+ assertEquals(isolationLevel, transactionOptions.getIsolationLevel());
+
+ mockSpanner.clearRequests();
}
- assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
- assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
}
@Test
public void testRunTransactionInAutoCommit() {
- try (Connection connection = createConnection()) {
- connection.setAutocommit(true);
+ for (IsolationLevel isolationLevel : DEFAULT_ISOLATION_LEVEL.getValidValues()) {
+ try (Connection connection = createConnection()) {
+ connection.setAutocommit(true);
+ connection.setDefaultIsolationLevel(isolationLevel);
- connection.runTransaction(
- transaction -> {
- assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
- assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
- return null;
- });
+ connection.runTransaction(
+ transaction -> {
+ assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
+ assertEquals(1L, transaction.executeUpdate(INSERT_STATEMENT));
+ return null;
+ });
+ }
+ assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ TransactionOptions transactionOptions =
+ mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0).getTransaction().getBegin();
+ assertEquals(isolationLevel, transactionOptions.getIsolationLevel());
+
+ mockSpanner.clearRequests();
}
- assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
- assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
}
@Test
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/TransactionMockServerTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/TransactionMockServerTest.java
new file mode 100644
index 00000000000..a6275af823c
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/TransactionMockServerTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.connection;
+
+import static com.google.cloud.spanner.connection.ConnectionProperties.DEFAULT_ISOLATION_LEVEL;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.ResultSet;
+import com.google.cloud.spanner.connection.ITAbstractSpannerTest.ITConnection;
+import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteBatchDmlRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.TransactionOptions.IsolationLevel;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class TransactionMockServerTest extends AbstractMockServerTest {
+
+ @Parameter public IsolationLevel isolationLevel;
+
+ @Parameters(name = "isolationLevel = {0}")
+ public static Object[] data() {
+ return DEFAULT_ISOLATION_LEVEL.getValidValues();
+ }
+
+ @Override
+ protected ITConnection createConnection() {
+ return createConnection(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ String.format(";default_isolation_level=%s", isolationLevel));
+ }
+
+ @Test
+ public void testQuery() {
+ try (Connection connection = createConnection()) {
+ //noinspection EmptyTryBlock
+ try (ResultSet ignore = connection.executeQuery(SELECT1_STATEMENT)) {}
+ connection.commit();
+ }
+ assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+ assertTrue(request.getTransaction().hasBegin());
+ assertTrue(request.getTransaction().getBegin().hasReadWrite());
+ assertEquals(isolationLevel, request.getTransaction().getBegin().getIsolationLevel());
+ assertFalse(request.getLastStatement());
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ }
+
+ @Test
+ public void testDml() {
+ try (Connection connection = createConnection()) {
+ connection.executeUpdate(INSERT_STATEMENT);
+ connection.commit();
+ }
+ assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+ assertTrue(request.getTransaction().hasBegin());
+ assertTrue(request.getTransaction().getBegin().hasReadWrite());
+ assertEquals(isolationLevel, request.getTransaction().getBegin().getIsolationLevel());
+ assertFalse(request.getLastStatement());
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ }
+
+ @Test
+ public void testDmlReturning() {
+ try (Connection connection = createConnection()) {
+ //noinspection EmptyTryBlock
+ try (ResultSet ignore = connection.executeQuery(INSERT_RETURNING_STATEMENT)) {}
+ connection.commit();
+ }
+ assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+ ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+ assertTrue(request.getTransaction().hasBegin());
+ assertTrue(request.getTransaction().getBegin().hasReadWrite());
+ assertEquals(isolationLevel, request.getTransaction().getBegin().getIsolationLevel());
+ assertFalse(request.getLastStatement());
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ }
+
+ @Test
+ public void testBatchDml() {
+ try (Connection connection = createConnection()) {
+ connection.startBatchDml();
+ connection.executeUpdate(INSERT_STATEMENT);
+ connection.executeUpdate(INSERT_STATEMENT);
+ connection.runBatch();
+ connection.commit();
+ }
+ assertEquals(1, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class));
+ ExecuteBatchDmlRequest request =
+ mockSpanner.getRequestsOfType(ExecuteBatchDmlRequest.class).get(0);
+ assertTrue(request.getTransaction().hasBegin());
+ assertTrue(request.getTransaction().getBegin().hasReadWrite());
+ assertEquals(isolationLevel, request.getTransaction().getBegin().getIsolationLevel());
+ assertFalse(request.getLastStatements());
+ assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+ }
+}