Repository: googleapis/java-spanner-jdbc
Pull request #1238 (2023-05-23 05:36:53)

build(deps): bump requests from 2.27.1 to 2.31.0 in /.kokoro
Bumps [requests](https://github.com/psf/requests) from 2.27.1 to 2.31.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/psf/requests/releases">requests's releases</a>.</em></p>
<blockquote>
<h2>v2.31.0</h2>
<h2>2.31.0 (2023-05-22)</h2>
<p><strong>Security</strong></p>
<ul>
<li>
<p>Versions of Requests between v2.3.0 and v2.30.0 are vulnerable to potential
forwarding of <code>Proxy-Authorization</code> headers to destination servers when
following HTTPS redirects.</p>
<p>When proxies are defined with user info (<a href="https://user:pass@proxy:8080">https://user:pass@proxy:8080</a>), Requests
will construct a <code>Proxy-Authorization</code> header that is attached to the request to
authenticate with the proxy.</p>
<p>In cases where Requests receives a redirect response, it previously reattached
the <code>Proxy-Authorization</code> header incorrectly, resulting in the value being
sent through the tunneled connection to the destination server. Users who rely on
defining their proxy credentials in the URL are <em>strongly</em> encouraged to upgrade
to Requests 2.31.0+ to prevent unintentional leakage and rotate their proxy
credentials once the change has been fully deployed.</p>
<p>Users who do not use a proxy or do not supply their proxy credentials through
the user information portion of their proxy URL are not subject to this
vulnerability.</p>
<p>Full details can be read in our <a href="https://github.com/psf/requests/security/advisories/GHSA-j8r2-6x86-q33q">Github Security Advisory</a>
and <a href="https://nvd.nist.gov/vuln/detail/CVE-2023-32681">CVE-2023-32681</a>.</p>
</li>
</ul>
<h2>v2.30.0</h2>
<h2>2.30.0 (2023-05-03)</h2>
<p><strong>Dependencies</strong></p>
<ul>
<li>
<p>⚠️ Added support for urllib3 2.0. ⚠️</p>
<p>This may contain minor breaking changes so we advise careful testing and
reviewing <a href="https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html">https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html</a>
prior to upgrading.</p>
<p>Users who wish to stay on urllib3 1.x can pin to <code>urllib3&lt;2</code>.</p>
</li>
</ul>
<h2>v2.29.0</h2>
<h2>2.29.0 (2023-04-26)</h2>
<p><strong>Improvements</strong></p>
<ul>
<li>Requests now defers chunked requests to the urllib3 implementation to improve
standardization. (<a href="https://redirect.github.com/psf/requests/issues/6226">#6226</a>)</li>
<li>Requests relaxes header component requirements to support bytes/str subclasses. (<a href="https://redirect.github.com/psf/requests/issues/6356">#6356</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/psf/requests/blob/main/HISTORY.md">requests's changelog</a>.</em></p>
<blockquote>
<h2>2.31.0 (2023-05-22)</h2>
<p><strong>Security</strong></p>
<ul>
<li>
<p>Versions of Requests between v2.3.0 and v2.30.0 are vulnerable to potential
forwarding of <code>Proxy-Authorization</code> headers to destination servers when
following HTTPS redirects.</p>
<p>When proxies are defined with user info (<a href="https://user:pass@proxy:8080">https://user:pass@proxy:8080</a>), Requests
will construct a <code>Proxy-Authorization</code> header that is attached to the request to
authenticate with the proxy.</p>
<p>In cases where Requests receives a redirect response, it previously reattached
the <code>Proxy-Authorization</code> header incorrectly, resulting in the value being
sent through the tunneled connection to the destination server. Users who rely on
defining their proxy credentials in the URL are <em>strongly</em> encouraged to upgrade
to Requests 2.31.0+ to prevent unintentional leakage and rotate their proxy
credentials once the change has been fully deployed.</p>
<p>Users who do not use a proxy or do not supply their proxy credentials through
the user information portion of their proxy URL are not subject to this
vulnerability.</p>
<p>Full details can be read in our <a href="https://github.com/psf/requests/security/advisories/GHSA-j8r2-6x86-q33q">Github Security Advisory</a>
and <a href="https://nvd.nist.gov/vuln/detail/CVE-2023-32681">CVE-2023-32681</a>.</p>
</li>
</ul>
<h2>2.30.0 (2023-05-03)</h2>
<p><strong>Dependencies</strong></p>
<ul>
<li>
<p>⚠️ Added support for urllib3 2.0. ⚠️</p>
<p>This may contain minor breaking changes so we advise careful testing and
reviewing <a href="https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html">https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html</a>
prior to upgrading.</p>
<p>Users who wish to stay on urllib3 1.x can pin to <code>urllib3&lt;2</code>.</p>
</li>
</ul>
<h2>2.29.0 (2023-04-26)</h2>
<p><strong>Improvements</strong></p>
<ul>
<li>Requests now defers chunked requests to the urllib3 implementation to improve
standardization. (<a href="https://redirect.github.com/psf/requests/issues/6226">#6226</a>)</li>
<li>Requests relaxes header component requirements to support bytes/str subclasses. (<a href="https://redirect.github.com/psf/requests/issues/6356">#6356</a>)</li>
</ul>
<h2>2.28.2 (2023-01-12)</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/psf/requests/commit/147c8511ddbfa5e8f71bbf5c18ede0c4ceb3bba4"><code>147c851</code></a> v2.31.0</li>
<li><a href="https://github.com/psf/requests/commit/74ea7cf7a6a27a4eeb2ae24e162bcc942a6706d5"><code>74ea7cf</code></a> Merge pull request from GHSA-j8r2-6x86-q33q</li>
<li><a href="https://github.com/psf/requests/commit/302225334678490ec66b3614a9dddb8a02c5f4fe"><code>3022253</code></a> test on pypy 3.8 and pypy 3.9 on windows and macos (<a href="https://redirect.github.com/psf/requests/issues/6424">#6424</a>)</li>
<li><a href="https://github.com/psf/requests/commit/b639e66c816514e40604d46f0088fbceec1a5149"><code>b639e66</code></a> test on py3.12 (<a href="https://redirect.github.com/psf/requests/issues/6448">#6448</a>)</li>
<li><a href="https://github.com/psf/requests/commit/d3d504436ef0c2ac7ec8af13738b04dcc8c694be"><code>d3d5044</code></a> Fixed a small typo (<a href="https://redirect.github.com/psf/requests/issues/6452">#6452</a>)</li>
<li><a href="https://github.com/psf/requests/commit/2ad18e0e10e7d7ecd5384c378f25ec8821a10a29"><code>2ad18e0</code></a> v2.30.0</li>
<li><a href="https://github.com/psf/requests/commit/f2629e9e3c7ce3c3c8c025bcd8db551101cbc773"><code>f2629e9</code></a> Remove strict parameter (<a href="https://redirect.github.com/psf/requests/issues/6434">#6434</a>)</li>
<li><a href="https://github.com/psf/requests/commit/87d63de8739263bbe17034fba2285c79780da7e8"><code>87d63de</code></a> v2.29.0</li>
<li><a href="https://github.com/psf/requests/commit/51716c4ef390136b0d4b800ec7665dd5503e64fc"><code>51716c4</code></a> enable the warnings plugin (<a href="https://redirect.github.com/psf/requests/issues/6416">#6416</a>)</li>
<li><a href="https://github.com/psf/requests/commit/a7da1ab3498b10ec3a3582244c94b2845f8a8e71"><code>a7da1ab</code></a> try on ubuntu 22.04 (<a href="https://redirect.github.com/psf/requests/issues/6418">#6418</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/psf/requests/compare/v2.27.1...v2.31.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=requests&package-manager=pip&previous-version=2.27.1&new-version=2.31.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/googleapis/java-spanner-jdbc/network/alerts).

</details>


Patch:
diff --git a/clirr-ignored-differences.xml b/clirr-ignored-differences.xml
index 235cb90..a92ad53 100644
--- a/clirr-ignored-differences.xml
+++ b/clirr-ignored-differences.xml
@@ -6,4 +6,14 @@
     <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
     <method>com.google.cloud.spanner.Dialect getDialect()</method>
   </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>com.google.cloud.spanner.connection.SavepointSupport getSavepointSupport()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>void setSavepointSupport(com.google.cloud.spanner.connection.SavepointSupport)</method>
+  </difference>
 </differences>
diff --git a/pom.xml b/pom.xml
index 9390393..0db72ba 100644
--- a/pom.xml
+++ b/pom.xml
@@ -69,7 +69,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-shared-dependencies</artifactId>
-        <version>3.9.0</version>
+        <version>3.10.1</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
@@ -393,7 +393,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-project-info-reports-plugin</artifactId>
-        <version>3.4.3</version>
+        <version>3.4.4</version>
         <reportSets>
           <reportSet>
             <reports>
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index 88f873d..f50aa3e 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -29,7 +29,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-spanner-jdbc</artifactId>
-      <version>2.9.14</version>
+      <version>2.9.16</version>
     </dependency>
     <!-- [END spanner-jdbc_install_without_bom] -->
 
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index fcbbc8e..fa6b15a 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>26.14.0</version>
+        <version>26.15.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
index 1a6cfe2..33cf3bc 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcConnection.java
@@ -28,7 +28,6 @@ import java.sql.SQLClientInfoException;
 import java.sql.SQLException;
 import java.sql.SQLWarning;
 import java.sql.SQLXML;
-import java.sql.Savepoint;
 import java.sql.Struct;
 import java.util.Properties;
 import java.util.concurrent.Executor;
@@ -42,7 +41,6 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper
       "Only isolation level TRANSACTION_SERIALIZABLE is supported";
   private static final String ONLY_CLOSE_ALLOWED =
       "Only holdability CLOSE_CURSORS_AT_COMMIT is supported";
-  private static final String SAVEPOINTS_UNSUPPORTED = "Savepoints are not supported";
   private static final String SQLXML_UNSUPPORTED = "SQLXML is not supported";
   private static final String STRUCTS_UNSUPPORTED = "Structs are not supported";
   private static final String ABORT_UNSUPPORTED = "Abort is not supported";
@@ -163,26 +161,6 @@ abstract class AbstractJdbcConnection extends AbstractJdbcWrapper
     lastWarning = null;
   }
 
-  @Override
-  public Savepoint setSavepoint() throws SQLException {
-    return checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
-  }
-
-  @Override
-  public Savepoint setSavepoint(String name) throws SQLException {
-    return checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
-  }
-
-  @Override
-  public void rollback(Savepoint savepoint) throws SQLException {
-    checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
-  }
-
-  @Override
-  public void releaseSavepoint(Savepoint savepoint) throws SQLException {
-    checkClosedAndThrowUnsupported(SAVEPOINTS_UNSUPPORTED);
-  }
-
   @Override
   public SQLXML createSQLXML() throws SQLException {
     return checkClosedAndThrowUnsupported(SQLXML_UNSUPPORTED);
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 679fa87..588e8e7 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
@@ -25,6 +25,7 @@ import com.google.cloud.spanner.Mutation;
 import com.google.cloud.spanner.ResultSet;
 import com.google.cloud.spanner.TimestampBound;
 import com.google.cloud.spanner.connection.AutocommitDmlMode;
+import com.google.cloud.spanner.connection.SavepointSupport;
 import com.google.cloud.spanner.connection.TransactionMode;
 import java.sql.Connection;
 import java.sql.SQLException;
@@ -256,6 +257,12 @@ public interface CloudSpannerJdbcConnection extends Connection {
    */
   void setRetryAbortsInternally(boolean retryAbortsInternally) throws SQLException;
 
+  /** Returns the current savepoint support for this connection. */
+  SavepointSupport getSavepointSupport() throws SQLException;
+
+  /** Sets how savepoints should be supported on this connection. */
+  void setSavepointSupport(SavepointSupport savepointSupport) throws SQLException;
+
   /**
    * Writes the specified mutation directly to the database and commits the change. The value is
    * readable after the successful completion of this method. Writing multiple mutations to a
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 867695f..a710ac8 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
@@ -23,6 +23,7 @@ import com.google.cloud.spanner.SpannerException;
 import com.google.cloud.spanner.TimestampBound;
 import com.google.cloud.spanner.connection.AutocommitDmlMode;
 import com.google.cloud.spanner.connection.ConnectionOptions;
+import com.google.cloud.spanner.connection.SavepointSupport;
 import com.google.cloud.spanner.connection.TransactionMode;
 import com.google.common.collect.Iterators;
 import java.sql.Array;
@@ -33,6 +34,7 @@ import java.sql.NClob;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.sql.Savepoint;
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.util.HashMap;
@@ -402,6 +404,70 @@ class JdbcConnection extends AbstractJdbcConnection {
     return "";
   }
 
+  @Override
+  public SavepointSupport getSavepointSupport() throws SQLException {
+    checkClosed();
+    return getSpannerConnection().getSavepointSupport();
+  }
+
+  @Override
+  public void setSavepointSupport(SavepointSupport savepointSupport) throws SQLException {
+    checkClosed();
+    try {
+      getSpannerConnection().setSavepointSupport(savepointSupport);
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
+  @Override
+  public Savepoint setSavepoint() throws SQLException {
+    checkClosed();
+    try {
+      JdbcSavepoint savepoint = JdbcSavepoint.unnamed();
+      getSpannerConnection().savepoint(savepoint.internalGetSavepointName());
+      return savepoint;
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
+  @Override
+  public Savepoint setSavepoint(String name) throws SQLException {
+    checkClosed();
+    try {
+      JdbcSavepoint savepoint = JdbcSavepoint.named(name);
+      getSpannerConnection().savepoint(savepoint.internalGetSavepointName());
+      return savepoint;
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
+  @Override
+  public void rollback(Savepoint savepoint) throws SQLException {
+    checkClosed();
+    JdbcPreconditions.checkArgument(savepoint instanceof JdbcSavepoint, savepoint);
+    JdbcSavepoint jdbcSavepoint = (JdbcSavepoint) savepoint;
+    try {
+      getSpannerConnection().rollbackToSavepoint(jdbcSavepoint.internalGetSavepointName());
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
+  @Override
+  public void releaseSavepoint(Savepoint savepoint) throws SQLException {
+    checkClosed();
+    JdbcPreconditions.checkArgument(savepoint instanceof JdbcSavepoint, savepoint);
+    JdbcSavepoint jdbcSavepoint = (JdbcSavepoint) savepoint;
+    try {
+      getSpannerConnection().releaseSavepoint(jdbcSavepoint.internalGetSavepointName());
+    } catch (SpannerException e) {
+      throw JdbcSqlExceptionFactory.of(e);
+    }
+  }
+
   @Override
   public Timestamp getCommitTimestamp() throws SQLException {
     checkClosed();
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcSavepoint.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcSavepoint.java
new file mode 100644
index 0000000..3cd4c41
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcSavepoint.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2023 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 java.sql.SQLException;
+import java.sql.Savepoint;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class JdbcSavepoint implements Savepoint {
+  private static final AtomicInteger COUNTER = new AtomicInteger();
+
+  static JdbcSavepoint named(String name) {
+    return new JdbcSavepoint(-1, name);
+  }
+
+  static JdbcSavepoint unnamed() {
+    int id = COUNTER.incrementAndGet();
+    return new JdbcSavepoint(id, String.format("s_%d", id));
+  }
+
+  private final int id;
+  private final String name;
+
+  private JdbcSavepoint(int id, String name) {
+    this.id = id;
+    this.name = name;
+  }
+
+  @Override
+  public int getSavepointId() throws SQLException {
+    JdbcPreconditions.checkState(this.id >= 0, "This is a named savepoint");
+    return id;
+  }
+
+  @Override
+  public String getSavepointName() throws SQLException {
+    JdbcPreconditions.checkState(this.id < 0, "This is an unnamed savepoint");
+    return name;
+  }
+
+  String internalGetSavepointName() {
+    return name;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/JdbcSavepointTest.java b/src/test/java/com/google/cloud/spanner/jdbc/JdbcSavepointTest.java
new file mode 100644
index 0000000..50b000b
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/JdbcSavepointTest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.SQLException;
+import java.sql.Savepoint;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class JdbcSavepointTest {
+
+  @Test
+  public void testNamed() throws SQLException {
+    Savepoint savepoint = JdbcSavepoint.named("test");
+    assertEquals("test", savepoint.getSavepointName());
+    assertThrows(SQLException.class, savepoint::getSavepointId);
+  }
+
+  @Test
+  public void testUnnamed() throws SQLException {
+    Savepoint savepoint = JdbcSavepoint.unnamed();
+    assertTrue(
+        String.format("Savepoint id: %d", savepoint.getSavepointId()),
+        savepoint.getSavepointId() > 0);
+    assertThrows(SQLException.class, savepoint::getSavepointName);
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/SavepointMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/SavepointMockServerTest.java
new file mode 100644
index 0000000..2c5d518
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/SavepointMockServerTest.java
@@ -0,0 +1,697 @@
+/*
+ * Copyright 2023 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.Dialect;
+import com.google.cloud.spanner.ErrorCode;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Mutation;
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.connection.AbstractMockServerTest;
+import com.google.cloud.spanner.connection.RandomResultSetGenerator;
+import com.google.cloud.spanner.connection.SavepointSupport;
+import com.google.cloud.spanner.connection.SpannerPool;
+import com.google.cloud.spanner.jdbc.JdbcSqlExceptionFactory.JdbcAbortedDueToConcurrentModificationException;
+import com.google.common.base.Strings;
+import com.google.protobuf.AbstractMessage;
+import com.google.spanner.v1.BeginTransactionRequest;
+import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteBatchDmlRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.RollbackRequest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Savepoint;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class SavepointMockServerTest extends AbstractMockServerTest {
+  @Parameter public Dialect dialect;
+
+  @Parameters(name = "dialect = {0}")
+  public static Object[] data() {
+    return Dialect.values();
+  }
+
+  @Before
+  public void setupDialect() {
+    mockSpanner.putStatementResult(StatementResult.detectDialectResult(this.dialect));
+  }
+
+  @After
+  public void clearRequests() {
+    mockSpanner.clearRequests();
+    SpannerPool.closeSpannerPool();
+  }
+
+  private String createUrl() {
+    return String.format(
+        "jdbc:cloudspanner://localhost:%d/projects/%s/instances/%s/databases/%s?usePlainText=true;autoCommit=false",
+        getPort(), "proj", "inst", "db");
+  }
+
+  private Connection createConnection() throws SQLException {
+    return DriverManager.getConnection(createUrl());
+  }
+
+  @Test
+  public void testCreateSavepoint() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setSavepoint("s1");
+
+      if (dialect == Dialect.POSTGRESQL) {
+        // PostgreSQL allows multiple savepoints with the same name.
+        connection.setSavepoint("s1");
+      } else {
+        assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+      }
+
+      // Test invalid identifiers.
+      assertThrows(SQLException.class, () -> connection.setSavepoint(null));
+      assertThrows(SQLException.class, () -> connection.setSavepoint(""));
+      assertThrows(SQLException.class, () -> connection.setSavepoint("1"));
+      assertThrows(SQLException.class, () -> connection.setSavepoint("-foo"));
+      assertThrows(SQLException.class, () -> connection.setSavepoint(Strings.repeat("t", 129)));
+    }
+  }
+
+  @Test
+  public void testCreateSavepointWhenDisabled() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.DISABLED);
+      assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+    }
+  }
+
+  @Test
+  public void testReleaseSavepoint() throws SQLException {
+    try (Connection connection = createConnection()) {
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.releaseSavepoint(s1);
+      assertThrows(SQLException.class, () -> connection.releaseSavepoint(s1));
+
+      Savepoint s1_2 = connection.setSavepoint("s1");
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.releaseSavepoint(s1_2);
+      // Releasing a savepoint also removes all savepoints after it.
+      assertThrows(SQLException.class, () -> connection.releaseSavepoint(s2));
+
+      if (dialect == Dialect.POSTGRESQL) {
+        // PostgreSQL allows multiple savepoints with the same name.
+        Savepoint savepoint1 = connection.setSavepoint("s1");
+        Savepoint savepoint2 = connection.setSavepoint("s2");
+        Savepoint savepoint1_2 = connection.setSavepoint("s1");
+        connection.releaseSavepoint(savepoint1_2);
+        connection.releaseSavepoint(savepoint2);
+        connection.releaseSavepoint(savepoint1);
+        assertThrows(SQLException.class, () -> connection.releaseSavepoint(savepoint1));
+      }
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepoint() throws SQLException {
+    for (SavepointSupport savepointSupport :
+        new SavepointSupport[] {SavepointSupport.ENABLED, SavepointSupport.FAIL_AFTER_ROLLBACK}) {
+      try (Connection connection = createConnection()) {
+        connection.unwrap(CloudSpannerJdbcConnection.class).setSavepointSupport(savepointSupport);
+
+        Savepoint s1 = connection.setSavepoint("s1");
+        connection.rollback(s1);
+        // Rolling back to a savepoint does not remove it, so we can roll back multiple times to the
+        // same savepoint.
+        connection.rollback(s1);
+
+        Savepoint s2 = connection.setSavepoint("s2");
+        connection.rollback(s1);
+        // Rolling back to a savepoint removes all savepoints after it.
+        assertThrows(SQLException.class, () -> connection.rollback(s2));
+
+        if (dialect == Dialect.POSTGRESQL) {
+          // PostgreSQL allows multiple savepoints with the same name.
+          Savepoint savepoint2 = connection.setSavepoint("s2");
+          Savepoint savepoint1 = connection.setSavepoint("s1");
+          connection.rollback(savepoint1);
+          connection.rollback(savepoint2);
+          connection.rollback(savepoint1);
+          connection.rollback(savepoint1);
+          connection.releaseSavepoint(savepoint1);
+          assertThrows(SQLException.class, () -> connection.rollback(savepoint1));
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testSavepointInAutoCommit() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(true);
+      assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+
+      // Starting a 'manual' transaction in autocommit mode should enable savepoints.
+      connection.createStatement().execute("begin transaction");
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.releaseSavepoint(s1);
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointInReadOnlyTransaction() throws SQLException {
+    for (SavepointSupport savepointSupport :
+        new SavepointSupport[] {SavepointSupport.ENABLED, SavepointSupport.FAIL_AFTER_ROLLBACK}) {
+      try (Connection connection = createConnection()) {
+        connection.unwrap(CloudSpannerJdbcConnection.class).setSavepointSupport(savepointSupport);
+        connection.setReadOnly(true);
+
+        // Read-only transactions also support savepoints, but they do not do anything. This feature
+        // is here purely for compatibility.
+        Savepoint s1 = connection.setSavepoint("s1");
+        try (ResultSet resultSet =
+            connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+          int count = 0;
+          while (resultSet.next()) {
+            count++;
+          }
+          assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+        }
+
+        connection.rollback(s1);
+        try (ResultSet resultSet =
+            connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+          int count = 0;
+          while (resultSet.next()) {
+            count++;
+          }
+          assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+        }
+        // Committing a read-only transaction is necessary to mark the end of the transaction.
+        // It is a no-op on Cloud Spanner.
+        connection.commit();
+
+        assertEquals(1, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+        BeginTransactionRequest beginRequest =
+            mockSpanner.getRequestsOfType(BeginTransactionRequest.class).get(0);
+        assertTrue(beginRequest.getOptions().hasReadOnly());
+        assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+      }
+      mockSpanner.clearRequests();
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointInReadWriteTransaction() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.ENABLED);
+
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+
+      connection.rollback(s1);
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+      connection.commit();
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(4, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithDmlStatements() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.ENABLED);
+
+      // First do a query that is included in the transaction.
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+      // Set a savepoint and execute a couple of DML statements.
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      // Rollback the last DML statement and commit.
+      connection.rollback(s2);
+
+      connection.commit();
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(5, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(7, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointFails() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.ENABLED);
+
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      // Set a savepoint and execute a couple of DML statements.
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      // Change the result of the initial query.
+      mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+      // Rollback to before the DML statements.
+      // This will succeed as long as we don't execute any further statements.
+      connection.rollback(s1);
+
+      // Trying to commit the transaction or execute any other statements on the transaction will
+      // fail.
+      assertThrows(JdbcAbortedDueToConcurrentModificationException.class, connection::commit);
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(2, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(4, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(6, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithFailAfterRollback() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.FAIL_AFTER_ROLLBACK);
+
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      // Set a savepoint and execute a couple of DML statements.
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      // Rollback to before the DML statements.
+      // This will succeed as long as we don't execute any further statements.
+      connection.rollback(s1);
+
+      // Trying to commit the transaction or execute any other statements on the transaction will
+      // fail with an FAILED_PRECONDITION error, as using a transaction after a rollback to
+      // savepoint has been disabled.
+      SQLException exception = assertThrows(SQLException.class, connection::commit);
+      assertEquals(
+          ErrorCode.FAILED_PRECONDITION.getGrpcStatusCode().value(), exception.getErrorCode());
+      assertEquals(
+          "FAILED_PRECONDITION: Using a read/write transaction after rolling back to a "
+              + "savepoint is not supported with SavepointSupport=FAIL_AFTER_ROLLBACK",
+          exception.getMessage());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointSucceedsWithRollback() throws SQLException {
+    for (SavepointSupport savepointSupport :
+        new SavepointSupport[] {SavepointSupport.ENABLED, SavepointSupport.FAIL_AFTER_ROLLBACK}) {
+      Statement statement = Statement.of("select * from foo where bar=true");
+      int numRows = 10;
+      RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+      mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+      try (Connection connection = createConnection()) {
+        connection.unwrap(CloudSpannerJdbcConnection.class).setSavepointSupport(savepointSupport);
+
+        try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+          int count = 0;
+          while (resultSet.next()) {
+            count++;
+          }
+          assertEquals(numRows, count);
+        }
+        // Change the result of the initial query and set a savepoint.
+        Savepoint s1 = connection.setSavepoint("s1");
+        mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+        // This will succeed as long as we don't execute any further statements.
+        connection.rollback(s1);
+
+        // Rolling back the transaction should now be a no-op, as it has already been rolled back.
+        connection.rollback();
+
+        // Read/write transactions are started with inlined Begin transaction options.
+        assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+        assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+        assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+        assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+      }
+      mockSpanner.clearRequests();
+    }
+  }
+
+  @Test
+  public void testMultipleRollbacksWithChangedResults() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+
+      // Change the result of the initial query to make sure that any retry will fail.
+      mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+      // This will succeed as long as we don't execute any further statements.
+      connection.rollback(s2);
+      // Rolling back one further should also work.
+      connection.rollback(s1);
+
+      // Rolling back the transaction should now be a no-op, as it has already been rolled back.
+      connection.rollback();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(3, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    }
+  }
+
+  @Test
+  public void testMultipleRollbacks() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.ENABLED);
+
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+
+      // First roll back one step and then one more.
+      connection.rollback(s2);
+      connection.rollback(s1);
+
+      // This will only commit the SELECT query.
+      connection.commit();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(4, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(6, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackMutations() throws SQLException {
+    try (Connection con = createConnection()) {
+      CloudSpannerJdbcConnection connection = con.unwrap(CloudSpannerJdbcConnection.class);
+      connection.setSavepointSupport(SavepointSupport.ENABLED);
+
+      connection.bufferedWrite(Mutation.newInsertBuilder("foo1").build());
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      connection.bufferedWrite(Mutation.newInsertBuilder("foo2").build());
+      connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      connection.bufferedWrite(Mutation.newInsertBuilder("foo3").build());
+
+      connection.rollback(s1);
+
+      // This will only commit the first mutation.
+      connection.commit();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+      CommitRequest commitRequest = mockSpanner.getRequestsOfType(CommitRequest.class).get(0);
+      assertEquals(1, commitRequest.getMutationsCount());
+      assertEquals("foo1", commitRequest.getMutations(0).getInsert().getTable());
+    }
+  }
+
+  @Test
+  public void testRollbackBatchDml() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection
+          .unwrap(CloudSpannerJdbcConnection.class)
+          .setSavepointSupport(SavepointSupport.ENABLED);
+
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (java.sql.Statement statement = connection.createStatement()) {
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.executeBatch();
+      }
+      Savepoint s2 = connection.setSavepoint("s2");
+
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s3 = connection.setSavepoint("s3");
+      try (java.sql.Statement statement = connection.createStatement()) {
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.executeBatch();
+      }
+      connection.setSavepoint("s4");
+
+      connection.rollback(s2);
+
+      connection.commit();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(3, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+      assertEquals(3, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest
+                          || request instanceof ExecuteBatchDmlRequest)
+              .collect(Collectors.toList());
+      assertEquals(8, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithoutInternalRetries() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.unwrap(CloudSpannerJdbcConnection.class).setRetryAbortsInternally(false);
+
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      // This should work.
+      connection.rollback(s1);
+      // Resuming after a rollback is not supported without internal retries enabled.
+      assertThrows(
+          SQLException.class,
+          () -> connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql()));
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithoutInternalRetriesInReadOnlyTransaction()
+      throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.unwrap(CloudSpannerJdbcConnection.class).setRetryAbortsInternally(false);
+      connection.setReadOnly(true);
+
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+
+      // Both rolling back and resuming after a rollback are supported in a read-only transaction,
+      // even if internal retries have been disabled.
+      connection.rollback(s1);
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+    }
+  }
+}