From 49b83215a15436ba9086b13d09d029e3c8d7703b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Fri, 7 May 2021 10:14:50 +0200 Subject: [PATCH] feat: support DATABASE statements Adds support for the following DATABASE statements in the Connection API: - CREATE DATABASE - ALTER DATABASE - DROP DATABASE - USE DATABASE Needed for https://github.com/googleapis/java-spanner-jdbc/issues/457 --- .../connection/ClientSideStatementImpl.java | 6 + .../ClientSideStatementSetExecutor.java | 3 +- .../cloud/spanner/connection/Connection.java | 58 +++++++ .../spanner/connection/ConnectionImpl.java | 95 ++++++++-- .../ConnectionStatementExecutor.java | 10 ++ .../ConnectionStatementExecutorImpl.java | 60 +++++++ .../cloud/spanner/connection/DdlClient.java | 56 ++++-- .../spanner/connection/StatementParser.java | 60 +++++++ .../spanner/connection/StatementResult.java | 7 +- .../connection/ClientSideStatements.json | 68 ++++++++ .../spanner/SpannerExceptionFactoryTest.java | 2 +- .../connection/AbstractMockServerTest.java | 12 ++ .../ConnectionWithDatabaseTest.java | 133 ++++++++++++++ .../connection/DatabaseStatementsTest.java | 163 ++++++++++++++++++ .../spanner/connection/DdlClientTest.java | 2 +- .../connection/ITAbstractSpannerTest.java | 33 ++++ .../connection/StatementParserTest.java | 67 +++++++ .../it/ITConnectionWithoutDatabaseTest.java | 101 +++++++++++ 18 files changed, 906 insertions(+), 30 deletions(-) create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionWithDatabaseTest.java create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DatabaseStatementsTest.java create mode 100644 google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITConnectionWithoutDatabaseTest.java diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementImpl.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementImpl.java index e9c9c1654e..ac2e990218 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementImpl.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementImpl.java @@ -36,6 +36,8 @@ class ClientSideStatementImpl implements ClientSideStatement { * ClientSideSetStatementImpl} that defines how the value is set. */ static class ClientSideSetStatementImpl { + /** The keyword for this statement, e.g. SET. */ + private String statementKeyword = "SET"; /** The property name that is to be set, e.g. AUTOCOMMIT. */ private String propertyName; /** The separator between the property and the value (i.e. '=' or '\s+'). */ @@ -45,6 +47,10 @@ static class ClientSideSetStatementImpl { /** The class name of the {@link ClientSideStatementValueConverter} to use. */ private String converterName; + String getStatementKeyword() { + return statementKeyword; + } + String getPropertyName() { return propertyName; } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementSetExecutor.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementSetExecutor.java index 4f4fe14042..36a458e41a 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementSetExecutor.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ClientSideStatementSetExecutor.java @@ -50,7 +50,8 @@ class ClientSideStatementSetExecutor implements ClientSideStatementExecutor { this.allowedValuesPattern = Pattern.compile( String.format( - "(?is)\\A\\s*set\\s+%s\\s*%s\\s*%s\\s*\\z", + "(?is)\\A\\s*%s\\s+%s\\s*%s\\s*%s\\s*\\z", + statement.getSetStatement().getStatementKeyword(), statement.getSetStatement().getPropertyName(), statement.getSetStatement().getSeparator(), statement.getSetStatement().getAllowedValues())); 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 813dda25cc..d778c7c6d3 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 @@ -23,6 +23,8 @@ import com.google.cloud.spanner.AbortedException; import com.google.cloud.spanner.AsyncResultSet; import com.google.cloud.spanner.CommitResponse; +import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.DatabaseNotFoundException; import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.Mutation; import com.google.cloud.spanner.Options.QueryOption; @@ -30,6 +32,7 @@ import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.SpannerBatchUpdateException; import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.SpannerExceptionFactory; import com.google.cloud.spanner.Statement; import com.google.cloud.spanner.TimestampBound; import com.google.cloud.spanner.connection.StatementResult.ResultType; @@ -1009,4 +1012,59 @@ final class InternalMetadataQuery implements QueryOption { private InternalMetadataQuery() {} } + + // DATABASE statements. + + /** + * Lists the databases on the instance that the connection is connected to. + * + * @return the databases on the instance that the connection is connected to + */ + default Iterable listDatabases() { + throw SpannerExceptionFactory.newSpannerException( + ErrorCode.UNIMPLEMENTED, "SHOW VARIABLE DATABASES is not implemented"); + } + + /** + * Changes the database that this connection is connected to. + * + * @param database The name of the database to connect to + * @throws DatabaseNotFoundException if the specified database does not exists on the instance + * that the connection is connected to + */ + default void useDatabase(String database) { + throw SpannerExceptionFactory.newSpannerException( + ErrorCode.UNIMPLEMENTED, "USE DATABASE is not implemented"); + } + + /** + * Creates a new database on the instance that this connection is connected to. + * + * @param database the name of the database that is to be created + */ + default void createDatabase(String database) { + throw SpannerExceptionFactory.newSpannerException( + ErrorCode.UNIMPLEMENTED, "CREATE DATABASE is not implemented"); + } + + /** + * Alters an existing database on the instance that this connection is connected to. + * + * @param databaseStatement the name of the database that is to be altered, followed by the + * altered options + */ + default void alterDatabase(String databaseStatement) { + throw SpannerExceptionFactory.newSpannerException( + ErrorCode.UNIMPLEMENTED, "ALTER DATABASE is not implemented"); + } + + /** + * Drops an existing database on the instance that this connection is connected to. + * + * @param database the name of the database that is to be dropped + */ + default void dropDatabase(String database) { + throw SpannerExceptionFactory.newSpannerException( + ErrorCode.UNIMPLEMENTED, "DROP DATABASE is not implemented"); + } } 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 345c881d29..d686c508df 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 @@ -18,12 +18,15 @@ import static com.google.cloud.spanner.SpannerApiFutures.get; +import com.google.api.client.util.Strings; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; import com.google.cloud.Timestamp; import com.google.cloud.spanner.AsyncResultSet; import com.google.cloud.spanner.CommitResponse; +import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseClient; +import com.google.cloud.spanner.DatabaseId; import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.Mutation; import com.google.cloud.spanner.Options.QueryOption; @@ -59,6 +62,8 @@ /** Implementation for {@link Connection}, the generic Spanner connection API (not JDBC). */ class ConnectionImpl implements Connection { private static final String CLOSED_ERROR_MSG = "This connection is closed"; + private static final String NOT_CONNECTED_TO_DB = + "This connection is not connected to a database."; private static final String ONLY_ALLOWED_IN_AUTOCOMMIT = "This method may only be called while in autocommit mode"; private static final String NOT_ALLOWED_IN_AUTOCOMMIT = @@ -213,10 +218,12 @@ static UnitOfWorkType of(TransactionMode transactionMode) { this.spannerPool = SpannerPool.INSTANCE; this.options = options; this.spanner = spannerPool.getSpanner(options, this); - if (options.isAutoConfigEmulator()) { - EmulatorUtil.maybeCreateInstanceAndDatabase(spanner, options.getDatabaseId()); + if (!Strings.isNullOrEmpty(options.getDatabaseName())) { + if (options.isAutoConfigEmulator()) { + EmulatorUtil.maybeCreateInstanceAndDatabase(spanner, options.getDatabaseId()); + } + this.dbClient = spanner.getDatabaseClient(options.getDatabaseId()); } - this.dbClient = spanner.getDatabaseClient(options.getDatabaseId()); this.retryAbortsInternally = options.isRetryAbortsInternally(); this.readOnly = options.isReadOnly(); this.autocommit = options.isAutocommit(); @@ -253,7 +260,7 @@ private DdlClient createDdlClient() { return DdlClient.newBuilder() .setDatabaseAdminClient(spanner.getDatabaseAdminClient()) .setInstanceId(options.getInstanceId()) - .setDatabaseName(options.getDatabaseName()) + .setDatabaseId(options.getDatabaseName()) .build(); } @@ -315,6 +322,10 @@ public boolean isClosed() { return closed; } + boolean isConnectedToDatabase() { + return dbClient != null; + } + @Override public void setAutocommit(boolean autocommit) { ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); @@ -642,6 +653,7 @@ public void beginTransaction() { @Override public ApiFuture beginTransactionAsync() { ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); + ConnectionPreconditions.checkState(isConnectedToDatabase(), NOT_CONNECTED_TO_DB); ConnectionPreconditions.checkState( !isBatchActive(), "This connection has an active batch and cannot begin a transaction"); ConnectionPreconditions.checkState( @@ -678,6 +690,7 @@ public void commit() { public ApiFuture commitAsync() { ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); + ConnectionPreconditions.checkState(isConnectedToDatabase(), NOT_CONNECTED_TO_DB); return endCurrentTransactionAsync(commit); } @@ -697,6 +710,7 @@ public void rollback() { public ApiFuture rollbackAsync() { ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); + ConnectionPreconditions.checkState(isConnectedToDatabase(), NOT_CONNECTED_TO_DB); return endCurrentTransactionAsync(rollback); } @@ -981,6 +995,7 @@ private ApiFuture internalExecuteBatchUpdateAsync(List */ @VisibleForTesting UnitOfWork getCurrentUnitOfWorkOrStartNewUnitOfWork() { + ConnectionPreconditions.checkState(isConnectedToDatabase(), NOT_CONNECTED_TO_DB); if (this.currentUnitOfWork == null || !this.currentUnitOfWork.isActive()) { this.currentUnitOfWork = createNewUnitOfWork(); } @@ -1096,18 +1111,8 @@ public void bufferedWrite(Iterable mutations) { @Override public void startBatchDdl() { - ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); - ConnectionPreconditions.checkState( - !isBatchActive(), "Cannot start a DDL batch when a batch is already active"); - ConnectionPreconditions.checkState( - !isReadOnly(), "Cannot start a DDL batch when the connection is in read-only mode"); - ConnectionPreconditions.checkState( - !isTransactionStarted(), "Cannot start a DDL batch while a transaction is active"); - ConnectionPreconditions.checkState( - !(isAutocommit() && isInTransaction()), - "Cannot start a DDL batch while in a temporary transaction"); - ConnectionPreconditions.checkState( - !transactionBeginMarked, "Cannot start a DDL batch when a transaction has begun"); + ConnectionPreconditions.checkState(isConnectedToDatabase(), NOT_CONNECTED_TO_DB); + checkDdlBatchOrDatabaseStatementAllowed("Cannot start a DDL batch"); this.batchMode = BatchMode.DDL; this.unitOfWorkType = UnitOfWorkType.DDL_BATCH; this.currentUnitOfWork = createNewUnitOfWork(); @@ -1180,4 +1185,62 @@ public boolean isDmlBatchActive() { ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); return this.batchMode == BatchMode.DML; } + + @Override + public Iterable listDatabases() { + ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); + return ddlClient.listDatabases(); + } + + @Override + public void useDatabase(String database) { + checkDdlBatchOrDatabaseStatementAllowed("Cannot change database"); + String databaseId = StatementParser.trimAndUnquoteIdentifier(database); + // Check that the database actually exists before we try to change. + ddlClient.getDatabase(databaseId); + // Get a new database client. + this.dbClient = + spanner.getDatabaseClient( + DatabaseId.of(options.getProjectId(), options.getInstanceId(), databaseId)); + this.ddlClient.setDefaultDatabaseId(databaseId); + } + + @Override + public void createDatabase(String database) { + checkDdlBatchOrDatabaseStatementAllowed("Cannot create a database"); + String databaseId = StatementParser.trimAndUnquoteIdentifier(database); + get(ddlClient.createDatabase(databaseId, Collections.emptyList())); + } + + @Override + public void alterDatabase(String databaseStatement) { + checkDdlBatchOrDatabaseStatementAllowed("Cannot alter a database"); + String databaseId = StatementParser.parseIdentifier(databaseStatement); + get( + ddlClient.executeDdl( + databaseId, + Collections.singletonList(String.format("ALTER DATABASE %s", databaseStatement)))); + } + + @Override + public void dropDatabase(String database) { + checkDdlBatchOrDatabaseStatementAllowed("Cannot drop a database"); + String databaseId = StatementParser.trimAndUnquoteIdentifier(database); + ddlClient.dropDatabase(databaseId); + } + + private void checkDdlBatchOrDatabaseStatementAllowed(String prefix) { + ConnectionPreconditions.checkState(!isClosed(), CLOSED_ERROR_MSG); + ConnectionPreconditions.checkState( + !isBatchActive(), String.format("%s when a batch is active", prefix)); + ConnectionPreconditions.checkState( + !isReadOnly(), String.format("%s when the connection is in read-only mode", prefix)); + ConnectionPreconditions.checkState( + !isTransactionStarted(), String.format("%s while a transaction is active", prefix)); + ConnectionPreconditions.checkState( + !(isAutocommit() && isInTransaction()), + String.format("%s while in a temporary transaction", prefix)); + ConnectionPreconditions.checkState( + !transactionBeginMarked, String.format("%s when a transaction has begun", prefix)); + } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutor.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutor.java index 5cbbdfb7c4..a5a6affbe3 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutor.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutor.java @@ -85,4 +85,14 @@ interface ConnectionStatementExecutor { StatementResult statementRunBatch(); StatementResult statementAbortBatch(); + + StatementResult statementShowDatabases(); + + StatementResult statementUseDatabase(String database); + + StatementResult statementCreateDatabase(String database); + + StatementResult statementAlterDatabase(String database); + + StatementResult statementDropDatabase(String database); } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutorImpl.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutorImpl.java index 1d7a202c5e..3726a2ae59 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutorImpl.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/ConnectionStatementExecutorImpl.java @@ -17,8 +17,11 @@ package com.google.cloud.spanner.connection; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.ABORT_BATCH; +import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.ALTER_DATABASE; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.BEGIN; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.COMMIT; +import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.CREATE_DATABASE; +import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.DROP_DATABASE; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.ROLLBACK; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.RUN_BATCH; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SET_AUTOCOMMIT; @@ -34,6 +37,7 @@ import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_AUTOCOMMIT_DML_MODE; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_COMMIT_RESPONSE; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_COMMIT_TIMESTAMP; +import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_DATABASES; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_OPTIMIZER_VERSION; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_READONLY; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_READ_ONLY_STALENESS; @@ -43,11 +47,13 @@ import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.SHOW_STATEMENT_TIMEOUT; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.START_BATCH_DDL; import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.START_BATCH_DML; +import static com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType.USE_DATABASE; import static com.google.cloud.spanner.connection.StatementResultImpl.noResult; import static com.google.cloud.spanner.connection.StatementResultImpl.resultSet; import com.google.cloud.spanner.CommitResponse; import com.google.cloud.spanner.CommitStats; +import com.google.cloud.spanner.Database; import com.google.cloud.spanner.ResultSet; import com.google.cloud.spanner.ResultSets; import com.google.cloud.spanner.Struct; @@ -56,6 +62,7 @@ import com.google.cloud.spanner.Type.StructField; import com.google.cloud.spanner.connection.ReadOnlyStalenessUtil.DurationValueGetter; import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; import com.google.protobuf.Duration; import java.util.Arrays; import java.util.concurrent.TimeUnit; @@ -289,4 +296,57 @@ public StatementResult statementAbortBatch() { getConnection().abortBatch(); return noResult(ABORT_BATCH); } + + @Override + public StatementResult statementShowDatabases() { + Iterable databases = getConnection().listDatabases(); + ResultSet resultSet = + ResultSets.forRows( + Type.struct( + StructField.of("NAME", Type.string()), + StructField.of("CREATE_TIME", Type.timestamp()), + StructField.of("VERSION_RETENTION_PERIOD", Type.string()), + StructField.of("EARLIEST_VERSION_TIME", Type.timestamp()), + StructField.of("STATE", Type.string())), + Iterables.transform( + databases, + database -> + Struct.newBuilder() + .set("NAME") + .to(database.getId().getDatabase()) + .set("CREATE_TIME") + .to(database.getCreateTime()) + .set("VERSION_RETENTION_PERIOD") + .to(database.getVersionRetentionPeriod()) + .set("EARLIEST_VERSION_TIME") + .to(database.getEarliestVersionTime()) + .set("STATE") + .to(database.getState().toString()) + .build())); + return StatementResultImpl.of(resultSet, SHOW_DATABASES); + } + + @Override + public StatementResult statementUseDatabase(String database) { + getConnection().useDatabase(database); + return noResult(USE_DATABASE); + } + + @Override + public StatementResult statementCreateDatabase(String database) { + getConnection().createDatabase(database); + return noResult(CREATE_DATABASE); + } + + @Override + public StatementResult statementAlterDatabase(String database) { + getConnection().alterDatabase(database); + return noResult(ALTER_DATABASE); + } + + @Override + public StatementResult statementDropDatabase(String database) { + getConnection().dropDatabase(database); + return noResult(DROP_DATABASE); + } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DdlClient.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DdlClient.java index f3c9cdba03..8d5d36cd2e 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DdlClient.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/DdlClient.java @@ -17,9 +17,12 @@ package com.google.cloud.spanner.connection; import com.google.api.gax.longrunning.OperationFuture; +import com.google.cloud.spanner.Database; import com.google.cloud.spanner.DatabaseAdminClient; +import com.google.cloud.spanner.DatabaseNotFoundException; import com.google.common.base.Preconditions; import com.google.common.base.Strings; +import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; import java.util.Collections; import java.util.List; @@ -31,12 +34,12 @@ class DdlClient { private final DatabaseAdminClient dbAdminClient; private final String instanceId; - private final String databaseName; + private String databaseId; static class Builder { private DatabaseAdminClient dbAdminClient; private String instanceId; - private String databaseName; + private String databaseId; private Builder() {} @@ -53,18 +56,14 @@ Builder setInstanceId(String instanceId) { return this; } - Builder setDatabaseName(String name) { - Preconditions.checkArgument( - !Strings.isNullOrEmpty(name), "Empty database name is not allowed"); - this.databaseName = name; + Builder setDatabaseId(String databaseId) { + this.databaseId = databaseId; return this; } DdlClient build() { Preconditions.checkState(dbAdminClient != null, "No DatabaseAdminClient specified"); Preconditions.checkState(!Strings.isNullOrEmpty(instanceId), "No InstanceId specified"); - Preconditions.checkArgument( - !Strings.isNullOrEmpty(databaseName), "No database name specified"); return new DdlClient(this); } } @@ -76,7 +75,25 @@ static Builder newBuilder() { private DdlClient(Builder builder) { this.dbAdminClient = builder.dbAdminClient; this.instanceId = builder.instanceId; - this.databaseName = builder.databaseName; + this.databaseId = builder.databaseId; + } + + void setDefaultDatabaseId(String databaseId) { + this.databaseId = databaseId; + } + + /** + * Gets the metadata of the given database on the instance that the DdlClient is connected to. + * + * @throws DatabaseNotFoundException if the database could not be found + */ + Database getDatabase(String databaseId) { + return dbAdminClient.getDatabase(instanceId, databaseId); + } + + /** Gets the list of databases on the instance that the DdlClient is connected to. */ + Iterable listDatabases() { + return dbAdminClient.listDatabases(instanceId).iterateAll(); } /** Execute a single DDL statement. */ @@ -86,6 +103,25 @@ OperationFuture executeDdl(String ddl) { /** Execute a list of DDL statements as one operation. */ OperationFuture executeDdl(List statements) { - return dbAdminClient.updateDatabaseDdl(instanceId, databaseName, statements, null); + ConnectionPreconditions.checkState( + databaseId != null, "This connection is not connected to a database."); + return dbAdminClient.updateDatabaseDdl(instanceId, databaseId, statements, null); + } + + /** Execute a list of DDL statements as one operation on a specific database. */ + OperationFuture executeDdl( + String databaseId, List statements) { + return dbAdminClient.updateDatabaseDdl(instanceId, databaseId, statements, null); + } + + /** Creates a new database. */ + OperationFuture createDatabase( + String databaseId, List additionalStatements) { + return dbAdminClient.createDatabase(instanceId, databaseId, additionalStatements); + } + + /** Drops an existing database. */ + void dropDatabase(String databaseId) { + dbAdminClient.dropDatabase(instanceId, databaseId); } } diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementParser.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementParser.java index f3822177f0..afa6347dab 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementParser.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementParser.java @@ -336,6 +336,66 @@ private boolean statementStartsWith(String sql, Iterable checkStatements return false; } + /** + * Removes any (optional) quotes (`) or triple-quotes (```) around the given identifier. + * + * @param identifier the identifier to remove the quotes from + * @return an unquoted identifier that can be used in administrative methods + */ + @InternalApi + public static String trimAndUnquoteIdentifier(String identifier) { + Preconditions.checkNotNull(identifier); + String trimmedIdentifier = removeCommentsAndTrim(identifier); + if (trimmedIdentifier.startsWith("```") + && trimmedIdentifier.length() >= 6 + && trimmedIdentifier.endsWith("```")) { + return trimmedIdentifier.substring(3, trimmedIdentifier.length() - 3); + } + if (trimmedIdentifier.startsWith("`") + && trimmedIdentifier.length() >= 2 + && trimmedIdentifier.endsWith("`")) { + return trimmedIdentifier.substring(1, trimmedIdentifier.length() - 1); + } + return trimmedIdentifier; + } + + /** + * Parses the first token in the given expression as an identifier. The expression may contain + * additional tokens after the identifier. The returned identifier is stripped for any quotes or + * triple-quotes. The method returns the entire expression if the first token could not be parsed + * as an identifier, for example if the expression contains an unclosed literal. + */ + public static String parseIdentifier(String expression) { + Preconditions.checkNotNull(expression); + String sql = removeCommentsAndTrim(expression); + boolean tripleQuote = sql.startsWith("```") && sql.length() >= 6; + boolean singleQuote = !tripleQuote && sql.startsWith("`") && sql.length() >= 2; + if (tripleQuote) { + // Find the second triple-quote and return everything in between. + int index = sql.indexOf("```", 3); + if (index > -1) { + return sql.substring(3, index); + } + } + if (!singleQuote) { + // Find the first whitespace character and return everything before it. + for (int index = 1; index < sql.length(); index++) { + if (Character.isWhitespace(sql.charAt(index))) { + return sql.substring(0, index); + } + } + return sql; + } + // Single-quoted identifiers are the 'hardest' as we need to take escaping into account. + for (int index = 1; index < sql.length(); index++) { + if (sql.charAt(index) == '`' && sql.charAt(index - 1) != '\\') { + return sql.substring(1, index); + } + } + + return expression; + } + /** * Removes comments from and trims the given sql statement. Spanner supports three types of * comments: diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementResult.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementResult.java index 94269d6ac7..f8ee14a5c0 100644 --- a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementResult.java +++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/StatementResult.java @@ -74,7 +74,12 @@ enum ClientSideStatementType { START_BATCH_DDL, START_BATCH_DML, RUN_BATCH, - ABORT_BATCH + ABORT_BATCH, + SHOW_DATABASES, + USE_DATABASE, + CREATE_DATABASE, + ALTER_DATABASE, + DROP_DATABASE } /** diff --git a/google-cloud-spanner/src/main/resources/com/google/cloud/spanner/connection/ClientSideStatements.json b/google-cloud-spanner/src/main/resources/com/google/cloud/spanner/connection/ClientSideStatements.json index 6761374ea5..56a8125062 100644 --- a/google-cloud-spanner/src/main/resources/com/google/cloud/spanner/connection/ClientSideStatements.json +++ b/google-cloud-spanner/src/main/resources/com/google/cloud/spanner/connection/ClientSideStatements.json @@ -294,6 +294,74 @@ "allowedValues": "(TRUE|FALSE)", "converterName": "ClientSideStatementValueConverters$BooleanConverter" } + }, + { + "name": "SHOW VARIABLE DATABASES", + "executorName": "ClientSideStatementNoParamExecutor", + "resultType": "RESULT_SET", + "regex": "(?is)\\A\\s*show\\s+variable\\s+databases\\s*\\z", + "method": "statementShowDatabases", + "exampleStatements": ["show variable databases"] + }, + { + "name": "USE DATABASE ", + "executorName": "ClientSideStatementSetExecutor", + "resultType": "NO_RESULT", + "regex": "(?is)\\A\\s*use\\s+database\\s*(?:\\s+)\\s*(.*)\\z", + "method": "statementUseDatabase", + "exampleStatements": ["use database test-db", "use database `some database`"], + "setStatement": { + "statementKeyword": "USE", + "propertyName": "DATABASE", + "separator": "\\s+", + "allowedValues": "(.*)", + "converterName": "ClientSideStatementValueConverters$StringValueConverter" + } + }, + { + "name": "CREATE DATABASE ", + "executorName": "ClientSideStatementSetExecutor", + "resultType": "NO_RESULT", + "regex": "(?is)\\A\\s*create\\s+database\\s*(?:\\s+)\\s*(.*)\\z", + "method": "statementCreateDatabase", + "exampleStatements": ["create database test-db", "create database `some database`"], + "setStatement": { + "statementKeyword": "CREATE", + "propertyName": "DATABASE", + "separator": "\\s+", + "allowedValues": "(.+)", + "converterName": "ClientSideStatementValueConverters$StringValueConverter" + } + }, + { + "name": "ALTER DATABASE ", + "executorName": "ClientSideStatementSetExecutor", + "resultType": "NO_RESULT", + "regex": "(?is)\\A\\s*alter\\s+database\\s*(?:\\s+)\\s*(.*)\\z", + "method": "statementAlterDatabase", + "exampleStatements": ["alter database test-db SET OPTIONS ( optimizer_version = 1 )", "alter database `some database` SET OPTIONS ( optimizer_version = 1 )"], + "setStatement": { + "statementKeyword": "ALTER", + "propertyName": "DATABASE", + "separator": "\\s+", + "allowedValues": "(.+)", + "converterName": "ClientSideStatementValueConverters$StringValueConverter" + } + }, + { + "name": "DROP DATABASE ", + "executorName": "ClientSideStatementSetExecutor", + "resultType": "NO_RESULT", + "regex": "(?is)\\A\\s*drop\\s+database\\s*(?:\\s+)\\s*(.*)\\z", + "method": "statementDropDatabase", + "exampleStatements": ["drop database test-db", "drop database `some database`"], + "setStatement": { + "statementKeyword": "DROP", + "propertyName": "DATABASE", + "separator": "\\s+", + "allowedValues": "(.*)", + "converterName": "ClientSideStatementValueConverters$StringValueConverter" + } } ] } \ No newline at end of file diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerExceptionFactoryTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerExceptionFactoryTest.java index 2c4801bd0d..ef9ca03c92 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerExceptionFactoryTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerExceptionFactoryTest.java @@ -52,7 +52,7 @@ static DatabaseNotFoundException newDatabaseNotFoundException(String name) { "Database", SpannerExceptionFactory.DATABASE_RESOURCE_TYPE, name); } - static StatusRuntimeException newStatusDatabaseNotFoundException(String name) { + public static StatusRuntimeException newStatusDatabaseNotFoundException(String name) { return newStatusResourceNotFoundException( "Database", SpannerExceptionFactory.DATABASE_RESOURCE_TYPE, name); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/AbstractMockServerTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/AbstractMockServerTest.java index 154384e2e5..1398f675f5 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/AbstractMockServerTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/AbstractMockServerTest.java @@ -228,12 +228,24 @@ ITConnection createConnection( return connection; } + ITConnection createConnectionWithoutDb() { + ConnectionOptions options = + ConnectionOptions.newBuilder().setUri(getBaseUrlWithoutDb()).build(); + return createITConnection(options); + } + protected String getBaseUrl() { return String.format( "cloudspanner://localhost:%d/projects/proj/instances/inst/databases/db?usePlainText=true;autocommit=false;retryAbortsInternally=true", server.getPort()); } + protected String getBaseUrlWithoutDb() { + return String.format( + "cloudspanner://localhost:%d/projects/proj/instances/inst?usePlainText=true;autocommit=false;retryAbortsInternally=true", + server.getPort()); + } + protected int getPort() { return server.getPort(); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionWithDatabaseTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionWithDatabaseTest.java new file mode 100644 index 0000000000..b55f8d176c --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ConnectionWithDatabaseTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2021 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.KeySet; +import com.google.cloud.spanner.Mutation; +import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.Statement; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ConnectionWithDatabaseTest extends AbstractMockServerTest { + + @Test + public void testExecuteDdlWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = + assertThrows( + SpannerException.class, + () -> + connection.execute(Statement.of("CREATE TABLE Foo (Id INT64) PRIMARY KEY (Id)"))); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testExecuteDmlWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = + assertThrows( + SpannerException.class, + () -> connection.execute(Statement.of("UPDATE Foo SET Bar=1 WHERE TRUE"))); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testExecuteQueryWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = + assertThrows( + SpannerException.class, () -> connection.execute(Statement.of("SELECT * FROM Foo"))); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testExecuteStartBatchDmlWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = + assertThrows( + SpannerException.class, () -> connection.execute(Statement.of("START BATCH DML"))); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testExecuteStartBatchDdlWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = + assertThrows( + SpannerException.class, () -> connection.execute(Statement.of("START BATCH DDL"))); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testBufferWriteWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = + assertThrows( + SpannerException.class, + () -> connection.bufferedWrite(Mutation.delete("Foo", KeySet.all()))); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testBeginTransactionWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + connection.setAutocommit(true); + SpannerException e = + assertThrows(SpannerException.class, () -> connection.beginTransaction()); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testCommitWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = assertThrows(SpannerException.class, () -> connection.commit()); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } + + @Test + public void testRollbackWithoutDatabase() { + try (Connection connection = createConnectionWithoutDb()) { + SpannerException e = assertThrows(SpannerException.class, () -> connection.rollback()); + assertEquals(ErrorCode.FAILED_PRECONDITION, e.getErrorCode()); + assertTrue(e.getMessage().contains("This connection is not connected to a database.")); + } + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DatabaseStatementsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DatabaseStatementsTest.java new file mode 100644 index 0000000000..930b5c92f0 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DatabaseStatementsTest.java @@ -0,0 +1,163 @@ +/* + * Copyright 2021 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.spanner.DatabaseNotFoundException; +import com.google.cloud.spanner.ErrorCode; +import com.google.cloud.spanner.ResultSet; +import com.google.cloud.spanner.SpannerExceptionFactoryTest; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.StatementResult.ResultType; +import com.google.longrunning.Operation; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.Any; +import com.google.protobuf.Empty; +import com.google.spanner.admin.database.v1.CreateDatabaseMetadata; +import com.google.spanner.admin.database.v1.CreateDatabaseRequest; +import com.google.spanner.admin.database.v1.Database; +import com.google.spanner.admin.database.v1.DropDatabaseRequest; +import com.google.spanner.admin.database.v1.GetDatabaseRequest; +import com.google.spanner.admin.database.v1.ListDatabasesRequest; +import com.google.spanner.admin.database.v1.ListDatabasesResponse; +import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata; +import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class DatabaseStatementsTest extends AbstractMockServerTest { + + @Test + public void testShowDatabases() { + mockDatabaseAdmin.addResponse( + ListDatabasesResponse.newBuilder() + .addDatabases( + Database.newBuilder().setName("projects/proj/instances/inst/databases/db1").build()) + .addDatabases( + Database.newBuilder().setName("projects/proj/instances/inst/databases/db2").build()) + .build()); + try (Connection connection = createConnectionWithoutDb()) { + StatementResult result = connection.execute(Statement.of("SHOW VARIABLE DATABASES")); + assertEquals(ResultType.RESULT_SET, result.getResultType()); + try (ResultSet resultSet = result.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("db1", resultSet.getString("NAME")); + assertTrue(resultSet.next()); + assertEquals("db2", resultSet.getString("NAME")); + assertFalse(resultSet.next()); + } + } + List requests = mockDatabaseAdmin.getRequests(); + assertEquals(1, requests.size()); + assertTrue(requests.get(0) instanceof ListDatabasesRequest); + } + + @Test + public void testUseDatabase() { + mockDatabaseAdmin.addResponse( + Database.newBuilder() + .setName("projects/proj/instances/inst/databases/my-database") + .build()); + try (Connection connection = createConnectionWithoutDb()) { + connection.execute(Statement.of("USE DATABASE my-database")); + } + List requests = mockDatabaseAdmin.getRequests(); + assertEquals(1, requests.size()); + assertTrue(requests.get(0) instanceof GetDatabaseRequest); + GetDatabaseRequest request = (GetDatabaseRequest) requests.get(0); + assertEquals("projects/proj/instances/inst/databases/my-database", request.getName()); + } + + @Test + public void testUseDatabase_WithInvalidDatabase() { + mockDatabaseAdmin.addException( + SpannerExceptionFactoryTest.newStatusDatabaseNotFoundException( + "projects/proj/instances/inst/databases/non-existing-database")); + try (Connection connection = createConnectionWithoutDb()) { + DatabaseNotFoundException e = + assertThrows( + DatabaseNotFoundException.class, + () -> connection.execute(Statement.of("USE DATABASE non-existing-database"))); + assertEquals(ErrorCode.NOT_FOUND, e.getErrorCode()); + } + } + + @Test + public void testCreateDatabase() { + mockDatabaseAdmin.addResponse( + Operation.newBuilder() + .setDone(true) + .setResponse( + Any.pack( + Database.newBuilder() + .setName("projects/p/instances/i/databases/my-database") + .build())) + .setMetadata(Any.pack(CreateDatabaseMetadata.getDefaultInstance())) + .build()); + try (Connection connection = createConnectionWithoutDb()) { + connection.execute(Statement.of("CREATE DATABASE my-database")); + } + List requests = mockDatabaseAdmin.getRequests(); + assertEquals(1, requests.size()); + assertTrue(requests.get(0) instanceof CreateDatabaseRequest); + CreateDatabaseRequest request = (CreateDatabaseRequest) requests.get(0); + assertEquals("CREATE DATABASE `my-database`", request.getCreateStatement()); + } + + @Test + public void testAlterDatabase() { + mockDatabaseAdmin.addResponse( + Operation.newBuilder() + .setDone(true) + .setResponse(Any.pack(Empty.getDefaultInstance())) + .setMetadata(Any.pack(UpdateDatabaseDdlMetadata.getDefaultInstance())) + .build()); + try (Connection connection = createConnectionWithoutDb()) { + connection.execute( + Statement.of("ALTER DATABASE `my-database` SET OPTIONS ( optimizer_version = 1 )")); + } + List requests = mockDatabaseAdmin.getRequests(); + assertEquals(1, requests.size()); + assertTrue(requests.get(0) instanceof UpdateDatabaseDdlRequest); + UpdateDatabaseDdlRequest request = (UpdateDatabaseDdlRequest) requests.get(0); + assertEquals(1, request.getStatementsCount()); + assertEquals( + "ALTER DATABASE `my-database` SET OPTIONS ( optimizer_version = 1 )", + request.getStatements(0)); + assertEquals("projects/proj/instances/inst/databases/my-database", request.getDatabase()); + } + + @Test + public void testDropDatabase() { + mockDatabaseAdmin.addResponse(Empty.getDefaultInstance()); + try (Connection connection = createConnectionWithoutDb()) { + connection.execute(Statement.of("DROP DATABASE `my-database`")); + } + List requests = mockDatabaseAdmin.getRequests(); + assertEquals(1, requests.size()); + assertTrue(requests.get(0) instanceof DropDatabaseRequest); + DropDatabaseRequest request = (DropDatabaseRequest) requests.get(0); + assertEquals("projects/proj/instances/inst/databases/my-database", request.getDatabase()); + } +} diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DdlClientTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DdlClientTest.java index f7070b150b..bd61c58b83 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DdlClientTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/DdlClientTest.java @@ -43,7 +43,7 @@ public class DdlClientTest { private DdlClient createSubject(DatabaseAdminClient client) { return DdlClient.newBuilder() .setInstanceId(instanceId) - .setDatabaseName(databaseId) + .setDatabaseId(databaseId) .setDatabaseAdminClient(client) .build(); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ITAbstractSpannerTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ITAbstractSpannerTest.java index 0a16b10209..9d6921faba 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ITAbstractSpannerTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/ITAbstractSpannerTest.java @@ -200,6 +200,27 @@ protected static Database getDatabase() { return database; } + /** + * Returns a connection URL that is extracted from the current test environment in the form + * cloudspanner:[//host]/projects/PROJECT_ID/instances/INSTANCE_ID + */ + public static StringBuilder extractConnectionUrlWithoutDatabase() { + SpannerOptions options = getTestEnv().getTestHelper().getOptions(); + StringBuilder url = new StringBuilder("cloudspanner:"); + if (options.getHost() != null) { + url.append(options.getHost().substring(options.getHost().indexOf(':') + 1)); + } + url.append( + String.format( + "/projects/%s/instances/%s", + getTestEnv().getTestHelper().getInstanceId().getProject(), + getTestEnv().getTestHelper().getInstanceId().getInstance())); + if (options.getCredentials() == NoCredentials.getInstance()) { + url.append(";usePlainText=true"); + } + return url; + } + /** * Returns a connection URL that is extracted from the given {@link SpannerOptions} and database * in the form @@ -227,6 +248,18 @@ public static void teardown() { ConnectionOptions.closeSpanner(); } + /** Creates a new connection to the test instance without connecting to a specific database. */ + public ITConnection createConnectionWithoutDb() { + StringBuilder url = extractConnectionUrlWithoutDatabase(); + appendConnectionUri(url); + ConnectionOptions.Builder builder = ConnectionOptions.newBuilder().setUri(url.toString()); + if (hasValidKeyFile()) { + builder.setCredentialsUrl(getKeyFile()); + } + ConnectionOptions options = builder.build(); + return createITConnection(options); + } + /** * Creates a new default connection to a test database. Use the method {@link * ITAbstractSpannerTest#appendConnectionUri(StringBuilder)} to append additional connection diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java index 6e0fe00720..593ce3a6c1 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java @@ -20,6 +20,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -597,6 +598,72 @@ public void testParseStatementsWithOneParameterAtTheEnd() throws CompileExceptio } } + @Test + public void testTrimAndUnquoteIdentifier() { + assertEquals("test", StatementParser.trimAndUnquoteIdentifier("test")); + assertEquals("my-database", StatementParser.trimAndUnquoteIdentifier("my-database")); + assertEquals("test", StatementParser.trimAndUnquoteIdentifier(" test ")); + assertEquals("test", StatementParser.trimAndUnquoteIdentifier("\n\t test\n\t ")); + assertEquals("test", StatementParser.trimAndUnquoteIdentifier("`test`")); + assertEquals("test", StatementParser.trimAndUnquoteIdentifier("```test```")); + assertEquals("test test", StatementParser.trimAndUnquoteIdentifier("```test test```")); + assertEquals("test ` ", StatementParser.trimAndUnquoteIdentifier("```test ` ```")); + assertEquals("test test", StatementParser.trimAndUnquoteIdentifier("`test test`")); + assertEquals( + "my-database", + StatementParser.trimAndUnquoteIdentifier( + " /* comment that should be removed */ my-database")); + assertEquals( + "my-database", + StatementParser.trimAndUnquoteIdentifier( + " -- comment that should be removed \nmy-database")); + assertEquals( + "my-database", + StatementParser.trimAndUnquoteIdentifier( + " # comment that should be removed \nmy-database")); + } + + @Test + public void testParseIdentifier() { + assertEquals("test", StatementParser.parseIdentifier("test")); + assertEquals("my-database", StatementParser.parseIdentifier("my-database")); + assertEquals("test", StatementParser.parseIdentifier(" test ")); + assertEquals("test", StatementParser.parseIdentifier("\n\t test\n\t ")); + assertEquals("test", StatementParser.parseIdentifier("`test`")); + assertEquals("test", StatementParser.parseIdentifier("```test```")); + assertEquals("test test", StatementParser.parseIdentifier("```test test```")); + assertEquals("test ` ", StatementParser.parseIdentifier("```test ` ```")); + assertEquals("test test", StatementParser.parseIdentifier("`test test`")); + assertEquals( + "my-database", + StatementParser.parseIdentifier(" /* comment that should be removed */ my-database")); + assertEquals( + "my-database", + StatementParser.parseIdentifier(" -- comment that should be removed \nmy-database")); + assertEquals( + "my-database", + StatementParser.parseIdentifier(" # comment that should be removed \nmy-database")); + + assertEquals("test", StatementParser.parseIdentifier("test test")); + assertEquals("my-database", StatementParser.parseIdentifier("my-database test")); + assertEquals("test", StatementParser.parseIdentifier(" test test")); + assertEquals("test", StatementParser.parseIdentifier("\n\t test\n\t test")); + assertEquals("test", StatementParser.parseIdentifier("`test`test")); + assertEquals("test", StatementParser.parseIdentifier("```test```test")); + assertEquals("test test", StatementParser.parseIdentifier("```test test```test")); + assertEquals("test ` ", StatementParser.parseIdentifier("```test ` ```test")); + assertEquals("test test", StatementParser.parseIdentifier("`test test`test")); + assertEquals( + "my-database", + StatementParser.parseIdentifier(" /* comment that should be removed */ my-database test")); + assertEquals( + "my-database", + StatementParser.parseIdentifier(" -- comment that should be removed \nmy-database test")); + assertEquals( + "my-database", + StatementParser.parseIdentifier(" # comment that should be removed \nmy-database test")); + } + private Set getAllStatements() throws CompileException { return ClientSideStatements.INSTANCE.getCompiledStatements(); } diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITConnectionWithoutDatabaseTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITConnectionWithoutDatabaseTest.java new file mode 100644 index 0000000000..6f144384ea --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITConnectionWithoutDatabaseTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2021 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.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.spanner.ParallelIntegrationTest; +import com.google.cloud.spanner.ResultSet; +import com.google.cloud.spanner.SpannerException; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.Connection; +import com.google.cloud.spanner.connection.ITAbstractSpannerTest; +import java.util.Random; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@Category(ParallelIntegrationTest.class) +@RunWith(JUnit4.class) +public class ITConnectionWithoutDatabaseTest extends ITAbstractSpannerTest { + + /** + * This shadows the same method in the super class to prevent it from creating a test database. + */ + @BeforeClass + public static void setup() {} + + @Test + public void testCreateUseAlterDropDatabase() { + try (ITConnection connection = createConnectionWithoutDb()) { + // This connection does not have a database, so queries are not allowed. + assertThrows(SpannerException.class, () -> connection.execute(Statement.of("SELECT 1"))); + + String databaseId = "testdb_" + new Random().nextInt(100000000); + // Ensure that the database does not exist. + assertFalse(databaseExists(connection, databaseId)); + + // Create a database and alter the options of it. + connection.execute(Statement.of(String.format("CREATE DATABASE `%s`", databaseId))); + connection.execute( + Statement.of( + String.format("ALTER DATABASE `%s` SET OPTIONS (optimizer_version=1)", databaseId))); + + // Instruct the connection to use the newly created database. + connection.execute(Statement.of(String.format("USE DATABASE `%s`", databaseId))); + // This should now work as the connection is using the database that was created. + try (ResultSet resultSet = connection.executeQuery(Statement.of("SELECT 1"))) { + assertTrue(resultSet.next()); + assertEquals(1L, resultSet.getLong(0)); + assertFalse(resultSet.next()); + } + // The options that we set above should also be visible now that we are using this database. + try (ResultSet resultSet = + connection.executeQuery( + Statement.of("SELECT * FROM INFORMATION_SCHEMA.DATABASE_OPTIONS"))) { + assertTrue(resultSet.next()); + assertEquals("optimizer_version", resultSet.getString("OPTION_NAME")); + assertEquals("1", resultSet.getString("OPTION_VALUE")); + assertFalse(resultSet.next()); + } + + // The database should show up in the list of all databases. + assertTrue(databaseExists(connection, databaseId)); + // Drop the database that we are currently using. + connection.execute(Statement.of(String.format("DROP DATABASE `%s`", databaseId))); + // The database should no longer show up in the list of all databases. + assertFalse(databaseExists(connection, databaseId)); + } + } + + private boolean databaseExists(Connection connection, String id) { + try (ResultSet resultSet = + connection.execute(Statement.of("SHOW VARIABLE DATABASES")).getResultSet()) { + while (resultSet.next()) { + if (resultSet.getString("NAME").equals(id)) { + return true; + } + } + } + return false; + } +}