Repository: googleapis/java-spanner-jdbc (java)

Issue:
PreparedStatement#getMetaData executes query
Calling `PreparedStatement.getMetaData()` will execute the query of the `PreparedStatement`. That is both inefficient and does not work if the prepared statement has unbound parameters. Executing the query in `PLAN` mode is enough to retrieve the metadata, and also works if there are unbound parameters.

Pull request description:
perf: use PLAN mode to get result metadata
Calling `PreparedStatement#getMetaData()` actually executed the query or DML statement of the `PreparedStatement`. This PR changes that to only executing the statement in `PLAN` mode to get the metadata of the statement. That is:
1. More efficient, as the statement does not need to be executed.
2. Enables getting the metadata without first specifying a value for each parameter, as `PLAN` mode is allowed with unbound parameters.

This change also allows calling `PreparedStatement#getMetaData()` for DML statements. This will result in an empty `ResultSetMetaData` instance, as DML statements do not return any relevant information for a `ResultSetMetaData`.

Fixes #387 


Patch:
diff --git a/.github/readme/synth.metadata/synth.metadata b/.github/readme/synth.metadata/synth.metadata
index 873d018..210b7f7 100644
--- a/.github/readme/synth.metadata/synth.metadata
+++ b/.github/readme/synth.metadata/synth.metadata
@@ -4,14 +4,14 @@
       "git": {
         "name": ".",
         "remote": "https://github.com/googleapis/java-spanner-jdbc.git",
-        "sha": "a7c44b0837caaa42933f252249eac938eeb0ef11"
+        "sha": "b83537e08c9f453904ddbe0d1b642477a45c16ac"
       }
     },
     {
       "git": {
         "name": "synthtool",
         "remote": "https://github.com/googleapis/synthtool.git",
-        "sha": "8c5628b86cfa8386de7b8fc1675e6b528b552d57"
+        "sha": "ac8f20f12e7a4c0b0ae1c6fa415f684a25ea82b7"
       }
     }
   ]
diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml
index 7c8816a..9b4fd4d 100644
--- a/.github/workflows/auto-release.yaml
+++ b/.github/workflows/auto-release.yaml
@@ -16,8 +16,8 @@ jobs:
             return;
           }
 
-          // only approve PRs like "chore(master): release <release version>"
-          if ( !context.payload.pull_request.title.startsWith("chore(master): release") ) {
+          // only approve PRs like "chore: release <release version>"
+          if ( !context.payload.pull_request.title.startsWith("chore: release") ) {
             return;
           }
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b9ae0c..aed0b02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
 # Changelog
 
+## [1.21.0](https://www.github.com/googleapis/java-spanner-jdbc/compare/v1.20.1...v1.21.0) (2021-03-10)
+
+
+### Features
+
+* add support for CommitStats ([#261](https://www.github.com/googleapis/java-spanner-jdbc/issues/261)) ([b32e7ae](https://www.github.com/googleapis/java-spanner-jdbc/commit/b32e7aebd4c8d24d052e4616b5dd7735878e01c3))
+* allow using UUID in PreparedStatement ([#365](https://www.github.com/googleapis/java-spanner-jdbc/issues/365)) ([4cbee6d](https://www.github.com/googleapis/java-spanner-jdbc/commit/4cbee6dcc2dfea2515437b55b0ecfc956205d739)), closes [#364](https://www.github.com/googleapis/java-spanner-jdbc/issues/364)
+
+
+### Dependencies
+
+* update dependency com.google.cloud:google-cloud-shared-dependencies to v0.20.1 ([#384](https://www.github.com/googleapis/java-spanner-jdbc/issues/384)) ([f0cdf11](https://www.github.com/googleapis/java-spanner-jdbc/commit/f0cdf117e20325601f1d1c13641267add5b39955))
+* update dependency com.google.cloud:google-cloud-spanner-bom to v5 ([#386](https://www.github.com/googleapis/java-spanner-jdbc/issues/386)) ([910c50c](https://www.github.com/googleapis/java-spanner-jdbc/commit/910c50c611cb30b96a2d5d0472afd2d8e3687013))
+
 ### [1.20.1](https://www.github.com/googleapis/java-spanner-jdbc/compare/v1.20.0...v1.20.1) (2021-02-26)
 
 
diff --git a/README.md b/README.md
index 7e3e78a..210ee1a 100644
--- a/README.md
+++ b/README.md
@@ -17,18 +17,18 @@ If you are using Maven, add this to your pom.xml file:
 <dependency>
   <groupId>com.google.cloud</groupId>
   <artifactId>google-cloud-spanner-jdbc</artifactId>
-  <version>1.20.1</version>
+  <version>1.21.0</version>
 </dependency>
 ```
 
 If you are using Gradle without BOM, add this to your dependencies
 ```Groovy
-compile 'com.google.cloud:google-cloud-spanner-jdbc:1.20.1'
+compile 'com.google.cloud:google-cloud-spanner-jdbc:1.21.0'
 ```
 
 If you are using SBT, add this to your dependencies
 ```Scala
-libraryDependencies += "com.google.cloud" % "google-cloud-spanner-jdbc" % "1.20.1"
+libraryDependencies += "com.google.cloud" % "google-cloud-spanner-jdbc" % "1.21.0"
 ```
 
 ## Authentication
diff --git a/clirr-ignored-differences.xml b/clirr-ignored-differences.xml
index 7e24377..ac9c147 100644
--- a/clirr-ignored-differences.xml
+++ b/clirr-ignored-differences.xml
@@ -94,4 +94,21 @@
     <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
     <method>void setTransactionMode(com.google.cloud.spanner.connection.TransactionMode)</method>
   </difference>
+  
+  <!-- Commit stats -->
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>com.google.cloud.spanner.CommitResponse getCommitResponse()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>boolean isReturnCommitStats()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>void setReturnCommitStats(boolean)</method>
+  </difference>
 </differences>
diff --git a/pom.xml b/pom.xml
index ba601e9..d787d0a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <artifactId>google-cloud-spanner-jdbc</artifactId>
-  <version>1.20.2-SNAPSHOT</version><!-- {x-version-update:google-cloud-spanner-jdbc:current} -->
+  <version>1.21.0</version><!-- {x-version-update:google-cloud-spanner-jdbc:current} -->
   <packaging>jar</packaging>
   <name>Google Cloud Spanner JDBC</name>
   <url>https://github.com/googleapis/java-spanner-jdbc</url>
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index b9af5cd..3aed797 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -28,7 +28,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-spanner-jdbc</artifactId>
-      <version>1.20.2-SNAPSHOT</version>
+      <version>1.21.0</version>
     </dependency>
     <!-- {x-version-update-end} -->
 
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index fbdf508..f777289 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -30,7 +30,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>19.0.0</version>
+        <version>19.1.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java
index bbd5975..0940997 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcPreparedStatement.java
@@ -29,7 +29,6 @@ import java.sql.NClob;
 import java.sql.PreparedStatement;
 import java.sql.Ref;
 import java.sql.ResultSet;
-import java.sql.ResultSetMetaData;
 import java.sql.RowId;
 import java.sql.SQLException;
 import java.sql.SQLXML;
@@ -232,14 +231,6 @@ abstract class AbstractJdbcPreparedStatement extends JdbcStatement implements Pr
     parameters.setParameter(parameterIndex, value, Types.ARRAY);
   }
 
-  @Override
-  public ResultSetMetaData getMetaData() throws SQLException {
-    checkClosed();
-    try (ResultSet rs = executeQuery()) {
-      return rs.getMetaData();
-    }
-  }
-
   @Override
   public void setDate(int parameterIndex, Date value, Calendar cal) throws SQLException {
     checkClosed();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
index 48a002d..5217396 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
@@ -18,6 +18,7 @@ package com.google.cloud.spanner.jdbc;
 
 import com.google.cloud.spanner.Options;
 import com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode;
 import com.google.cloud.spanner.SpannerException;
 import com.google.cloud.spanner.connection.StatementResult;
 import com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType;
@@ -147,6 +148,20 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
     }
   }
 
+  /**
+   * Executes a SQL statement on the connection of this {@link Statement} as a query using the given
+   * {@link QueryAnalyzeMode}.
+   *
+   * @param statement the SQL statement to executed
+   * @param analyzeMode the {@link QueryAnalyzeMode} to use
+   * @return the result of the SQL statement as a {@link ResultSet}
+   * @throws SQLException if a database error occurs.
+   */
+  ResultSet analyzeQuery(com.google.cloud.spanner.Statement statement, QueryAnalyzeMode analyzeMode)
+      throws SQLException {
+    return executeQuery(statement, analyzeMode);
+  }
+
   /**
    * Executes a SQL statement on the connection of this {@link Statement} as a query.
    *
@@ -157,11 +172,24 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
    */
   ResultSet executeQuery(com.google.cloud.spanner.Statement statement, QueryOption... options)
       throws SQLException {
+    return executeQuery(statement, null, options);
+  }
+
+  private ResultSet executeQuery(
+      com.google.cloud.spanner.Statement statement,
+      QueryAnalyzeMode analyzeMode,
+      QueryOption... options)
+      throws SQLException {
     StatementTimeout originalTimeout = setTemporaryStatementTimeout();
     try {
-      return JdbcResultSet.of(
-          this,
-          connection.getSpannerConnection().executeQuery(statement, getQueryOptions(options)));
+      com.google.cloud.spanner.ResultSet resultSet;
+      if (analyzeMode == null) {
+        resultSet =
+            connection.getSpannerConnection().executeQuery(statement, getQueryOptions(options));
+      } else {
+        resultSet = connection.getSpannerConnection().analyzeQuery(statement, analyzeMode);
+      }
+      return JdbcResultSet.of(this, resultSet);
     } catch (SpannerException e) {
       throw JdbcSqlExceptionFactory.of(e);
     } finally {
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
index f13a64f..4a5f001 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
@@ -17,6 +17,8 @@
 package com.google.cloud.spanner.jdbc;
 
 import com.google.cloud.spanner.AbortedException;
+import com.google.cloud.spanner.CommitResponse;
+import com.google.cloud.spanner.CommitStats;
 import com.google.cloud.spanner.Mutation;
 import com.google.cloud.spanner.ResultSet;
 import com.google.cloud.spanner.TimestampBound;
@@ -152,6 +154,24 @@ public interface CloudSpannerJdbcConnection extends Connection {
    */
   Timestamp getCommitTimestamp() throws SQLException;
 
+  /**
+   * @return the {@link CommitResponse} of the last read/write transaction. If the last transaction
+   *     was not a read/write transaction, or a read/write transaction that did not return a {@link
+   *     CommitResponse} because the transaction was not committed, the method will throw a {@link
+   *     SQLException}. The {@link CommitResponse} will include {@link CommitStats} if {@link
+   *     #isReturnCommitStats()} returns true.
+   */
+  CommitResponse getCommitResponse() throws SQLException;
+
+  /**
+   * Sets whether this connection should request commit statistics from Cloud Spanner for read/write
+   * transactions and for DML statements in autocommit mode.
+   */
+  void setReturnCommitStats(boolean returnCommitStats) throws SQLException;
+
+  /** @return true if this connection requests commit statistics from Cloud Spanner. */
+  boolean isReturnCommitStats() throws SQLException;
+
   /**
    * @return the read {@link Timestamp} of the last read-only transaction. If the last transaction
    *     was not a read-only transaction, or a read-only transaction that did not return a read
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
index 6c23db4..bf9120f 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
@@ -17,6 +17,7 @@
 package com.google.cloud.spanner.jdbc;
 
 import com.google.api.client.util.Preconditions;
+import com.google.cloud.spanner.CommitResponse;
 import com.google.cloud.spanner.Mutation;
 import com.google.cloud.spanner.SpannerException;
 import com.google.cloud.spanner.TimestampBound;
@@ -381,6 +382,36 @@ class JdbcConnection extends AbstractJdbcConnection {
     }
   }
 
+  @Override
+  public CommitResponse getCommitResponse() throws SQLException {
+    checkClosed();
+    try {
+      return getSpannerConnection().getCommitResponse();
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
+  @Override
+  public void setReturnCommitStats(boolean returnCommitStats) throws SQLException {
+    checkClosed();
+    try {
+      getSpannerConnection().setReturnCommitStats(returnCommitStats);
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
+  @Override
+  public boolean isReturnCommitStats() throws SQLException {
+    checkClosed();
+    try {
+      return getSpannerConnection().isReturnCommitStats();
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
   @Override
   public Timestamp getReadTimestamp() throws SQLException {
     checkClosed();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
index 7a0e842..d8eeb62 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
@@ -17,11 +17,17 @@
 package com.google.cloud.spanner.jdbc;
 
 import com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode;
+import com.google.cloud.spanner.ResultSets;
 import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.Struct;
+import com.google.cloud.spanner.Type;
 import com.google.cloud.spanner.connection.StatementParser;
 import com.google.cloud.spanner.jdbc.JdbcParameterStore.ParametersInfo;
+import com.google.common.collect.ImmutableList;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
 
 /** Implementation of {@link PreparedStatement} for Cloud Spanner. */
@@ -86,4 +92,20 @@ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement {
     checkClosed();
     return new JdbcParameterMetaData(this);
   }
+
+  @Override
+  public ResultSetMetaData getMetaData() throws SQLException {
+    checkClosed();
+    if (StatementParser.INSTANCE.isUpdateStatement(sql)) {
+      // Return metadata for an empty result set as DML statements do not return any results (as a
+      // result set).
+      com.google.cloud.spanner.ResultSet resultSet =
+          ResultSets.forRows(Type.struct(), ImmutableList.<Struct>of());
+      resultSet.next();
+      return new JdbcResultSetMetaData(JdbcResultSet.of(resultSet), this);
+    }
+    try (ResultSet rs = analyzeQuery(createStatement(), QueryAnalyzeMode.PLAN)) {
+      return rs.getMetaData();
+    }
+  }
 }
diff --git a/synth.metadata b/synth.metadata
index 507032d..ac98e78 100644
--- a/synth.metadata
+++ b/synth.metadata
@@ -4,14 +4,14 @@
       "git": {
         "name": ".",
         "remote": "https://github.com/googleapis/java-spanner-jdbc.git",
-        "sha": "f18abc9361c1f3535c5fdec8eee45dd1fb821334"
+        "sha": "b32e7aebd4c8d24d052e4616b5dd7735878e01c3"
       }
     },
     {
       "git": {
         "name": "synthtool",
         "remote": "https://github.com/googleapis/synthtool.git",
-        "sha": "d0bdade9a962042dc0f770cf631086f3db59b5b0"
+        "sha": "0b064d767537e0675fc053e53fca473c5c701fb8"
       }
     }
   ],
diff --git a/versions.txt b/versions.txt
index 4ac31ec..d9d8cbb 100644
--- a/versions.txt
+++ b/versions.txt
@@ -1,4 +1,4 @@
 # Format:
 # module:released-version:current-version
 
-google-cloud-spanner-jdbc:1.20.1:1.20.2-SNAPSHOT
+google-cloud-spanner-jdbc:1.21.0:1.21.0


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcCommitStatsTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcCommitStatsTest.java
new file mode 100644
index 0000000..0782566
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcCommitStatsTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.jdbc;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.CommitResponse;
+import com.google.cloud.spanner.Mutation;
+import com.google.cloud.spanner.connection.AbstractMockServerTest;
+import com.google.cloud.spanner.connection.SpannerPool;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class JdbcCommitStatsTest extends AbstractMockServerTest {
+
+  @After
+  public void closeSpannerPool() {
+    SpannerPool.closeSpannerPool();
+  }
+
+  @Test
+  public void testDefaultReturnCommitStats() throws SQLException {
+    try (java.sql.Connection connection = createJdbcConnection()) {
+      try (java.sql.ResultSet rs =
+          connection.createStatement().executeQuery("SHOW VARIABLE RETURN_COMMIT_STATS")) {
+        assertTrue(rs.next());
+        assertFalse(rs.getBoolean("RETURN_COMMIT_STATS"));
+        assertFalse(rs.next());
+      }
+    }
+  }
+
+  @Test
+  public void testReturnCommitStatsInConnectionUrl() throws SQLException {
+    try (java.sql.Connection connection =
+        DriverManager.getConnection(
+            String.format("jdbc:%s;returnCommitStats=true", getBaseUrl()))) {
+      try (java.sql.ResultSet rs =
+          connection.createStatement().executeQuery("SHOW VARIABLE RETURN_COMMIT_STATS")) {
+        assertTrue(rs.next());
+        assertTrue(rs.getBoolean("RETURN_COMMIT_STATS"));
+        assertFalse(rs.next());
+      }
+    }
+  }
+
+  @Test
+  public void testSetReturnCommitStats() throws SQLException {
+    try (java.sql.Connection connection = createJdbcConnection()) {
+      connection.createStatement().execute("SET RETURN_COMMIT_STATS=true");
+      try (java.sql.ResultSet rs =
+          connection.createStatement().executeQuery("SHOW VARIABLE RETURN_COMMIT_STATS")) {
+        assertTrue(rs.next());
+        assertTrue(rs.getBoolean("RETURN_COMMIT_STATS"));
+        assertFalse(rs.next());
+      }
+      connection.createStatement().execute("SET RETURN_COMMIT_STATS=false");
+      try (java.sql.ResultSet rs =
+          connection.createStatement().executeQuery("SHOW VARIABLE RETURN_COMMIT_STATS")) {
+        assertTrue(rs.next());
+        assertFalse(rs.getBoolean("RETURN_COMMIT_STATS"));
+        assertFalse(rs.next());
+      }
+    }
+  }
+
+  @Test
+  public void testSetAndUseReturnCommitStats() throws SQLException {
+    try (CloudSpannerJdbcConnection connection =
+        createJdbcConnection().unwrap(CloudSpannerJdbcConnection.class)) {
+      connection.setReturnCommitStats(true);
+      connection.bufferedWrite(Mutation.newInsertBuilder("FOO").set("ID").to(1L).build());
+      connection.commit();
+      CommitResponse response = connection.getCommitResponse();
+      assertNotNull(response);
+      assertNotNull(response.getCommitStats());
+      assertThat(response.getCommitStats().getMutationCount()).isAtLeast(1);
+    }
+  }
+
+  @Test
+  public void testSetAndUseReturnCommitStatsUsingSql() throws SQLException {
+    try (java.sql.Connection connection = createJdbcConnection()) {
+      connection.createStatement().execute("SET RETURN_COMMIT_STATS=true");
+      // Use a Mutation as the mock server only returns a non-zero mutation count for mutations, and
+      // not for DML statements.
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .bufferedWrite(Mutation.newInsertBuilder("FOO").set("ID").to(1L).build());
+      connection.commit();
+      try (ResultSet rs =
+          connection.createStatement().executeQuery("SHOW VARIABLE COMMIT_RESPONSE")) {
+        assertTrue(rs.next());
+        assertNotNull(rs.getTimestamp("COMMIT_TIMESTAMP"));
+        assertThat(rs.getLong("MUTATION_COUNT")).isAtLeast(1L);
+        assertFalse(rs.next());
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java
index 27389b4..cd0f944 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcConnectionTest.java
@@ -18,7 +18,11 @@ package com.google.cloud.spanner.jdbc;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.mockito.Matchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -51,6 +55,7 @@ import java.util.concurrent.Executor;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
 
 @RunWith(JUnit4.class)
 public class JdbcConnectionTest {
@@ -277,6 +282,14 @@ public class JdbcConnectionTest {
         "releaseSavepoint",
         new Class<?>[] {Savepoint.class},
         new Object[] {null});
+
+    testClosed(CloudSpannerJdbcConnection.class, "isReturnCommitStats");
+    testClosed(
+        CloudSpannerJdbcConnection.class,
+        "setReturnCommitStats",
+        new Class<?>[] {boolean.class},
+        new Object[] {true});
+    testClosed(CloudSpannerJdbcConnection.class, "getCommitResponse");
   }
 
   private void testClosed(Class<? extends Connection> clazz, String name)
@@ -705,4 +718,81 @@ public class JdbcConnectionTest {
       }
     }
   }
+
+  @Test
+  public void testIsReturnCommitStats() throws SQLException {
+    try (JdbcConnection connection = createConnection(mock(ConnectionOptions.class))) {
+      assertFalse(connection.isReturnCommitStats());
+      connection.setReturnCommitStats(true);
+      assertTrue(connection.isReturnCommitStats());
+    }
+  }
+
+  @Test
+  public void testIsReturnCommitStats_throwsSqlException() throws SQLException {
+    ConnectionOptions options = mock(ConnectionOptions.class);
+    com.google.cloud.spanner.connection.Connection spannerConnection =
+        mock(com.google.cloud.spanner.connection.Connection.class);
+    when(options.getConnection()).thenReturn(spannerConnection);
+    when(spannerConnection.isReturnCommitStats())
+        .thenThrow(
+            SpannerExceptionFactory.newSpannerException(
+                ErrorCode.FAILED_PRECONDITION, "test exception"));
+    try (JdbcConnection connection =
+        new JdbcConnection(
+            "jdbc:cloudspanner://localhost/projects/project/instances/instance/databases/database;credentialsUrl=url",
+            options)) {
+      connection.isReturnCommitStats();
+      fail("missing expected exception");
+    } catch (SQLException e) {
+      assertTrue(e instanceof JdbcSqlException);
+      assertEquals(Code.FAILED_PRECONDITION, ((JdbcSqlException) e).getCode());
+    }
+  }
+
+  @Test
+  public void testSetReturnCommitStats_throwsSqlException() throws SQLException {
+    ConnectionOptions options = mock(ConnectionOptions.class);
+    com.google.cloud.spanner.connection.Connection spannerConnection =
+        mock(com.google.cloud.spanner.connection.Connection.class);
+    when(options.getConnection()).thenReturn(spannerConnection);
+    Mockito.doThrow(
+            SpannerExceptionFactory.newSpannerException(
+                ErrorCode.FAILED_PRECONDITION, "test exception"))
+        .when(spannerConnection)
+        .setReturnCommitStats(any(boolean.class));
+    try (JdbcConnection connection =
+        new JdbcConnection(
+            "jdbc:cloudspanner://localhost/projects/project/instances/instance/databases/database;credentialsUrl=url",
+            options)) {
+      connection.setReturnCommitStats(true);
+      fail("missing expected exception");
+    } catch (SQLException e) {
+      assertTrue(e instanceof JdbcSqlException);
+      assertEquals(Code.FAILED_PRECONDITION, ((JdbcSqlException) e).getCode());
+    }
+  }
+
+  @Test
+  public void testGetCommitResponse_throwsSqlException() throws SQLException {
+    ConnectionOptions options = mock(ConnectionOptions.class);
+    com.google.cloud.spanner.connection.Connection spannerConnection =
+        mock(com.google.cloud.spanner.connection.Connection.class);
+    when(options.getConnection()).thenReturn(spannerConnection);
+    Mockito.doThrow(
+            SpannerExceptionFactory.newSpannerException(
+                ErrorCode.FAILED_PRECONDITION, "test exception"))
+        .when(spannerConnection)
+        .setReturnCommitStats(any(boolean.class));
+    try (JdbcConnection connection =
+        new JdbcConnection(
+            "jdbc:cloudspanner://localhost/projects/project/instances/instance/databases/database;credentialsUrl=url",
+            options)) {
+      connection.setReturnCommitStats(true);
+      fail("missing expected exception");
+    } catch (SQLException e) {
+      assertTrue(e instanceof JdbcSqlException);
+      assertEquals(Code.FAILED_PRECONDITION, ((JdbcSqlException) e).getCode());
+    }
+  }
 }
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java
index 85334b2..5a1235f 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatementTest.java
@@ -25,6 +25,7 @@ import static org.mockito.Matchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode;
 import com.google.cloud.spanner.ResultSet;
 import com.google.cloud.spanner.ResultSets;
 import com.google.cloud.spanner.Statement;
@@ -293,7 +294,7 @@ public class JdbcPreparedStatementTest {
                     .set("AMOUNT")
                     .to(Math.PI)
                     .build()));
-    when(connection.executeQuery(Statement.of(sql))).thenReturn(rs);
+    when(connection.analyzeQuery(Statement.of(sql), QueryAnalyzeMode.PLAN)).thenReturn(rs);
     try (JdbcPreparedStatement ps =
         new JdbcPreparedStatement(createMockConnection(connection), sql)) {
       ResultSetMetaData metadata = ps.getMetaData();
@@ -306,4 +307,15 @@ public class JdbcPreparedStatementTest {
       assertEquals(Types.DOUBLE, metadata.getColumnType(3));
     }
   }
+
+  @Test
+  public void testGetResultSetMetaDataForDml() throws SQLException {
+    Connection connection = mock(Connection.class);
+    try (JdbcPreparedStatement ps =
+        new JdbcPreparedStatement(
+            createMockConnection(connection), "UPDATE FOO SET BAR=1 WHERE TRUE")) {
+      ResultSetMetaData metadata = ps.getMetaData();
+      assertEquals(0, metadata.getColumnCount());
+    }
+  }
 }
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java
index b4e02a9..2890ed9 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/it/ITJdbcPreparedStatementTest.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.spanner.jdbc.it;
 
+import static com.google.cloud.spanner.testing.EmulatorSpannerHelper.isUsingEmulator;
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -23,11 +24,12 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
 
-import com.google.api.client.util.Base64;
 import com.google.cloud.spanner.IntegrationTest;
 import com.google.cloud.spanner.jdbc.ITAbstractJdbcTest;
 import com.google.common.base.Strings;
+import com.google.common.io.BaseEncoding;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.StringReader;
@@ -38,6 +40,7 @@ import java.sql.Date;
 import java.sql.ParameterMetaData;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.sql.Timestamp;
@@ -222,7 +225,7 @@ public class ITJdbcPreparedStatementTest extends ITAbstractJdbcTest {
   }
 
   private static byte[] parseBytes(String value) {
-    return Base64.decodeBase64(value);
+    return BaseEncoding.base64().decode(value);
   }
 
   private List<Singer> createSingers() {
@@ -825,6 +828,53 @@ public class ITJdbcPreparedStatementTest extends ITAbstractJdbcTest {
     }
   }
 
+  @Test
+  public void test09_MetaData_FromQuery() throws SQLException {
+    assumeFalse("The emulator does not support PLAN mode", isUsingEmulator());
+    try (Connection con = createConnection()) {
+      try (PreparedStatement ps =
+          con.prepareStatement("SELECT * FROM TableWithAllColumnTypes WHERE ColInt64=?")) {
+        ResultSetMetaData metadata = ps.getMetaData();
+        assertEquals(22, metadata.getColumnCount());
+        int index = 0;
+        assertEquals("ColInt64", metadata.getColumnLabel(++index));
+        assertEquals("ColFloat64", metadata.getColumnLabel(++index));
+        assertEquals("ColBool", metadata.getColumnLabel(++index));
+        assertEquals("ColString", metadata.getColumnLabel(++index));
+        assertEquals("ColStringMax", metadata.getColumnLabel(++index));
+        assertEquals("ColBytes", metadata.getColumnLabel(++index));
+        assertEquals("ColBytesMax", metadata.getColumnLabel(++index));
+        assertEquals("ColDate", metadata.getColumnLabel(++index));
+        assertEquals("ColTimestamp", metadata.getColumnLabel(++index));
+        assertEquals("ColCommitTS", metadata.getColumnLabel(++index));
+        assertEquals("ColNumeric", metadata.getColumnLabel(++index));
+        assertEquals("ColInt64Array", metadata.getColumnLabel(++index));
+        assertEquals("ColFloat64Array", metadata.getColumnLabel(++index));
+        assertEquals("ColBoolArray", metadata.getColumnLabel(++index));
+        assertEquals("ColStringArray", metadata.getColumnLabel(++index));
+        assertEquals("ColStringMaxArray", metadata.getColumnLabel(++index));
+        assertEquals("ColBytesArray", metadata.getColumnLabel(++index));
+        assertEquals("ColBytesMaxArray", metadata.getColumnLabel(++index));
+        assertEquals("ColDateArray", metadata.getColumnLabel(++index));
+        assertEquals("ColTimestampArray", metadata.getColumnLabel(++index));
+        assertEquals("ColNumericArray", metadata.getColumnLabel(++index));
+        assertEquals("ColComputed", metadata.getColumnLabel(++index));
+      }
+    }
+  }
+
+  @Test
+  public void test10_MetaData_FromDML() throws SQLException {
+    try (Connection con = createConnection()) {
+      try (PreparedStatement ps =
+          con.prepareStatement(
+              "UPDATE TableWithAllColumnTypes SET ColBool=FALSE WHERE ColInt64=?")) {
+        ResultSetMetaData metadata = ps.getMetaData();
+        assertEquals(0, metadata.getColumnCount());
+      }
+    }
+  }
+
   private void assertDefaultParameterMetaData(ParameterMetaData pmd, int expectedParamCount)
       throws SQLException {
     assertEquals(expectedParamCount, pmd.getParameterCount());