diff --git a/java-spanner/google-cloud-spanner/clirr-ignored-differences.xml b/java-spanner/google-cloud-spanner/clirr-ignored-differences.xml index cdcb343a6a01..6ae30aeebe3f 100644 --- a/java-spanner/google-cloud-spanner/clirr-ignored-differences.xml +++ b/java-spanner/google-cloud-spanner/clirr-ignored-differences.xml @@ -1156,4 +1156,10 @@ com/google/cloud/spanner/spi/v1/ChannelFinder * findServer(*) + + + 7012 + com/google/cloud/spanner/DatabaseClient + com.google.spanner.v1.TransactionOptions$IsolationLevel getDatabaseDefaultIsolationLevel() + diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java index 0293c7248d79..c73e38be1d7b 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClient.java @@ -16,6 +16,7 @@ package com.google.cloud.spanner; +import com.google.api.core.InternalApi; import com.google.api.gax.rpc.ServerStream; import com.google.cloud.Timestamp; import com.google.cloud.spanner.Options.ReadOnlyTransactionOption; @@ -42,6 +43,23 @@ default Dialect getDialect() { throw new UnsupportedOperationException("method should be overwritten"); } + /** + * Returns the default transaction isolation level that is configured for the database with the + * {@code default_transaction_isolation} database option. Returns {@link + * IsolationLevel#ISOLATION_LEVEL_UNSPECIFIED} if the option has not been set, and {@link + * IsolationLevel#UNRECOGNIZED} if the option is set to a value that is not known to this version + * of the client. + * + *

The option is read once, together with the dialect of the database, and is cached for the + * lifetime of this client. Changes to the option after it has been read are not reflected. + * + * @return the default transaction isolation level configured for the database. + */ + @InternalApi + default IsolationLevel getDatabaseDefaultIsolationLevel() { + throw new UnsupportedOperationException("method should be overwritten"); + } + /** * Returns the {@link DatabaseRole} used by the client connection. The database role that is used * determines the access permissions that a connection has. This can for example be used to create diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java index 66eea4a882ef..bbc282f7703a 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseClientImpl.java @@ -27,6 +27,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.spanner.v1.BatchWriteResponse; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; import io.opentelemetry.api.common.Attributes; import java.util.HashMap; import java.util.Map; @@ -80,6 +81,11 @@ public Dialect getDialect() { return this.multiplexedSessionDatabaseClient.getDialect(); } + @Override + public IsolationLevel getDatabaseDefaultIsolationLevel() { + return this.multiplexedSessionDatabaseClient.getDatabaseDefaultIsolationLevel(); + } + private final AbstractLazyInitializer statementFactorySupplier = new AbstractLazyInitializer() { @Override diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseMetadata.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseMetadata.java new file mode 100644 index 000000000000..fd0e9aa624d7 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/DatabaseMetadata.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 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; + +import com.google.spanner.v1.TransactionOptions.IsolationLevel; +import java.util.Locale; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** Database options that are read from {@code information_schema.database_options}. */ +@NullMarked +final class DatabaseMetadata { + static final String DATABASE_DIALECT_OPTION = "database_dialect"; + static final String DEFAULT_TRANSACTION_ISOLATION_OPTION = "default_transaction_isolation"; + + // The values of default_transaction_isolation. Spanner accepts any case when the option is set, + // and stores and returns these lower-case values for both GoogleSQL and PostgreSQL databases. + static final String SERIALIZABLE_VALUE = "serializable"; + static final String REPEATABLE_READ_VALUE = "repeatable read"; + + /** Reads all the options in {@link DatabaseMetadata} with a single query. */ + static final Statement STATEMENT = + Statement.of( + String.format( + "select option_name, option_value from information_schema.database_options " + + "where option_name in ('%s', '%s')", + DATABASE_DIALECT_OPTION, DEFAULT_TRANSACTION_ISOLATION_OPTION)); + + private final String dialect; + private final IsolationLevel defaultIsolationLevel; + + private DatabaseMetadata(String dialect, IsolationLevel defaultIsolationLevel) { + this.dialect = dialect; + this.defaultIsolationLevel = defaultIsolationLevel; + } + + /** + * Creates a {@link DatabaseMetadata} from the result of {@link #STATEMENT}. Options that are not + * in the result have not been set. + */ + static DatabaseMetadata fromResultSet(ResultSet resultSet) { + String dialect = Dialect.GOOGLE_STANDARD_SQL.name(); + IsolationLevel defaultIsolationLevel = IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED; + while (resultSet.next()) { + switch (resultSet.getString(0)) { + case DATABASE_DIALECT_OPTION: + dialect = resultSet.getString(1); + break; + case DEFAULT_TRANSACTION_ISOLATION_OPTION: + defaultIsolationLevel = + parseIsolationLevel(resultSet.isNull(1) ? null : resultSet.getString(1)); + break; + default: + break; + } + } + return new DatabaseMetadata(dialect, defaultIsolationLevel); + } + + /** + * Parses the value of the {@code default_transaction_isolation} option. {@code null} means that + * the option has not been set and returns {@link IsolationLevel#ISOLATION_LEVEL_UNSPECIFIED}. + * Values that are not known to this version of the client return {@link + * IsolationLevel#UNRECOGNIZED}. + */ + private static IsolationLevel parseIsolationLevel(@Nullable String optionValue) { + if (optionValue == null) { + return IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED; + } + switch (optionValue.toLowerCase(Locale.ROOT)) { + case SERIALIZABLE_VALUE: + return IsolationLevel.SERIALIZABLE; + case REPEATABLE_READ_VALUE: + return IsolationLevel.REPEATABLE_READ; + default: + return IsolationLevel.UNRECOGNIZED; + } + } + + Dialect getDialect() { + return Dialect.fromName(dialect); + } + + IsolationLevel getDefaultIsolationLevel() { + return defaultIsolationLevel; + } +} diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java index 03558aded997..b4c95a0309dc 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/MultiplexedSessionDatabaseClient.java @@ -33,6 +33,7 @@ import com.google.common.base.Preconditions; import com.google.common.util.concurrent.Futures; import com.google.spanner.v1.BatchWriteResponse; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -68,14 +69,6 @@ final class MultiplexedSessionDatabaseClient extends AbstractMultiplexedSessionD */ private static final int MAX_INITIAL_CREATE_SESSION_ATTEMPTS = 10; - @VisibleForTesting - static final Statement DETERMINE_DIALECT_STATEMENT = - Statement.newBuilder( - "select option_value " - + "from information_schema.database_options " - + "where option_name='database_dialect'") - .build(); - /** * Represents a single transaction on a multiplexed session. This can be both a single-use or * multi-use transaction, and both read/write or read-only transaction. This can be compared to a @@ -534,17 +527,18 @@ private int getSingleUseChannelHint() { } } - private final AbstractLazyInitializer dialectSupplier = - new AbstractLazyInitializer() { + /** + * Reads the {@link DatabaseMetadata} with a single query the first time that it is needed, and + * caches it for the lifetime of this client. The metadata is not refreshed when the multiplexed + * session is replaced. + */ + private final AbstractLazyInitializer databaseMetadataSupplier = + new AbstractLazyInitializer() { @Override - protected Dialect initialize() { - try (ResultSet dialectResultSet = singleUse().executeQuery(DETERMINE_DIALECT_STATEMENT)) { - if (dialectResultSet.next()) { - return Dialect.fromName(dialectResultSet.getString(0)); - } + protected DatabaseMetadata initialize() { + try (ResultSet resultSet = singleUse().executeQuery(DatabaseMetadata.STATEMENT)) { + return DatabaseMetadata.fromResultSet(resultSet); } - // This should not really happen, but it is the safest fallback value. - return Dialect.GOOGLE_STANDARD_SQL; } @Override @@ -556,7 +550,7 @@ protected boolean shouldCacheError(Exception e) { @Override public Dialect getDialect() { try { - return dialectSupplier.get(); + return databaseMetadataSupplier.get().getDialect(); } catch (Exception exception) { throw SpannerExceptionFactory.asSpannerException(exception); } @@ -564,7 +558,16 @@ public Dialect getDialect() { Future getDialectAsync() { try { - return MAINTAINER_SERVICE.submit(dialectSupplier::get); + return MAINTAINER_SERVICE.submit(() -> databaseMetadataSupplier.get().getDialect()); + } catch (Exception exception) { + throw SpannerExceptionFactory.asSpannerException(exception); + } + } + + @Override + public IsolationLevel getDatabaseDefaultIsolationLevel() { + try { + return databaseMetadataSupplier.get().getDefaultIsolationLevel(); } catch (Exception exception) { throw SpannerExceptionFactory.asSpannerException(exception); } diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java index 02a3b4b80172..11d962af320e 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseClientImplTest.java @@ -3212,7 +3212,7 @@ public void testGetDialect_RecoversAfterTransientCreateSessionFailure() throws E public void testGetDialectAndStatementFactory_RecoverAfterTransientQueryFailure() { mockSpanner.putStatementResult( StatementResult.exception( - MultiplexedSessionDatabaseClient.DETERMINE_DIALECT_STATEMENT, + DatabaseMetadata.STATEMENT, Status.DEADLINE_EXCEEDED .withDescription("Transient dialect query timeout") .asRuntimeException())); @@ -3245,7 +3245,7 @@ public void testGetDialectAndStatementFactory_RecoverAfterTransientQueryFailure( public void testGetDialect_CachesNonRetryableQueryFailure() { mockSpanner.putStatementResult( StatementResult.exception( - MultiplexedSessionDatabaseClient.DETERMINE_DIALECT_STATEMENT, + DatabaseMetadata.STATEMENT, Status.PERMISSION_DENIED .withDescription("Permission denied on information_schema") .asRuntimeException())); @@ -3263,6 +3263,56 @@ public void testGetDialect_CachesNonRetryableQueryFailure() { } } + @Test + public void testGetDatabaseDefaultIsolationLevelDefault() { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + assertEquals( + IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED, client.getDatabaseDefaultIsolationLevel()); + } + + @Test + public void testGetDatabaseDefaultIsolationLevelRepeatableRead() { + mockSpanner.putStatementResult( + StatementResult.detectDialectResult(Dialect.POSTGRESQL, IsolationLevel.REPEATABLE_READ)); + try { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + assertEquals(Dialect.POSTGRESQL, client.getDialect()); + assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel()); + } finally { + mockSpanner.putStatementResult( + StatementResult.detectDialectResult(Dialect.GOOGLE_STANDARD_SQL)); + } + } + + @Test + public void testGetDialectAndDefaultIsolationLevelCachedWithSingleQuery() { + mockSpanner.putStatementResult( + StatementResult.detectDialectResult(Dialect.POSTGRESQL, IsolationLevel.REPEATABLE_READ)); + mockSpanner.setIncludeDetermineDialectStatementInRequests(true); + try { + DatabaseClient client = + spanner.getDatabaseClient(DatabaseId.of(TEST_PROJECT, TEST_INSTANCE, TEST_DATABASE)); + assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel()); + assertEquals(Dialect.POSTGRESQL, client.getDialect()); + assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel()); + assertEquals(Dialect.POSTGRESQL, client.getDialect()); + + assertEquals(1L, countDatabaseMetadataQueries()); + } finally { + mockSpanner.setIncludeDetermineDialectStatementInRequests(false); + mockSpanner.putStatementResult( + StatementResult.detectDialectResult(Dialect.GOOGLE_STANDARD_SQL)); + } + } + + private long countDatabaseMetadataQueries() { + return mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(DatabaseMetadata.STATEMENT.getSql())) + .count(); + } + @Test public void testUntypedNullParameters() { Statement statement = diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseMetadataTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseMetadataTest.java new file mode 100644 index 000000000000..c9f6378ab561 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/DatabaseMetadataTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 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; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.google.cloud.spanner.Type.StructField; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class DatabaseMetadataTest { + private static final Type OPTION_TYPE = + Type.struct( + StructField.of("option_name", Type.string()), + StructField.of("option_value", Type.string())); + + private static Struct option(String name, String value) { + return Struct.newBuilder().set("option_name").to(name).set("option_value").to(value).build(); + } + + private static DatabaseMetadata fromOptions(Struct... options) { + try (ResultSet resultSet = ResultSets.forRows(OPTION_TYPE, Arrays.asList(options))) { + return DatabaseMetadata.fromResultSet(resultSet); + } + } + + @Test + public void testFromResultSetWithoutOptionsReturnsDefaults() { + DatabaseMetadata metadata = fromOptions(); + + assertEquals(Dialect.GOOGLE_STANDARD_SQL, metadata.getDialect()); + assertEquals(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED, metadata.getDefaultIsolationLevel()); + } + + @Test + public void testFromResultSetReadsAllOptions() { + DatabaseMetadata metadata = + fromOptions( + option(DatabaseMetadata.DATABASE_DIALECT_OPTION, "POSTGRESQL"), + option(DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, "repeatable read")); + + assertEquals(Dialect.POSTGRESQL, metadata.getDialect()); + assertEquals(IsolationLevel.REPEATABLE_READ, metadata.getDefaultIsolationLevel()); + } + + @Test + public void testFromResultSetWithSerializable() { + DatabaseMetadata metadata = + fromOptions(option(DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, "serializable")); + + assertEquals(IsolationLevel.SERIALIZABLE, metadata.getDefaultIsolationLevel()); + } + + @Test + public void testFromResultSetWithNullIsolationLevelReturnsDefault() { + DatabaseMetadata metadata = + fromOptions(option(DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, null)); + + assertEquals(IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED, metadata.getDefaultIsolationLevel()); + } + + @Test + public void testFromResultSetWithUnknownIsolationLevel() { + DatabaseMetadata metadata = + fromOptions( + option(DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, "read committed")); + + assertEquals(IsolationLevel.UNRECOGNIZED, metadata.getDefaultIsolationLevel()); + } + + @Test + public void testFromResultSetWithUnknownDialect() { + DatabaseMetadata metadata = + fromOptions( + option(DatabaseMetadata.DATABASE_DIALECT_OPTION, "UNKNOWN_DIALECT"), + option(DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, "repeatable read")); + + assertThrows(IllegalArgumentException.class, metadata::getDialect); + // The other options can still be read. + assertEquals(IsolationLevel.REPEATABLE_READ, metadata.getDefaultIsolationLevel()); + } +} diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockSpannerServiceImpl.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockSpannerServiceImpl.java index a598e64045fa..e5d9299e4c96 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockSpannerServiceImpl.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockSpannerServiceImpl.java @@ -72,6 +72,7 @@ import com.google.spanner.v1.StructType.Field; import com.google.spanner.v1.Transaction; import com.google.spanner.v1.TransactionOptions; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; import com.google.spanner.v1.TransactionOptions.ModeCase; import com.google.spanner.v1.TransactionOptions.ReadWrite; import com.google.spanner.v1.TransactionSelector; @@ -94,6 +95,7 @@ import java.util.Comparator; import java.util.Deque; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -319,8 +321,45 @@ public static StatementResult exception(Statement statement, StatusRuntimeExcept /** Creates a result for the query that detects the dialect that is used for the database. */ public static StatementResult detectDialectResult(Dialect resultDialect) { - return StatementResult.query( - MultiplexedSessionDatabaseClient.DETERMINE_DIALECT_STATEMENT, + return detectDialectResult(resultDialect, IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED); + } + + /** + * Creates a result for the query that detects the dialect and default transaction isolation + * level of the database. The isolation level is returned in the same format as Spanner. {@link + * IsolationLevel#ISOLATION_LEVEL_UNSPECIFIED} means that the database option has not been set. + */ + public static StatementResult detectDialectResult( + Dialect resultDialect, IsolationLevel defaultIsolationLevel) { + Map options = new LinkedHashMap<>(); + options.put(DatabaseMetadata.DATABASE_DIALECT_OPTION, resultDialect.toString()); + switch (defaultIsolationLevel) { + case ISOLATION_LEVEL_UNSPECIFIED: + break; + case SERIALIZABLE: + options.put( + DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, + DatabaseMetadata.SERIALIZABLE_VALUE); + break; + case REPEATABLE_READ: + options.put( + DatabaseMetadata.DEFAULT_TRANSACTION_ISOLATION_OPTION, + DatabaseMetadata.REPEATABLE_READ_VALUE); + break; + default: + throw new IllegalArgumentException( + "Unsupported isolation level: " + defaultIsolationLevel); + } + return detectDatabaseMetadataResult(options); + } + + /** + * Creates a result for the query that reads the database options that are cached by the client. + * The result contains one row for each option in the given map. Options that are not in the map + * are treated as not set. + */ + private static StatementResult detectDatabaseMetadataResult(Map options) { + ResultSet.Builder builder = ResultSet.newBuilder() .setMetadata( ResultSetMetadata.newBuilder() @@ -328,19 +367,28 @@ public static StatementResult detectDialectResult(Dialect resultDialect) { StructType.newBuilder() .addFields( Field.newBuilder() - .setName("DIALECT") + .setName("option_name") + .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) + .build()) + .addFields( + Field.newBuilder() + .setName("option_value") .setType(Type.newBuilder().setCode(TypeCode.STRING).build()) .build()) .build()) - .build()) - .addRows( - ListValue.newBuilder() - .addValues( - com.google.protobuf.Value.newBuilder() - .setStringValue(resultDialect.toString()) - .build()) - .build()) - .build()); + .build()); + for (Entry option : options.entrySet()) { + builder.addRows( + ListValue.newBuilder() + .addValues( + com.google.protobuf.Value.newBuilder().setStringValue(option.getKey()).build()) + .addValues( + com.google.protobuf.Value.newBuilder() + .setStringValue(option.getValue()) + .build()) + .build()); + } + return StatementResult.query(DatabaseMetadata.STATEMENT, builder.build()); } private static class KeepLastElementDeque extends LinkedList { @@ -610,10 +658,9 @@ private static void checkStreamException( private double abortProbability = 0.0010D; /** - * Flip this switch to true if you want the {@link - * MultiplexedSessionDatabaseClient#DETERMINE_DIALECT_STATEMENT} statement to be included in the - * recorded requests on the mock server. It is ignored by default to prevent tests that do not - * expect this request to suddenly start failing. + * Flip this switch to true if you want the {@link DatabaseMetadata#STATEMENT} statement to be + * included in the recorded requests on the mock server. It is ignored by default to prevent tests + * that do not expect this request to suddenly start failing. */ private boolean includeDetermineDialectStatementInRequests = false; @@ -777,9 +824,8 @@ public void setAbortProbability(double probability) { } /** - * Set this to true if you want the {@link - * MultiplexedSessionDatabaseClient#DETERMINE_DIALECT_STATEMENT} statement to be included in the - * recorded requests on the mock server. It is ignored by default to prevent tests that do not + * Set this to true if you want the {@link DatabaseMetadata#STATEMENT} statement to be included in + * the recorded requests on the mock server. It is ignored by default to prevent tests that do not * expect this request to suddenly start failing. */ public void setIncludeDetermineDialectStatementInRequests(boolean include) { @@ -1283,9 +1329,7 @@ public void executeBatchDml( public void executeStreamingSql( ExecuteSqlRequest request, StreamObserver responseObserver) { if (includeDetermineDialectStatementInRequests - || !request - .getSql() - .equals(MultiplexedSessionDatabaseClient.DETERMINE_DIALECT_STATEMENT.getSql())) { + || !request.getSql().equals(DatabaseMetadata.STATEMENT.getSql())) { maybeFreezeAndRecordRequest(request); } Preconditions.checkNotNull(request.getSession()); diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseDefaultIsolationLevelTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseDefaultIsolationLevelTest.java new file mode 100644 index 000000000000..e6fed9087e65 --- /dev/null +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITDatabaseDefaultIsolationLevelTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 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.it; + +import static com.google.cloud.spanner.testing.EmulatorSpannerHelper.isUsingEmulator; +import static org.junit.Assert.assertEquals; +import static org.junit.Assume.assumeFalse; + +import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.DatabaseClient; +import com.google.cloud.spanner.Dialect; +import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.ParallelIntegrationTest; +import com.google.cloud.spanner.testing.RemoteSpannerHelper; +import com.google.spanner.v1.TransactionOptions.IsolationLevel; +import java.util.Collections; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +/** Integration tests for {@link DatabaseClient#getDatabaseDefaultIsolationLevel()}. */ +@Category(ParallelIntegrationTest.class) +@RunWith(Parameterized.class) +public class ITDatabaseDefaultIsolationLevelTest { + @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(); + + @Parameter public Dialect dialect; + + @Parameters(name = "dialect = {0}") + public static Object[] data() { + return Dialect.values(); + } + + @Test + public void testDefaultIsolationLevelWhenOptionIsNotSet() { + RemoteSpannerHelper testHelper = env.getTestHelper(); + Database database = testHelper.createTestDatabase(dialect, Collections.emptyList()); + DatabaseClient client = testHelper.getDatabaseClient(database); + + assertEquals( + IsolationLevel.ISOLATION_LEVEL_UNSPECIFIED, client.getDatabaseDefaultIsolationLevel()); + assertEquals(dialect, client.getDialect()); + } + + @Test + public void testDefaultIsolationLevelRepeatableRead() throws Exception { + assumeFalse( + "The emulator does not support the default_transaction_isolation option", + isUsingEmulator()); + RemoteSpannerHelper testHelper = env.getTestHelper(); + Database database = testHelper.createTestDatabase(dialect, Collections.emptyList()); + String databaseId = database.getId().getDatabase(); + String alterDatabase = + dialect == Dialect.POSTGRESQL + ? "ALTER DATABASE " + + databaseId + + " SET default_transaction_isolation TO 'repeatable read'" + : "ALTER DATABASE " + + databaseId + + " SET OPTIONS (default_transaction_isolation = 'repeatable read')"; + testHelper + .getClient() + .getDatabaseAdminClient() + .updateDatabaseDdl( + testHelper.getInstanceId().getInstance(), + databaseId, + Collections.singletonList(alterDatabase), + null) + .get(); + + // The client is created after the option has been set, so it reads the new value. + DatabaseClient client = testHelper.getDatabaseClient(database); + assertEquals(IsolationLevel.REPEATABLE_READ, client.getDatabaseDefaultIsolationLevel()); + assertEquals(dialect, client.getDialect()); + } +}