Repository: googleapis/java-spanner-jdbc
Pull request #1333 (2023-09-04 12:59:48)

deps: update actions/checkout action to v4 - abandoned
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://togithub.com/actions/checkout) | action | major | `v3` -> `v4` |
| [actions/checkout](https://togithub.com/actions/checkout) | action | major | `v2` -> `v4` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

### [`v4`](https://togithub.com/actions/checkout/blob/HEAD/CHANGELOG.md#v400)

[Compare Source](https://togithub.com/actions/checkout/compare/v3...v4)

-   [Support fetching without the --progress option](https://togithub.com/actions/checkout/pull/1067)
-   [Update to node20](https://togithub.com/actions/checkout/pull/1436)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [x] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/googleapis/java-spanner-jdbc).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNi43OC44IiwidXBkYXRlZEluVmVyIjoiMzYuNzguOCIsInRhcmdldEJyYW5jaCI6Im1haW4ifQ==-->


forking-renovate[bot]: ### Edited/Blocked Notification

Renovate will not automatically rebase this PR, because it does not recognize the last commit author and assumes somebody else may have edited the PR.

You can manually request rebase by checking the rebase/retry box above.

 ⚠ **Warning**: custom changes will be lost.

Patch:
diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml
index fa33591..52eaa54 100644
--- a/.github/.OwlBot.lock.yaml
+++ b/.github/.OwlBot.lock.yaml
@@ -13,5 +13,5 @@
 # limitations under the License.
 docker:
   image: gcr.io/cloud-devrel-public-resources/owlbot-java:latest
-  digest: sha256:3a95f1b9b1102865ca551b76be51d2bdb850900c4db2f6d79269e7af81ac8f84
-# created: 2023-07-27T18:37:44.251188775Z
+  digest: sha256:88ba8dcc5c2c7792e1c3511381f4ab329002a1c42c512f66ca87ced572dfbf9f
+# created: 2023-09-05T18:54:42.225408832Z
diff --git a/.github/workflows/spring-data-jdbc-sample.yaml b/.github/workflows/spring-data-jdbc-sample.yaml
new file mode 100644
index 0000000..028a486
--- /dev/null
+++ b/.github/workflows/spring-data-jdbc-sample.yaml
@@ -0,0 +1,30 @@
+# 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.
+# Github action job to test core java library features on
+# downstream client libraries before they are released.
+on:
+  pull_request:
+name: spring-data-jdbc-sample
+jobs:
+  spring-data-jdbc:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-java@v3
+        with:
+          distribution: temurin
+          java-version: 17
+      - name: Run tests
+        run: mvn test
+        working-directory: samples/spring-data-jdbc
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3992709..beda76d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
 # Changelog
 
+## [2.12.1](https://github.com/googleapis/java-spanner-jdbc/compare/v2.12.0...v2.12.1) (2023-09-12)
+
+
+### Dependencies
+
+* Update dependency com.google.cloud:google-cloud-spanner-bom to v6.46.0 ([#1338](https://github.com/googleapis/java-spanner-jdbc/issues/1338)) ([0bcb5dc](https://github.com/googleapis/java-spanner-jdbc/commit/0bcb5dc9d0c6e4d3878ceb748c09e87c75d88675))
+* Update dependency com.google.cloud:google-cloud-spanner-bom to v6.47.0 ([#1341](https://github.com/googleapis/java-spanner-jdbc/issues/1341)) ([0010650](https://github.com/googleapis/java-spanner-jdbc/commit/00106505771ed75f83ceaf181f45f19e4251cd78))
+
+## [2.12.0](https://github.com/googleapis/java-spanner-jdbc/compare/v2.11.9...v2.12.0) (2023-08-28)
+
+
+### Features
+
+* Return generated keys ([#1310](https://github.com/googleapis/java-spanner-jdbc/issues/1310)) ([9b5ab37](https://github.com/googleapis/java-spanner-jdbc/commit/9b5ab377587de09004474cb1cf488919fc83d6cb))
+
+
+### Bug Fixes
+
+* Session leak for invalid update ([#1323](https://github.com/googleapis/java-spanner-jdbc/issues/1323)) ([a7d0fbb](https://github.com/googleapis/java-spanner-jdbc/commit/a7d0fbb529ff71b45d6ddbbad8fc3be43e7c966f))
+
 ## [2.11.9](https://github.com/googleapis/java-spanner-jdbc/compare/v2.11.8...v2.11.9) (2023-08-21)
 
 
diff --git a/clirr-ignored-differences.xml b/clirr-ignored-differences.xml
index a92ad53..acf11c5 100644
--- a/clirr-ignored-differences.xml
+++ b/clirr-ignored-differences.xml
@@ -16,4 +16,46 @@
     <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
     <method>void setSavepointSupport(com.google.cloud.spanner.connection.SavepointSupport)</method>
   </difference>
+
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>int getMaxPartitionedParallelism()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>int getMaxPartitions()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>boolean isAutoPartitionMode()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>boolean isDataBoostEnabled()</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>void setAutoPartitionMode(boolean)</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>void setDataBoostEnabled(boolean)</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>void setMaxPartitionedParallelism(int)</method>
+  </difference>
+  <difference>
+    <differenceType>7012</differenceType>
+    <className>com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection</className>
+    <method>void setMaxPartitions(int)</method>
+  </difference>
+
 </differences>
diff --git a/pom.xml b/pom.xml
index 4a33dea..5d2bd1a 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>2.11.10-SNAPSHOT</version><!-- {x-version-update:google-cloud-spanner-jdbc:current} -->
+  <version>2.12.2-SNAPSHOT</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>
@@ -62,14 +62,14 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-spanner-bom</artifactId>
-        <version>6.45.3</version>
+        <version>6.47.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-shared-dependencies</artifactId>
-        <version>3.14.0</version>
+        <version>3.15.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
diff --git a/renovate.json b/renovate.json
index 6525dc2..efe6430 100644
--- a/renovate.json
+++ b/renovate.json
@@ -10,7 +10,10 @@
     ":maintainLockFilesDisabled",
     ":autodetectPinVersions"
   ],
-  "ignorePaths": [".kokoro/requirements.txt"],
+  "ignorePaths": [
+    ".kokoro/requirements.txt",
+    ".github/workflows/**"
+  ],
   "packageRules": [
     {
       "packagePatterns": [
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index be63235..0e0e2d7 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.11.9</version>
+      <version>2.12.1</version>
     </dependency>
     <!-- [END spanner-jdbc_install_without_bom] -->
 
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 5cc1db0..f0f4191 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>2.11.10-SNAPSHOT</version>
+      <version>2.12.2-SNAPSHOT</version>
     </dependency>
     <!-- {x-version-update-end} -->
 
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index b0a7026..a00b7a5 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -9,7 +9,7 @@
 
   <!--
     The parent pom defines common style checks and testing strategies for our samples.
-    Removing or replacing it should not affect the execution of the samples in anyway.
+    Removing or replacing it should not affect the execution of the samples in any way.
   -->
   <parent>
     <groupId>com.google.cloud.samples</groupId>
@@ -30,7 +30,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>26.22.0</version>
+        <version>26.23.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
@@ -42,19 +42,6 @@
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-spanner-jdbc</artifactId>
     </dependency>
-    <!-- [END spanner-jdbc_install_with_bom] -->
-
-    <dependency>
-      <groupId>junit</groupId>
-      <artifactId>junit</artifactId>
-      <version>4.13.2</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>com.google.truth</groupId>
-      <artifactId>truth</artifactId>
-      <version>1.1.5</version>
-      <scope>test</scope>
-    </dependency>
   </dependencies>
+  <!-- [END spanner-jdbc_install_with_bom] -->
 </project>
diff --git a/samples/spring-data-jdbc/README.md b/samples/spring-data-jdbc/README.md
new file mode 100644
index 0000000..da1d695
--- /dev/null
+++ b/samples/spring-data-jdbc/README.md
@@ -0,0 +1,95 @@
+# Spring Data JDBC Sample Application with Cloud Spanner PostgreSQL
+
+This sample application shows how to develop portable applications using Spring Data JDBC in
+combination with Cloud Spanner PostgreSQL. This application can be configured to run on either a
+[Cloud Spanner PostgreSQL](https://cloud.google.com/spanner/docs/postgresql-interface) database or
+an open-source PostgreSQL database. The only change that is needed to switch between the two is
+changing the active Spring profile that is used by the application.
+
+The application uses the Cloud Spanner JDBC driver to connect to Cloud Spanner PostgreSQL, and it
+uses the PostgreSQL JDBC driver to connect to open-source PostgreSQL. Spring Data JDBC works with
+both drivers and offers a single consistent API to the application developer, regardless of the
+actual database or JDBC driver being used.
+
+This sample shows:
+
+1. How to use Spring Data JDBC with Cloud Spanner PostgreSQL.
+2. How to develop a portable application that runs on both Google Cloud Spanner PostgreSQL and
+   open-source PostgreSQL with the same code base.
+3. How to use bit-reversed sequences to automatically generate primary key values for entities.
+
+__NOTE__: This application does __not require PGAdapter__. Instead, it connects to Cloud Spanner
+PostgreSQL using the Cloud Spanner JDBC driver.
+
+## Cloud Spanner PostgreSQL
+
+Cloud Spanner PostgreSQL provides language support by expressing Spanner database functionality
+through a subset of open-source PostgreSQL language constructs, with extensions added to support
+Spanner functionality like interleaved tables and hinting.
+
+The PostgreSQL interface makes the capabilities of Spanner —__fully managed, unlimited scale, strong
+consistency, high performance, and up to 99.999% global availability__— accessible using the
+PostgreSQL dialect. Unlike other services that manage actual PostgreSQL database instances, Spanner
+uses PostgreSQL-compatible syntax to expose its existing scale-out capabilities. This provides
+familiarity for developers and portability for applications, but not 100% PostgreSQL compatibility.
+The SQL syntax that Spanner supports is semantically equivalent PostgreSQL, meaning schemas
+and queries written against the PostgreSQL interface can be easily ported to another PostgreSQL
+environment.
+
+This sample showcases this portability with an application that works on both Cloud Spanner PostgreSQL
+and open-source PostgreSQL with the same code base.
+
+## Spring Data JDBC
+
+[Spring Data JDBC](https://spring.io/projects/spring-data-jdbc) is part of the larger Spring Data
+family. It makes it easy to implement JDBC based repositories. This module deals with enhanced
+support for JDBC based data access layers.
+
+Spring Data JDBC aims at being conceptually easy. In order to achieve this it does NOT offer caching,
+lazy loading, write behind or many other features of JPA. This makes Spring Data JDBC a simple,
+limited, opinionated ORM.
+
+## Sample Application
+
+This sample shows how to create a portable application using Spring Data JDBC and the Cloud Spanner
+PostgreSQL dialect. The application works on both Cloud Spanner PostgreSQL and open-source
+PostgreSQL. You can switch between the two by changing the active Spring profile:
+* Profile `cs` runs the application on Cloud Spanner PostgreSQL.
+* Profile `pg` runs the application on open-source PostgreSQL.
+
+The default profile is `cs`. You can change the default profile by modifying the
+[application.properties](src/main/resources/application.properties) file.
+
+### Running the Application
+
+1. Choose the database system that you want to use by choosing a profile. The default profile is
+   `cs`, which runs the application on Cloud Spanner PostgreSQL. Modify the default profile in the
+   [application.properties](src/main/resources/application.properties) file.
+2. Modify either [application-cs.properties](src/main/resources/application-cs.properties) or
+   [application-pg.properties](src/main/resources/application-pg.properties) to point to an existing
+   database. If you use Cloud Spanner, the database that the configuration file references must be a
+   database that uses the PostgreSQL dialect.
+3. Run the application with `mvn spring-boot:run`.
+
+### Main Application Components
+
+The main application components are:
+* [DatabaseSeeder.java](src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java): This
+  class is responsible for creating the database schema and inserting some initial test data. The
+  schema is created from the [create_schema.sql](src/main/resources/create_schema.sql) file. The
+  `DatabaseSeeder` class loads this file into memory and executes it on the active database using
+  standard JDBC APIs. The class also removes Cloud Spanner-specific extensions to the PostgreSQL
+  dialect when the application runs on open-source PostgreSQL.
+* [JdbcConfiguration.java](src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java):
+  Spring Data JDBC by default detects the database dialect based on the JDBC driver that is used.
+  This class overrides this default and instructs Spring Data JDBC to also use the PostgreSQL
+  dialect for Cloud Spanner PostgreSQL.
+* [AbstractEntity.java](src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java):
+  This is the shared base class for all entities in this sample application. It defines a number of
+  standard attributes, such as the identifier (primary key). The primary key is automatically
+  generated using a (bit-reversed) sequence. [Bit-reversed sequential values](https://cloud.google.com/spanner/docs/schema-design#bit_reverse_primary_key)
+  are considered a good choice for primary keys on Cloud Spanner.
+* [Application.java](src/main/java/com/google/cloud/spanner/sample/Application.java): The starter
+  class of the application. It contains a command-line runner that executes a selection of queries
+  and updates on the database.
+
diff --git a/samples/spring-data-jdbc/pom.xml b/samples/spring-data-jdbc/pom.xml
new file mode 100644
index 0000000..ba78496
--- /dev/null
+++ b/samples/spring-data-jdbc/pom.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  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>
+
+  <groupId>org.example</groupId>
+  <artifactId>cloud-spanner-spring-data-jdbc-example</artifactId>
+  <version>1.0-SNAPSHOT</version>
+  <description>
+    Sample application showing how to use Spring Data JDBC with Cloud Spanner PostgreSQL.
+  </description>
+
+  <properties>
+    <java.version>17</java.version>
+    <maven.compiler.source>17</maven.compiler.source>
+    <maven.compiler.target>17</maven.compiler.target>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+  </properties>
+
+  <dependencyManagement>
+    <dependencies>
+      <dependency>
+        <groupId>org.springframework.data</groupId>
+        <artifactId>spring-data-bom</artifactId>
+        <version>2023.0.4</version>
+        <scope>import</scope>
+        <type>pom</type>
+      </dependency>
+      <dependency>
+        <groupId>com.google.cloud</groupId>
+        <artifactId>libraries-bom</artifactId>
+        <version>26.23.0</version>
+        <scope>import</scope>
+        <type>pom</type>
+      </dependency>
+    </dependencies>
+  </dependencyManagement>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.springframework.boot</groupId>
+      <artifactId>spring-boot-starter-data-jdbc</artifactId>
+      <version>3.1.3</version>
+    </dependency>
+
+    <!-- Add both the Cloud Spanner and the PostgreSQL JDBC driver. -->
+    <dependency>
+      <groupId>com.google.cloud</groupId>
+      <artifactId>google-cloud-spanner-jdbc</artifactId>
+      <version>2.12.1</version>
+    </dependency>
+    <dependency>
+      <groupId>org.postgresql</groupId>
+      <artifactId>postgresql</artifactId>
+      <version>42.6.0</version>
+    </dependency>
+
+    <dependency>
+      <groupId>com.google.collections</groupId>
+      <artifactId>google-collections</artifactId>
+      <version>1.0</version>
+    </dependency>
+
+    <!-- Test dependencies -->
+    <dependency>
+      <groupId>com.google.cloud</groupId>
+      <artifactId>google-cloud-spanner</artifactId>
+      <type>test-jar</type>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.api</groupId>
+      <artifactId>gax-grpc</artifactId>
+      <classifier>testlib</classifier>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <version>4.13.2</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+  
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>com.spotify.fmt</groupId>
+        <artifactId>fmt-maven-plugin</artifactId>
+        <version>2.20</version>
+        <executions>
+          <execution>
+            <goals>
+              <goal>format</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/Application.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/Application.java
new file mode 100644
index 0000000..bc55afd
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/Application.java
@@ -0,0 +1,126 @@
+/*
+ * 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.sample;
+
+import com.google.cloud.spanner.sample.entities.Album;
+import com.google.cloud.spanner.sample.entities.Singer;
+import com.google.cloud.spanner.sample.entities.Track;
+import com.google.cloud.spanner.sample.repositories.AlbumRepository;
+import com.google.cloud.spanner.sample.repositories.SingerRepository;
+import com.google.cloud.spanner.sample.repositories.TrackRepository;
+import com.google.cloud.spanner.sample.service.SingerService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Application implements CommandLineRunner {
+  private static final Logger logger = LoggerFactory.getLogger(Application.class);
+
+  public static void main(String[] args) {
+    SpringApplication.run(Application.class, args).close();
+  }
+
+  private final DatabaseSeeder databaseSeeder;
+
+  private final SingerService singerService;
+
+  private final SingerRepository singerRepository;
+
+  private final AlbumRepository albumRepository;
+
+  private final TrackRepository trackRepository;
+
+  public Application(
+      SingerService singerService,
+      DatabaseSeeder databaseSeeder,
+      SingerRepository singerRepository,
+      AlbumRepository albumRepository,
+      TrackRepository trackRepository) {
+    this.databaseSeeder = databaseSeeder;
+    this.singerService = singerService;
+    this.singerRepository = singerRepository;
+    this.albumRepository = albumRepository;
+    this.trackRepository = trackRepository;
+  }
+
+  @Override
+  public void run(String... args) {
+
+    // Set the system property 'drop_schema' to true to drop any existing database
+    // schema when the application is executed.
+    if (Boolean.parseBoolean(System.getProperty("drop_schema", "false"))) {
+      logger.info("Dropping existing schema if it exists");
+      databaseSeeder.dropDatabaseSchemaIfExists();
+    }
+
+    logger.info("Creating database schema if it does not already exist");
+    databaseSeeder.createDatabaseSchemaIfNotExists();
+    logger.info("Deleting existing test data");
+    databaseSeeder.deleteTestData();
+    logger.info("Inserting fresh test data");
+    databaseSeeder.insertTestData();
+
+    Iterable<Singer> allSingers = singerRepository.findAll();
+    for (Singer singer : allSingers) {
+      logger.info(
+          "Found singer: {} with {} albums",
+          singer,
+          albumRepository.countAlbumsBySingerId(singer.getId()));
+      for (Album album : albumRepository.findAlbumsBySingerId(singer.getId())) {
+        logger.info("\tAlbum: {}, released at {}", album, album.getReleaseDate());
+      }
+    }
+
+    // Create a new singer and three albums in a transaction.
+    Singer insertedSinger =
+        singerService.createSingerAndAlbums(
+            new Singer("Amethyst", "Jiang"),
+            new Album(databaseSeeder.randomTitle()),
+            new Album(databaseSeeder.randomTitle()),
+            new Album(databaseSeeder.randomTitle()));
+    logger.info(
+        "Inserted singer {} {} {}",
+        insertedSinger.getId(),
+        insertedSinger.getFirstName(),
+        insertedSinger.getLastName());
+
+    // Create a new track record and insert it into the database.
+    Album album = albumRepository.getFirst().orElseThrow();
+    Track track = new Track(album, 1, databaseSeeder.randomTitle());
+    track.setSampleRate(3.14d);
+    // Spring Data JDBC supports the same base CRUD operations on entities as for example
+    // Spring Data JPA.
+    trackRepository.save(track);
+
+    // List all singers that have a last name starting with an 'J'.
+    logger.info("All singers with a last name starting with an 'J':");
+    for (Singer singer : singerRepository.findSingersByLastNameStartingWith("J")) {
+      logger.info("\t{}", singer.getFullName());
+    }
+
+    // The singerService.listSingersWithLastNameStartingWith(..) method uses a read-only
+    // transaction. You should prefer read-only transactions to read/write transactions whenever
+    // possible, as read-only transactions do not take locks.
+    logger.info("All singers with a last name starting with an 'A', 'B', or 'C'.");
+    for (Singer singer : singerService.listSingersWithLastNameStartingWith("A", "B", "C")) {
+      logger.info("\t{}", singer.getFullName());
+    }
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java
new file mode 100644
index 0000000..eabd04c
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/DatabaseSeeder.java
@@ -0,0 +1,342 @@
+/*
+ * 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.sample;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.cloud.spanner.sample.entities.Singer;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.math.BigDecimal;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.function.Supplier;
+import javax.annotation.Nonnull;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.util.FileCopyUtils;
+
+/** This component creates the database schema and seeds it with some random test data. */
+@Component
+public class DatabaseSeeder {
+
+  /** Randomly generated names. */
+  public static final ImmutableList<Singer> INITIAL_SINGERS =
+      ImmutableList.of(
+          new Singer("Aaliyah", "Smith"),
+          new Singer("Benjamin", "Jones"),
+          new Singer("Chloe", "Brown"),
+          new Singer("David", "Williams"),
+          new Singer("Elijah", "Johnson"),
+          new Singer("Emily", "Miller"),
+          new Singer("Gabriel", "Garcia"),
+          new Singer("Hannah", "Rodriguez"),
+          new Singer("Isabella", "Hernandez"),
+          new Singer("Jacob", "Perez"));
+
+  private static final Random RANDOM = new Random();
+
+  private final JdbcTemplate jdbcTemplate;
+
+  @Value("classpath:create_schema.sql")
+  private Resource createSchemaFile;
+
+  @Value("classpath:drop_schema.sql")
+  private Resource dropSchemaFile;
+
+  /** This value is determined once using a system query, and then cached. */
+  private final Supplier<Boolean> isCloudSpannerPG;
+
+  public DatabaseSeeder(JdbcTemplate jdbcTemplate) {
+    this.jdbcTemplate = jdbcTemplate;
+    this.isCloudSpannerPG =
+        Suppliers.memoize(() -> JdbcConfiguration.isCloudSpannerPG(jdbcTemplate));
+  }
+
+  /** Reads a resource file into a string. */
+  private static String resourceAsString(Resource resource) {
+    try (Reader reader = new InputStreamReader(resource.getInputStream(), UTF_8)) {
+      return FileCopyUtils.copyToString(reader);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * Returns true if this application is currently running on a Cloud Spanner PostgreSQL database,
+   * and false if it is running on an open-source PostgreSQL database.
+   */
+  private boolean isCloudSpanner() {
+    return isCloudSpannerPG.get();
+  }
+
+  /**
+   * Removes all statements that start with a 'skip_on_open_source_pg' comment if the application is
+   * running on open-source PostgreSQL. This ensures that we can use the same DDL script both on
+   * Cloud Spanner and on open-source PostgreSQL. It also removes any empty statements in the given
+   * array.
+   */
+  private String[] updateDdlStatements(String[] statements) {
+    if (!isCloudSpanner()) {
+      for (int i = 0; i < statements.length; i++) {
+        // Replace any line that starts with '/* skip_on_open_source_pg */' with an empty string.
+        statements[i] =
+            statements[i].replaceAll("(?m)^\\s*/\\*\\s*skip_on_open_source_pg\\s*\\*/.+$", "");
+      }
+    }
+    // Remove any empty statements from the script.
+    return Arrays.stream(statements)
+        .filter(statement -> !statement.isBlank())
+        .toArray(String[]::new);
+  }
+
+  /** Creates the database schema if it does not yet exist. */
+  public void createDatabaseSchemaIfNotExists() {
+    // We can safely just split the script based on ';', as we know that there are no literals or
+    // other strings that contain semicolons in the script.
+    String[] statements = updateDdlStatements(resourceAsString(createSchemaFile).split(";"));
+    // Execute all the DDL statements as a JDBC batch. That ensures that Cloud Spanner will apply
+    // all statements in a single DDL batch, which again is a lot more efficient than executing them
+    // one-by-one.
+    jdbcTemplate.batchUpdate(statements);
+  }
+
+  /** Drops the database schema if it exists. */
+  public void dropDatabaseSchemaIfExists() {
+    // We can safely just split the script based on ';', as we know that there are no literals or
+    // other strings that contain semicolons in the script.
+    String[] statements = updateDdlStatements(resourceAsString(dropSchemaFile).split(";"));
+    // Execute all the DDL statements as a JDBC batch. That ensures that Cloud Spanner will apply
+    // all statements in a single DDL batch, which again is a lot more efficient than executing them
+    // one-by-one.
+    jdbcTemplate.batchUpdate(statements);
+  }
+
+  /** Deletes all data currently in the sample tables. */
+  public void deleteTestData() {
+    // Delete all data in one batch.
+    jdbcTemplate.batchUpdate(
+        "delete from concerts",
+        "delete from venues",
+        "delete from tracks",
+        "delete from albums",
+        "delete from singers");
+  }
+
+  /** Inserts some initial test data into the database. */
+  public void insertTestData() {
+    jdbcTemplate.batchUpdate(
+        "insert into singers (first_name, last_name) values (?, ?)",
+        new BatchPreparedStatementSetter() {
+          @Override
+          public void setValues(@Nonnull PreparedStatement preparedStatement, int i)
+              throws SQLException {
+            preparedStatement.setString(1, INITIAL_SINGERS.get(i).getFirstName());
+            preparedStatement.setString(2, INITIAL_SINGERS.get(i).getLastName());
+          }
+
+          @Override
+          public int getBatchSize() {
+            return INITIAL_SINGERS.size();
+          }
+        });
+
+    List<Long> singerIds =
+        jdbcTemplate.query(
+            "select id from singers",
+            resultSet -> {
+              ImmutableList.Builder<Long> builder = ImmutableList.builder();
+              while (resultSet.next()) {
+                builder.add(resultSet.getLong(1));
+              }
+              return builder.build();
+            });
+    jdbcTemplate.batchUpdate(
+        "insert into albums (title, marketing_budget, release_date, cover_picture, singer_id) values (?, ?, ?, ?, ?)",
+        new BatchPreparedStatementSetter() {
+          @Override
+          public void setValues(@Nonnull PreparedStatement preparedStatement, int i)
+              throws SQLException {
+            preparedStatement.setString(1, randomTitle());
+            preparedStatement.setBigDecimal(2, randomBigDecimal());
+            preparedStatement.setObject(3, randomDate());
+            preparedStatement.setBytes(4, randomBytes());
+            preparedStatement.setLong(5, randomElement(singerIds));
+          }
+
+          @Override
+          public int getBatchSize() {
+            return INITIAL_SINGERS.size() * 20;
+          }
+        });
+  }
+
+  /** Generates a random title for an album or a track. */
+  static String randomTitle() {
+    return randomElement(ADJECTIVES) + " " + randomElement(NOUNS);
+  }
+
+  /** Returns a random element from the given list. */
+  static <T> T randomElement(List<T> list) {
+    return list.get(RANDOM.nextInt(list.size()));
+  }
+
+  /** Generates a random {@link BigDecimal}. */
+  BigDecimal randomBigDecimal() {
+    return BigDecimal.valueOf(RANDOM.nextDouble());
+  }
+
+  /** Generates a random {@link LocalDate}. */
+  static LocalDate randomDate() {
+    return LocalDate.of(RANDOM.nextInt(200) + 1800, RANDOM.nextInt(12) + 1, RANDOM.nextInt(28) + 1);
+  }
+
+  /** Generates a random byte array with a length between 4 and 1024 bytes. */
+  static byte[] randomBytes() {
+    int size = RANDOM.nextInt(1020) + 4;
+    byte[] res = new byte[size];
+    RANDOM.nextBytes(res);
+    return res;
+  }
+
+  /** Some randomly generated nouns that are used to generate random titles. */
+  private static final ImmutableList<String> NOUNS =
+      ImmutableList.of(
+          "apple",
+          "banana",
+          "cherry",
+          "dog",
+          "elephant",
+          "fish",
+          "grass",
+          "house",
+          "key",
+          "lion",
+          "monkey",
+          "nail",
+          "orange",
+          "pen",
+          "queen",
+          "rain",
+          "shoe",
+          "tree",
+          "umbrella",
+          "van",
+          "whale",
+          "xylophone",
+          "zebra");
+
+  /** Some randomly generated adjectives that are used to generate random titles. */
+  private static final ImmutableList<String> ADJECTIVES =
+      ImmutableList.of(
+          "able",
+          "angelic",
+          "artistic",
+          "athletic",
+          "attractive",
+          "autumnal",
+          "calm",
+          "careful",
+          "cheerful",
+          "clever",
+          "colorful",
+          "confident",
+          "courageous",
+          "creative",
+          "curious",
+          "daring",
+          "determined",
+          "different",
+          "dreamy",
+          "efficient",
+          "elegant",
+          "energetic",
+          "enthusiastic",
+          "exciting",
+          "expressive",
+          "faithful",
+          "fantastic",
+          "funny",
+          "gentle",
+          "gifted",
+          "great",
+          "happy",
+          "helpful",
+          "honest",
+          "hopeful",
+          "imaginative",
+          "intelligent",
+          "interesting",
+          "inventive",
+          "joyful",
+          "kind",
+          "knowledgeable",
+          "loving",
+          "loyal",
+          "magnificent",
+          "mature",
+          "mysterious",
+          "natural",
+          "nice",
+          "optimistic",
+          "peaceful",
+          "perfect",
+          "pleasant",
+          "powerful",
+          "proud",
+          "quick",
+          "relaxed",
+          "reliable",
+          "responsible",
+          "romantic",
+          "safe",
+          "sensitive",
+          "sharp",
+          "simple",
+          "sincere",
+          "skillful",
+          "smart",
+          "sociable",
+          "strong",
+          "successful",
+          "sweet",
+          "talented",
+          "thankful",
+          "thoughtful",
+          "unique",
+          "upbeat",
+          "valuable",
+          "victorious",
+          "vivacious",
+          "warm",
+          "wealthy",
+          "wise",
+          "wonderful",
+          "worthy",
+          "youthful");
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java
new file mode 100644
index 0000000..2066647
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/JdbcConfiguration.java
@@ -0,0 +1,84 @@
+/*
+ * 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.sample;
+
+import com.google.cloud.spanner.jdbc.JdbcSqlException;
+import com.google.rpc.Code;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.dao.DataAccessException;
+import org.springframework.dao.IncorrectResultSizeDataAccessException;
+import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration;
+import org.springframework.data.relational.core.dialect.Dialect;
+import org.springframework.data.relational.core.dialect.PostgresDialect;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
+
+@Configuration
+public class JdbcConfiguration extends AbstractJdbcConfiguration {
+
+  /** Override the dialect auto-detection, so it also returns PostgreSQL for Cloud Spanner. */
+  @Override
+  public Dialect jdbcDialect(@Nonnull NamedParameterJdbcOperations operations) {
+    if (isCloudSpannerPG(operations.getJdbcOperations())) {
+      return PostgresDialect.INSTANCE;
+    }
+    return super.jdbcDialect(operations);
+  }
+
+  /** Returns true if the current database is a Cloud Spanner PostgreSQL database. */
+  public static boolean isCloudSpannerPG(JdbcOperations operations) {
+    try {
+      Long value =
+          operations.queryForObject(
+              "select 1 "
+                  + "from information_schema.database_options "
+                  + "where schema_name='public' "
+                  + "and option_name='database_dialect' "
+                  + "and option_value='POSTGRESQL'",
+              Long.class);
+      // Shouldn't really be anything else than 1 if the query succeeded, but this avoids complaints
+      // from the compiler.
+      if (Objects.equals(1L, value)) {
+        return true;
+      }
+    } catch (IncorrectResultSizeDataAccessException exception) {
+      // This indicates that it is a valid Cloud Spanner database, but not one that uses the
+      // PostgreSQL dialect.
+      throw new RuntimeException(
+          "The selected Cloud Spanner database does not use the PostgreSQL dialect");
+    } catch (DataAccessException exception) {
+      if (exception.getCause() instanceof JdbcSqlException) {
+        JdbcSqlException jdbcSqlException = (JdbcSqlException) exception;
+        if (jdbcSqlException.getCode() == Code.PERMISSION_DENIED
+            || jdbcSqlException.getCode() == Code.NOT_FOUND) {
+          throw new RuntimeException(
+              "Failed to get the dialect of the Cloud Spanner database. "
+                  + "Please check that the selected database exists and that you have permission to access it. "
+                  + "Cause: "
+                  + exception.getCause().getMessage(),
+              exception.getCause());
+        }
+      }
+      // ignore and fall through
+    } catch (Throwable exception) {
+      // ignore and fall through
+    }
+    return false;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java
new file mode 100644
index 0000000..4c6d3f2
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/AbstractEntity.java
@@ -0,0 +1,74 @@
+/*
+ * 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.sample.entities;
+
+import java.time.OffsetDateTime;
+import org.springframework.data.annotation.Id;
+
+public abstract class AbstractEntity {
+
+  /** This ID is generated using a (bit-reversed) sequence. */
+  @Id private Long id;
+
+  private OffsetDateTime createdAt;
+
+  private OffsetDateTime updatedAt;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof AbstractEntity)) {
+      return false;
+    }
+    AbstractEntity other = (AbstractEntity) o;
+    if (this == other) {
+      return true;
+    }
+    return this.getClass().equals(other.getClass())
+        && this.id != null
+        && other.id != null
+        && this.id.equals(other.id);
+  }
+
+  @Override
+  public int hashCode() {
+    return this.id == null ? 0 : this.id.hashCode();
+  }
+
+  public Long getId() {
+    return id;
+  }
+
+  protected void setId(Long id) {
+    this.id = id;
+  }
+
+  public OffsetDateTime getCreatedAt() {
+    return createdAt;
+  }
+
+  protected void setCreatedAt(OffsetDateTime createdAt) {
+    this.createdAt = createdAt;
+  }
+
+  public OffsetDateTime getUpdatedAt() {
+    return updatedAt;
+  }
+
+  protected void setUpdatedAt(OffsetDateTime updatedAt) {
+    this.updatedAt = updatedAt;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Album.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Album.java
new file mode 100644
index 0000000..dfde31a
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Album.java
@@ -0,0 +1,86 @@
+/*
+ * 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.sample.entities;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import org.springframework.data.relational.core.mapping.Table;
+
+@Table("albums")
+public class Album extends AbstractEntity {
+
+  private String title;
+
+  private BigDecimal marketingBudget;
+
+  private LocalDate releaseDate;
+
+  private byte[] coverPicture;
+
+  private Long singerId;
+
+  public Album() {}
+
+  public Album(String title) {
+    this.title = title;
+  }
+
+  @Override
+  public String toString() {
+    return getTitle();
+  }
+
+  public String getTitle() {
+    return title;
+  }
+
+  public void setTitle(String title) {
+    this.title = title;
+  }
+
+  public BigDecimal getMarketingBudget() {
+    return marketingBudget;
+  }
+
+  public void setMarketingBudget(BigDecimal marketingBudget) {
+    this.marketingBudget = marketingBudget;
+  }
+
+  public LocalDate getReleaseDate() {
+    return releaseDate;
+  }
+
+  public void setReleaseDate(LocalDate releaseDate) {
+    this.releaseDate = releaseDate;
+  }
+
+  public byte[] getCoverPicture() {
+    return coverPicture;
+  }
+
+  public void setCoverPicture(byte[] coverPicture) {
+    this.coverPicture = coverPicture;
+  }
+
+  public Long getSingerId() {
+    return singerId;
+  }
+
+  public void setSingerId(Long singerId) {
+    this.singerId = singerId;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java
new file mode 100644
index 0000000..29f2b6d
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Concert.java
@@ -0,0 +1,80 @@
+/*
+ * 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.sample.entities;
+
+import java.time.OffsetDateTime;
+import org.springframework.data.relational.core.mapping.Table;
+
+@Table("concerts")
+public class Concert extends AbstractEntity {
+
+  private Long venueId;
+
+  private Long singerId;
+
+  private String name;
+
+  private OffsetDateTime startTime;
+
+  private OffsetDateTime endTime;
+
+  public Concert(Venue venue, Singer singer, String name) {
+    this.venueId = venue.getId();
+    this.singerId = singer.getId();
+    this.name = name;
+  }
+
+  public Long getVenueId() {
+    return venueId;
+  }
+
+  public void setVenueId(Long venueId) {
+    this.venueId = venueId;
+  }
+
+  public Long getSingerId() {
+    return singerId;
+  }
+
+  public void setSingerId(Long singerId) {
+    this.singerId = singerId;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public void setName(String name) {
+    this.name = name;
+  }
+
+  public OffsetDateTime getStartTime() {
+    return startTime;
+  }
+
+  public void setStartTime(OffsetDateTime startTime) {
+    this.startTime = startTime;
+  }
+
+  public OffsetDateTime getEndTime() {
+    return endTime;
+  }
+
+  public void setEndTime(OffsetDateTime endTime) {
+    this.endTime = endTime;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java
new file mode 100644
index 0000000..255459a
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Singer.java
@@ -0,0 +1,73 @@
+/*
+ * 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.sample.entities;
+
+import org.springframework.data.annotation.ReadOnlyProperty;
+import org.springframework.data.relational.core.mapping.Table;
+
+@Table("singers")
+public class Singer extends AbstractEntity {
+
+  private String firstName;
+
+  private String lastName;
+
+  /** Mark fullName as a {@link ReadOnlyProperty}, as it is generated by the database. */
+  @ReadOnlyProperty private String fullName;
+
+  private Boolean active;
+
+  public Singer() {}
+
+  public Singer(String firstName, String lastName) {
+    this.firstName = firstName;
+    this.lastName = lastName;
+  }
+
+  @Override
+  public String toString() {
+    return getFullName();
+  }
+
+  public String getFirstName() {
+    return firstName;
+  }
+
+  public void setFirstName(String firstName) {
+    this.firstName = firstName;
+  }
+
+  public String getLastName() {
+    return lastName;
+  }
+
+  public void setLastName(String lastName) {
+    this.lastName = lastName;
+  }
+
+  public String getFullName() {
+    return fullName;
+  }
+
+  public Boolean getActive() {
+    return active;
+  }
+
+  public void setActive(Boolean active) {
+    this.active = active;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Track.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Track.java
new file mode 100644
index 0000000..268b6e2
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Track.java
@@ -0,0 +1,84 @@
+/*
+ * 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.sample.entities;
+
+import org.springframework.data.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Table;
+
+/**
+ * The "tracks" table is interleaved in "albums". That means that the first part of the primary key
+ * (the "id" column) references the Album that this Track belongs to. That again means that we do
+ * not auto-generate the id for this entity. We can achieve this by adding an extra property,
+ * albumId, that is mapped to the "id" column. We can then manually set an albumId value before
+ * inserting the record in the database.
+ */
+@Table("tracks")
+public class Track extends AbstractEntity {
+
+  /**
+   * We need to map this to the "id" column to be able to explicitly set it, instead of letting
+   * Spring Data generate it. This is necessary, because Track is interleaved in Album. That again
+   * means that we must use the ID value of the Album for a Track.
+   */
+  @Column("id")
+  private Long albumId;
+
+  /** This is the second part of the primary key of a Track. */
+  private int trackNumber;
+
+  private String title;
+
+  private Double sampleRate;
+
+  public Track(Album album, int trackNumber, String title) {
+    setAlbumId(album.getId());
+    this.trackNumber = trackNumber;
+    this.title = title;
+  }
+
+  public Long getAlbumId() {
+    return albumId;
+  }
+
+  private void setAlbumId(Long albumId) {
+    this.albumId = albumId;
+  }
+
+  public int getTrackNumber() {
+    return trackNumber;
+  }
+
+  public void setTrackNumber(int trackNumber) {
+    this.trackNumber = trackNumber;
+  }
+
+  public String getTitle() {
+    return title;
+  }
+
+  public void setTitle(String title) {
+    this.title = title;
+  }
+
+  public Double getSampleRate() {
+    return sampleRate;
+  }
+
+  public void setSampleRate(Double sampleRate) {
+    this.sampleRate = sampleRate;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java
new file mode 100644
index 0000000..e7855cd
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/entities/Venue.java
@@ -0,0 +1,46 @@
+/*
+ * 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.sample.entities;
+
+import org.springframework.data.relational.core.mapping.Table;
+
+@Table("venues")
+public class Venue extends AbstractEntity {
+  private String name;
+
+  private String description;
+
+  public Venue(String name) {
+    this.name = name;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public void setName(String name) {
+    this.name = name;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  public void setDescription(String description) {
+    this.description = description;
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java
new file mode 100644
index 0000000..de90bf7
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/AlbumRepository.java
@@ -0,0 +1,40 @@
+/*
+ * 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.sample.repositories;
+
+import com.google.cloud.spanner.sample.entities.Album;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jdbc.repository.query.Query;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface AlbumRepository extends CrudRepository<Album, Long> {
+
+  /**
+   * The implementation for this method is automatically generated and will fetch all albums of the
+   * given singer.
+   */
+  List<Album> findAlbumsBySingerId(Long singerId);
+
+  long countAlbumsBySingerId(Long singerId);
+
+  /** Returns the first album in the database. */
+  @Query("select * from albums limit 1")
+  Optional<Album> getFirst();
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java
new file mode 100644
index 0000000..fb2ce9c
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/ConcertRepository.java
@@ -0,0 +1,24 @@
+/*
+ * 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.sample.repositories;
+
+import com.google.cloud.spanner.sample.entities.Concert;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ConcertRepository extends CrudRepository<Concert, Long> {}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java
new file mode 100644
index 0000000..a55d02f
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/SingerRepository.java
@@ -0,0 +1,34 @@
+/*
+ * 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.sample.repositories;
+
+import com.google.cloud.spanner.sample.entities.Singer;
+import java.util.List;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface SingerRepository extends CrudRepository<Singer, Long> {
+
+  /**
+   * The implementation for this method is automatically generated and will fetch all singers with
+   * the given last name.
+   */
+  List<Singer> findSingersByLastName(String lastName);
+
+  List<Singer> findSingersByLastNameStartingWith(String prefix);
+}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java
new file mode 100644
index 0000000..597df5f
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/TrackRepository.java
@@ -0,0 +1,24 @@
+/*
+ * 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.sample.repositories;
+
+import com.google.cloud.spanner.sample.entities.Track;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface TrackRepository extends CrudRepository<Track, Long> {}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java
new file mode 100644
index 0000000..f029979
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/repositories/VenueRepository.java
@@ -0,0 +1,24 @@
+/*
+ * 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.sample.repositories;
+
+import com.google.cloud.spanner.sample.entities.Venue;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface VenueRepository extends CrudRepository<Venue, Long> {}
diff --git a/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java
new file mode 100644
index 0000000..afb496e
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/java/com/google/cloud/spanner/sample/service/SingerService.java
@@ -0,0 +1,67 @@
+/*
+ * 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.sample.service;
+
+import com.google.cloud.spanner.sample.entities.Album;
+import com.google.cloud.spanner.sample.entities.Singer;
+import com.google.cloud.spanner.sample.repositories.AlbumRepository;
+import com.google.cloud.spanner.sample.repositories.SingerRepository;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class SingerService {
+  private final SingerRepository singerRepository;
+
+  private final AlbumRepository albumRepository;
+
+  public SingerService(SingerRepository singerRepository, AlbumRepository albumRepository) {
+    this.singerRepository = singerRepository;
+    this.albumRepository = albumRepository;
+  }
+
+  /** Creates a singer and a list of albums in a transaction. */
+  @Transactional
+  public Singer createSingerAndAlbums(Singer singer, Album... albums) {
+    // Saving a singer will return an updated singer entity that has the primary key value set.
+    singer = singerRepository.save(singer);
+    for (Album album : albums) {
+      // Set the singerId that was generated on the Album before saving it.
+      album.setSingerId(singer.getId());
+      albumRepository.save(album);
+    }
+    return singer;
+  }
+
+  /**
+   * Searches for all singers that have a last name starting with any of the given prefixes. This
+   * method uses a read-only transaction. Read-only transactions should be preferred to read/write
+   * transactions whenever possible, as read-only transactions do not take locks.
+   */
+  @Transactional(readOnly = true)
+  public List<Singer> listSingersWithLastNameStartingWith(String... prefixes) {
+    ImmutableList.Builder<Singer> result = ImmutableList.builder();
+    // This is not the most efficient way to search for this, but the main purpose of this method is
+    // to show how to use read-only transactions.
+    for (String prefix : prefixes) {
+      result.addAll(singerRepository.findSingersByLastNameStartingWith(prefix));
+    }
+    return result.build();
+  }
+}
diff --git a/samples/spring-data-jdbc/src/main/resources/application-cs.properties b/samples/spring-data-jdbc/src/main/resources/application-cs.properties
new file mode 100644
index 0000000..96aaae7
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/resources/application-cs.properties
@@ -0,0 +1,9 @@
+
+# This profile uses a Cloud Spanner PostgreSQL database.
+
+spanner.project=my-project
+spanner.instance=my-instance
+spanner.database=spring-data-jdbc
+
+spring.datasource.url=jdbc:cloudspanner:/projects/${spanner.project}/instances/${spanner.instance}/databases/${spanner.database}
+spring.datasource.driver-class-name=com.google.cloud.spanner.jdbc.JdbcDriver
diff --git a/samples/spring-data-jdbc/src/main/resources/application-pg.properties b/samples/spring-data-jdbc/src/main/resources/application-pg.properties
new file mode 100644
index 0000000..894f63e
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/resources/application-pg.properties
@@ -0,0 +1,7 @@
+
+# This profile uses an open-source PostgreSQL database.
+
+spring.datasource.url=jdbc:postgresql://localhost:5432/spring-data-jdbc
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.username=postgres
+spring.datasource.password=mysecretpassword
diff --git a/samples/spring-data-jdbc/src/main/resources/application.properties b/samples/spring-data-jdbc/src/main/resources/application.properties
new file mode 100644
index 0000000..3347f31
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/resources/application.properties
@@ -0,0 +1,9 @@
+
+# This application can use both a Cloud Spanner PostgreSQL database or an open-source PostgreSQL
+# database. Which database is used is determined by the active profile:
+# 1. 'cs' means use Cloud Spanner.
+# 2. 'pg' means use open-source PostgreSQL.
+
+# Activate the Cloud Spanner profile by default.
+# Change to 'pg' to activate the PostgreSQL profile.
+spring.profiles.default=cs
diff --git a/samples/spring-data-jdbc/src/main/resources/create_schema.sql b/samples/spring-data-jdbc/src/main/resources/create_schema.sql
new file mode 100644
index 0000000..60552d3
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/resources/create_schema.sql
@@ -0,0 +1,68 @@
+/*
+ This script creates the database schema for this sample application.
+ All lines that start with /* skip_on_open_source_pg */ are skipped when the application is running on a
+ normal PostgreSQL database. The same lines are executed when the application is running on a Cloud
+ Spanner database. The script is executed by the DatabaseSeeder class.
+*/
+
+create sequence if not exists id_generator
+/* skip_on_open_source_pg */ bit_reversed_positive
+;
+    
+create table if not exists singers (
+    id         bigint not null primary key default nextval('id_generator'),
+    first_name varchar,
+    last_name  varchar,
+    full_name  varchar generated always as (CASE WHEN first_name IS NULL THEN last_name
+                                                 WHEN last_name  IS NULL THEN first_name
+                                                 ELSE first_name || ' ' || last_name END) stored,
+    active     boolean default true,
+    created_at timestamptz default current_timestamp,
+    updated_at timestamptz default current_timestamp
+);
+
+create table if not exists albums (
+    id               bigint not null primary key default nextval('id_generator'),
+    title            varchar not null,
+    marketing_budget numeric,
+    release_date     date,
+    cover_picture    bytea,
+    singer_id        bigint not null,
+    created_at timestamptz default current_timestamp,
+    updated_at timestamptz default current_timestamp,
+    constraint fk_albums_singers foreign key (singer_id) references singers (id)
+);
+
+create table if not exists tracks (
+    id           bigint not null,
+    track_number bigint not null,
+    title        varchar not null,
+    sample_rate  float8 not null,
+    created_at timestamptz default current_timestamp,
+    updated_at timestamptz default current_timestamp,
+    primary key (id, track_number)
+)
+/* skip_on_open_source_pg */ interleave in parent albums on delete cascade
+;
+
+create table if not exists venues (
+    id          bigint not null primary key default nextval('id_generator'),
+    name        varchar not null,
+    description jsonb not null,
+    created_at timestamptz default current_timestamp,
+    updated_at timestamptz default current_timestamp
+);
+
+create table if not exists concerts (
+    id          bigint not null primary key default nextval('id_generator'),
+    venue_id    bigint not null,
+    singer_id   bigint not null,
+    name        varchar not null,
+    start_time  timestamptz not null,
+    end_time    timestamptz not null,
+    created_at timestamptz default current_timestamp,
+    updated_at timestamptz default current_timestamp,
+    constraint fk_concerts_venues  foreign key (venue_id)  references venues  (id),
+    constraint fk_concerts_singers foreign key (singer_id) references singers (id),
+    constraint chk_end_time_after_start_time check (end_time > start_time)
+);
diff --git a/samples/spring-data-jdbc/src/main/resources/drop_schema.sql b/samples/spring-data-jdbc/src/main/resources/drop_schema.sql
new file mode 100644
index 0000000..23e7b65
--- /dev/null
+++ b/samples/spring-data-jdbc/src/main/resources/drop_schema.sql
@@ -0,0 +1,5 @@
+drop table if exists concerts;
+drop table if exists venues;
+drop table if exists tracks;
+drop table if exists albums;
+drop table if exists singers;
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 e7b1a47..9713172 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/AbstractJdbcStatement.java
@@ -24,12 +24,15 @@ import com.google.cloud.spanner.connection.AbstractStatementParser;
 import com.google.cloud.spanner.connection.Connection;
 import com.google.cloud.spanner.connection.StatementResult;
 import com.google.cloud.spanner.connection.StatementResult.ClientSideStatementType;
+import com.google.rpc.Code;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.SQLWarning;
 import java.sql.Statement;
 import java.util.Arrays;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.function.Supplier;
 
 /** Base class for Cloud Spanner JDBC {@link Statement}s */
 abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Statement {
@@ -117,6 +120,25 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
     }
   }
 
+  /** Functional interface that throws {@link SQLException}. */
+  interface JdbcFunction<T, R> {
+    R apply(T t) throws SQLException;
+  }
+
+  /** Runs the given function with the timeout that has been set on this statement. */
+  protected <T> T runWithStatementTimeout(JdbcFunction<Connection, T> function)
+      throws SQLException {
+    checkClosed();
+    StatementTimeout originalTimeout = setTemporaryStatementTimeout();
+    try {
+      return function.apply(getConnection().getSpannerConnection());
+    } catch (SpannerException spannerException) {
+      throw JdbcSqlExceptionFactory.of(spannerException);
+    } finally {
+      resetStatementTimeout(originalTimeout);
+    }
+  }
+
   /**
    * Sets the statement timeout of the Spanner {@link Connection} to the query timeout of this JDBC
    * {@link Statement} and returns the original timeout of the Spanner {@link Connection} so it can
@@ -183,21 +205,74 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
       QueryAnalyzeMode analyzeMode,
       QueryOption... options)
       throws SQLException {
+    Options.QueryOption[] queryOptions = getQueryOptions(options);
+    return doWithStatementTimeout(
+        () -> {
+          com.google.cloud.spanner.ResultSet resultSet;
+          if (analyzeMode == null) {
+            resultSet = connection.getSpannerConnection().executeQuery(statement, queryOptions);
+          } else {
+            resultSet = connection.getSpannerConnection().analyzeQuery(statement, analyzeMode);
+          }
+          return JdbcResultSet.of(this, resultSet);
+        });
+  }
+
+  private <T> T doWithStatementTimeout(Supplier<T> runnable) throws SQLException {
+    return doWithStatementTimeout(runnable, ignore -> Boolean.TRUE);
+  }
+
+  private <T> T doWithStatementTimeout(
+      Supplier<T> runnable, Function<T, Boolean> shouldResetTimeout) throws SQLException {
     StatementTimeout originalTimeout = setTemporaryStatementTimeout();
+    T result = null;
     try {
-      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);
+      result = runnable.get();
+      return result;
+    } catch (SpannerException spannerException) {
+      throw JdbcSqlExceptionFactory.of(spannerException);
     } finally {
-      resetStatementTimeout(originalTimeout);
+      if (shouldResetTimeout.apply(result)) {
+        resetStatementTimeout(originalTimeout);
+      }
+    }
+  }
+
+  /**
+   * Executes a SQL statement on the connection of this {@link Statement} as an update (DML)
+   * statement.
+   *
+   * @param statement The SQL statement to execute
+   * @return the number of rows that was inserted/updated/deleted
+   * @throws SQLException if a database error occurs, or if the number of rows affected is larger
+   *     than {@link Integer#MAX_VALUE}
+   */
+  int executeUpdate(com.google.cloud.spanner.Statement statement) throws SQLException {
+    return checkedCast(executeLargeUpdate(statement));
+  }
+
+  /**
+   * Do a checked cast from long to int. Throws a {@link SQLException} with code {@link
+   * Code#OUT_OF_RANGE} if the update count is too big to fit in an int.
+   */
+  int checkedCast(long updateCount) throws SQLException {
+    if (updateCount > Integer.MAX_VALUE) {
+      throw JdbcSqlExceptionFactory.of(
+          "update count too large for executeUpdate: " + updateCount, Code.OUT_OF_RANGE);
     }
+    return (int) updateCount;
+  }
+
+  /**
+   * Executes a SQL statement on the connection of this {@link Statement} as an update (DML)
+   * statement.
+   *
+   * @param statement The SQL statement to execute
+   * @return the number of rows that was inserted/updated/deleted
+   * @throws SQLException if a database error occurs
+   */
+  long executeLargeUpdate(com.google.cloud.spanner.Statement statement) throws SQLException {
+    return doWithStatementTimeout(() -> connection.getSpannerConnection().executeUpdate(statement));
   }
 
   /**
@@ -210,24 +285,16 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
    * @throws SQLException if a database error occurs.
    */
   StatementResult execute(com.google.cloud.spanner.Statement statement) throws SQLException {
-    StatementTimeout originalTimeout = setTemporaryStatementTimeout();
-    boolean mustResetTimeout = false;
-    try {
-      StatementResult result = connection.getSpannerConnection().execute(statement);
-      mustResetTimeout = !resultIsSetStatementTimeout(result);
-      if (mustResetTimeout && resultIsShowStatementTimeout(result)) {
-        // it was a 'SHOW STATEMENT_TIMEOUT statement, we need to re-run to get the correct value
-        mustResetTimeout = false;
-        result = rerunShowStatementTimeout(statement, originalTimeout);
-      }
-      return result;
-    } catch (SpannerException e) {
-      throw JdbcSqlExceptionFactory.of(e);
-    } finally {
-      if (mustResetTimeout) {
-        resetStatementTimeout(originalTimeout);
-      }
+    StatementResult statementResult =
+        doWithStatementTimeout(
+            () -> connection.getSpannerConnection().execute(statement),
+            result -> !resultIsSetStatementTimeout(result));
+    if (resultIsShowStatementTimeout(statementResult)) {
+      // We can safely re-run it without first resetting the timeout to the original value, as that
+      // has already been done by the 'doWithStatementTimeout' function.
+      return rerunShowStatementTimeout(statement);
     }
+    return statementResult;
   }
 
   /**
@@ -250,18 +317,22 @@ abstract class AbstractJdbcStatement extends AbstractJdbcWrapper implements Stat
    *     executed was a SET STATEMENT_TIMEOUT statement.
    */
   private boolean resultIsSetStatementTimeout(StatementResult result) {
-    return result.getClientSideStatementType() == ClientSideStatementType.SET_STATEMENT_TIMEOUT;
+    return result != null
+        && result.getClientSideStatementType() == ClientSideStatementType.SET_STATEMENT_TIMEOUT;
   }
 
   private boolean resultIsShowStatementTimeout(StatementResult result) {
-    return result.getClientSideStatementType() == ClientSideStatementType.SHOW_STATEMENT_TIMEOUT;
+    return result != null
+        && result.getClientSideStatementType() == ClientSideStatementType.SHOW_STATEMENT_TIMEOUT;
   }
 
-  private StatementResult rerunShowStatementTimeout(
-      com.google.cloud.spanner.Statement statement, StatementTimeout originalTimeout)
+  private StatementResult rerunShowStatementTimeout(com.google.cloud.spanner.Statement statement)
       throws SQLException {
-    resetStatementTimeout(originalTimeout);
-    return connection.getSpannerConnection().execute(statement);
+    try {
+      return connection.getSpannerConnection().execute(statement);
+    } catch (SpannerException spannerException) {
+      throw JdbcSqlExceptionFactory.of(spannerException);
+    }
   }
 
   @Override
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 cf368f6..c161ee5 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcConnection.java
@@ -22,12 +22,14 @@ import com.google.cloud.spanner.CommitResponse;
 import com.google.cloud.spanner.CommitStats;
 import com.google.cloud.spanner.Dialect;
 import com.google.cloud.spanner.Mutation;
-import com.google.cloud.spanner.ResultSet;
+import com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.PartitionOptions;
 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.ResultSet;
 import java.sql.SQLException;
 import java.sql.Timestamp;
 import java.util.Iterator;
@@ -334,6 +336,73 @@ public interface CloudSpannerJdbcConnection extends Connection {
     return Dialect.GOOGLE_STANDARD_SQL;
   }
 
+  /**
+   * Enable data boost for partitioned queries. See also {@link
+   * CloudSpannerJdbcStatement#partitionQuery(String, PartitionOptions, QueryOption...)} and {@link
+   * CloudSpannerJdbcPreparedStatement#partitionQuery(PartitionOptions, QueryOption...)}.
+   */
+  default void setDataBoostEnabled(boolean dataBoostEnabled) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
+   * Returns whether data boost is enabled for partitioned queries. See also {@link
+   * CloudSpannerJdbcStatement#partitionQuery(String, PartitionOptions, QueryOption...)} and {@link
+   * CloudSpannerJdbcPreparedStatement#partitionQuery(PartitionOptions, QueryOption...)}.
+   */
+  default boolean isDataBoostEnabled() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
+   * Sets whether this connection should always use partitioned queries when a query is executed on
+   * this connection. Setting this flag to <code>true</code> and then executing a query that cannot
+   * be partitioned, or executing a query in a read/write transaction, will cause an error. Use this
+   * flag in combination with {@link #setDataBoostEnabled(boolean)} to force all queries on this
+   * connection to use data boost.
+   */
+  default void setAutoPartitionMode(boolean alwaysUsePartitionedQueries) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /** Returns whether this connection will execute all queries as partitioned queries. */
+  default boolean isAutoPartitionMode() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
+   * Sets the maximum number of partitions that should be included as a hint to Cloud Spanner when
+   * partitioning a query on this connection. Note that this is only a hint and Cloud Spanner might
+   * choose to ignore the hint.
+   */
+  default void setMaxPartitions(int maxPartitions) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
+   * Gets the maximum number of partitions that should be included as a hint to Cloud Spanner when
+   * partitioning a query on this connection. Note that this is only a hint and Cloud Spanner might
+   * choose to ignore the hint.
+   */
+  default int getMaxPartitions() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
+   * Sets the maximum degree of parallelism that is used when executing a partitioned query. A
+   * partitioned query will use up to <code>maxThreads</code> to execute and retrieve the results
+   * from Cloud Spanner. Set this value to <code>0</code>> to use the number of available processors
+   * as returned by {@link Runtime#availableProcessors()}.
+   */
+  default void setMaxPartitionedParallelism(int maxThreads) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  /** Returns the maximum degree of parallelism that is used for partitioned queries. */
+  default int getMaxPartitionedParallelism() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
   /**
    * @see
    *     com.google.cloud.spanner.connection.Connection#addTransactionRetryListener(com.google.cloud.spanner.connection.TransactionRetryListener)
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcPartitionedQueryResultSet.java b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcPartitionedQueryResultSet.java
new file mode 100644
index 0000000..3b6118f
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcPartitionedQueryResultSet.java
@@ -0,0 +1,33 @@
+/*
+ * 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 com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.PartitionOptions;
+import java.sql.ResultSet;
+
+/**
+ * Result set that is returned for partitioned queries, e.g. for 'run partitioned query select ...'
+ * or for {@link CloudSpannerJdbcPreparedStatement#runPartitionedQuery(PartitionOptions,
+ * QueryOption...)}.
+ */
+public interface CloudSpannerJdbcPartitionedQueryResultSet extends ResultSet {
+  /** Returns the number of partitions that this result set contains. */
+  int getNumPartitions();
+
+  /** Returns the degree of parallelism that this result set uses. */
+  int getParallelism();
+}
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcPreparedStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcPreparedStatement.java
new file mode 100644
index 0000000..8e72d95
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcPreparedStatement.java
@@ -0,0 +1,61 @@
+/*
+ * 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 com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.PartitionOptions;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * This interface is implemented by {@link PreparedStatement}s that are created on Cloud Spanner
+ * JDBC connections.
+ */
+public interface CloudSpannerJdbcPreparedStatement extends PreparedStatement {
+
+  /**
+   * Partitions this query, so it can be executed in parallel. This method returns a {@link
+   * ResultSet} with a string-representation of the partitions that were created. These strings can
+   * be used to execute a partition either on this connection or an any other connection (on this
+   * host or an any other host) by calling the method {@link #runPartition()}. This method will
+   * automatically enable data boost for the query if {@link
+   * CloudSpannerJdbcConnection#isDataBoostEnabled()} returns true.
+   */
+  ResultSet partitionQuery(PartitionOptions partitionOptions, QueryOption... options)
+      throws SQLException;
+
+  /**
+   * Executes the given partition of a query. The partition that should be executed must be set as a
+   * string parameter on this {@link PreparedStatement} using {@link #setString(int, String)}. The
+   * value should be a string that was returned by {@link #partitionQuery(PartitionOptions,
+   * QueryOption...)}.
+   */
+  ResultSet runPartition() throws SQLException;
+
+  /**
+   * Executes the given query as a partitioned query. The query will first be partitioned using the
+   * {@link #partitionQuery(PartitionOptions, QueryOption...)} method. Each of the partitions will
+   * then be executed in the background, and the results will be merged into a single result set.
+   *
+   * <p>This method will use {@link CloudSpannerJdbcConnection#getMaxPartitionedParallelism()}
+   * threads to execute the partitioned query. Set this variable to a higher/lower value to
+   * increase/decrease the degree of parallelism used for execution.
+   */
+  CloudSpannerJdbcPartitionedQueryResultSet runPartitionedQuery(
+      PartitionOptions partitionOptions, QueryOption... options) throws SQLException;
+}
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcStatement.java
new file mode 100644
index 0000000..6115c0b
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/CloudSpannerJdbcStatement.java
@@ -0,0 +1,60 @@
+/*
+ * 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 com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.PartitionOptions;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/**
+ * This interface is implemented by {@link Statement}s that are created on Cloud Spanner JDBC
+ * connections.
+ */
+public interface CloudSpannerJdbcStatement extends Statement {
+
+  /**
+   * Partitions the given query, so it can be executed in parallel. This method returns a {@link
+   * ResultSet} with a string-representation of the partitions that were created. These strings can
+   * be used to execute a partition either on this connection or an any other connection (on this
+   * host or an any other host) by calling the method {@link #runPartition(String)}. This method
+   * will automatically enable data boost for the query if {@link
+   * CloudSpannerJdbcConnection#isDataBoostEnabled()} returns true.
+   */
+  ResultSet partitionQuery(String query, PartitionOptions partitionOptions, QueryOption... options)
+      throws SQLException;
+
+  /**
+   * Executes the given partition of a query. The encodedPartitionId should be a string that was
+   * returned by {@link #partitionQuery(String, PartitionOptions, QueryOption...)}.
+   */
+  ResultSet runPartition(String encodedPartitionId) throws SQLException;
+
+  /**
+   * Executes the given query as a partitioned query. The query will first be partitioned using the
+   * {@link #partitionQuery(String, PartitionOptions, QueryOption...)} method. Each of the
+   * partitions will then be executed in the background, and the results will be merged into a
+   * single result set.
+   *
+   * <p>This method will use {@link CloudSpannerJdbcConnection#getMaxPartitionedParallelism()}
+   * threads to execute the partitioned query. Set this variable to a higher/lower value to
+   * increase/decrease the degree of parallelism used for execution.
+   */
+  CloudSpannerJdbcPartitionedQueryResultSet runPartitionedQuery(
+      String query, PartitionOptions partitionOptions, QueryOption... options) throws SQLException;
+}
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 6d6bd80..6031afe 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcConnection.java
@@ -25,6 +25,7 @@ import com.google.cloud.spanner.Mutation;
 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.Connection;
 import com.google.cloud.spanner.connection.ConnectionOptions;
 import com.google.cloud.spanner.connection.SavepointSupport;
 import com.google.cloud.spanner.connection.TransactionMode;
@@ -44,6 +45,8 @@ import java.sql.Timestamp;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
 
 /** Jdbc Connection class for Google Cloud Spanner */
 class JdbcConnection extends AbstractJdbcConnection {
@@ -592,6 +595,72 @@ class JdbcConnection extends AbstractJdbcConnection {
     }
   }
 
+  /**
+   * Convenience method for calling a setter and translating any {@link SpannerException} to a
+   * {@link SQLException}.
+   */
+  private <T> void set(BiConsumer<Connection, T> setter, T value) throws SQLException {
+    checkClosed();
+    try {
+      setter.accept(getSpannerConnection(), value);
+    } catch (SpannerException spannerException) {
+      throw JdbcSqlExceptionFactory.of(spannerException);
+    }
+  }
+
+  /**
+   * Convenience method for calling a getter and translating any {@link SpannerException} to a
+   * {@link SQLException}.
+   */
+  private <R> R get(Function<Connection, R> getter) throws SQLException {
+    checkClosed();
+    try {
+      return getter.apply(getSpannerConnection());
+    } catch (SpannerException spannerException) {
+      throw JdbcSqlExceptionFactory.of(spannerException);
+    }
+  }
+
+  @Override
+  public void setDataBoostEnabled(boolean dataBoostEnabled) throws SQLException {
+    set(Connection::setDataBoostEnabled, dataBoostEnabled);
+  }
+
+  @Override
+  public boolean isDataBoostEnabled() throws SQLException {
+    return get(Connection::isDataBoostEnabled);
+  }
+
+  @Override
+  public void setAutoPartitionMode(boolean autoPartitionMode) throws SQLException {
+    set(Connection::setAutoPartitionMode, autoPartitionMode);
+  }
+
+  @Override
+  public boolean isAutoPartitionMode() throws SQLException {
+    return get(Connection::isAutoPartitionMode);
+  }
+
+  @Override
+  public void setMaxPartitions(int maxPartitions) throws SQLException {
+    set(Connection::setMaxPartitions, maxPartitions);
+  }
+
+  @Override
+  public int getMaxPartitions() throws SQLException {
+    return get(Connection::getMaxPartitions);
+  }
+
+  @Override
+  public void setMaxPartitionedParallelism(int maxThreads) throws SQLException {
+    set(Connection::setMaxPartitionedParallelism, maxThreads);
+  }
+
+  @Override
+  public int getMaxPartitionedParallelism() throws SQLException {
+    return get(Connection::getMaxPartitionedParallelism);
+  }
+
   @SuppressWarnings("deprecation")
   private static final class JdbcToSpannerTransactionRetryListener
       implements com.google.cloud.spanner.connection.TransactionRetryListener {
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPartitionedQueryResultSet.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPartitionedQueryResultSet.java
new file mode 100644
index 0000000..1d5ef66
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPartitionedQueryResultSet.java
@@ -0,0 +1,51 @@
+/*
+ * 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 com.google.cloud.spanner.connection.PartitionedQueryResultSet;
+import com.google.common.base.Preconditions;
+import java.sql.Statement;
+
+/**
+ * {@link java.sql.ResultSet} implementation that is returned for queries that are executed with
+ * `RUN PARTITIONED QUERY ...`.
+ */
+class JdbcPartitionedQueryResultSet extends JdbcResultSet
+    implements CloudSpannerJdbcPartitionedQueryResultSet {
+  static JdbcPartitionedQueryResultSet of(
+      Statement statement, PartitionedQueryResultSet resultSet) {
+    return new JdbcPartitionedQueryResultSet(
+        Preconditions.checkNotNull(statement), Preconditions.checkNotNull(resultSet));
+  }
+
+  private final PartitionedQueryResultSet partitionedQueryResultSet;
+
+  private JdbcPartitionedQueryResultSet(Statement statement, PartitionedQueryResultSet resultSet) {
+    super(statement, resultSet);
+    this.partitionedQueryResultSet = resultSet;
+  }
+
+  @Override
+  public int getNumPartitions() {
+    return partitionedQueryResultSet.getNumPartitions();
+  }
+
+  @Override
+  public int getParallelism() {
+    return partitionedQueryResultSet.getParallelism();
+  }
+}
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 3ab302c..518807d 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcPreparedStatement.java
@@ -16,7 +16,9 @@
 
 package com.google.cloud.spanner.jdbc;
 
+import com.google.cloud.spanner.Dialect;
 import com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.PartitionOptions;
 import com.google.cloud.spanner.ReadContext.QueryAnalyzeMode;
 import com.google.cloud.spanner.ResultSets;
 import com.google.cloud.spanner.SpannerException;
@@ -33,10 +35,10 @@ import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
 
 /** Implementation of {@link PreparedStatement} for Cloud Spanner. */
-class JdbcPreparedStatement extends AbstractJdbcPreparedStatement {
+class JdbcPreparedStatement extends AbstractJdbcPreparedStatement
+    implements CloudSpannerJdbcPreparedStatement {
   private static final char POS_PARAM_CHAR = '?';
   private final String sql;
-  private final String sqlWithoutComments;
   private final ParametersInfo parameters;
   private final ImmutableList<String> generatedKeysColumns;
 
@@ -46,9 +48,15 @@ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement {
     super(connection);
     this.sql = sql;
     try {
-      this.sqlWithoutComments = parser.removeCommentsAndTrim(this.sql);
+      // The PostgreSQL parser allows comments to be present in the SQL string that is used to parse
+      // the query parameters.
+      String sqlForParameterExtraction =
+          getConnection().getDialect() == Dialect.POSTGRESQL
+              ? this.sql
+              : parser.removeCommentsAndTrim(this.sql);
       this.parameters =
-          parser.convertPositionalParametersToNamedParameters(POS_PARAM_CHAR, sqlWithoutComments);
+          parser.convertPositionalParametersToNamedParameters(
+              POS_PARAM_CHAR, sqlForParameterExtraction);
     } catch (SpannerException e) {
       throw JdbcSqlExceptionFactory.of(e);
     }
@@ -128,4 +136,37 @@ class JdbcPreparedStatement extends AbstractJdbcPreparedStatement {
       return rs.getMetaData();
     }
   }
+
+  @Override
+  public ResultSet partitionQuery(PartitionOptions partitionOptions, QueryOption... options)
+      throws SQLException {
+    return runWithStatementTimeout(
+        connection ->
+            JdbcResultSet.of(
+                this, connection.partitionQuery(createStatement(), partitionOptions, options)));
+  }
+
+  @Override
+  public ResultSet runPartition() throws SQLException {
+    return runWithStatementTimeout(
+        connection -> {
+          if (getParameters().getHighestIndex() < 1 || getParameters().getParameter(1) == null) {
+            throw JdbcSqlExceptionFactory.of(
+                "No query parameter has been set. runPartition() requires the partition ID to be set as a query parameter with index 1. Call PreparedStatement#setString(1, \"some-partition-id\") before calling runPartition().",
+                Code.FAILED_PRECONDITION);
+          }
+          String partitionId = getParameters().getParameter(1).toString();
+          return JdbcResultSet.of(this, connection.runPartition(partitionId));
+        });
+  }
+
+  @Override
+  public CloudSpannerJdbcPartitionedQueryResultSet runPartitionedQuery(
+      PartitionOptions partitionOptions, QueryOption... options) throws SQLException {
+    return runWithStatementTimeout(
+        connection ->
+            JdbcPartitionedQueryResultSet.of(
+                this,
+                connection.runPartitionedQuery(createStatement(), partitionOptions, options)));
+  }
 }
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java
index 94b4604..4152fa5 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcResultSet.java
@@ -21,6 +21,7 @@ import com.google.cloud.spanner.Struct;
 import com.google.cloud.spanner.Type;
 import com.google.cloud.spanner.Type.Code;
 import com.google.cloud.spanner.Value;
+import com.google.cloud.spanner.connection.PartitionedQueryResultSet;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import java.io.ByteArrayInputStream;
@@ -59,9 +60,13 @@ class JdbcResultSet extends AbstractJdbcResultSet {
   }
 
   static JdbcResultSet of(Statement statement, com.google.cloud.spanner.ResultSet resultSet) {
-    Preconditions.checkNotNull(statement);
-    Preconditions.checkNotNull(resultSet);
-    return new JdbcResultSet(statement, resultSet);
+    // Return a JDBC version of a PartitionedQueryResultSet if the Cloud Spanner Java client
+    // returned a PartitionedQueryResultSet.
+    if (resultSet instanceof PartitionedQueryResultSet) {
+      return JdbcPartitionedQueryResultSet.of(statement, (PartitionedQueryResultSet) resultSet);
+    }
+    return new JdbcResultSet(
+        Preconditions.checkNotNull(statement), Preconditions.checkNotNull(resultSet));
   }
 
   /**
@@ -123,7 +128,7 @@ class JdbcResultSet extends AbstractJdbcResultSet {
   private boolean nextCalledForMetaDataResult = false;
   private long currentRow = 0L;
 
-  private JdbcResultSet(Statement statement, com.google.cloud.spanner.ResultSet spanner) {
+  JdbcResultSet(Statement statement, com.google.cloud.spanner.ResultSet spanner) {
     super(spanner);
     this.statement = statement;
   }
diff --git a/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java b/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java
index 6c6a45a..60cc3e5 100644
--- a/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java
+++ b/src/main/java/com/google/cloud/spanner/jdbc/JdbcStatement.java
@@ -19,6 +19,8 @@ package com.google.cloud.spanner.jdbc;
 import static com.google.cloud.spanner.jdbc.JdbcConnection.NO_GENERATED_KEY_COLUMNS;
 
 import com.google.cloud.spanner.Options;
+import com.google.cloud.spanner.Options.QueryOption;
+import com.google.cloud.spanner.PartitionOptions;
 import com.google.cloud.spanner.ResultSets;
 import com.google.cloud.spanner.SpannerBatchUpdateException;
 import com.google.cloud.spanner.SpannerException;
@@ -40,7 +42,7 @@ import java.util.List;
 import java.util.stream.Collectors;
 
 /** Implementation of {@link java.sql.Statement} for Google Cloud Spanner. */
-class JdbcStatement extends AbstractJdbcStatement {
+class JdbcStatement extends AbstractJdbcStatement implements CloudSpannerJdbcStatement {
   static final ImmutableList<String> ALL_COLUMNS = ImmutableList.of("*");
 
   enum BatchType {
@@ -79,11 +81,7 @@ class JdbcStatement extends AbstractJdbcStatement {
 
   private int executeUpdate(String sql, ImmutableList<String> generatedKeysColumns)
       throws SQLException {
-    long result = executeLargeUpdate(sql, generatedKeysColumns);
-    if (result > Integer.MAX_VALUE) {
-      throw JdbcSqlExceptionFactory.of("update count too large: " + result, Code.OUT_OF_RANGE);
-    }
-    return (int) result;
+    return checkedCast(executeLargeUpdate(sql, generatedKeysColumns));
   }
 
   /**
@@ -300,11 +298,7 @@ class JdbcStatement extends AbstractJdbcStatement {
   @Override
   public int getUpdateCount() throws SQLException {
     checkClosed();
-    if (currentUpdateCount > Integer.MAX_VALUE) {
-      throw JdbcSqlExceptionFactory.of(
-          "update count too large: " + currentUpdateCount, Code.OUT_OF_RANGE);
-    }
-    return (int) currentUpdateCount;
+    return checkedCast(currentUpdateCount);
   }
 
   /**
@@ -481,12 +475,7 @@ class JdbcStatement extends AbstractJdbcStatement {
   int[] convertUpdateCounts(long[] updateCounts) throws SQLException {
     int[] res = new int[updateCounts.length];
     for (int index = 0; index < updateCounts.length; index++) {
-      if (updateCounts[index] > Integer.MAX_VALUE) {
-        throw JdbcSqlExceptionFactory.of(
-            String.format("Update count too large for int: %d", updateCounts[index]),
-            Code.OUT_OF_RANGE);
-      }
-      res[index] = (int) updateCounts[index];
+      res[index] = checkedCast(updateCounts[index]);
     }
     return res;
   }
@@ -598,4 +587,29 @@ class JdbcStatement extends AbstractJdbcStatement {
   static boolean isNullOrEmpty(String[] columnNames) {
     return columnNames == null || columnNames.length == 0;
   }
+
+  @Override
+  public ResultSet partitionQuery(
+      String query, PartitionOptions partitionOptions, QueryOption... options) throws SQLException {
+    return runWithStatementTimeout(
+        connection ->
+            JdbcResultSet.of(
+                this, connection.partitionQuery(Statement.of(query), partitionOptions, options)));
+  }
+
+  @Override
+  public ResultSet runPartition(String encodedPartitionId) throws SQLException {
+    return runWithStatementTimeout(
+        connection -> JdbcResultSet.of(this, connection.runPartition(encodedPartitionId)));
+  }
+
+  @Override
+  public CloudSpannerJdbcPartitionedQueryResultSet runPartitionedQuery(
+      String query, PartitionOptions partitionOptions, QueryOption... options) throws SQLException {
+    return runWithStatementTimeout(
+        connection ->
+            JdbcPartitionedQueryResultSet.of(
+                this,
+                connection.runPartitionedQuery(Statement.of(query), partitionOptions, options)));
+  }
 }
diff --git a/synth.metadata b/synth.metadata
index f335115..f877cda 100644
--- a/synth.metadata
+++ b/synth.metadata
@@ -85,7 +85,6 @@
     "renovate.json",
     "samples/install-without-bom/pom.xml",
     "samples/pom.xml",
-    "samples/snapshot/pom.xml",
-    "samples/snippets/pom.xml"
+    "samples/snapshot/pom.xml"
   ]
 }
\ No newline at end of file
diff --git a/versions.txt b/versions.txt
index 4a69a3a..2da2362 100644
--- a/versions.txt
+++ b/versions.txt
@@ -1,4 +1,4 @@
 # Format:
 # module:released-version:current-version
 
-google-cloud-spanner-jdbc:2.11.9:2.11.10-SNAPSHOT
+google-cloud-spanner-jdbc:2.12.1:2.12.2-SNAPSHOT


Test patch:
diff --git a/.github/workflows/integration-tests-against-emulator.yaml b/.github/workflows/integration-tests-against-emulator.yaml
index 8ee7c4d..f299be4 100644
--- a/.github/workflows/integration-tests-against-emulator.yaml
+++ b/.github/workflows/integration-tests-against-emulator.yaml
@@ -16,7 +16,7 @@ jobs:
           - 9020:9020
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4
     - uses: stCarolas/setup-maven@v4
       with:
         maven-version: 3.8.1
diff --git a/samples/spring-data-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java b/samples/spring-data-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java
new file mode 100644
index 0000000..726b32c
--- /dev/null
+++ b/samples/spring-data-jdbc/src/test/java/com/google/cloud/spanner/sample/ApplicationTest.java
@@ -0,0 +1,766 @@
+/*
+ * 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.sample;
+
+import static com.google.cloud.spanner.sample.DatabaseSeeder.INITIAL_SINGERS;
+import static com.google.cloud.spanner.sample.DatabaseSeeder.randomDate;
+import static com.google.cloud.spanner.sample.DatabaseSeeder.randomTitle;
+import static junit.framework.TestCase.assertEquals;
+
+import com.google.cloud.Timestamp;
+import com.google.cloud.spanner.Dialect;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.connection.AbstractMockServerTest;
+import com.google.cloud.spanner.sample.entities.Singer;
+import com.google.common.collect.Streams;
+import com.google.longrunning.Operation;
+import com.google.protobuf.Any;
+import com.google.protobuf.Empty;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.NullValue;
+import com.google.protobuf.Value;
+import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata;
+import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteBatchDmlRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.ResultSet;
+import com.google.spanner.v1.ResultSetMetadata;
+import com.google.spanner.v1.ResultSetStats;
+import com.google.spanner.v1.StructType;
+import com.google.spanner.v1.StructType.Field;
+import com.google.spanner.v1.Type;
+import com.google.spanner.v1.TypeAnnotationCode;
+import com.google.spanner.v1.TypeCode;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.LongStream;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.springframework.boot.SpringApplication;
+
+@RunWith(JUnit4.class)
+public class ApplicationTest extends AbstractMockServerTest {
+
+  @BeforeClass
+  public static void setupQueryResults() {
+    // Set the database dialect.
+    mockSpanner.putStatementResult(StatementResult.detectDialectResult(Dialect.POSTGRESQL));
+    // Set up a result for the dialect check that is executed by the JdbcConfiguration class.
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                "select 1 "
+                    + "from information_schema.database_options "
+                    + "where schema_name='public' "
+                    + "and option_name='database_dialect' "
+                    + "and option_value='POSTGRESQL'"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("c")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue("1").build())
+                        .build())
+                .build()));
+    // Add a DDL response to the server.
+    addDdlResponseToSpannerAdmin();
+
+    // Set up results for the 'delete all test data' operations.
+    mockSpanner.putStatementResult(
+        StatementResult.update(Statement.of("delete from concerts"), 0L));
+    mockSpanner.putStatementResult(StatementResult.update(Statement.of("delete from venues"), 0L));
+    mockSpanner.putStatementResult(StatementResult.update(Statement.of("delete from tracks"), 0L));
+    mockSpanner.putStatementResult(StatementResult.update(Statement.of("delete from albums"), 0L));
+    mockSpanner.putStatementResult(StatementResult.update(Statement.of("delete from singers"), 0L));
+
+    // Set up results for inserting test data.
+    for (Singer singer : INITIAL_SINGERS) {
+      mockSpanner.putStatementResult(
+          StatementResult.update(
+              Statement.newBuilder("insert into singers (first_name, last_name) values ($1, $2)")
+                  .bind("p1")
+                  .to(singer.getFirstName())
+                  .bind("p2")
+                  .to(singer.getLastName())
+                  .build(),
+              1L));
+    }
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of("select id from singers"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addAllRows(
+                    LongStream.rangeClosed(1L, INITIAL_SINGERS.size())
+                        .mapToObj(
+                            id ->
+                                ListValue.newBuilder()
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setStringValue(String.valueOf(Long.reverse(id)))
+                                            .build())
+                                    .build())
+                        .collect(Collectors.toList()))
+                .build()));
+    mockSpanner.putPartialStatementResult(
+        StatementResult.update(
+            Statement.of(
+                "insert into albums (title, marketing_budget, release_date, cover_picture, singer_id) values ($1, $2, $3, $4, $5)"),
+            1L));
+
+    // Set up results for the queries that the application runs.
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                "SELECT \"singers\".\"id\" AS \"id\", \"singers\".\"active\" AS \"active\", "
+                    + "\"singers\".\"last_name\" AS \"last_name\", \"singers\".\"full_name\" AS \"full_name\", "
+                    + "\"singers\".\"updated_at\" AS \"updated_at\", \"singers\".\"created_at\" AS \"created_at\", "
+                    + "\"singers\".\"first_name\" AS \"first_name\" FROM \"singers\""),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("active")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("last_name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("full_name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("updated_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("created_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("first_name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addAllRows(
+                    Streams.mapWithIndex(
+                            INITIAL_SINGERS.stream(),
+                            (singer, index) ->
+                                ListValue.newBuilder()
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setStringValue(String.valueOf(Long.reverse(index + 1)))
+                                            .build())
+                                    .addValues(Value.newBuilder().setBoolValue(true).build())
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setStringValue(singer.getLastName())
+                                            .build())
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setStringValue(
+                                                singer.getFirstName() + " " + singer.getLastName())
+                                            .build())
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setNullValue(NullValue.NULL_VALUE)
+                                            .build())
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setNullValue(NullValue.NULL_VALUE)
+                                            .build())
+                                    .addValues(
+                                        Value.newBuilder()
+                                            .setStringValue(singer.getFirstName())
+                                            .build())
+                                    .build())
+                        .collect(Collectors.toList()))
+                .build()));
+    mockSpanner.putPartialStatementResult(
+        StatementResult.query(
+            Statement.of("SELECT COUNT(*) FROM \"albums\" WHERE \"albums\".\"singer_id\" = $1"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("c")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue("10").build())
+                        .build())
+                .build()));
+    for (long singerId : LongStream.rangeClosed(1L, INITIAL_SINGERS.size()).toArray()) {
+      mockSpanner.putStatementResult(
+          StatementResult.query(
+              Statement.newBuilder(
+                      "SELECT \"albums\".\"id\" AS \"id\", \"albums\".\"title\" AS \"title\", "
+                          + "\"albums\".\"singer_id\" AS \"singer_id\", \"albums\".\"updated_at\" AS \"updated_at\", "
+                          + "\"albums\".\"created_at\" AS \"created_at\", \"albums\".\"release_date\" AS \"release_date\", "
+                          + "\"albums\".\"cover_picture\" AS \"cover_picture\", \"albums\".\"marketing_budget\" AS \"marketing_budget\" "
+                          + "FROM \"albums\" WHERE \"albums\".\"singer_id\" = $1")
+                  .bind("p1")
+                  .to(Long.reverse(singerId))
+                  .build(),
+              ResultSet.newBuilder()
+                  .setMetadata(
+                      ResultSetMetadata.newBuilder()
+                          .setRowType(
+                              StructType.newBuilder()
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("id")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.INT64).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("title")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.STRING).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("singer_id")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.INT64).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("updated_at")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("created_at")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("release_date")
+                                          .setType(Type.newBuilder().setCode(TypeCode.DATE).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("cover_picture")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.BYTES).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("marketing_budget")
+                                          .setType(
+                                              Type.newBuilder()
+                                                  .setCode(TypeCode.NUMERIC)
+                                                  .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC)
+                                                  .build())
+                                          .build())
+                                  .build())
+                          .build())
+                  .addAllRows(
+                      IntStream.rangeClosed(1, 10)
+                          .mapToObj(
+                              albumId ->
+                                  ListValue.newBuilder()
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(
+                                                  String.valueOf(Long.reverse(albumId * singerId)))
+                                              .build())
+                                      .addValues(Value.newBuilder().setStringValue(randomTitle()))
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(
+                                                  String.valueOf(Long.reverse(singerId))))
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setNullValue(NullValue.NULL_VALUE)
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setNullValue(NullValue.NULL_VALUE)
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(randomDate().toString())
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setNullValue(NullValue.NULL_VALUE)
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setNullValue(NullValue.NULL_VALUE)
+                                              .build())
+                                      .build())
+                          .collect(Collectors.toList()))
+                  .build()));
+    }
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder(
+                    "INSERT INTO \"singers\" (\"active\", \"created_at\", \"first_name\", \"last_name\", \"updated_at\") VALUES ($1, $2, $3, $4, $5)\n"
+                        + "RETURNING *")
+                .bind("p1")
+                .to((Boolean) null)
+                .bind("p2")
+                .to((Timestamp) null)
+                .bind("p3")
+                .to("Amethyst")
+                .bind("p4")
+                .to("Jiang")
+                .bind("p5")
+                .to((Timestamp) null)
+                .build(),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("active")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("last_name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("full_name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("updated_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("created_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("first_name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(
+                            Value.newBuilder()
+                                .setStringValue(
+                                    String.valueOf(Long.reverse(INITIAL_SINGERS.size() + 2)))
+                                .build())
+                        .addValues(Value.newBuilder().setBoolValue(true).build())
+                        .addValues(Value.newBuilder().setStringValue("Amethyst").build())
+                        .addValues(Value.newBuilder().setStringValue("Amethyst Jiang").build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setStringValue("Jiang").build())
+                        .build())
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+    mockSpanner.putPartialStatementResult(
+        StatementResult.query(
+            Statement.of(
+                "INSERT INTO \"albums\" (\"cover_picture\", \"created_at\", \"marketing_budget\", \"release_date\", \"singer_id\", \"title\", \"updated_at\") VALUES ($1, $2, $3, $4, $5, $6, $7)\n"
+                    + "RETURNING *"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("title")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("singer_id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("updated_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("created_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("release_date")
+                                        .setType(Type.newBuilder().setCode(TypeCode.DATE).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("cover_picture")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BYTES).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("marketing_budget")
+                                        .setType(
+                                            Type.newBuilder()
+                                                .setCode(TypeCode.NUMERIC)
+                                                .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC)
+                                                .build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(
+                            Value.newBuilder()
+                                .setStringValue(String.valueOf(Long.reverse(1L)))
+                                .build())
+                        .addValues(Value.newBuilder().setStringValue(randomTitle()))
+                        .addValues(Value.newBuilder().setStringValue(String.valueOf(1L)))
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(
+                            Value.newBuilder().setStringValue(randomDate().toString()).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .build())
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of("select * from albums limit 1"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("title")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("singer_id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("updated_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("created_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("release_date")
+                                        .setType(Type.newBuilder().setCode(TypeCode.DATE).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("cover_picture")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BYTES).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("marketing_budget")
+                                        .setType(
+                                            Type.newBuilder()
+                                                .setCode(TypeCode.NUMERIC)
+                                                .setTypeAnnotation(TypeAnnotationCode.PG_NUMERIC)
+                                                .build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(
+                            Value.newBuilder()
+                                .setStringValue(String.valueOf(Long.reverse(1L)))
+                                .build())
+                        .addValues(Value.newBuilder().setStringValue(randomTitle()))
+                        .addValues(Value.newBuilder().setStringValue(String.valueOf(1L)))
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(
+                            Value.newBuilder().setStringValue(randomDate().toString()).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .build())
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+    mockSpanner.putPartialStatementResult(
+        StatementResult.query(
+            Statement.of(
+                "INSERT INTO \"tracks\" (\"created_at\", \"id\", \"sample_rate\", \"title\", \"track_number\", \"updated_at\") VALUES ($1, $2, $3, $4, $5, $6)\n"
+                    + "RETURNING *"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("created_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("id")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("sample_rate")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.FLOAT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("title")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("track_number")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("updated_at")
+                                        .setType(
+                                            Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setStringValue("1").build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .addValues(Value.newBuilder().setStringValue(randomTitle()).build())
+                        .addValues(Value.newBuilder().setStringValue("1").build())
+                        .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                        .build())
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+    for (String prefix : new String[] {"J%", "A%", "B%", "C%"}) {
+      mockSpanner.putStatementResult(
+          StatementResult.query(
+              Statement.newBuilder(
+                      "SELECT \"singers\".\"id\" AS \"id\", \"singers\".\"active\" AS \"active\", "
+                          + "\"singers\".\"last_name\" AS \"last_name\", \"singers\".\"full_name\" AS \"full_name\", "
+                          + "\"singers\".\"updated_at\" AS \"updated_at\", \"singers\".\"created_at\" AS \"created_at\", "
+                          + "\"singers\".\"first_name\" AS \"first_name\" "
+                          + "FROM \"singers\" WHERE \"singers\".\"last_name\" LIKE $1")
+                  .bind("p1")
+                  .to(prefix)
+                  .build(),
+              ResultSet.newBuilder()
+                  .setMetadata(
+                      ResultSetMetadata.newBuilder()
+                          .setRowType(
+                              StructType.newBuilder()
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("id")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.INT64).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("active")
+                                          .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("last_name")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.STRING).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("full_name")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.STRING).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("updated_at")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("created_at")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                                          .build())
+                                  .addFields(
+                                      Field.newBuilder()
+                                          .setName("first_name")
+                                          .setType(
+                                              Type.newBuilder().setCode(TypeCode.STRING).build())
+                                          .build())
+                                  .build())
+                          .build())
+                  .addAllRows(
+                      Streams.mapWithIndex(
+                              INITIAL_SINGERS.stream()
+                                  .filter(
+                                      singer ->
+                                          singer.getLastName().startsWith(prefix.substring(0, 1))),
+                              (singer, index) ->
+                                  ListValue.newBuilder()
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(
+                                                  String.valueOf(Long.reverse(index + 1)))
+                                              .build())
+                                      .addValues(Value.newBuilder().setBoolValue(true).build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(singer.getLastName())
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(
+                                                  singer.getFirstName()
+                                                      + " "
+                                                      + singer.getLastName())
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setNullValue(NullValue.NULL_VALUE)
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setNullValue(NullValue.NULL_VALUE)
+                                              .build())
+                                      .addValues(
+                                          Value.newBuilder()
+                                              .setStringValue(singer.getFirstName())
+                                              .build())
+                                      .build())
+                          .collect(Collectors.toList()))
+                  .build()));
+    }
+  }
+
+  @Test
+  public void testRunApplication() {
+    System.setProperty("port", String.valueOf(getPort()));
+    SpringApplication.run(Application.class).close();
+
+    assertEquals(
+        34,
+        mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+            .filter(request -> !request.getSql().equals("SELECT 1"))
+            .count());
+    assertEquals(3, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class));
+    assertEquals(5, mockSpanner.countRequestsOfType(CommitRequest.class));
+  }
+
+  private static void addDdlResponseToSpannerAdmin() {
+    mockDatabaseAdmin.addResponse(
+        Operation.newBuilder()
+            .setDone(true)
+            .setResponse(Any.pack(Empty.getDefaultInstance()))
+            .setMetadata(Any.pack(UpdateDatabaseDdlMetadata.getDefaultInstance()))
+            .build());
+  }
+}
diff --git a/samples/spring-data-jdbc/src/test/resources/application-cs.properties b/samples/spring-data-jdbc/src/test/resources/application-cs.properties
new file mode 100644
index 0000000..05f7cfa
--- /dev/null
+++ b/samples/spring-data-jdbc/src/test/resources/application-cs.properties
@@ -0,0 +1,9 @@
+
+# This profile uses a Cloud Spanner PostgreSQL database.
+
+spanner.project=my-project
+spanner.instance=my-instance
+spanner.database=spring-data-jdbc
+
+spring.datasource.url=jdbc:cloudspanner://localhost:${port}/projects/${spanner.project}/instances/${spanner.instance}/databases/${spanner.database}?usePlainText=true
+spring.datasource.driver-class-name=com.google.cloud.spanner.jdbc.JdbcDriver
diff --git a/samples/spring-data-jdbc/src/test/resources/application-pg.properties b/samples/spring-data-jdbc/src/test/resources/application-pg.properties
new file mode 100644
index 0000000..894f63e
--- /dev/null
+++ b/samples/spring-data-jdbc/src/test/resources/application-pg.properties
@@ -0,0 +1,7 @@
+
+# This profile uses an open-source PostgreSQL database.
+
+spring.datasource.url=jdbc:postgresql://localhost:5432/spring-data-jdbc
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.username=postgres
+spring.datasource.password=mysecretpassword
diff --git a/samples/spring-data-jdbc/src/test/resources/application.properties b/samples/spring-data-jdbc/src/test/resources/application.properties
new file mode 100644
index 0000000..3347f31
--- /dev/null
+++ b/samples/spring-data-jdbc/src/test/resources/application.properties
@@ -0,0 +1,9 @@
+
+# This application can use both a Cloud Spanner PostgreSQL database or an open-source PostgreSQL
+# database. Which database is used is determined by the active profile:
+# 1. 'cs' means use Cloud Spanner.
+# 2. 'pg' means use open-source PostgreSQL.
+
+# Activate the Cloud Spanner profile by default.
+# Change to 'pg' to activate the PostgreSQL profile.
+spring.profiles.default=cs
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/ExecuteMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/ExecuteMockServerTest.java
index f74de66..65a54a8 100644
--- a/src/test/java/com/google/cloud/spanner/jdbc/ExecuteMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/jdbc/ExecuteMockServerTest.java
@@ -17,39 +17,97 @@
 package com.google.cloud.spanner.jdbc;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import com.google.cloud.spanner.Dialect;
 import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
 import com.google.cloud.spanner.SessionPoolOptions;
 import com.google.cloud.spanner.connection.AbstractMockServerTest;
 import com.google.cloud.spanner.connection.ConnectionOptions;
+import com.google.cloud.spanner.connection.SpannerPool;
+import com.google.longrunning.Operation;
+import com.google.protobuf.Any;
+import com.google.protobuf.Empty;
 import com.google.protobuf.ListValue;
 import com.google.protobuf.Value;
 import com.google.rpc.Code;
+import com.google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata;
 import com.google.spanner.v1.ResultSetMetadata;
+import com.google.spanner.v1.ResultSetStats;
 import com.google.spanner.v1.StructType;
 import com.google.spanner.v1.StructType.Field;
 import com.google.spanner.v1.Type;
 import com.google.spanner.v1.TypeCode;
 import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.sql.Statement;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
 import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
 
 /**
  * Test class for verifying that the methods execute, executeQuery, and executeUpdate work as
- * intended.
+ * intended. It also verifies that they always also include any comments in the statement for the
+ * PostgreSQL dialect, as these may contain hints.
  */
-@RunWith(JUnit4.class)
+@RunWith(Parameterized.class)
 public class ExecuteMockServerTest extends AbstractMockServerTest {
-  private static final String QUERY = "select * from my_table";
+
+  @Parameters(name = "dialect = {0}")
+  public static Object[] parameters() {
+    return Dialect.values();
+  }
+
+  @Parameter public Dialect dialect;
+
+  private static final String DDL = "create table my_table";
+  private static final long LARGE_UPDATE_COUNT = 2L * Integer.MAX_VALUE;
+
+  private String query;
+  private String dml;
+  private String largeDml;
+  private String dmlReturning;
 
   @Before
   public void setupResults() {
+    query =
+        dialect == Dialect.POSTGRESQL
+            ? "/*@ lock_scanned_ranges = exclusive */ select * from my_table"
+            : "select * from my_table";
+    dml =
+        dialect == Dialect.POSTGRESQL
+            ? "/*@ lock_scanned_ranges = exclusive */ insert into my_table (id, value) values (1, 'One')"
+            : "insert into my_table (id, value) values (1, 'One')";
+    String DML_THEN_RETURN_ID =
+        dml + (dialect == Dialect.POSTGRESQL ? "\nRETURNING \"id\"" : "\nTHEN RETURN `id`");
+    largeDml =
+        dialect == Dialect.POSTGRESQL
+            ? "/*@ lock_scanned_ranges = exclusive */ update my_table set value='new value' where true"
+            : "update my_table set value='new value' where true";
+    String LARGE_DML_THEN_RETURN_ID =
+        largeDml + (dialect == Dialect.POSTGRESQL ? "\nRETURNING \"id\"" : "\nTHEN RETURN `id`");
+    dmlReturning =
+        dialect == Dialect.POSTGRESQL
+            ? "/*@ lock_scanned_ranges = exclusive */ insert into my_table (id, value) values (1, 'One') RETURNING *"
+            : "insert into my_table (id, value) values (1, 'One') THEN RETURN *";
+
+    // This forces a refresh of the Spanner instance that is used for a connection, which again is
+    // needed in order to refresh the dialect of the database.
+    SpannerPool.closeSpannerPool();
+    mockSpanner.putStatementResult(StatementResult.detectDialectResult(dialect));
     super.setupResults();
+
     com.google.spanner.v1.ResultSet resultSet =
         com.google.spanner.v1.ResultSet.newBuilder()
             .setMetadata(
@@ -74,8 +132,58 @@ public class ExecuteMockServerTest extends AbstractMockServerTest {
                     .addValues(Value.newBuilder().setStringValue("One").build())
                     .build())
             .build();
+    com.google.spanner.v1.ResultSet returnIdResultSet =
+        com.google.spanner.v1.ResultSet.newBuilder()
+            .setMetadata(
+                ResultSetMetadata.newBuilder()
+                    .setRowType(
+                        StructType.newBuilder()
+                            .addFields(
+                                Field.newBuilder()
+                                    .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                    .setName("id")
+                                    .build())
+                            .build())
+                    .build())
+            .addRows(
+                ListValue.newBuilder()
+                    .addValues(Value.newBuilder().setStringValue("1").build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(com.google.cloud.spanner.Statement.of(query), resultSet));
+    mockSpanner.putStatementResult(
+        StatementResult.update(com.google.cloud.spanner.Statement.of(dml), 1L));
     mockSpanner.putStatementResult(
-        StatementResult.query(com.google.cloud.spanner.Statement.of(QUERY), resultSet));
+        StatementResult.update(
+            com.google.cloud.spanner.Statement.of(largeDml), LARGE_UPDATE_COUNT));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            com.google.cloud.spanner.Statement.of(dmlReturning),
+            resultSet
+                .toBuilder()
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            com.google.cloud.spanner.Statement.of(DML_THEN_RETURN_ID),
+            returnIdResultSet
+                .toBuilder()
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            com.google.cloud.spanner.Statement.of(LARGE_DML_THEN_RETURN_ID),
+            returnIdResultSet
+                .toBuilder()
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(LARGE_UPDATE_COUNT).build())
+                .build()));
+    mockDatabaseAdmin.addResponse(
+        Operation.newBuilder()
+            .setDone(true)
+            .setResponse(Any.pack(Empty.getDefaultInstance()))
+            .setMetadata(Any.pack(UpdateDatabaseDdlMetadata.getDefaultInstance()))
+            .build());
   }
 
   private String createUrl() {
@@ -84,6 +192,501 @@ public class ExecuteMockServerTest extends AbstractMockServerTest {
         getPort(), "proj", "inst", "db");
   }
 
+  private Connection createConnection() throws SQLException {
+    return DriverManager.getConnection(createUrl());
+  }
+
+  @Test
+  public void testStatementExecuteQuery() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      try (ResultSet resultSet = statement.executeQuery(query)) {
+        verifyResultSet(resultSet);
+      }
+      try (ResultSet resultSet = statement.executeQuery(dmlReturning)) {
+        verifyResultSet(resultSet);
+      }
+      verifyException(() -> statement.executeQuery(dml));
+      verifyException(() -> statement.executeQuery(largeDml));
+      verifyException(() -> statement.executeQuery(DDL));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteUpdate() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      assertEquals(1, statement.executeUpdate(dml));
+      assertEquals(0, statement.executeUpdate(DDL));
+      verifyOverflow(() -> statement.executeUpdate(largeDml));
+      verifyException(() -> statement.executeUpdate(query));
+      verifyException(() -> statement.executeUpdate(dmlReturning));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteUpdateReturnGeneratedKeys() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      // TODO: Add tests for RETURN_GENERATED_KEYS when that is supported.
+      assertEquals(1, statement.executeUpdate(dml, Statement.NO_GENERATED_KEYS));
+      assertEquals(0, statement.executeUpdate(DDL, Statement.NO_GENERATED_KEYS));
+      verifyOverflow(() -> statement.executeUpdate(largeDml, Statement.NO_GENERATED_KEYS));
+      verifyException(() -> statement.executeUpdate(query, Statement.NO_GENERATED_KEYS));
+      verifyException(() -> statement.executeUpdate(dmlReturning, Statement.NO_GENERATED_KEYS));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteUpdateReturnColumnNames() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      assertEquals(1, statement.executeUpdate(dml, new String[] {"id"}));
+      assertEquals(0, statement.executeUpdate(DDL, new String[] {"id"}));
+      verifyOverflow(() -> statement.executeUpdate(largeDml, new String[] {"id"}));
+      verifyException(
+          () -> statement.executeUpdate(query, new String[] {"id"}), Code.FAILED_PRECONDITION);
+      assertEquals(1, statement.executeUpdate(dmlReturning, new String[] {"id"}));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteUpdateReturnColumnIndexes() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      assertEquals(1, statement.executeUpdate(dml, new int[] {1}));
+      assertEquals(0, statement.executeUpdate(DDL, new int[] {1}));
+      verifyOverflow(() -> statement.executeUpdate(largeDml, new int[] {1}));
+      verifyException(() -> statement.executeUpdate(query, new int[] {1}));
+      verifyException(() -> statement.executeUpdate(dmlReturning, new int[] {1}));
+    }
+  }
+
+  @Test
+  public void testStatementLargeExecuteUpdate() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      assertEquals(1L, statement.executeLargeUpdate(dml));
+      assertEquals(0L, statement.executeLargeUpdate(DDL));
+      assertEquals(LARGE_UPDATE_COUNT, statement.executeLargeUpdate(largeDml));
+      verifyException(() -> statement.executeLargeUpdate(query));
+      verifyException(() -> statement.executeLargeUpdate(dmlReturning));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteLargeUpdateReturnGeneratedKeys() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      // TODO: Add tests for RETURN_GENERATED_KEYS when that is supported.
+      assertEquals(1, statement.executeLargeUpdate(dml, Statement.NO_GENERATED_KEYS));
+      assertEquals(0, statement.executeLargeUpdate(DDL, Statement.NO_GENERATED_KEYS));
+      assertEquals(
+          LARGE_UPDATE_COUNT, statement.executeLargeUpdate(largeDml, Statement.NO_GENERATED_KEYS));
+      verifyException(() -> statement.executeLargeUpdate(query, Statement.NO_GENERATED_KEYS));
+      verifyException(
+          () -> statement.executeLargeUpdate(dmlReturning, Statement.NO_GENERATED_KEYS));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteLargeUpdateReturnColumnNames() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      assertEquals(1, statement.executeLargeUpdate(dml, new String[] {"id"}));
+      assertEquals(0, statement.executeLargeUpdate(DDL, new String[] {"id"}));
+      assertEquals(LARGE_UPDATE_COUNT, statement.executeLargeUpdate(largeDml, new String[] {"id"}));
+      verifyException(
+          () -> statement.executeLargeUpdate(query, new String[] {"id"}), Code.FAILED_PRECONDITION);
+      assertEquals(1L, statement.executeLargeUpdate(dmlReturning, new String[] {"id"}));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteLargeUpdateReturnColumnIndexes() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      assertEquals(1, statement.executeLargeUpdate(dml, new int[] {1}));
+      assertEquals(0, statement.executeLargeUpdate(DDL, new int[] {1}));
+      assertEquals(LARGE_UPDATE_COUNT, statement.executeLargeUpdate(largeDml, new int[] {1}));
+      verifyException(() -> statement.executeLargeUpdate(query, new int[] {1}));
+      verifyException(() -> statement.executeLargeUpdate(dmlReturning, new int[] {1}));
+    }
+  }
+
+  @Test
+  public void testStatementExecute() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      verifyUpdateCount(statement, () -> statement.execute(dml), 1L);
+      verifyUpdateCount(statement, () -> statement.execute(largeDml), LARGE_UPDATE_COUNT);
+      verifyUpdateCount(statement, () -> statement.execute(DDL), Statement.SUCCESS_NO_INFO);
+      verifyResultSet(statement, () -> statement.execute(query));
+      verifyResultSet(statement, () -> statement.execute(dmlReturning));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteReturnGeneratedKeys() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      // TODO: Add tests for RETURN_GENERATED_KEYS when that is supported.
+      verifyUpdateCount(statement, () -> statement.execute(dml, Statement.NO_GENERATED_KEYS), 1L);
+      verifyUpdateCount(
+          statement,
+          () -> statement.execute(largeDml, Statement.NO_GENERATED_KEYS),
+          LARGE_UPDATE_COUNT);
+      verifyUpdateCount(
+          statement,
+          () -> statement.execute(DDL, Statement.NO_GENERATED_KEYS),
+          Statement.SUCCESS_NO_INFO);
+      verifyResultSet(statement, () -> statement.execute(query, Statement.NO_GENERATED_KEYS));
+      verifyResultSet(
+          statement, () -> statement.execute(dmlReturning, Statement.NO_GENERATED_KEYS));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteReturnColumnNames() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      verifyUpdateCount(statement, () -> statement.execute(dml, new String[] {"id"}), 1L);
+      verifyUpdateCount(
+          statement, () -> statement.execute(largeDml, new String[] {"id"}), LARGE_UPDATE_COUNT);
+      verifyUpdateCount(
+          statement, () -> statement.execute(DDL, new String[] {"id"}), Statement.SUCCESS_NO_INFO);
+      verifyResultSet(statement, () -> statement.execute(query, new String[] {"id"}));
+      verifyResultSet(statement, () -> statement.execute(dmlReturning, new String[] {"id"}));
+    }
+  }
+
+  @Test
+  public void testStatementExecuteReturnColumnIndexes() throws SQLException {
+    try (Connection connection = createConnection();
+        Statement statement = connection.createStatement()) {
+      verifyUpdateCount(statement, () -> statement.execute(dml, new int[] {1}), 1L);
+      verifyUpdateCount(
+          statement, () -> statement.execute(largeDml, new int[] {1}), LARGE_UPDATE_COUNT);
+      verifyUpdateCount(
+          statement, () -> statement.execute(DDL, new int[] {1}), Statement.SUCCESS_NO_INFO);
+      verifyResultSet(statement, () -> statement.execute(query, new int[] {1}));
+      verifyResultSet(statement, () -> statement.execute(dmlReturning, new int[] {1}));
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteQuery() throws SQLException {
+    try (Connection connection = createConnection()) {
+      try (ResultSet resultSet = connection.prepareStatement(query).executeQuery()) {
+        verifyResultSet(resultSet);
+      }
+      try (ResultSet resultSet = connection.prepareStatement(dmlReturning).executeQuery()) {
+        verifyResultSet(resultSet);
+      }
+      verifyException(() -> connection.prepareStatement(dml).executeQuery());
+      verifyException(() -> connection.prepareStatement(largeDml).executeQuery());
+      verifyException(() -> connection.prepareStatement(DDL).executeQuery());
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteUpdate() throws SQLException {
+    try (Connection connection = createConnection()) {
+      assertEquals(1, connection.prepareStatement(dml).executeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL).executeUpdate());
+      verifyOverflow(() -> connection.prepareStatement(largeDml).executeUpdate());
+      verifyException(() -> connection.prepareStatement(query).executeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(dmlReturning).executeUpdate(), Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteUpdateReturnGeneratedKeys() throws SQLException {
+    try (Connection connection = createConnection()) {
+      // TODO: Add tests for RETURN_GENERATED_KEYS when that is supported.
+      assertEquals(
+          1, connection.prepareStatement(dml, Statement.NO_GENERATED_KEYS).executeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL,
+      // Statement.NO_GENERATED_KEYS).executeUpdate());
+      verifyOverflow(
+          () -> connection.prepareStatement(largeDml, Statement.NO_GENERATED_KEYS).executeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(query, Statement.NO_GENERATED_KEYS).executeUpdate());
+      verifyException(
+          () ->
+              connection
+                  .prepareStatement(dmlReturning, Statement.NO_GENERATED_KEYS)
+                  .executeUpdate(),
+          Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteUpdateReturnColumnNames() throws SQLException {
+    try (Connection connection = createConnection()) {
+      assertEquals(1, connection.prepareStatement(dml, new String[] {"id"}).executeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL, new String[] {"id"}).executeUpdate());
+      verifyOverflow(
+          () -> connection.prepareStatement(largeDml, new String[] {"id"}).executeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(query, new String[] {"id"}).executeUpdate(),
+          Code.FAILED_PRECONDITION);
+      assertEquals(
+          1, connection.prepareStatement(dmlReturning, new String[] {"id"}).executeUpdate());
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteUpdateReturnColumnIndexes() throws SQLException {
+    try (Connection connection = createConnection()) {
+      assertEquals(1, connection.prepareStatement(dml, new int[] {1}).executeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL, new int[] {1}).executeUpdate());
+      verifyOverflow(() -> connection.prepareStatement(largeDml, new int[] {1}).executeUpdate());
+      verifyException(() -> connection.prepareStatement(query, new int[] {1}).executeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(dmlReturning, new int[] {1}).executeUpdate(),
+          Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementLargeExecuteUpdate() throws SQLException {
+    try (Connection connection = createConnection()) {
+      assertEquals(1L, connection.prepareStatement(dml).executeLargeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0L, connection.prepareStatement(DDL).executeLargeUpdate());
+      assertEquals(LARGE_UPDATE_COUNT, connection.prepareStatement(largeDml).executeLargeUpdate());
+      verifyException(() -> connection.prepareStatement(query).executeLargeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(dmlReturning).executeLargeUpdate(),
+          Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteLargeUpdateReturnGeneratedKeys() throws SQLException {
+    try (Connection connection = createConnection()) {
+      // TODO: Add tests for RETURN_GENERATED_KEYS when that is supported.
+      assertEquals(
+          1, connection.prepareStatement(dml, Statement.NO_GENERATED_KEYS).executeLargeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL,
+      // Statement.NO_GENERATED_KEYS).executeLargeUpdate());
+      assertEquals(
+          LARGE_UPDATE_COUNT,
+          connection.prepareStatement(largeDml, Statement.NO_GENERATED_KEYS).executeLargeUpdate());
+      verifyException(
+          () ->
+              connection.prepareStatement(query, Statement.NO_GENERATED_KEYS).executeLargeUpdate());
+      verifyException(
+          () ->
+              connection
+                  .prepareStatement(dmlReturning, Statement.NO_GENERATED_KEYS)
+                  .executeLargeUpdate(),
+          Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteLargeUpdateReturnColumnNames() throws SQLException {
+    try (Connection connection = createConnection()) {
+      assertEquals(1, connection.prepareStatement(dml, new String[] {"id"}).executeLargeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL, new String[]
+      // {"id"}).executeLargeUpdate());
+      assertEquals(
+          LARGE_UPDATE_COUNT,
+          connection.prepareStatement(largeDml, new String[] {"id"}).executeLargeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(query, new String[] {"id"}).executeLargeUpdate(),
+          Code.FAILED_PRECONDITION);
+      assertEquals(
+          1L, connection.prepareStatement(dmlReturning, new String[] {"id"}).executeLargeUpdate());
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteLargeUpdateReturnColumnIndexes() throws SQLException {
+    try (Connection connection = createConnection()) {
+      assertEquals(1, connection.prepareStatement(dml, new int[] {1}).executeLargeUpdate());
+      // TODO: Enable the next statement once PreparedStatement supports executing DDL through the
+      //       executeUpdate() method.
+      // assertEquals(0, connection.prepareStatement(DDL, new int[] {1}).executeLargeUpdate());
+      assertEquals(
+          LARGE_UPDATE_COUNT,
+          connection.prepareStatement(largeDml, new int[] {1}).executeLargeUpdate());
+      verifyException(() -> connection.prepareStatement(query, new int[] {1}).executeLargeUpdate());
+      verifyException(
+          () -> connection.prepareStatement(dmlReturning, new int[] {1}).executeLargeUpdate(),
+          Code.INVALID_ARGUMENT);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecute() throws SQLException {
+    try (Connection connection = createConnection()) {
+      verifyPreparedUpdateCount(connection.prepareStatement(dml), PreparedStatement::execute, 1L);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(largeDml), PreparedStatement::execute, LARGE_UPDATE_COUNT);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(DDL), PreparedStatement::execute, Statement.SUCCESS_NO_INFO);
+      verifyPreparedResultSet(connection.prepareStatement(query), PreparedStatement::execute);
+      verifyPreparedResultSet(
+          connection.prepareStatement(dmlReturning), PreparedStatement::execute);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteReturnGeneratedKeys() throws SQLException {
+    try (Connection connection = createConnection()) {
+      // TODO: Add tests for RETURN_GENERATED_KEYS when that is supported.
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(dml, Statement.NO_GENERATED_KEYS),
+          PreparedStatement::execute,
+          1L);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(largeDml, Statement.NO_GENERATED_KEYS),
+          PreparedStatement::execute,
+          LARGE_UPDATE_COUNT);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(DDL, Statement.NO_GENERATED_KEYS),
+          PreparedStatement::execute,
+          Statement.SUCCESS_NO_INFO);
+      verifyPreparedResultSet(
+          connection.prepareStatement(query, Statement.NO_GENERATED_KEYS),
+          PreparedStatement::execute);
+      verifyPreparedResultSet(
+          connection.prepareStatement(dmlReturning, Statement.NO_GENERATED_KEYS),
+          PreparedStatement::execute);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteReturnColumnNames() throws SQLException {
+    try (Connection connection = createConnection()) {
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(dml, new String[] {"id"}), PreparedStatement::execute, 1L);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(largeDml, new String[] {"id"}),
+          PreparedStatement::execute,
+          LARGE_UPDATE_COUNT);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(DDL, new String[] {"id"}),
+          PreparedStatement::execute,
+          Statement.SUCCESS_NO_INFO);
+      verifyPreparedResultSet(
+          connection.prepareStatement(query, new String[] {"id"}), PreparedStatement::execute);
+      verifyPreparedResultSet(
+          connection.prepareStatement(dmlReturning, new String[] {"id"}),
+          PreparedStatement::execute);
+    }
+  }
+
+  @Test
+  public void testPreparedStatementExecuteReturnColumnIndexes() throws SQLException {
+    try (Connection connection = createConnection()) {
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(dml, new int[] {1}), PreparedStatement::execute, 1L);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(largeDml, new int[] {1}),
+          PreparedStatement::execute,
+          LARGE_UPDATE_COUNT);
+      verifyPreparedUpdateCount(
+          connection.prepareStatement(DDL, new int[] {1}),
+          PreparedStatement::execute,
+          Statement.SUCCESS_NO_INFO);
+      verifyPreparedResultSet(
+          connection.prepareStatement(query, new int[] {1}), PreparedStatement::execute);
+      verifyPreparedResultSet(
+          connection.prepareStatement(dmlReturning, new int[] {1}), PreparedStatement::execute);
+    }
+  }
+
+  private void verifyResultSet(ResultSet resultSet) throws SQLException {
+    assertNotNull(resultSet.getMetaData());
+    assertEquals(2, resultSet.getMetaData().getColumnCount());
+    assertTrue(resultSet.next());
+    assertFalse(resultSet.next());
+  }
+
+  private void verifyException(ThrowingRunnable runnable) {
+    verifyException(runnable, Code.INVALID_ARGUMENT);
+  }
+
+  private void verifyOverflow(ThrowingRunnable runnable) {
+    verifyException(runnable, Code.OUT_OF_RANGE);
+  }
+
+  private void verifyException(ThrowingRunnable runnable, Code code) {
+    SQLException exception = assertThrows(SQLException.class, runnable);
+    assertTrue(exception instanceof JdbcSqlException);
+    JdbcSqlException sqlException = (JdbcSqlException) exception;
+    assertEquals(code, sqlException.getCode());
+  }
+
+  interface ThrowingFunction<T> {
+    T apply() throws SQLException;
+  }
+
+  interface ThrowingPreparedFunction<T> {
+    T apply(PreparedStatement statement) throws SQLException;
+  }
+
+  private void verifyPreparedUpdateCount(
+      PreparedStatement statement, ThrowingPreparedFunction<Boolean> function, long updateCount)
+      throws SQLException {
+    verifyUpdateCount(statement, () -> function.apply(statement), updateCount);
+  }
+
+  private void verifyUpdateCount(
+      Statement statement, ThrowingFunction<Boolean> function, long updateCount)
+      throws SQLException {
+    assertFalse(function.apply());
+    if (updateCount > Integer.MAX_VALUE) {
+      verifyOverflow(statement::getUpdateCount);
+    } else {
+      assertEquals((int) updateCount, statement.getUpdateCount());
+    }
+    assertEquals(updateCount, statement.getLargeUpdateCount());
+    assertNull(statement.getResultSet());
+    assertFalse(statement.getMoreResults());
+    assertEquals(-1, statement.getUpdateCount());
+    assertEquals(-1L, statement.getLargeUpdateCount());
+  }
+
+  private void verifyPreparedResultSet(
+      PreparedStatement statement, ThrowingPreparedFunction<Boolean> function) throws SQLException {
+    verifyResultSet(statement, () -> function.apply(statement));
+  }
+
+  private void verifyResultSet(Statement statement, ThrowingFunction<Boolean> function)
+      throws SQLException {
+    assertTrue(function.apply());
+    assertEquals(-1, statement.getUpdateCount());
+    assertEquals(-1L, statement.getLargeUpdateCount());
+    assertNotNull(statement.getResultSet());
+    try (ResultSet resultSet = statement.getResultSet()) {
+      assertTrue(resultSet.next());
+      assertFalse(resultSet.next());
+    }
+
+    assertFalse(statement.getMoreResults());
+    assertEquals(-1, statement.getUpdateCount());
+    assertEquals(-1L, statement.getLargeUpdateCount());
+  }
+
   @Test
   public void testInvalidExecuteUpdate_shouldNotLeakSession() throws SQLException {
     int maxSessions = 1;
@@ -103,7 +706,7 @@ public class ExecuteMockServerTest extends AbstractMockServerTest {
       for (int i = 0; i < (maxSessions + 1); i++) {
         SQLException exception =
             assertThrows(
-                SQLException.class, () -> connection.createStatement().executeUpdate(QUERY));
+                SQLException.class, () -> connection.createStatement().executeUpdate(query));
         assertTrue(exception instanceof JdbcSqlException);
         JdbcSqlException jdbcSqlException = (JdbcSqlException) exception;
         // This would be RESOURCE_EXHAUSTED if the query leaked a session.
diff --git a/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java b/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java
new file mode 100644
index 0000000..7e4e749
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/jdbc/PartitionedQueryMockServerTest.java
@@ -0,0 +1,502 @@
+/*
+ * 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.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.Dialect;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.PartitionOptions;
+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.SpannerPool;
+import com.google.protobuf.ByteString;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.PartitionQueryRequest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+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 PartitionedQueryMockServerTest 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",
+        getPort(), "proj", "inst", "db");
+  }
+
+  private Connection createConnection() throws SQLException {
+    return DriverManager.getConnection(createUrl());
+  }
+
+  @Test
+  public void testPartitionedQueryUsingSql() throws SQLException {
+    int numRows = 5;
+    int maxPartitions = 4;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    Statement statement =
+        Statement.newBuilder(
+                String.format(
+                    "select * from my_table where active=%s",
+                    dialect == Dialect.POSTGRESQL ? "$1" : "@p1"))
+            .bind("p1")
+            .to(true)
+            .build();
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    String prefix = dialect == Dialect.POSTGRESQL ? "spanner." : "";
+
+    try (Connection connection = createConnection()) {
+      // This will automatically enable Data Boost for any partitioned query that is executed on
+      // this connection. The property is ignored for any query that is not a partitioned query.
+      connection.createStatement().execute(String.format("set %sdata_boost_enabled=true", prefix));
+      // Sets the maximum number of partitions that should be used by Cloud Spanner.
+      // This is just a hint that can be ignored by Cloud Spanner, but the mock server that is used
+      // for testing respects this hint.
+      connection
+          .createStatement()
+          .execute(String.format("set %smax_partitions=%d", prefix, maxPartitions));
+
+      List<String> partitionIds = new ArrayList<>();
+      try (PreparedStatement partitionStatement =
+          connection.prepareStatement("partition select * from my_table where active=?")) {
+        partitionStatement.setBoolean(1, true);
+        try (ResultSet partitions = partitionStatement.executeQuery()) {
+          assertNotNull(partitions.getMetaData());
+          while (partitions.next()) {
+            partitionIds.add(partitions.getString(1));
+          }
+        }
+      }
+
+      for (String partitionId : partitionIds) {
+        // Execute `run partition '<partition-id>'` to get the results of a single partition.
+        // This can be done from any JDBC connection, including from other hosts.
+        try (ResultSet resultSet =
+            connection
+                .createStatement()
+                .executeQuery(String.format("run partition '%s'", partitionId))) {
+          assertNotNull(resultSet.getMetaData());
+          int rowCount = 0;
+          while (resultSet.next()) {
+            rowCount++;
+          }
+          assertEquals(numRows, rowCount);
+        }
+      }
+
+      for (String partitionId : partitionIds) {
+        // The partition ID may also be set as a query parameter.
+        try (PreparedStatement runStatement = connection.prepareStatement("run partition")) {
+          runStatement.setString(1, partitionId);
+          int rowCount = 0;
+          try (ResultSet resultSet = runStatement.executeQuery()) {
+            assertNotNull(resultSet.getMetaData());
+            while (resultSet.next()) {
+              rowCount++;
+            }
+            assertEquals(numRows, rowCount);
+          }
+        }
+      }
+
+      // You can also run a query directly as a partitioned query on a single JDBC connection. This
+      // will not give you the full benefits of a partitioned query, as the entire query is handled
+      // by a single host and is returned as a single result set. The result set uses multiple
+      // threads internally to execute the separate partitions.
+      // This gives users a simple way to access the Data Boost feature that should be accessible
+      // from most generic frameworks and tools that work with JDBC.
+      try (PreparedStatement partitionStatement =
+          connection.prepareStatement(
+              "run partitioned query select * from my_table where active=?")) {
+        partitionStatement.setBoolean(1, true);
+        try (ResultSet results = partitionStatement.executeQuery()) {
+          assertNotNull(results.getMetaData());
+          assertEquals(18, results.getMetaData().getColumnCount());
+          int rowCount = 0;
+          while (results.next()) {
+            rowCount++;
+          }
+          // The mock server is not smart enough to actually partition the query and only return
+          // a fraction of the rows per partition. The total row count will therefore be equal to
+          // the number of partitions multiplied by the number of rows.
+          assertEquals(numRows * maxPartitions, rowCount);
+
+          // Partitioned queries return a result set with some additional metadata that can be
+          // inspected to determine the number of partitions and the degree of parallelism that the
+          // query used.
+          CloudSpannerJdbcPartitionedQueryResultSet partitionedQueryResultSet =
+              results.unwrap(CloudSpannerJdbcPartitionedQueryResultSet.class);
+          assertEquals(maxPartitions, partitionedQueryResultSet.getNumPartitions());
+          assertEquals(1, partitionedQueryResultSet.getParallelism());
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testPartitionedQueryStatement() throws SQLException {
+    int numRows = 5;
+    int maxPartitions = 4;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    Statement statement = Statement.of("select * from my_table where active=true");
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+
+    try (Connection connection = createConnection()) {
+      CloudSpannerJdbcConnection cloudSpannerJdbcConnection =
+          connection.unwrap(CloudSpannerJdbcConnection.class);
+      // This will automatically enable Data Boost for any partitioned query that is executed on
+      // this connection. The property is ignored for any query that is not a partitioned query.
+      cloudSpannerJdbcConnection.setDataBoostEnabled(true);
+      // Sets the maximum number of partitions that should be used by Cloud Spanner.
+      // This is just a hint that can be ignored by Cloud Spanner, but the mock server that is used
+      // for testing respects this hint.
+      cloudSpannerJdbcConnection.setMaxPartitions(maxPartitions);
+
+      List<String> partitionIds = new ArrayList<>();
+      try (ResultSet partitions =
+          connection
+              .createStatement()
+              .unwrap(CloudSpannerJdbcStatement.class)
+              .partitionQuery(
+                  "select * from my_table where active=true",
+                  PartitionOptions.getDefaultInstance())) {
+        assertNotNull(partitions.getMetaData());
+        while (partitions.next()) {
+          partitionIds.add(partitions.getString(1));
+        }
+      }
+
+      for (String partitionId : partitionIds) {
+        // Execute `run partition '<partition-id>'` to get the results of a single partition.
+        // This can be done from any JDBC connection, including from other hosts.
+        try (ResultSet resultSet =
+            connection
+                .createStatement()
+                .unwrap(CloudSpannerJdbcStatement.class)
+                .runPartition(partitionId)) {
+          assertNotNull(resultSet.getMetaData());
+          int rowCount = 0;
+          while (resultSet.next()) {
+            rowCount++;
+          }
+          assertEquals(numRows, rowCount);
+        }
+      }
+
+      // You can also run a query directly as a partitioned query on a single JDBC connection. This
+      // will not give you the full benefits of a partitioned query, as the entire query is handled
+      // by a single host and is returned as a single result set. The result set uses multiple
+      // threads internally to execute the separate partitions.
+      // This gives users a simple way to access the Data Boost feature that should be accessible
+      // from most generic frameworks and tools that work with JDBC.
+      try (CloudSpannerJdbcPartitionedQueryResultSet results =
+          connection
+              .createStatement()
+              .unwrap(CloudSpannerJdbcStatement.class)
+              .runPartitionedQuery(
+                  "select * from my_table where active=true",
+                  PartitionOptions.getDefaultInstance())) {
+        assertNotNull(results.getMetaData());
+        int rowCount = 0;
+        while (results.next()) {
+          rowCount++;
+        }
+        // The mock server is not smart enough to actually partition the query and only return
+        // a fraction of the rows per partition. The total row count will therefore be equal to
+        // the number of partitions multiplied by the number of rows.
+        assertEquals(numRows * maxPartitions, rowCount);
+
+        // Partitioned queries return a result set with some additional metadata that can be
+        // inspected to determine the number of partitions and the degree of parallelism that the
+        // query used.
+        assertEquals(maxPartitions, results.getNumPartitions());
+        assertEquals(1, results.getParallelism());
+      }
+    }
+  }
+
+  @Test
+  public void testPartitionedQueryPreparedStatement() throws SQLException {
+    int numRows = 5;
+    int maxPartitions = 4;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    Statement statement =
+        Statement.newBuilder(
+                String.format(
+                    "select * from my_table where active=%s",
+                    dialect == Dialect.POSTGRESQL ? "$1" : "@p1"))
+            .bind("p1")
+            .to(true)
+            .build();
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+
+    try (Connection connection = createConnection()) {
+      CloudSpannerJdbcConnection cloudSpannerJdbcConnection =
+          connection.unwrap(CloudSpannerJdbcConnection.class);
+      // This will automatically enable Data Boost for any partitioned query that is executed on
+      // this connection. The property is ignored for any query that is not a partitioned query.
+      cloudSpannerJdbcConnection.setDataBoostEnabled(true);
+      // Sets the maximum number of partitions that should be used by Cloud Spanner.
+      // This is just a hint that can be ignored by Cloud Spanner, but the mock server that is used
+      // for testing respects this hint.
+      cloudSpannerJdbcConnection.setMaxPartitions(maxPartitions);
+
+      List<String> partitionIds = new ArrayList<>();
+      try (PreparedStatement partitionStatement =
+          connection.prepareStatement("select * from my_table where active=?")) {
+        partitionStatement.setBoolean(1, true);
+        try (ResultSet partitions =
+            partitionStatement
+                .unwrap(CloudSpannerJdbcPreparedStatement.class)
+                .partitionQuery(PartitionOptions.getDefaultInstance())) {
+          assertNotNull(partitions.getMetaData());
+          while (partitions.next()) {
+            partitionIds.add(partitions.getString(1));
+          }
+        }
+      }
+
+      for (String partitionId : partitionIds) {
+        // The partition ID can also be set as a query parameter.
+        // The statement text in this case is ignored and can be an empty string.
+        try (PreparedStatement runStatement = connection.prepareStatement("")) {
+          runStatement.setString(1, partitionId);
+          int rowCount = 0;
+          try (ResultSet resultSet =
+              runStatement.unwrap(CloudSpannerJdbcPreparedStatement.class).runPartition()) {
+            assertNotNull(resultSet.getMetaData());
+            while (resultSet.next()) {
+              rowCount++;
+            }
+            assertEquals(numRows, rowCount);
+          }
+        }
+      }
+
+      // You can also run a query directly as a partitioned query on a single JDBC connection. This
+      // will not give you the full benefits of a partitioned query, as the entire query is handled
+      // by a single host and is returned as a single result set. The result set uses multiple
+      // threads internally to execute the separate partitions.
+      // This gives users a simple way to access the Data Boost feature that should be accessible
+      // from most generic frameworks and tools that work with JDBC.
+      try (PreparedStatement preparedStatement =
+          connection.prepareStatement("select * from my_table where active=?")) {
+        preparedStatement.setBoolean(1, true);
+        try (CloudSpannerJdbcPartitionedQueryResultSet results =
+            preparedStatement
+                .unwrap(CloudSpannerJdbcPreparedStatement.class)
+                .runPartitionedQuery(PartitionOptions.getDefaultInstance())) {
+          assertNotNull(results.getMetaData());
+          int rowCount = 0;
+          while (results.next()) {
+            rowCount++;
+          }
+          // The mock server is not smart enough to actually partition the query and only return
+          // a fraction of the rows per partition. The total row count will therefore be equal to
+          // the number of partitions multiplied by the number of rows.
+          assertEquals(numRows * maxPartitions, rowCount);
+
+          // Partitioned queries return a result set with some additional metadata that can be
+          // inspected to determine the number of partitions and the degree of parallelism that the
+          // query used.
+          assertEquals(maxPartitions, results.getNumPartitions());
+          assertEquals(1, results.getParallelism());
+        }
+      }
+    }
+  }
+
+  @Test
+  public void testAutoPartitionMode() throws SQLException {
+    int numRows = 5;
+    int maxPartitions = 4;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    Statement statement = Statement.of("select * from my_table where active=true");
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+
+    try (Connection connection = createConnection()) {
+      CloudSpannerJdbcConnection cloudSpannerJdbcConnection =
+          connection.unwrap(CloudSpannerJdbcConnection.class);
+      // This will automatically enable Data Boost for any partitioned query that is executed on
+      // this connection. The property is ignored for any query that is not a partitioned query.
+      cloudSpannerJdbcConnection.setDataBoostEnabled(true);
+      // Sets the maximum number of partitions that should be used by Cloud Spanner.
+      // This is just a hint that can be ignored by Cloud Spanner, but the mock server that is used
+      // for testing respects this hint.
+      cloudSpannerJdbcConnection.setMaxPartitions(maxPartitions);
+      cloudSpannerJdbcConnection.setAutoPartitionMode(true);
+
+      try (ResultSet results =
+          connection.createStatement().executeQuery("select * from my_table where active=true")) {
+        assertNotNull(results.getMetaData());
+        assertEquals(18, results.getMetaData().getColumnCount());
+        int rowCount = 0;
+        while (results.next()) {
+          rowCount++;
+        }
+        // The mock server is not smart enough to actually partition the query and only return
+        // a fraction of the rows per partition. The total row count will therefore be equal to
+        // the number of partitions multiplied by the number of rows.
+        assertEquals(numRows * maxPartitions, rowCount);
+        assertEquals(1, mockSpanner.countRequestsOfType(PartitionQueryRequest.class));
+
+        // Partitioned queries return a result set with some additional metadata that can be
+        // inspected to determine the number of partitions and the degree of parallelism that the
+        // query used.
+        assertEquals(
+            maxPartitions, results.unwrap(JdbcPartitionedQueryResultSet.class).getNumPartitions());
+        assertEquals(1, results.unwrap(JdbcPartitionedQueryResultSet.class).getParallelism());
+
+        // Verify that we can run metadata queries in auto_partition_mode.
+        // Just add a random result for the table metadata query. We don't care about the result,
+        // only about the fact that it should be allowed, and that it is executed in normal mode.
+        if (dialect == Dialect.GOOGLE_STANDARD_SQL) {
+          mockSpanner.putPartialStatementResult(
+              StatementResult.query(
+                  Statement.of(
+                      "SELECT TABLE_CATALOG AS TABLE_CAT, TABLE_SCHEMA AS TABLE_SCHEM, TABLE_NAME,\n"
+                          + "       CASE WHEN TABLE_TYPE = 'BASE TABLE' THEN 'TABLE' ELSE TABLE_TYPE END AS TABLE_TYPE,\n"
+                          + "       NULL AS REMARKS, NULL AS TYPE_CAT, NULL AS TYPE_SCHEM, NULL AS TYPE_NAME,\n"
+                          + "       NULL AS SELF_REFERENCING_COL_NAME, NULL AS REF_GENERATION\n"
+                          + "FROM INFORMATION_SCHEMA.TABLES AS T"),
+                  SELECT_COUNT_RESULTSET_BEFORE_INSERT));
+        } else {
+          mockSpanner.putPartialStatementResult(
+              StatementResult.query(
+                  Statement.of(
+                      "/*\n"
+                          + " * Copyright 2022 Google LLC\n"
+                          + " *\n"
+                          + " * Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+                          + " * you may not use this file except in compliance with the License.\n"
+                          + " * You may obtain a copy of the License at\n"
+                          + " *\n"
+                          + " *       http://www.apache.org/licenses/LICENSE-2.0\n"
+                          + " *\n"
+                          + " * Unless required by applicable law or agreed to in writing, software\n"
+                          + " * distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+                          + " * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+                          + " * See the License for the specific language governing permissions and\n"
+                          + " * limitations under the License.\n"
+                          + " */\n"
+                          + "\n"
+                          + "SELECT TABLE_CATALOG AS \"TABLE_CAT\", TABLE_SCHEMA AS \"TABLE_SCHEM\", TABLE_NAME AS \"TABLE_NAME\",\n"
+                          + "       CASE WHEN TABLE_TYPE = 'BASE TABLE' THEN 'TABLE' ELSE TABLE_TYPE END AS \"TABLE_TYPE\",\n"
+                          + "       NULL AS \"REMARKS\", NULL AS \"TYPE_CAT\", NULL AS \"TYPE_SCHEM\", NULL AS \"TYPE_NAME\",\n"
+                          + "       NULL AS \"SELF_REFERENCING_COL_NAME\", NULL AS \"REF_GENERATION\"\n"
+                          + "FROM INFORMATION_SCHEMA.TABLES AS T"),
+                  SELECT_COUNT_RESULTSET_BEFORE_INSERT));
+        }
+        try (ResultSet tables = connection.getMetaData().getTables(null, null, null, null)) {
+          assertTrue(tables.next());
+          assertEquals(0, tables.getInt(1));
+          assertFalse(tables.next());
+        }
+        assertEquals(
+            1,
+            mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+                .filter(req -> req.getSql().contains("FROM INFORMATION_SCHEMA.TABLES AS T"))
+                .count());
+        ExecuteSqlRequest request =
+            mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+                .filter(req -> req.getSql().contains("FROM INFORMATION_SCHEMA.TABLES AS T"))
+                .findFirst()
+                .orElse(ExecuteSqlRequest.getDefaultInstance());
+        assertTrue(request.hasTransaction());
+        assertTrue(request.getTransaction().hasSingleUse());
+        assertTrue(request.getTransaction().getSingleUse().hasReadOnly());
+        assertTrue(request.getTransaction().getSingleUse().getReadOnly().hasStrong());
+        assertEquals(ByteString.EMPTY, request.getPartitionToken());
+        assertEquals(1, mockSpanner.countRequestsOfType(PartitionQueryRequest.class));
+      }
+    }
+  }
+
+  @Test
+  public void testAutoPartitionModeEmptyResult() throws SQLException {
+    int numRows = 0;
+    int maxPartitions = 1;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows);
+    Statement statement = Statement.of("select * from my_table where active=true");
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+
+    try (Connection connection = createConnection()) {
+      CloudSpannerJdbcConnection cloudSpannerJdbcConnection =
+          connection.unwrap(CloudSpannerJdbcConnection.class);
+      // This will automatically enable Data Boost for any partitioned query that is executed on
+      // this connection. The property is ignored for any query that is not a partitioned query.
+      cloudSpannerJdbcConnection.setDataBoostEnabled(true);
+      // Sets the maximum number of partitions that should be used by Cloud Spanner.
+      // This is just a hint that can be ignored by Cloud Spanner, but the mock server that is used
+      // for testing respects this hint.
+      cloudSpannerJdbcConnection.setMaxPartitions(maxPartitions);
+      cloudSpannerJdbcConnection.setAutoPartitionMode(true);
+
+      try (ResultSet results =
+          connection.createStatement().executeQuery("select * from my_table where active=true")) {
+        assertNotNull(results.getMetaData());
+        assertEquals(18, results.getMetaData().getColumnCount());
+        int rowCount = 0;
+        while (results.next()) {
+          rowCount++;
+        }
+        assertEquals(0, rowCount);
+        assertEquals(1, mockSpanner.countRequestsOfType(PartitionQueryRequest.class));
+
+        // Partitioned queries return a result set with some additional metadata that can be
+        // inspected to determine the number of partitions and the degree of parallelism that the
+        // query used.
+        assertEquals(
+            maxPartitions, results.unwrap(JdbcPartitionedQueryResultSet.class).getNumPartitions());
+        assertEquals(1, results.unwrap(JdbcPartitionedQueryResultSet.class).getParallelism());
+      }
+    }
+  }
+}