Repository: GoogleCloudPlatform/pgadapter
Pull request #532 (2022-12-06 19:06:17)

feat: support VACUUM as a no-op
Support the `VACUUM` statement as a no-op. Executing a `VACUUM` statement on PGAdapter is a no-operation, but it will verify that the syntax that is used is valid, and that the tables and columns that are referenced are valid and do exist.

go/pgadapter-vacuum


codecov[bot]: # [Codecov](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) Report
> Merging [#532](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (aaaf9e8) into [postgresql-dialect](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/commit/9fca9ba487515d63b586bb4ed6329f2d84d98996?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (9fca9ba) will **decrease** coverage by `0.03%`.
> The diff coverage is `86.33%`.

```diff
@@                   Coverage Diff                    @@
##             postgresql-dialect     #532      +/-   ##
========================================================
- Coverage                 88.02%   87.98%   -0.04%     
- Complexity                 2053     2082      +29     
========================================================
  Files                       122      123       +1     
  Lines                      6779     6917     +138     
  Branches                    955      983      +28     
========================================================
+ Hits                       5967     6086     +119     
- Misses                      578      592      +14     
- Partials                    234      239       +5     
```

| Flag | Coverage Δ | |
|---|---|---|
| all_tests | `87.98% <86.33%> (-0.04%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) | Coverage Δ | |
|---|---|---|
| [...d/spanner/pgadapter/wireprotocol/QueryMessage.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3dpcmVwcm90b2NvbC9RdWVyeU1lc3NhZ2UuamF2YQ==) | `100.00% <ø> (ø)` | |
| [...panner/pgadapter/statements/BackendConnection.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3N0YXRlbWVudHMvQmFja2VuZENvbm5lY3Rpb24uamF2YQ==) | `91.18% <65.38%> (-1.47%)` | :arrow_down: |
| [.../spanner/pgadapter/statements/VacuumStatement.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3N0YXRlbWVudHMvVmFjdXVtU3RhdGVtZW50LmphdmE=) | `90.47% <90.47%> (ø)` | |
| [...oud/spanner/pgadapter/statements/SimpleParser.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3N0YXRlbWVudHMvU2ltcGxlUGFyc2VyLmphdmE=) | `92.07% <100.00%> (+0.08%)` | :arrow_up: |
| [...d/spanner/pgadapter/wireprotocol/ParseMessage.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/532/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3dpcmVwcm90b2NvbC9QYXJzZU1lc3NhZ2UuamF2YQ==) | `91.52% <100.00%> (+0.45%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform)


Patch:
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
index bd128f23..ca733e3c 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
@@ -14,6 +14,7 @@
 
 package com.google.cloud.spanner.pgadapter.statements;
 
+import static com.google.cloud.spanner.pgadapter.error.PGExceptionFactory.toPGException;
 import static com.google.cloud.spanner.pgadapter.statements.SimpleParser.isCommand;
 import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.COPY;
 
@@ -135,7 +136,7 @@ public class BackendConnection {
   }
 
   static <T> PGException setAndReturn(SettableFuture<T> future, Throwable throwable) {
-    PGException pgException = PGExceptionFactory.toPGException(throwable);
+    PGException pgException = toPGException(throwable);
     future.setException(pgException);
     return pgException;
   }
@@ -476,6 +477,47 @@ public class BackendConnection {
     }
   }
 
+  private final class Vacuum extends BufferedStatement<StatementResult> {
+    final VacuumStatement vacuumStatement;
+
+    Vacuum(VacuumStatement vacuumStatement) {
+      super(vacuumStatement.parsedStatement, vacuumStatement.originalStatement);
+      this.vacuumStatement = vacuumStatement;
+    }
+
+    @Override
+    void execute() {
+      try {
+        checkConnectionState();
+        if (spannerConnection.isInTransaction()) {
+          throw PGExceptionFactory.newPGException(
+              "VACUUM cannot run inside a transaction block", SQLState.ActiveSqlTransaction);
+        }
+        for (TableOrIndexName table : vacuumStatement.getTables()) {
+          ImmutableList<TableOrIndexName> columns = vacuumStatement.getTableColumns(table);
+          Statement statement;
+          if (columns == null || columns.isEmpty()) {
+            statement = Statement.of("select * from " + table + " limit 1");
+          } else {
+            statement =
+                Statement.of(
+                    "select "
+                        + columns.stream().map(c -> c.name).collect(Collectors.joining(","))
+                        + " from "
+                        + table
+                        + " limit 1");
+          }
+          // Just analyze the query to ensure the table and column names are valid.
+          spannerConnection.analyzeQuery(statement, QueryAnalyzeMode.PLAN);
+        }
+        result.set(NO_RESULT);
+      } catch (Exception exception) {
+        result.setException(toPGException(exception));
+        throw exception;
+      }
+    }
+  }
+
   private final class Truncate extends BufferedStatement<StatementResult> {
     final TruncateStatement truncateStatement;
 
@@ -602,6 +644,12 @@ public class BackendConnection {
     return copyOut.result;
   }
 
+  public Future<StatementResult> execute(VacuumStatement vacuumStatement) {
+    Vacuum vacuum = new Vacuum(vacuumStatement);
+    bufferedStatements.add(vacuum);
+    return vacuum.result;
+  }
+
   public Future<StatementResult> execute(TruncateStatement truncateStatement) {
     Truncate truncate = new Truncate(truncateStatement);
     bufferedStatements.add(truncate);
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java
index a2dc8e8b..ccbd8eb9 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/SimpleParser.java
@@ -386,6 +386,10 @@ public class SimpleParser {
   }
 
   List<TableOrIndexName> readColumnListInParentheses(String name) {
+    return readColumnListInParentheses(name, true);
+  }
+
+  List<TableOrIndexName> readColumnListInParentheses(String name, boolean required) {
     if (eatToken("(")) {
       List<String> expressions = parseExpressionListUntilKeyword(")", true);
       if (!eatToken(")")) {
@@ -394,9 +398,11 @@ public class SimpleParser {
             SQLState.SyntaxError);
       }
       return expressionListToColumnNames(name, expressions);
-    } else {
+    } else if (required) {
       throw PGExceptionFactory.newPGException(
           String.format("missing opening parentheses for %s", name), SQLState.SyntaxError);
+    } else {
+      return null;
     }
   }
 
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/VacuumStatement.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/VacuumStatement.java
new file mode 100644
index 00000000..1c0d2ee5
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/VacuumStatement.java
@@ -0,0 +1,242 @@
+// Copyright 2022 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.pgadapter.statements;
+
+import static com.google.cloud.spanner.pgadapter.error.PGExceptionFactory.checkArgument;
+
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement;
+import com.google.cloud.spanner.connection.AbstractStatementParser.StatementType;
+import com.google.cloud.spanner.connection.StatementResult;
+import com.google.cloud.spanner.pgadapter.ConnectionHandler;
+import com.google.cloud.spanner.pgadapter.error.PGException;
+import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory;
+import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata;
+import com.google.cloud.spanner.pgadapter.parsers.BooleanParser;
+import com.google.cloud.spanner.pgadapter.statements.SimpleParser.TableOrIndexName;
+import com.google.cloud.spanner.pgadapter.statements.VacuumStatement.ParsedVacuumStatement.Builder;
+import com.google.cloud.spanner.pgadapter.statements.VacuumStatement.ParsedVacuumStatement.IndexCleanup;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.Futures;
+import java.util.List;
+import java.util.concurrent.Future;
+
+public class VacuumStatement extends IntermediatePortalStatement {
+  static final class ParsedVacuumStatement {
+    enum IndexCleanup {
+      AUTO,
+      ON,
+      OFF;
+    }
+
+    static final class Builder {
+      final ImmutableList.Builder<TableOrIndexName> tables = ImmutableList.builder();
+      final ImmutableMap.Builder<TableOrIndexName, ImmutableList<TableOrIndexName>> columns =
+          ImmutableMap.builder();
+      boolean full;
+      boolean freeze;
+      boolean verbose;
+      boolean analyze;
+      boolean disablePageSkipping;
+      boolean skipLocked;
+      IndexCleanup indexCleanup = IndexCleanup.OFF;
+      boolean processToast;
+      boolean truncate;
+      Integer parallel;
+    }
+
+    final ImmutableList<TableOrIndexName> tables;
+    final ImmutableMap<TableOrIndexName, ImmutableList<TableOrIndexName>> columns;
+    final boolean full;
+    final boolean freeze;
+    final boolean verbose;
+    final boolean analyze;
+    final boolean disablePageSkipping;
+    final boolean skipLocked;
+    final IndexCleanup indexCleanup;
+    final boolean processToast;
+    final boolean truncate;
+    final int parallel;
+
+    private ParsedVacuumStatement(Builder builder) {
+      this.tables = builder.tables.build();
+      this.columns = builder.columns.build();
+      this.full = builder.full;
+      this.freeze = builder.freeze;
+      this.analyze = builder.analyze;
+      this.verbose = builder.verbose;
+      this.disablePageSkipping = builder.disablePageSkipping;
+      this.skipLocked = builder.skipLocked;
+      this.indexCleanup = builder.indexCleanup;
+      this.processToast = builder.processToast;
+      this.truncate = builder.truncate;
+      this.parallel =
+          builder.parallel == null
+              ? 0
+              : checkArgument(
+                  builder.parallel, builder.parallel > 0, "parallel must be greater than 0");
+    }
+  }
+
+  private final ParsedVacuumStatement parsedVacuumStatement;
+
+  public VacuumStatement(
+      ConnectionHandler connectionHandler,
+      OptionsMetadata options,
+      String name,
+      ParsedStatement parsedStatement,
+      Statement originalStatement) {
+    super(
+        name,
+        new IntermediatePreparedStatement(
+            connectionHandler,
+            options,
+            name,
+            NO_PARAMETER_TYPES,
+            parsedStatement,
+            originalStatement),
+        NO_PARAMS,
+        ImmutableList.of(),
+        ImmutableList.of());
+    this.parsedVacuumStatement = parse(originalStatement.getSql());
+  }
+
+  @Override
+  public String getCommandTag() {
+    return "VACUUM";
+  }
+
+  @Override
+  public StatementType getStatementType() {
+    return StatementType.DDL;
+  }
+
+  @Override
+  public void executeAsync(BackendConnection backendConnection) {
+    this.executed = true;
+    setFutureStatementResult(backendConnection.execute(this));
+  }
+
+  public ImmutableList<TableOrIndexName> getTables() {
+    return parsedVacuumStatement.tables;
+  }
+
+  public ImmutableList<TableOrIndexName> getTableColumns(TableOrIndexName table) {
+    return parsedVacuumStatement.columns.get(table);
+  }
+
+  @Override
+  public Future<StatementResult> describeAsync(BackendConnection backendConnection) {
+    // Return null to indicate that this VACUUM statement does not return any
+    // RowDescriptionResponse.
+    return Futures.immediateFuture(null);
+  }
+
+  @Override
+  public IntermediatePortalStatement createPortal(
+      String name,
+      byte[][] parameters,
+      List<Short> parameterFormatCodes,
+      List<Short> resultFormatCodes) {
+    // VACUUM does not support binding any parameters, so we just return the same statement.
+    return this;
+  }
+
+  static ParsedVacuumStatement parse(String sql) {
+    Preconditions.checkNotNull(sql);
+
+    SimpleParser parser = new SimpleParser(sql);
+    ParsedVacuumStatement.Builder builder = new Builder();
+    if (!parser.eatKeyword("vacuum")) {
+      throw PGExceptionFactory.newPGException("not a valid VACUUM statement: " + sql);
+    }
+    if (parser.eatToken("(")) {
+      List<String> options = parser.parseExpressionList();
+      if (!parser.eatToken(")")) {
+        throw PGExceptionFactory.newPGException(
+            "missing closing parentheses for VACUUM options list");
+      }
+      for (String option : options) {
+        SimpleParser optionParser = new SimpleParser(option);
+        if (optionParser.eatKeyword("full")) {
+          builder.full = readOptionalBooleanValue("full", optionParser, true);
+        } else if (optionParser.eatKeyword("freeze")) {
+          builder.freeze = readOptionalBooleanValue("freeze", optionParser, true);
+        } else if (optionParser.eatKeyword("verbose")) {
+          builder.verbose = readOptionalBooleanValue("verbose", optionParser, true);
+        } else if (optionParser.eatKeyword("analyze")) {
+          builder.analyze = readOptionalBooleanValue("analyze", optionParser, true);
+        } else if (optionParser.eatKeyword("disable_page_skipping")) {
+          builder.disablePageSkipping =
+              readOptionalBooleanValue("disable_page_skipping", optionParser, true);
+        } else if (optionParser.eatKeyword("skip_locked")) {
+          builder.skipLocked = readOptionalBooleanValue("skip_locked", optionParser, true);
+        } else if (optionParser.eatKeyword("index_cleanup")) {
+          if (optionParser.eatKeyword("auto")) {
+            builder.indexCleanup = IndexCleanup.AUTO;
+          } else {
+            boolean value = readOptionalBooleanValue("index_cleanup", optionParser, true);
+            builder.indexCleanup = value ? IndexCleanup.ON : IndexCleanup.OFF;
+          }
+        } else if (optionParser.eatKeyword("process_toast")) {
+          builder.processToast = readOptionalBooleanValue("process_toast", optionParser, true);
+        } else if (optionParser.eatKeyword("truncate")) {
+          builder.truncate = readOptionalBooleanValue("truncate", optionParser, true);
+        } else if (optionParser.eatKeyword("parallel")) {
+          String value = optionParser.parseExpression();
+          builder.parallel = optionParser.parseInt(value);
+        } else {
+          throw PGExceptionFactory.newPGException("Unknown option: " + option);
+        }
+      }
+    } else {
+      builder.full = parser.eatKeyword("full");
+      builder.freeze = parser.eatKeyword("freeze");
+      builder.verbose = parser.eatKeyword("verbose");
+      builder.analyze = parser.eatKeyword("analyze");
+    }
+
+    while (parser.getPos() < parser.getSql().length()) {
+      TableOrIndexName table = parser.readTableOrIndexName();
+      if (table == null) {
+        throw PGExceptionFactory.newPGException("Invalid table name");
+      }
+      builder.tables.add(table);
+      List<TableOrIndexName> columns = parser.readColumnListInParentheses(table.toString(), false);
+      if (columns != null) {
+        builder.columns.put(table, ImmutableList.copyOf(columns));
+      }
+      if (!parser.eatToken(",")) {
+        break;
+      }
+    }
+    parser.throwIfHasMoreTokens();
+    return new ParsedVacuumStatement(builder);
+  }
+
+  static boolean readOptionalBooleanValue(String name, SimpleParser parser, boolean defaultValue) {
+    parser.skipWhitespaces();
+    if (parser.getPos() >= parser.getSql().length()) {
+      return defaultValue;
+    }
+    try {
+      return BooleanParser.toBoolean(parser.getSql().substring(parser.getPos()));
+    } catch (PGException ignore) {
+      throw PGExceptionFactory.newPGException(name + " requires a Boolean value");
+    }
+  }
+}
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/ParseMessage.java b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/ParseMessage.java
index 00f512d9..611c878d 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/ParseMessage.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/ParseMessage.java
@@ -20,6 +20,7 @@ import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.DEALL
 import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.EXECUTE;
 import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.PREPARE;
 import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.TRUNCATE;
+import static com.google.cloud.spanner.pgadapter.wireprotocol.QueryMessage.VACUUM;
 
 import com.google.api.core.InternalApi;
 import com.google.cloud.spanner.Dialect;
@@ -35,6 +36,7 @@ import com.google.cloud.spanner.pgadapter.statements.IntermediatePreparedStateme
 import com.google.cloud.spanner.pgadapter.statements.InvalidStatement;
 import com.google.cloud.spanner.pgadapter.statements.PrepareStatement;
 import com.google.cloud.spanner.pgadapter.statements.TruncateStatement;
+import com.google.cloud.spanner.pgadapter.statements.VacuumStatement;
 import com.google.cloud.spanner.pgadapter.wireoutput.ParseCompleteResponse;
 import com.google.common.base.Strings;
 import java.text.MessageFormat;
@@ -124,6 +126,13 @@ public class ParseMessage extends AbstractQueryProtocolMessage {
             name,
             parsedStatement,
             originalStatement);
+      } else if (isCommand(VACUUM, originalStatement.getSql())) {
+        return new VacuumStatement(
+            connectionHandler,
+            connectionHandler.getServer().getOptions(),
+            name,
+            parsedStatement,
+            originalStatement);
       } else if (isCommand(TRUNCATE, originalStatement.getSql())) {
         return new TruncateStatement(
             connectionHandler,
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/QueryMessage.java b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/QueryMessage.java
index 00a5dffd..4c189fad 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/QueryMessage.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/QueryMessage.java
@@ -28,6 +28,7 @@ public class QueryMessage extends ControlMessage {
   public static final String PREPARE = "PREPARE";
   public static final String EXECUTE = "EXECUTE";
   public static final String DEALLOCATE = "DEALLOCATE";
+  public static final String VACUUM = "VACUUM";
   public static final String TRUNCATE = "TRUNCATE";
 
   private final Statement originalStatement;


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
index bd127db5..f8925dd2 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
@@ -38,6 +38,7 @@ import com.google.cloud.spanner.Spanner;
 import com.google.cloud.spanner.SpannerOptions;
 import com.google.cloud.spanner.Statement;
 import com.google.cloud.spanner.connection.RandomResultSetGenerator;
+import com.google.cloud.spanner.pgadapter.error.SQLState;
 import com.google.cloud.spanner.pgadapter.wireprotocol.ControlMessage.PreparedType;
 import com.google.cloud.spanner.pgadapter.wireprotocol.DescribeMessage;
 import com.google.cloud.spanner.pgadapter.wireprotocol.ExecuteMessage;
@@ -3283,6 +3284,117 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
     assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
   }
 
+  @Test
+  public void testVacuumStatement_noTables() throws SQLException {
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.createStatement().execute("vacuum");
+    }
+    assertEquals(0, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+  }
+
+  @Test
+  public void testVacuumStatement_oneTable() throws SQLException {
+    String sql = "select * from my_table limit 1";
+    mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql), SELECT1_RESULTSET));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.createStatement().execute("vacuum my_table");
+    }
+    assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+    assertEquals(sql, request.getSql());
+    assertEquals(QueryMode.PLAN, request.getQueryMode());
+  }
+
+  @Test
+  public void testVacuumStatement_multipleTables() throws SQLException {
+    String sql1 = "select * from my_table1 limit 1";
+    String sql2 = "select * from my_table2 limit 1";
+    mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql1), SELECT1_RESULTSET));
+    mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql2), SELECT2_RESULTSET));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.createStatement().execute("vacuum my_table1, my_table2");
+    }
+    assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    ExecuteSqlRequest request1 = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+    assertEquals(sql1, request1.getSql());
+    assertEquals(QueryMode.PLAN, request1.getQueryMode());
+    ExecuteSqlRequest request2 = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(1);
+    assertEquals(sql2, request2.getSql());
+    assertEquals(QueryMode.PLAN, request1.getQueryMode());
+  }
+
+  @Test
+  public void testVacuumStatement_oneTableWithColumns() throws SQLException {
+    String sql = "select col1,col2 from my_table limit 1";
+    mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql), SELECT1_RESULTSET));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.createStatement().execute("vacuum my_table (col1, col2)");
+    }
+    assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+    assertEquals(sql, request.getSql());
+    assertEquals(QueryMode.PLAN, request.getQueryMode());
+  }
+
+  @Test
+  public void testVacuumStatement_multipleTablesWithColumns() throws SQLException {
+    String sql1 = "select col1 from my_table1 limit 1";
+    String sql2 = "select col1,col2 from my_table2 limit 1";
+    mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql1), SELECT1_RESULTSET));
+    mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql2), SELECT2_RESULTSET));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.createStatement().execute("vacuum my_table1 (col1), my_table2(col1,col2)");
+    }
+    assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    ExecuteSqlRequest request1 = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+    assertEquals(sql1, request1.getSql());
+    assertEquals(QueryMode.PLAN, request1.getQueryMode());
+    ExecuteSqlRequest request2 = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(1);
+    assertEquals(sql2, request2.getSql());
+    assertEquals(QueryMode.PLAN, request1.getQueryMode());
+  }
+
+  @Test
+  public void testVacuumStatement_unknownTable() throws SQLException {
+    String sql = "select * from unknown_table limit 1";
+    mockSpanner.putStatementResult(
+        StatementResult.exception(
+            Statement.of(sql),
+            Status.NOT_FOUND
+                .withDescription("Table not found: unknown_table")
+                .asRuntimeException()));
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      SQLException exception =
+          assertThrows(
+              SQLException.class,
+              () -> connection.createStatement().execute("vacuum unknown_table"));
+      assertEquals(
+          "ERROR: Table not found: unknown_table - Statement: 'select * from unknown_table limit 1'",
+          exception.getMessage());
+    }
+    assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    ExecuteSqlRequest request = mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).get(0);
+    assertEquals(sql, request.getSql());
+    assertEquals(QueryMode.PLAN, request.getQueryMode());
+  }
+
+  @Test
+  public void testVacuumStatementInTransaction() throws SQLException {
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      connection.setAutoCommit(false);
+      PSQLException exception =
+          assertThrows(PSQLException.class, () -> connection.createStatement().execute("vacuum"));
+      assertEquals("ERROR: VACUUM cannot run inside a transaction block", exception.getMessage());
+      assertEquals(SQLState.ActiveSqlTransaction.toString(), exception.getSQLState());
+    }
+    assertEquals(0, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+  }
+
   @Test
   public void testTruncateTransactional() throws SQLException {
     String sql = "delete from foo";
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/statements/VacuumStatementTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/statements/VacuumStatementTest.java
new file mode 100644
index 00000000..285b6e8e
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/statements/VacuumStatementTest.java
@@ -0,0 +1,244 @@
+// Copyright 2022 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.pgadapter.statements;
+
+import static com.google.cloud.spanner.pgadapter.statements.VacuumStatement.parse;
+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.pgadapter.error.PGException;
+import com.google.cloud.spanner.pgadapter.statements.SimpleParser.TableOrIndexName;
+import com.google.cloud.spanner.pgadapter.statements.VacuumStatement.ParsedVacuumStatement;
+import com.google.cloud.spanner.pgadapter.statements.VacuumStatement.ParsedVacuumStatement.IndexCleanup;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class VacuumStatementTest {
+
+  @Test
+  public void testParseNoOptions() {
+    ParsedVacuumStatement statement = parse("vacuum");
+    assertFalse(statement.analyze);
+    assertFalse(statement.disablePageSkipping);
+    assertFalse(statement.freeze);
+    assertFalse(statement.full);
+    assertFalse(statement.processToast);
+    assertFalse(statement.skipLocked);
+    assertFalse(statement.truncate);
+    assertFalse(statement.verbose);
+    assertEquals(0, statement.parallel);
+    assertEquals(IndexCleanup.OFF, statement.indexCleanup);
+    assertTrue(statement.tables.isEmpty());
+    assertTrue(statement.columns.isEmpty());
+  }
+
+  @Test
+  public void testParseAnalyze() {
+    assertTrue(parse("vacuum (analyze)").analyze);
+    assertTrue(parse("vacuum (analyze t)").analyze);
+    assertFalse(parse("vacuum (analyze off)").analyze);
+    assertTrue(parse("vacuum analyze").analyze);
+  }
+
+  @Test
+  public void testParseDisablePageSkipping() {
+    assertTrue(parse("vacuum (disable_page_skipping)").disablePageSkipping);
+    assertTrue(parse("vacuum (disable_page_skipping on)").disablePageSkipping);
+    assertFalse(parse("vacuum (disable_page_skipping f)").disablePageSkipping);
+
+    assertFalse(parse("vacuum disable_page_skipping").disablePageSkipping);
+    assertEquals("disable_page_skipping", parse("vacuum disable_page_skipping").tables.get(0).name);
+  }
+
+  @Test
+  public void testParseFreeze() {
+    assertTrue(parse("vacuum (freeze)").freeze);
+    assertTrue(parse("vacuum (freeze 1)").freeze);
+    assertFalse(parse("vacuum (freeze 0)").freeze);
+    assertTrue(parse("vacuum freeze").freeze);
+  }
+
+  @Test
+  public void testParseFull() {
+    assertTrue(parse("vacuum (full)").full);
+    assertTrue(parse("vacuum (full 1)").full);
+    assertFalse(parse("vacuum (full 0)").full);
+    assertTrue(parse("vacuum full").full);
+  }
+
+  @Test
+  public void testParseProcessToast() {
+    assertTrue(parse("vacuum (process_toast)").processToast);
+    assertTrue(parse("vacuum (process_toast true)").processToast);
+    assertFalse(parse("vacuum (process_toast false)").processToast);
+
+    assertFalse(parse("vacuum process_toast").processToast);
+    assertEquals("process_toast", parse("vacuum process_toast").tables.get(0).name);
+  }
+
+  @Test
+  public void testParseSkipLocked() {
+    assertTrue(parse("vacuum (skip_locked)").skipLocked);
+    assertTrue(parse("vacuum (skip_locked true)").skipLocked);
+    assertFalse(parse("vacuum (skip_locked false)").skipLocked);
+
+    assertFalse(parse("vacuum skip_locked").skipLocked);
+    assertEquals("skip_locked", parse("vacuum skip_locked").tables.get(0).name);
+  }
+
+  @Test
+  public void testParseTruncate() {
+    assertTrue(parse("vacuum (truncate)").truncate);
+    assertTrue(parse("vacuum (truncate 1)").truncate);
+    assertFalse(parse("vacuum (truncate 0)").truncate);
+
+    // TODO: Add support for keyword detection in the parser, so this is seen as an invalid table
+    // name.
+    assertFalse(parse("vacuum truncate").truncate);
+    assertEquals("truncate", parse("vacuum truncate").tables.get(0).name);
+  }
+
+  @Test
+  public void testParseVerbose() {
+    assertTrue(parse("vacuum (verbose)").verbose);
+    assertTrue(parse("vacuum (verbose 1)").verbose);
+    assertFalse(parse("vacuum (verbose 0)").verbose);
+    assertTrue(parse("vacuum verbose").verbose);
+  }
+
+  @Test
+  public void testParseParallel() {
+    assertEquals(1, parse("vacuum (parallel 1)").parallel);
+    assertEquals(100, parse("vacuum (parallel 100)").parallel);
+
+    assertThrows(PGException.class, () -> parse("vacuum (parallel)"));
+    assertThrows(PGException.class, () -> parse("vacuum (parallel foo)"));
+    assertThrows(PGException.class, () -> parse("vacuum (parallel '1')"));
+    assertThrows(PGException.class, () -> parse("vacuum (parallel [1])"));
+    assertThrows(PGException.class, () -> parse("vacuum parallel 2"));
+    assertThrows(PGException.class, () -> parse("vacuum (parallel 0)"));
+    assertThrows(PGException.class, () -> parse("vacuum (parallel -1)"));
+    assertEquals(0, parse("vacuum parallel").parallel);
+    assertEquals("parallel", parse("vacuum parallel").tables.get(0).name);
+  }
+
+  @Test
+  public void testParseIndexCleanup() {
+    assertEquals(IndexCleanup.ON, parse("vacuum (index_cleanup)").indexCleanup);
+    assertEquals(IndexCleanup.ON, parse("vacuum (index_cleanup on)").indexCleanup);
+    assertEquals(IndexCleanup.ON, parse("vacuum (index_cleanup t)").indexCleanup);
+    assertEquals(IndexCleanup.ON, parse("vacuum (index_cleanup true)").indexCleanup);
+    assertEquals(IndexCleanup.ON, parse("vacuum (index_cleanup 1)").indexCleanup);
+    assertEquals(IndexCleanup.OFF, parse("vacuum (index_cleanup off)").indexCleanup);
+    assertEquals(IndexCleanup.OFF, parse("vacuum (index_cleanup f)").indexCleanup);
+    assertEquals(IndexCleanup.OFF, parse("vacuum (index_cleanup false)").indexCleanup);
+    assertEquals(IndexCleanup.OFF, parse("vacuum (index_cleanup 0)").indexCleanup);
+    assertEquals(IndexCleanup.AUTO, parse("vacuum (index_cleanup auto)").indexCleanup);
+
+    assertThrows(PGException.class, () -> parse("vacuum (index_cleanup foo)"));
+    assertEquals(IndexCleanup.OFF, parse("vacuum index_cleanup").indexCleanup);
+    assertEquals("index_cleanup", parse("vacuum index_cleanup").tables.get(0).name);
+  }
+
+  @Test
+  public void testParseTables() {
+    assertEquals(0, parse("vacuum").tables.size());
+    assertEquals(ImmutableList.of(TableOrIndexName.of("foo")), parse("vacuum foo").tables);
+    assertEquals(
+        ImmutableList.of(TableOrIndexName.of("foo"), TableOrIndexName.of("bar")),
+        parse("vacuum foo, bar").tables);
+    assertEquals(
+        ImmutableList.of(
+            TableOrIndexName.of("foo"),
+            TableOrIndexName.of("\"bar\""),
+            TableOrIndexName.of("\"my_schema\"", "baz")),
+        parse("vacuum foo, \"bar\", \"my_schema\".baz ").tables);
+    assertEquals(
+        ImmutableList.of(
+            TableOrIndexName.of("public", "\"foo\""),
+            TableOrIndexName.of("\"bar\""),
+            TableOrIndexName.of("\"my_schema\"", "baz")),
+        parse("vacuum public.\"foo\", \"bar\", \"my_schema\".baz ").tables);
+
+    assertThrows(
+        PGException.class,
+        () -> parse("vacuum public.\"foo\", \"bar\", \"my_schema\".baz garbage"));
+  }
+
+  @Test
+  public void testParseColumns() {
+    assertEquals(0, parse("vacuum").columns.size());
+    assertEquals(ImmutableList.of(TableOrIndexName.of("foo")), parse("vacuum foo(col1)").tables);
+    assertEquals(
+        ImmutableMap.of(TableOrIndexName.of("foo"), ImmutableList.of(TableOrIndexName.of("col1"))),
+        parse("vacuum foo(col1)").columns);
+
+    assertEquals(
+        ImmutableList.of(TableOrIndexName.of("foo"), TableOrIndexName.of("bar")),
+        parse("vacuum foo(col1), bar (col1, col2)").tables);
+    assertEquals(
+        ImmutableMap.of(
+            TableOrIndexName.of("foo"),
+            ImmutableList.of(TableOrIndexName.of("col1")),
+            TableOrIndexName.of("bar"),
+            ImmutableList.of(TableOrIndexName.of("col1"), TableOrIndexName.of("col2"))),
+        parse("vacuum foo(col1), bar (col1, col2)").columns);
+
+    assertEquals(
+        ImmutableList.of(
+            TableOrIndexName.of("foo"),
+            TableOrIndexName.of("\"bar\""),
+            TableOrIndexName.of("\"my_schema\"", "baz")),
+        parse(
+                "vacuum foo(col1), \"bar\" (col1, col2), \"my_schema\".baz (\"col1\", col2, \"col3\")")
+            .tables);
+    assertEquals(
+        ImmutableMap.of(
+            TableOrIndexName.of("foo"), ImmutableList.of(TableOrIndexName.of("col1")),
+            TableOrIndexName.of("\"bar\""),
+                ImmutableList.of(TableOrIndexName.of("col1"), TableOrIndexName.of("col2")),
+            TableOrIndexName.of("\"my_schema\"", "baz"),
+                ImmutableList.of(
+                    TableOrIndexName.of("\"col1\""),
+                    TableOrIndexName.of("col2"),
+                    TableOrIndexName.of("\"col3\""))),
+        parse(
+                "vacuum foo(col1), \"bar\" (col1, col2), \"my_schema\".baz (\"col1\", col2, \"col3\")")
+            .columns);
+
+    assertEquals(
+        ImmutableList.of(
+            TableOrIndexName.of("public", "\"foo\""),
+            TableOrIndexName.of("\"bar\""),
+            TableOrIndexName.of("\"my_schema\"", "baz")),
+        parse("vacuum public.\"foo\"(col1), \"bar\" (col1, col2), \"my_schema\".baz\n(col1)")
+            .tables);
+    assertEquals(
+        ImmutableMap.of(
+            TableOrIndexName.of("public", "\"foo\""), ImmutableList.of(TableOrIndexName.of("col1")),
+            TableOrIndexName.of("\"bar\""),
+                ImmutableList.of(TableOrIndexName.of("col1"), TableOrIndexName.of("col2")),
+            TableOrIndexName.of("\"my_schema\"", "baz"),
+                ImmutableList.of(TableOrIndexName.of("col1"))),
+        parse("vacuum public.\"foo\"(col1), \"bar\" (col1, col2), \"my_schema\".baz\n(col1)")
+            .columns);
+  }
+}