Repository: GoogleCloudPlatform/pgadapter
Pull request #749 (2023-03-24 13:21:06)

feat: add support for Ruby ActiveRecord
Adds experimental support for Ruby ActiveRecord 7.x. This PR adds:
* Tests using an in-mem mock Spanner server to verify that the requests that we receive from Ruby ActiveRecord.
* Integration tests running against a real Cloud Spanner instance.
* A sample application using all supported data types.
* Documentation and instructions for how to set up an application to use Ruby ActiveRecord with PGAdapter, including known limitations.


codecov[bot]: ## [Codecov](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) Report
> :exclamation: No coverage uploaded for pull request base (`postgresql-dialect@2a78f4c`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#section-missing-base-commit).
> The diff coverage is `86.14%`.

```diff
@@                  Coverage Diff                  @@
##             postgresql-dialect     #749   +/-   ##
=====================================================
  Coverage                      ?   89.40%           
  Complexity                    ?     2361           
=====================================================
  Files                         ?      132           
  Lines                         ?     7875           
  Branches                      ?     1129           
=====================================================
  Hits                          ?     7041           
  Misses                        ?      582           
  Partials                      ?      252           
```

| Flag | Coverage Δ | |
|---|---|---|
| all_tests | `89.40% <86.14%> (?)` | |

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

| [Impacted Files](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) | Coverage Δ | |
|---|---|---|
| [...panner/pgadapter/utils/ObjDescriptionReplacer.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3V0aWxzL09iakRlc2NyaXB0aW9uUmVwbGFjZXIuamF2YQ==) | `0.00% <0.00%> (ø)` | |
| [.../cloud/spanner/pgadapter/session/SessionState.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3Nlc3Npb24vU2Vzc2lvblN0YXRlLmphdmE=) | `93.33% <50.00%> (ø)` | |
| [...ud/spanner/pgadapter/utils/ClientAutoDetector.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3V0aWxzL0NsaWVudEF1dG9EZXRlY3Rvci5qYXZh) | `88.96% <85.00%> (ø)` | |
| [...panner/pgadapter/utils/RegexQueryPartReplacer.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3V0aWxzL1JlZ2V4UXVlcnlQYXJ0UmVwbGFjZXIuamF2YQ==) | `93.33% <93.33%> (ø)` | |
| [.../cloud/spanner/pgadapter/statements/PgCatalog.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3N0YXRlbWVudHMvUGdDYXRhbG9nLmphdmE=) | `95.45% <94.66%> (ø)` | |
| [...oud/spanner/pgadapter/utils/QueryPartReplacer.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/749?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3V0aWxzL1F1ZXJ5UGFydFJlcGxhY2VyLmphdmE=) | `100.00% <100.00%> (ø)` | |

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

olavloite: @ palladius FYI
olavloite: > Generally, LGTM. Why do we need a mock server just for ruby/active record? Can't we have one for all cases?

Thanks for the view. We have a specific mock server for Ruby/ActiveRecord, but it is based on the generic mock server that we have for other tests: https://github.com/GoogleCloudPlatform/pgadapter/blob/ef17dba47753b73b5b7eb6d084364e09eb3a504e/src/test/java/com/google/cloud/spanner/pgadapter/ruby/AbstractRubyMockServerTest.java#L47

The reason that we have a specific one for Ruby is that the Ruby driver will execute some specific queries either when a new connection is created, or when specific methods are executed on the driver (e.g. it will check for the existence of specific types the first time those types are used). We keep these specific queries in separate mock servers per driver/ecosystem. We for example also have a similar setup for .NET, as the .NET driver also executes specific queries when some data types are used.

Patch:
diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml
index 6a09f95e..8762611e 100644
--- a/.github/workflows/integration.yaml
+++ b/.github/workflows/integration.yaml
@@ -88,6 +88,10 @@ jobs:
       - uses: actions/setup-node@v3
         with:
           node-version: 16
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: '3.0'
+          bundler-cache: true
       - name: Setup psql
         run: |
           sudo apt-get update
diff --git a/.github/workflows/units.yaml b/.github/workflows/units.yaml
index 8b98995e..a9edf423 100644
--- a/.github/workflows/units.yaml
+++ b/.github/workflows/units.yaml
@@ -28,6 +28,10 @@ jobs:
       - uses: actions/setup-node@v3
         with:
           node-version: 16
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: '3.0'
+          bundler-cache: true
       - run: mvn -B test -Ptest-all
   windows:
     runs-on: windows-latest
@@ -74,4 +78,8 @@ jobs:
       - uses: actions/setup-node@v3
         with:
           node-version: 16
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: '3.0'
+          bundler-cache: true
       - run: mvn -B test -Ptest-all
diff --git a/.gitignore b/.gitignore
index f1509949..9caa9efb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,6 @@ src/test/csharp/**/obj
 src/test/nodejs/**/node_modules
 src/test/nodejs/**/package-lock.json
 src/test/nodejs/**/.DS_Store
+
+src/test/ruby/**/Gemfile.lock
+samples/ruby/**/Gemfile.lock
diff --git a/README.md b/README.md
index 0953e9d8..97b09332 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,9 @@ PGAdapter can be used with the following frameworks and tools:
    See also [this directory](samples/python/sqlalchemy-sample) for a sample application using `SQLAlchemy 1.x`.
 1. `pgbench` can be used with PGAdapter, but with some limitations. See [pgbench.md](docs/pgbench.md)
    for more details.
+1. `Ruby ActiveRecord`: Version 7.x has _experimental support_ and with limitations. Please read the
+   instructions in [PGAdapter - Ruby ActiveRecord Connection Options](docs/ruby-activerecord.md)
+   carefully for how to set up ActiveRecord to work with PGAdapter.
 
 ## FAQ
 See [Frequently Asked Questions](docs/faq.md) for answers to frequently asked questions.
diff --git a/docs/ruby-activerecord.md b/docs/ruby-activerecord.md
new file mode 100644
index 00000000..a07b17e2
--- /dev/null
+++ b/docs/ruby-activerecord.md
@@ -0,0 +1,76 @@
+# PGAdapter - Ruby ActiveRecord Connection Options
+
+## Limitations
+PGAdapter has experimental support for Ruby ActiveRecord 7.x with Cloud Spanner PostgreSQL databases.
+Developing new applications using Ruby ActiveRecord 7.x is possible as long as the listed limitations
+are taken into account.
+Porting an existing application from PostgreSQL to Cloud Spanner is likely to require code changes.
+
+See [Limitations](../samples/ruby/activerecord/README.md#limitations) in the `activerecord`
+sample directory for a full list of limitations.
+
+## Usage
+
+First start PGAdapter:
+
+```shell
+export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
+docker pull gcr.io/cloud-spanner-pg-adapter/pgadapter
+docker run \
+  -d -p 5432:5432 \
+  -v ${GOOGLE_APPLICATION_CREDENTIALS}:${GOOGLE_APPLICATION_CREDENTIALS}:ro \
+  -e GOOGLE_APPLICATION_CREDENTIALS \
+  gcr.io/cloud-spanner-pg-adapter/pgadapter \
+  -p my-project -i my-instance \
+  -x
+```
+
+Then connect to PGAdapter and use `ActiveRecord` using the connection parameters listed below.
+Note the following additional options:
+1. Configure ActiveRecord to use `timestamptz` as the default `datetime_type`.
+2. Disable datetime with precision in ActiveRecord. This prevents ActiveRecord from trying to use
+   column types like `timestamptz(6)`.
+3. `advisory_locks: false` instructs ActiveRecord not to use `advisory_locks` for migrations.
+   PGAdapter does not support `advisory_locks`.
+4. `"spanner.ddl_transaction_mode": "AutocommitExplicitTransaction"` instructs PGAdapter to
+   automatically commit the active transaction and start a DDL batch if a DDL statement is encountered
+   in a transaction. ActiveRecord uses DDL transactions for migrations. This setting is not needed
+   if you do not execute any migrations.
+5. `"spanner.emulate_pg_class_tables": "true"` instructs PGAdapter to emulate `pg_class` tables that
+   are needed for ActiveRecord to detect the tables and columns in the database.
+
+```ruby
+# Make sure that the PostgreSQL-adapter uses timestamptz without any type modifiers.
+ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
+module ActiveRecord::ConnectionAdapters
+  class PostgreSQLAdapter
+    def supports_datetime_with_precision?
+      false
+    end
+  end
+end
+
+ActiveRecord::Base.establish_connection(
+  adapter: 'postgresql',
+  database: ENV['PGDATABASE'] || 'my-database',
+  host: ENV['PGHOST'] || 'localhost',
+  port: ENV['PGPORT'] || '5432',
+  pool: 5,
+  # Advisory locks are not supported by PGAdapter.
+  advisory_locks: false,
+  
+  # These settings ensure that migrations and schema inspections work.
+  # The 'AutocommitExplicitTransaction' DDL-mode converts PostgreSQL DDL transactions
+  # to 
+  variables: {
+    "spanner.ddl_transaction_mode": "AutocommitExplicitTransaction",
+    "spanner.emulate_pg_class_tables": "true"
+  },
+)
+```
+
+## Full Sample and Limitations
+[This directory](../samples/ruby/activerecord) contains a full sample of how to work with
+`Ruby ActiveRecord` with Cloud Spanner and PGAdapter. The sample readme file also lists the
+[current limitations](../samples/ruby/activerecord/README.md#limitations) when working with
+`ActiveRecord`.
diff --git a/pom.xml b/pom.xml
index a8f76407..4388dab7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -31,7 +31,8 @@
       com.google.cloud.spanner.pgadapter.golang.GolangTest,
       com.google.cloud.spanner.pgadapter.csharp.DotnetTest,
       com.google.cloud.spanner.pgadapter.python.PythonTest,
-      com.google.cloud.spanner.pgadapter.nodejs.NodeJSTest
+      com.google.cloud.spanner.pgadapter.nodejs.NodeJSTest,
+      com.google.cloud.spanner.pgadapter.ruby.RubyTest,
     </excludedTests>
 
     <spanner.version>6.40.0</spanner.version>
diff --git a/samples/ruby/activerecord/Gemfile b/samples/ruby/activerecord/Gemfile
new file mode 100644
index 00000000..3dd8d918
--- /dev/null
+++ b/samples/ruby/activerecord/Gemfile
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+gem 'docker-api'
+gem 'pg'
+gem 'rake'
+gem 'activerecord', '~> 7.0'
+gem 'sinatra-activerecord'
+gem 'composite_primary_keys', '~> 14'
diff --git a/samples/ruby/activerecord/README.md b/samples/ruby/activerecord/README.md
new file mode 100644
index 00000000..ab5a4534
--- /dev/null
+++ b/samples/ruby/activerecord/README.md
@@ -0,0 +1,154 @@
+# PGAdapter and Ruby ActiveRecord
+
+PGAdapter has experimental support for [ActiveRecord 7.x](https://guides.rubyonrails.org/active_record_basics.html).
+This document shows how to use this sample application, and lists the
+limitations when working with `ActiveRecord` with PGAdapter.
+
+The [application.rb](application.rb) file contains a sample application using `ActiveRecord` with PGAdapter.
+Use this as a reference for features of `ActiveRecord` that are supported with PGAdapter. This sample
+assumes that the reader is familiar with `ActiveRecord`, and it is not intended as a tutorial for how
+to use `ActiveRecord` in general.
+
+See [Limitations](#limitations) for a full list of known limitations when working with `ActiveRecord`.
+
+## Running the Sample
+The sample application automatically starts PGAdapter in a Docker container. You must have Docker
+installed on your local system to run the sample. Furthermore, you must configure the following.
+
+1. Set the environment variable `GOOGLE_APPLICATION_CREDENTIALS` to point to a valid (service) account file
+   that should be used to run the sample.
+2. Modify the `config/database.yml` file so it points to your database.
+3. If you already have PostgreSQL running on your local host on port 5432: Modify the port number in the
+   `database.yml` to for example 5433. This port number will be used to start PGAdapter.
+
+Alternatively, you can also set the following environment variables to configure the database and port number:
+
+```shell
+GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
+PGPORT=5432
+PGDATABASE=projects/my-project/instances/my-instance/databases/my-database
+```
+
+Make sure the database that you point the sample to is empty, as the sample application will
+automatically create the required data model in that database.
+
+Run the sample with the following command:
+
+```shell
+bundle exec rake run
+```
+
+
+## Configuration
+You need to add some extra options to your Ruby ActiveRecord configuration to work with Cloud Spanner.
+
+The following code snippets show the additional options that must be set when working with PGAdapter
+and Cloud Spanner. These options ensure that:
+1. ActiveRecord uses `timestamptz` instead of `timestamp(6)` as the default date/time data type.
+2. `advisory_locks` are not used for migrations.
+3. DDL transactions are converted to DDL batches. See [DDL options](../../../docs/ddl.md) for more information.
+4. `pg_class` and related tables are emulated by PGAdapter.
+
+### Initialization
+The following initialization code makes sure that ActiveRecord will use `timestamptz` as the default
+date/time type for PostgreSQL databases. In addition, it will prevent ActiveRecord from using type
+modifiers for `timestamptz` data types (e.g. `timestamptz(6)`).
+
+This initialization code is only required for executing migrations. You may omit this from your
+initialization code when you are not running migrations.
+
+```ruby
+# Make sure that the PostgreSQL-adapter uses timestamptz without any type modifiers.
+ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
+
+module ActiveRecord::ConnectionAdapters
+  class PostgreSQLAdapter
+    def supports_datetime_with_precision?
+      false
+    end
+  end
+end
+```
+
+__NOTE__: The `datetime_type` option was added in ActiveRecord 7.0.
+Use the following configuration for ActiveRecord 6.1 or lower:
+
+```ruby
+ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:datetime] = { name: "timestamptz" }
+
+module ActiveRecord::ConnectionAdapters
+   class PostgreSQLAdapter
+      def supports_datetime_with_precision?
+         false
+      end
+   end
+end
+```
+
+### database.yml
+The following is the `database.yml` that is used by this sample. It uses the standard
+`PGHOST`, `PGPORT` and `PGDATABASE` environment variables. Note that PGAdapter will
+automatically be started on the value that is set in `PGPORT`. Ensure that you set this
+environment variable to an available port on your system.
+
+```yaml
+default: &default
+  adapter: postgresql
+  host: <%= ENV['PGHOST'] || "localhost" %>
+  port: <%= ENV['PGPORT'] || "5432" %>
+  database: <%= ENV['PGDATABASE'] || "projects/my-project/instances/my-instance/databases/my-database" %>
+  pool: 5
+  # Advisory locks are not supported by PGAdapter
+  advisory_locks: false
+  # These settings ensure that migrations and schema inspections work.
+  variables:
+    "spanner.ddl_transaction_mode": "AutocommitExplicitTransaction"
+    "spanner.emulate_pg_class_tables": "true"
+
+development:
+  <<: *default
+```
+
+## Data Model
+The data model is automatically created using a standard ActiveRecord migration script.
+See [db/migrate/01_create_tables.rb](db/migrate/01_create_tables.rb) for the migration script.
+
+You can drop the data model again using the `drop_data_model.sql` script in this folder.
+
+
+## Limitations
+The following limitations are currently known:
+
+| Limitation                     | Workaround                                                                                                                                                                                                                                                                                                                                               |
+|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Schema Dumper                  | Cloud Spanner does not support all PostgreSQL `pg_catalog` tables. Using `ActiveRecord::SchemaDumper` to get export the current schema is not guaranteed to include all objects in the database.                                                                                                                                                         |
+| DDL Transactions               | Cloud Spanner does not support DDL statements in a transaction. Add `"spanner.ddl_transaction_mode": "AutocommitExplicitTransaction"` to the `variables` section in your `database.yml` file to automatically convert DDL transactions to [non-atomic DDL batches](../../../docs/ddl.md). See [config/database.yml](config/database.yml) for an example. |
+| Generated primary keys         | Manually assign a value to the primary key column in your code. The recommended primary key type is a random UUID. Sequences / SERIAL / IDENTITY columns are currently not supported.                                                                                                                                                                    |
+| Upsert                         | `upsert` and `upsert_all` are not supported.                                                                                                                                                                                                                                                                                                             |
+| SELECT ... FOR UPDATE          | Pessimistic locking / `SELECT ... FOR UPDATE` is not supported.                                                                                                                                                                                                                                                                                          |
+| Transaction isolation level    | Only isolation level `:serializable` is supported.                                                                                                                                                                                                                                                                                                       |
+
+### Schema Dumper
+Dumping the schema of a database is not guaranteed to produce a complete result. There is currently
+no workaround for this limitation.
+
+### Generated Primary Keys
+Generated primary keys are not supported and should be replaced with primary key definitions that
+are manually assigned. See https://cloud.google.com/spanner/docs/schema-design#primary-key-prevent-hotspots
+for more information on choosing a good primary key. This sample uses random UUIDs that are generated
+by the client and stored as strings for primary keys.
+
+```ruby
+Singer.create({singer_id: SecureRandom.uuid,
+               first_name: 'Bob',
+               last_name: 'Anderson'})
+```
+
+### Upsert / ON CONFLICT Clauses
+`INSERT ... ON CONFLICT ...` are not supported by Cloud Spanner and should not be used. Trying to
+use `upsert` or `upsert_all` will fail.
+
+### Pessimistic Locking - SELECT ... FOR UPDATE
+Locking clauses, like `SELECT ... FOR UPDATE`, are not supported (see also https://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html).
+These are normally also not required, as Cloud Spanner uses isolation level `serializable` for
+read/write transactions.
diff --git a/samples/ruby/activerecord/Rakefile b/samples/ruby/activerecord/Rakefile
new file mode 100644
index 00000000..e3d84c3c
--- /dev/null
+++ b/samples/ruby/activerecord/Rakefile
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'config/environment'
+require 'sinatra/activerecord/rake'
+require "docker"
+
+Bundler.require
+
+desc 'Runs a simple ActiveRecord tutorial for Google Cloud Spanner PostgreSQL.'
+task :run do
+  credentials = ENV["GOOGLE_APPLICATION_CREDENTIALS"]
+  fail "You must set the environment variable GOOGLE_APPLICATION_CREDENTIALS before running the sample." if credentials.nil?
+
+  container = create_pgadapter_container(credentials)
+  begin
+    puts "Starting PGAdapter..."
+    container.start!
+    sleep(2)
+    sh 'rake db:migrate'
+    sh 'rake db:seed'
+    sh 'ruby application.rb'
+  ensure
+    container.stop!
+    container.remove
+  end
+end
+
+def create_pgadapter_container(credentials)
+  puts "Downloading PGAdapter image..."
+  Docker::Image.create "fromImage" => "gcr.io/cloud-spanner-pg-adapter/pgadapter:ruby-active-record"
+  puts "Creating PGAdapter container..."
+  Docker::Container.create(
+    "Image" => "gcr.io/cloud-spanner-pg-adapter/pgadapter:ruby-active-record",
+    "ExposedPorts" => { "5432/tcp" => {} },
+    "HostConfig" => {
+      "PortBindings" => {
+        "5432/tcp" => [{ "HostPort" => ENV["PGPORT"] || "5432" }]
+      },
+      "Binds" => ["#{credentials}:/credentials.json:ro"],
+    },
+    "Volumes" => { "/credentials.json" => {}},
+    # Start PGAdapter with the given credentials and no default project, instance or database.
+    # This means that the fully qualified database name must be used in the database.yml file.
+    "Cmd" => ["-c", "/credentials.json", "-x"]
+  )
+end
\ No newline at end of file
diff --git a/samples/ruby/activerecord/application.rb b/samples/ruby/activerecord/application.rb
new file mode 100644
index 00000000..98e7ef15
--- /dev/null
+++ b/samples/ruby/activerecord/application.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require 'active_record'
+require 'io/console'
+require_relative 'config/environment'
+require_relative 'models/singer'
+require_relative 'models/album'
+require_relative 'models/concert'
+
+# This sample application shows the basic features of
+# ActiveRecord with Google Cloud Spanner PostgreSQL-dialect.
+class Application
+  def self.run
+    # Query the current singers in the database.
+    query_singers
+
+    # Update a random singer.
+    update_singer
+
+    # Execute a query on the singers table.
+    query_singers_by_name
+
+    # List all singers and related venue information.
+    query_concerts
+  end
+
+  def self.query_singers
+    # Fetch all singers and albums from the database.
+    # The database has been pre-filled by the `db/seeds.rb` script.
+    puts ''
+    puts 'Known singers and their albums:'
+    puts ''
+    Singer.all.each do |singer|
+      puts "#{singer.first_name} #{singer.last_name} has albums:"
+      singer.albums.each do |album|
+        puts "   #{album.title} has tracks:"
+        album.tracks.each do |track|
+          puts "       #{track.track_number} #{track.title}"
+        end
+      end
+    end
+  end
+
+  def self.update_singer
+    # Select a random singer and update the name of this singer.
+    puts ''
+    singer = Singer.all.sample
+    puts ''
+    puts "Current name of singer #{singer.id} is '#{singer.first_name} #{singer.last_name}'"
+    puts "Updating name to 'Dave Anderson'"
+    singer.first_name = 'Dave'
+    singer.last_name = 'Anderson'
+    singer.save!
+    singer.reload
+    puts "New name of singer #{singer.id}: #{singer.first_name} #{singer.last_name}"
+  end
+
+  def self.query_singers_by_name
+    # Select all singers whose last name start with 'A'.
+    # This should include at least the singer that was updated
+    # in the previous step, but probably also a number of other singers.
+    puts ''
+    puts "Getting all singers with a last name that starts with 'A'"
+
+    last_name = Singer.arel_table['last_name']
+    # The 'escape=nil, case_sensitive=true' is necessary to work around the lack of support
+    # for ILIKE in Cloud Spanner.
+    Singer.where(last_name.matches("A%", escape=nil, case_sensitive=true)).each do |s|
+      puts "#{s.first_name} #{s.last_name}"
+    end
+  end
+
+  # Lists all concerts and related venue information.
+  def self.query_concerts
+    puts ''
+    puts 'Known concerts:'
+    puts ''
+    Concert.all.each do |concert|
+      puts "#{concert.name} at #{concert.venue.name}"
+      puts "  Start time: #{concert.start_time}"
+      puts "  Venue description: #{concert.venue.description}"
+    end
+  end
+
+end
+
+Application.run
+
diff --git a/samples/ruby/activerecord/config/database.yml b/samples/ruby/activerecord/config/database.yml
new file mode 100644
index 00000000..5e4c336c
--- /dev/null
+++ b/samples/ruby/activerecord/config/database.yml
@@ -0,0 +1,15 @@
+default: &default
+  adapter: postgresql
+  host: <%= ENV['PGHOST'] || "localhost" %>
+  port: <%= ENV['PGPORT'] || "5432" %>
+  database: <%= ENV['PGDATABASE'] || "projects/my-project/instances/my-instance/databases/my-database" %>
+  pool: 5
+  # Advisory locks are not supported by PGAdapter
+  advisory_locks: false
+  # These settings ensure that migrations and schema inspections work.
+  variables:
+    "spanner.ddl_transaction_mode": "AutocommitExplicitTransaction"
+    "spanner.emulate_pg_class_tables": "true"
+
+development:
+  <<: *default
diff --git a/samples/ruby/activerecord/config/environment.rb b/samples/ruby/activerecord/config/environment.rb
new file mode 100644
index 00000000..3de1ad7f
--- /dev/null
+++ b/samples/ruby/activerecord/config/environment.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require 'active_record'
+require 'bundler'
+
+Bundler.require
+
+# Make sure that the PostgreSQL-adapter uses timestamptz without any type modifiers.
+# You only need this if your application:
+# 1. Executes migrations OR
+# 2. Creates the `schema_migrations` and/or `ar_internal_metadata` tables.
+
+# The following sets the default type that should be used by PostgreSQL for datetime.
+# This is only supported in ActiveRecord 7.0 and higher.
+ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
+
+# Remove the above line and uncomment the line below if you are using ActiveRecord 6.1 or lower.
+# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:datetime] = { name: "timestamptz" }
+
+# The following ensures that ActiveRecord does not use any type modifiers for timestamp types.
+# That is; Cloud Spanner only supports `timestamptz` and not for example `timestamptz(6)`.
+module ActiveRecord::ConnectionAdapters
+  class PostgreSQLAdapter
+    def supports_datetime_with_precision?
+      false
+    end
+  end
+end
diff --git a/samples/ruby/activerecord/db/migrate/01_create_tables.rb b/samples/ruby/activerecord/db/migrate/01_create_tables.rb
new file mode 100644
index 00000000..629d96bd
--- /dev/null
+++ b/samples/ruby/activerecord/db/migrate/01_create_tables.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+# Simple migration to create the schema for the sample application.
+class CreateTables < ActiveRecord::Migration[7.0]
+  def change
+    create_table :singers, id: false, primary_key: :singer_id do |t|
+      t.string :singer_id, limit: 36, null: false, primary_key: true
+      t.string :first_name, limit: 100
+      t.string :last_name, limit: 200, null: false
+      t.virtual :full_name, type: :string, as: "coalesce(concat(first_name, ' '::varchar, last_name), last_name)", stored: true
+      t.boolean :active
+
+      t.datetime :created_at
+      t.datetime :updated_at
+      t.integer :lock_version, null: false
+    end
+
+    create_table :albums, id: false, primary_key: :album_id do |t|
+      t.string :album_id, limit: 36, null: false, primary_key: true
+      t.string :title
+      t.numeric :marketing_budget
+      t.date :release_date
+      t.binary :cover_picture
+      # We do not include an index on the foreign key because Cloud Spanner
+      # will automatically create a managed index for the foreign key
+      # constraint.
+      t.references :singer, foreign_key: {primary_key: :singer_id},
+                   type: :string, limit: 36, index: false
+
+      t.datetime :created_at
+      t.datetime :updated_at
+      t.integer :lock_version, null: false
+    end
+
+    # The ActiveRecord PostgreSQL provider does not know how to create an interleaved table.
+    # These tables must therefore be created using a hand-written SQL script.
+    # Note that interleaved tables require you to use a composite primary key.
+    # This also requires "gem 'composite_primary_keys', '~> 14'" to be part of your project.
+    execute "create table tracks (
+        album_id     varchar(36) not null,
+        track_number bigint not null,
+        title        varchar not null,
+        sample_rate  float8 not null,
+        created_at   timestamptz,
+        updated_at   timestamptz,
+        lock_version bigint not null,
+        primary key (album_id, track_number)
+    ) interleave in parent albums on delete cascade;
+    "
+
+    create_table :venues, id: false, primary_key: :venue_id do |t|
+      t.string :venue_id, limit: 36, null: false, primary_key: true
+      t.string :name
+      t.jsonb :description
+      t.datetime :created_at
+      t.datetime :updated_at
+      t.integer :lock_version, null: false
+    end
+
+    create_table :concerts, id: false, primary_key: :venue_id do |t|
+      t.string :concert_id, limit: 36, null: false, primary_key: true
+      t.references :venue, foreign_key: {primary_key: :venue_id},
+                   type: :string, limit: 36, index: false
+      t.references :singer, foreign_key: {primary_key: :singer_id},
+                   type: :string, limit: 36, index: false
+      t.string :name
+      t.datetime :start_time, null: false
+      t.datetime :end_time, null: false
+      t.datetime :created_at
+      t.datetime :updated_at
+      t.integer :lock_version, null: false
+      t.check_constraint "end_time > start_time", name: :chk_end_time_after_start_time
+    end
+
+  end
+end
diff --git a/samples/ruby/activerecord/db/schema.rb b/samples/ruby/activerecord/db/schema.rb
new file mode 100644
index 00000000..402b6703
--- /dev/null
+++ b/samples/ruby/activerecord/db/schema.rb
@@ -0,0 +1,72 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema.define(version: 1) do
+
+  create_table "albums", primary_key: "album_id", id: { type: :string, limit: 36 }, comment: "", force: :cascade do |t|
+    t.string "title"
+    t.decimal "marketing_budget"
+    t.date "release_date"
+    t.binary "cover_picture"
+    t.string "singer_id", limit: 36
+    t.datetime "created_at"
+    t.datetime "updated_at"
+    t.bigint "lock_version", null: false
+    t.index [], name: "IDX_albums_singer_id_N_CB7481F91EC719C7"
+  end
+
+  create_table "concerts", primary_key: "concert_id", id: { type: :string, limit: 36 }, comment: "", force: :cascade do |t|
+    t.string "venue_id", limit: 36
+    t.string "singer_id", limit: 36
+    t.string "name"
+    t.datetime "start_time", null: false
+    t.datetime "end_time", null: false
+    t.datetime "created_at"
+    t.datetime "updated_at"
+    t.bigint "lock_version", null: false
+    t.index [], name: "IDX_concerts_singer_id_N_E69EF1394455F7E2"
+    t.index [], name: "IDX_concerts_venue_id_N_9722D7D8AF4ABB9E"
+    t.check_constraint nil, name: "chk_end_time_after_start_time"
+  end
+
+  create_table "singers", primary_key: "singer_id", id: { type: :string, limit: 36 }, comment: "", force: :cascade do |t|
+    t.string "first_name", limit: 100
+    t.string "last_name", limit: 200, null: false
+    t.string "full_name"
+    t.boolean "active"
+    t.datetime "created_at"
+    t.datetime "updated_at"
+    t.bigint "lock_version", null: false
+  end
+
+  create_table "tracks", primary_key: ["album_id", "track_number"], comment: "", force: :cascade do |t|
+    t.string "album_id", limit: 36, null: false
+    t.bigint "track_number", null: false
+    t.string "title", null: false
+    t.float "sample_rate", null: false
+    t.datetime "created_at"
+    t.datetime "updated_at"
+    t.bigint "lock_version", null: false
+  end
+
+  create_table "venues", primary_key: "venue_id", id: { type: :string, limit: 36 }, comment: "", force: :cascade do |t|
+    t.string "name"
+    t.jsonb "description"
+    t.datetime "created_at"
+    t.datetime "updated_at"
+    t.bigint "lock_version", null: false
+  end
+
+  add_foreign_key "albums", "singers", primary_key: "singer_id"
+  add_foreign_key "concerts", "singers", primary_key: "singer_id"
+  add_foreign_key "concerts", "venues", primary_key: "venue_id"
+end
diff --git a/samples/ruby/activerecord/db/seeds.rb b/samples/ruby/activerecord/db/seeds.rb
new file mode 100644
index 00000000..08b08c8b
--- /dev/null
+++ b/samples/ruby/activerecord/db/seeds.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require 'securerandom'
+require_relative '../config/environment.rb'
+require_relative '../models/album'
+require_relative '../models/concert'
+require_relative '../models/singer'
+require_relative '../models/track'
+require_relative '../models/venue'
+
+first_names = %w[Emma Oliver Amelia Noah Charlotte Jacob Liam Ava Henry Sophia Mia Mason Evelyn Oliver Emily Amelia William Charlotte Noah Harper Elijah Avery James]
+last_names = %w[Smith, Johnson, Williams, Jones, Brown, Davis, Miller, Wilson, Moore, Taylor, Anderson, Thomas, Jackson, White, Harris, Martin, Thompson, Garcia, Martinez, Robinson]
+
+nouns = %w[
+dog cat house car tree book computer phone television radio
+sun moon star planet ocean sea river mountain valley forest
+person girl boy woman man mother father sister brother family
+]
+
+adjectives = %w[
+big small old young hot cold happy sad good bad
+pretty ugly tall short fat thin smart stupid fast slow
+brave cowardly kind cruel gentle rough friendly unfriendly
+]
+
+adverbs = %w[
+quickly slowly carefully carelessly well badly suddenly gradually easily difficulty
+loudly quietly happily sadly carefully carelessly correctly incorrectly interestingly boringly
+]
+
+verbs = %w[
+run jump walk talk eat sleep drink think see hear
+sing dance play read write draw paint cook clean wash
+work study learn teach help share care love hate
+]
+
+venue_names = [
+  "The Groove Room",
+  "The Getaway",
+  "The Sound Stage",
+  "The Vibe Bar",
+  "The Beat Club",
+  "The Rhythm Lounge",
+  "The Melody Room",
+  "The Harmony Cafe",
+  "The Treble Room",
+  "The Bass Line",
+  "The Jam Spot",
+  "The Music Hall",
+  "The Concert Club",
+  "The Rock Bar",
+  "The Jazz Club",
+  "The Blues Bar",
+  "The Country Club",
+  "The Folk Cafe",
+  "The Classical Music Hall",
+  "The Opera House",
+  "The Ballet Theatre",
+  "The Symphony Hall",
+  "The Choir Hall",
+  "The Bandstand",
+  "The Music Studio",
+  "The Recording Studio",
+  "The Rehearsal Space",
+  "The Music School",
+  "The Music Store",
+  "The Music Museum",
+  "The Music Library",
+  "The Music Festival",
+  "The Music Awards",
+  "The Music Conference",
+  "The Music Competition",
+  "The Music Showcase",
+  "The Music Tour",
+  "The Music Concert",
+  "The Music Recital",
+  "The Music Performance",
+  "The Music Jam Session",
+  "The Music Open Mic Night"
+]
+
+rnd = Random.new
+
+puts "Deleting existing data..."
+
+Concert.delete_all
+puts "Deleted concerts"
+
+Venue.delete_all
+puts "Deleted venues"
+
+Album.delete_all
+puts "Deleted albums"
+
+Singer.delete_all
+puts "Deleted singers"
+
+puts "Creating random singers, albums, tracks, venues and concerts..."
+Singer.transaction do
+  singers = []
+  albums = []
+  tracks = []
+  venues = []
+  concerts = []
+  10.times do
+    singers.append({singer_id: SecureRandom.uuid,
+                    first_name: first_names.sample,
+                    last_name: last_names.sample,
+                    active: true})
+  end
+  Singer.create singers
+  puts "Created #{singers.length} singers"
+
+  30.times do
+    singer_id = singers.sample[:singer_id]
+    albums.append({album_id: SecureRandom.uuid,
+                   title: "#{adjectives.sample} #{nouns.sample}",
+                   singer_id: singer_id,
+                   marketing_budget: rand * 10000,
+                   release_date: Date.new(rand(100) + 1923, rand(12) + 1, rand(28) + 1),
+                   cover_picture: rnd.bytes(rand(200) + 10)})
+  end
+  Album.create albums
+  puts "Created #{albums.length} albums"
+
+  albums.each do |album|
+    10.times do |num|
+      tracks.append({album_id: album[:album_id],
+                     track_number: num+1,
+                     title: "#{adverbs.sample} #{verbs.sample}",
+                     sample_rate: rand})
+    end
+  end
+  Track.create tracks
+  puts "Created #{tracks.length} tracks"
+
+  10.times do
+    venues.append({venue_id: SecureRandom.uuid,
+                   name: venue_names.sample,
+                   description: {
+                     capacity: rand(20000),
+                     type: "Arena",
+                   }})
+  end
+  Venue.create venues
+  puts "Created #{venues.length} venues"
+
+  50.times do
+    singer = singers.sample
+    date = Date.new(rand(100) + 1923, rand(12) + 1, rand(28) + 1)
+    start_time = DateTime.new(date.year, date.month, date.day, rand(24), rand(60), 0)
+    end_time = start_time + (rand(5) + 1).hours
+    concerts.append({concert_id: SecureRandom.uuid,
+                     name: "#{singer[:first_name]} #{singer[:last_name]} Live #{date}",
+                     venue_id: venues.sample[:venue_id],
+                     singer_id: singer[:singer_id],
+                     start_time: start_time,
+                     end_time: end_time})
+  end
+  Concert.create concerts
+  puts "Created #{concerts.length} concerts"
+end
+puts "Finished seeding data"
diff --git a/samples/ruby/activerecord/drop_data_model.sql b/samples/ruby/activerecord/drop_data_model.sql
new file mode 100644
index 00000000..7fb793a3
--- /dev/null
+++ b/samples/ruby/activerecord/drop_data_model.sql
@@ -0,0 +1,12 @@
+-- Executing the schema drop in a batch will improve execution speed.
+start batch ddl;
+
+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;
+drop table if exists ar_internal_metadata;
+drop table if exists schema_migrations;
+
+run batch;
diff --git a/samples/ruby/activerecord/models/album.rb b/samples/ruby/activerecord/models/album.rb
new file mode 100644
index 00000000..ab0a6eb8
--- /dev/null
+++ b/samples/ruby/activerecord/models/album.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'singer'
+require_relative 'track'
+
+# Model for an Album of a Singer.
+class Album < ActiveRecord::Base
+  belongs_to :singer
+  has_many :tracks
+end
diff --git a/samples/ruby/activerecord/models/concert.rb b/samples/ruby/activerecord/models/concert.rb
new file mode 100644
index 00000000..a5c0f57c
--- /dev/null
+++ b/samples/ruby/activerecord/models/concert.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'singer'
+require_relative 'venue'
+
+# Model for Concert.
+class Concert < ActiveRecord::Base
+  belongs_to :singer
+  belongs_to :venue
+end
diff --git a/samples/ruby/activerecord/models/singer.rb b/samples/ruby/activerecord/models/singer.rb
new file mode 100644
index 00000000..0e1eb546
--- /dev/null
+++ b/samples/ruby/activerecord/models/singer.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'album'
+require_relative 'concert'
+
+# Model for a Singer entity.
+class Singer < ActiveRecord::Base
+  has_many :albums
+  has_many :concerts
+end
diff --git a/samples/ruby/activerecord/models/track.rb b/samples/ruby/activerecord/models/track.rb
new file mode 100644
index 00000000..ce6a25ac
--- /dev/null
+++ b/samples/ruby/activerecord/models/track.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+# Model for a Track on an Album.
+class Track < ActiveRecord::Base
+  self.primary_keys = :album_id, :track_number
+  belongs_to :album
+end
diff --git a/samples/ruby/activerecord/models/venue.rb b/samples/ruby/activerecord/models/venue.rb
new file mode 100644
index 00000000..df0051d0
--- /dev/null
+++ b/samples/ruby/activerecord/models/venue.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'concert'
+
+# Model for a Venue entity.
+class Venue < ActiveRecord::Base
+  has_many :concerts
+end
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/PgCatalog.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/PgCatalog.java
index 5b2d1ac4..acb7139a 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/PgCatalog.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/PgCatalog.java
@@ -119,7 +119,10 @@ public class PgCatalog {
           RegexQueryPartReplacer.replace(
               Pattern.compile("pg_table_is_visible\\s*\\(.+\\)"), Suppliers.ofInstance("true")),
           RegexQueryPartReplacer.replace(
-              Pattern.compile("=\\s*ANY\\s*\\(current_schemas\\(true\\)\\)"),
+              Pattern.compile("=\\s*ANY\\s*\\(current_schemas\\(\\s*true\\s*\\)\\)"),
+              Suppliers.ofInstance(" IN ('pg_catalog', 'public')")),
+          RegexQueryPartReplacer.replace(
+              Pattern.compile("=\\s*ANY\\s*\\(current_schemas\\(\\s*false\\s*\\)\\)"),
               Suppliers.ofInstance(" IN ('pg_catalog', 'public')")));
 
   private final ImmutableSet<String> checkPrefixes;
@@ -819,7 +822,7 @@ public class PgCatalog {
 
   @InternalApi
   public static class EmptyPgEnum implements PgCatalogTable {
-    private static final String PG_ENUM_CTE =
+    public static final String PG_ENUM_CTE =
         "pg_enum as (\n"
             + "select * from ("
             + "select 0::bigint as oid, 0::bigint as enumtypid, 0.0::float8 as enumsortorder, ''::varchar as enumlabel\n"
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/utils/ClientAutoDetector.java b/src/main/java/com/google/cloud/spanner/pgadapter/utils/ClientAutoDetector.java
index dc66c759..7cf1c38c 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/utils/ClientAutoDetector.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/utils/ClientAutoDetector.java
@@ -124,6 +124,104 @@ public class ClientAutoDetector {
         return errorHints;
       }
     },
+    RAILS {
+      final ImmutableList<QueryPartReplacer> functionReplacements =
+          ImmutableList.of(
+              RegexQueryPartReplacer.replaceAndStop(
+                  Pattern.compile(
+                      "SELECT\\s+a\\.attname\\s+"
+                          + "FROM\\s+\\(\\s+"
+                          + "SELECT\\s+indrelid\\s*,\\s*indkey\\s*,\\s*generate_subscripts\\s*\\(\\s*indkey\\s*,\\s*1\\)\\s*idx\\s+"
+                          + "FROM\\s+pg_index\\s+"
+                          + "WHERE indrelid\\s*=\\s*'\"?(.+?)\"?'::regclass\\s+"
+                          + "AND indisprimary\\s*"
+                          + "\\)\\s*i\\s+"
+                          + "JOIN\\s+pg_attribute\\s+a\\s+"
+                          + "ON\\s+a\\.attrelid\\s*=\\s*i\\.indrelid\\s+"
+                          + "AND\\s+a\\.attnum\\s*=\\s*i\\.indkey\\[i\\.idx]\\s*"
+                          + "ORDER\\s+BY\\s+i\\.idx"),
+                  "SELECT ic.column_name as attname\n"
+                      + "FROM information_schema.index_columns ic\n"
+                      + "INNER JOIN information_schema.indexes i using (table_catalog, table_schema, table_name, index_name)\n"
+                      + "WHERE ic.table_schema='public' and ic.table_name='$1'\n"
+                      + "AND i.index_type='PRIMARY_KEY'\n"
+                      + "ORDER BY ordinal_position"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile(
+                      "format_type\\s*\\(\\s*a\\.atttypid\\s*,\\s*a\\.atttypmod\\s*\\)"),
+                  "a.spanner_type as format_type"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("pg_get_expr\\s*\\(\\s*d\\.adbin\\s*,\\s*d\\.adrelid\\s*\\)"),
+                  "d.adbin as pg_get_expr"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("col_description\\s*\\(\\s*.+\\s*,\\s*.+\\s*\\)"), "''::varchar"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile(
+                      "pg_catalog\\.obj_description\\s*\\(\\s*.+\\s*,\\s*'pg_class'\\s*\\)\\s*AS\\s+"),
+                  "''::varchar AS "),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile(
+                      "pg_catalog\\.obj_description\\s*\\(\\s*.+\\s*,\\s*'pg_class'\\s*\\)"),
+                  "''::varchar AS obj_description"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("pg_get_indexdef\\s*\\(.+\\)"),
+                  "'CREATE INDEX ON USING btree ( )'::varchar AS pg_get_indexdef"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("pg_get_constraintdef\\s*\\(.+\\)\\s*AS\\s+"), "conbin AS "),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("'\"(.+?)\"'::regclass"), "'''\"public\".\"$1\"'''"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile(
+                      "string_agg\\(enum\\.enumlabel, ',' ORDER BY enum\\.enumsortorder\\)"),
+                  "''::varchar"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("(\\s+.+?)\\.oid::regclass::text"),
+                  " substr($1.oid, 12, length($1.oid) - 13)"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile(
+                      "t\\.typinput\\s*=\\s*'array_in\\(\\s*cstring\\s*,\\s*oid,\\s*integer\\)'::regprocedure"),
+                  "t.typinput='array_in'"),
+              RegexQueryPartReplacer.replace(
+                  Pattern.compile("SELECT\\s+distinct\\s+i\\.relname\\s*,"), "SELECT i.relname,"));
+
+      @Override
+      boolean isClient(List<String> orderedParameterKeys, Map<String, String> parameters) {
+        return parameters.containsKey("application_name")
+            && (parameters.get("application_name").endsWith("rake")
+                || parameters.get("application_name").endsWith(".rb")
+                || parameters.get("application_name").contains("rails"));
+      }
+
+      @Override
+      public ImmutableList<QueryPartReplacer> getQueryPartReplacements() {
+        return functionReplacements;
+      }
+
+      @Override
+      public ImmutableMap<String, String> getDefaultParameters() {
+        return ImmutableMap.of("spanner.emulate_pg_class_tables", "true");
+      }
+
+      @Override
+      public ImmutableList<String> getErrorHints(PGException exception) {
+        if (exception.getMessage() != null
+            && exception
+                .getMessage()
+                .contains("DDL statements are only allowed outside explicit transactions")) {
+          return ImmutableList.of(
+              "Using Ruby ActiveRecord migrations requires that the option 'spanner.ddl_transaction_mode=AutocommitExplicitTransaction' has been set. "
+                  + "Please add \"spanner.ddl_transaction_mode\": \"AutocommitExplicitTransaction\" to the \"variables\" section of your database.yml file.\n"
+                  + "See https://github.com/GoogleCloudPlatform/pgadapter/blob/-/samples/ruby/activerecord/README.md for more information.");
+        }
+        if (exception.getMessage() != null
+            && exception.getMessage().contains("SELECT pg_try_advisory_lock")) {
+          return ImmutableList.of(
+              "PGAdapter does not support advisory locks. Please 'add advisory_locks: false' to your database.yml file. "
+                  + "See https://edgeguides.rubyonrails.org/configuring.html#configuring-a-postgresql-database for more information.");
+        }
+        return super.getErrorHints(exception);
+      }
+    },
     JDBC {
       @Override
       boolean isClient(List<String> orderedParameterKeys, Map<String, String> parameters) {


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ruby/AbstractRubyMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/AbstractRubyMockServerTest.java
new file mode 100644
index 00000000..d2d50eb8
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/AbstractRubyMockServerTest.java
@@ -0,0 +1,1472 @@
+// 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.pgadapter.ruby;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.pgadapter.AbstractMockServerTest;
+import com.google.cloud.spanner.pgadapter.statements.PgCatalog.EmptyPgEnum;
+import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgCollation;
+import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgConstraint;
+import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgIndex;
+import com.google.cloud.spanner.pgadapter.statements.PgCatalog.PgNamespace;
+import com.google.common.collect.ImmutableMap;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.NullValue;
+import com.google.protobuf.Value;
+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.TypeCode;
+import java.io.File;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.junit.BeforeClass;
+import org.postgresql.core.Oid;
+
+public class AbstractRubyMockServerTest extends AbstractMockServerTest {
+  static String randomUuid() {
+    return UUID.randomUUID().toString();
+  }
+
+  private static final Statement SELECT_TYPES =
+      Statement.of(
+          "with "
+              + PG_TYPE_PREFIX
+              + "\nSELECT t.oid, t.typname\n"
+              + "FROM pg_type as t\n"
+              + "WHERE t.typname IN ('int2', 'int4', 'int8', 'oid', 'float4', 'float8', 'numeric', 'bool', 'timestamp', 'timestamptz')\n");
+  private static final ResultSetMetadata SELECT_TYPES_METADATA =
+      ResultSetMetadata.newBuilder()
+          .setRowType(
+              StructType.newBuilder()
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("oid")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typname")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .build())
+          .build();
+
+  private static final com.google.spanner.v1.ResultSet SELECT_TYPES_RESULTSET =
+      ResultSet.newBuilder()
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT2)).build())
+                  .addValues(Value.newBuilder().setStringValue("int2").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT4)).build())
+                  .addValues(Value.newBuilder().setStringValue("int4").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT8)).build())
+                  .addValues(Value.newBuilder().setStringValue("int8").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT4)).build())
+                  .addValues(Value.newBuilder().setStringValue("float4").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT8)).build())
+                  .addValues(Value.newBuilder().setStringValue("float8").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.NUMERIC)).build())
+                  .addValues(Value.newBuilder().setStringValue("numeric").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.TIMESTAMP)).build())
+                  .addValues(Value.newBuilder().setStringValue("timestamp").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.TIMESTAMPTZ)).build())
+                  .addValues(Value.newBuilder().setStringValue("timestamptz").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.BOOL)).build())
+                  .addValues(Value.newBuilder().setStringValue("bool").build())
+                  .build())
+          .setMetadata(SELECT_TYPES_METADATA)
+          .build();
+
+  private static final Statement SELECT_TYPES_EXTENDED =
+      Statement.of(
+          "with "
+              + PG_TYPE_PREFIX
+              + ",\n"
+              + "pg_range as (\n"
+              + "select * from (select 0::bigint as rngtypid, 0::bigint as rngsubtype, 0::bigint as rngmultitypid, 0::bigint as rngcollation, 0::bigint as rngsubopc, ''::varchar as rngcanonical, ''::varchar as rngsubdiff\n"
+              + ") range where false)\n"
+              + "SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype\n"
+              + "FROM pg_type as t\n"
+              + "LEFT JOIN pg_range as r ON oid = rngtypid\n"
+              + "WHERE\n"
+              + "  t.typname IN ('int2', 'int4', 'int8', 'oid', 'float4', 'float8', 'text', 'varchar', 'char', "
+              + "'name', 'bpchar', 'bool', 'bit', 'varbit', 'date', 'money', 'bytea', 'point', 'hstore', 'json', "
+              + "'jsonb', 'cidr', 'inet', 'uuid', 'xml', 'tsvector', 'macaddr', 'citext', 'ltree', 'line', "
+              + "'lseg', 'box', 'path', 'polygon', 'circle', 'time', 'timestamp', 'timestamptz', 'numeric', 'interval')\n");
+  private static final ResultSetMetadata SELECT_TYPES_EXTENDED_METADATA =
+      ResultSetMetadata.newBuilder()
+          .setRowType(
+              StructType.newBuilder()
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("oid")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typname")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typelem")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typdelim")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typinput")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("rngsubtype")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typtype")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("typbasetype")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .build())
+          .build();
+
+  private static final ListValue VARCHAR_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.VARCHAR)).build())
+          .addValues(Value.newBuilder().setStringValue("varchar").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("varcharin").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue BOOL_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.BOOL)).build())
+          .addValues(Value.newBuilder().setStringValue("bool").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("boolin").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue TIMESTAMPTZ_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.TIMESTAMPTZ)).build())
+          .addValues(Value.newBuilder().setStringValue("timestamptz").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("timestamptzin").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue INT8_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT8)).build())
+          .addValues(Value.newBuilder().setStringValue("int8").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("int8in").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue NUMERIC_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.NUMERIC)).build())
+          .addValues(Value.newBuilder().setStringValue("numeric").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("numericin").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue DATE_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.DATE)).build())
+          .addValues(Value.newBuilder().setStringValue("date").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("datein").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue BYTEA_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.BYTEA)).build())
+          .addValues(Value.newBuilder().setStringValue("bytea").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("byteain").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+  private static final ListValue FLOAT8_TYPE_ROW =
+      ListValue.newBuilder()
+          .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT8)).build())
+          .addValues(Value.newBuilder().setStringValue("float8").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .addValues(Value.newBuilder().setStringValue(",").build())
+          .addValues(Value.newBuilder().setStringValue("float8in").build())
+          .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+          .addValues(Value.newBuilder().setStringValue("b").build())
+          .addValues(Value.newBuilder().setStringValue("0").build())
+          .build();
+
+  private static final com.google.spanner.v1.ResultSet SELECT_TYPES_EXTENDED_RESULTSET =
+      ResultSet.newBuilder()
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT2)).build())
+                  .addValues(Value.newBuilder().setStringValue("int2").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("int2in").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT4)).build())
+                  .addValues(Value.newBuilder().setStringValue("int4").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("int4in").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(INT8_TYPE_ROW)
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT4)).build())
+                  .addValues(Value.newBuilder().setStringValue("float4").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("float4in").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(FLOAT8_TYPE_ROW)
+          .addRows(NUMERIC_TYPE_ROW)
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.TIMESTAMP)).build())
+                  .addValues(Value.newBuilder().setStringValue("timestamp").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("timestampin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(TIMESTAMPTZ_TYPE_ROW)
+          .addRows(BOOL_TYPE_ROW)
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.TEXT)).build())
+                  .addValues(Value.newBuilder().setStringValue("text").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("textin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(VARCHAR_TYPE_ROW)
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.JSONB)).build())
+                  .addValues(Value.newBuilder().setStringValue("jsonb").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("jsonbin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(BYTEA_TYPE_ROW)
+          .addRows(DATE_TYPE_ROW)
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .build();
+
+  private static final Statement SELECT_RANGE_TYPES =
+      Statement.of(
+          "with "
+              + PG_TYPE_PREFIX
+              + ",\n"
+              + "pg_range as (\n"
+              + "select * from (select 0::bigint as rngtypid, 0::bigint as rngsubtype, 0::bigint as rngmultitypid, 0::bigint as rngcollation, 0::bigint as rngsubopc, ''::varchar as rngcanonical, ''::varchar as rngsubdiff\n"
+              + ") range where false)\n"
+              + "SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype\n"
+              + "FROM pg_type as t\n"
+              + "LEFT JOIN pg_range as r ON oid = rngtypid\n"
+              + "WHERE\n"
+              + "  t.typtype IN ('r', 'e', 'd')\n");
+  private static final String SELECT_RANGE_TYPES_V6 =
+      "with "
+          + PG_TYPE_PREFIX
+          + ",\n"
+          + "pg_range as (\n"
+          + "select * from (select 0::bigint as rngtypid, 0::bigint as rngsubtype, 0::bigint as rngmultitypid, 0::bigint as rngcollation, 0::bigint as rngsubopc, ''::varchar as rngcanonical, ''::varchar as rngsubdiff\n"
+          + ") range where false)\n"
+          + "SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype\n"
+          + "FROM pg_type as t\n"
+          + "LEFT JOIN pg_range as r ON oid = rngtypid\n"
+          + "WHERE t.oid IN (%d)";
+  private static final Statement SELECT_RANGE_TYPES_V6_1043 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 1043));
+  private static final Statement SELECT_RANGE_TYPES_V6_16 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 16));
+  private static final Statement SELECT_RANGE_TYPES_V6_1184 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 1184));
+  private static final Statement SELECT_RANGE_TYPES_V6_20 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 20));
+  private static final Statement SELECT_RANGE_TYPES_V6_1700 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 1700));
+  private static final Statement SELECT_RANGE_TYPES_V6_1082 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 1082));
+  private static final Statement SELECT_RANGE_TYPES_V6_17 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 17));
+  private static final Statement SELECT_RANGE_TYPES_V6_701 =
+      Statement.of(String.format(SELECT_RANGE_TYPES_V6, 701));
+  private static final ResultSet SELECT_RANGE_TYPES_RESULTSET =
+      ResultSet.newBuilder().setMetadata(SELECT_TYPES_EXTENDED_METADATA).build();
+  private static final ResultSet SELECT_RANGE_TYPES_1043_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(VARCHAR_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_16_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(BOOL_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_1184_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(TIMESTAMPTZ_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_20_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(INT8_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_1700_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(NUMERIC_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_1082_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(DATE_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_17_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(BYTEA_TYPE_ROW)
+          .build();
+  private static final ResultSet SELECT_RANGE_TYPES_701_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .addRows(FLOAT8_TYPE_ROW)
+          .build();
+
+  private static final Statement SELECT_ARRAY_TYPES =
+      Statement.of(
+          "with "
+              + PG_TYPE_PREFIX
+              + ",\n"
+              + "pg_range as (\n"
+              + "select * from (select 0::bigint as rngtypid, 0::bigint as rngsubtype, 0::bigint as rngmultitypid, 0::bigint as rngcollation, 0::bigint as rngsubopc, ''::varchar as rngcanonical, ''::varchar as rngsubdiff\n"
+              + ") range where false)\n"
+              + "SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype\n"
+              + "FROM pg_type as t\n"
+              + "LEFT JOIN pg_range as r ON oid = rngtypid\n"
+              + "WHERE\n"
+              + "  t.typelem IN (21, 23, 20, 700, 701, 1700, 1114, 1184, 16, 25, 1043, 3802, 17, 1082)\n");
+
+  private static final Statement SELECT_ARRAY_TYPES_V6 =
+      Statement.of(
+          "with "
+              + PG_TYPE_PREFIX
+              + ",\n"
+              + "pg_range as (\n"
+              + "select * from (select 0::bigint as rngtypid, 0::bigint as rngsubtype, 0::bigint as rngmultitypid, 0::bigint as rngcollation, 0::bigint as rngsubopc, ''::varchar as rngcanonical, ''::varchar as rngsubdiff\n"
+              + ") range where false)\n"
+              + "SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype\n"
+              + "FROM pg_type as t\n"
+              + "LEFT JOIN pg_range as r ON oid = rngtypid\n"
+              + "WHERE\n"
+              + "  t.typname IN ('int2', 'int4', 'int8', 'oid', 'float4', 'float8', 'text', 'varchar', 'char', 'name', 'bpchar', 'bool', 'bit', 'varbit', 'timestamptz', 'date', 'money', 'bytea', 'point', 'hstore', 'json', 'jsonb', 'cidr', 'inet', 'uuid', 'xml', 'tsvector', 'macaddr', 'citext', 'ltree', 'line', 'lseg', 'box', 'path', 'polygon', 'circle', 'time', 'timestamp', 'numeric', 'interval')\n"
+              + "  OR t.typtype IN ('r', 'e', 'd')\n"
+              + "  OR t.typinput='array_in'\n"
+              + "  OR t.typelem != 0\n");
+  private static final com.google.spanner.v1.ResultSet SELECT_ARRAY_TYPES_RESULTSET =
+      ResultSet.newBuilder()
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.INT2_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_int2").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT2)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.INT4_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_int4").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT4)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.INT8_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_int8").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.INT8)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT4_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_float4").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT4)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT8_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_float8").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.FLOAT8)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.NUMERIC_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_numeric").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.NUMERIC)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder()
+                          .setStringValue(String.valueOf(Oid.TIMESTAMP_ARRAY))
+                          .build())
+                  .addValues(Value.newBuilder().setStringValue("_timestamp").build())
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.TIMESTAMP)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder()
+                          .setStringValue(String.valueOf(Oid.TIMESTAMPTZ_ARRAY))
+                          .build())
+                  .addValues(Value.newBuilder().setStringValue("_timestamptz").build())
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.TIMESTAMPTZ)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.BOOL_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_bool").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.BOOL)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.TEXT_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_text").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.TEXT)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.VARCHAR_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_varchar").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.VARCHAR)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.JSONB_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_jsonb").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.JSONB)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.BYTEA_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_bytea").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.BYTEA)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(
+                      Value.newBuilder().setStringValue(String.valueOf(Oid.DATE_ARRAY)).build())
+                  .addValues(Value.newBuilder().setStringValue("_date").build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.DATE)).build())
+                  .addValues(Value.newBuilder().setStringValue(",").build())
+                  .addValues(Value.newBuilder().setStringValue("arrayin").build())
+                  .addValues(Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())
+                  .addValues(Value.newBuilder().setStringValue("b").build())
+                  .addValues(Value.newBuilder().setStringValue("0").build())
+                  .build())
+          .setMetadata(SELECT_TYPES_EXTENDED_METADATA)
+          .build();
+
+  protected static final Statement SELECT_MIGRATIONS_TABLE =
+      getSelectRelNameStatement("schema_migrations");
+  protected static final Statement SELECT_MIGRATIONS_TABLE_EXTENDED =
+      getSelectRelNameStatementAllRelKinds("schema_migrations");
+  protected static final ResultSetMetadata SELECT_RELNAME_METADATA =
+      ResultSetMetadata.newBuilder()
+          .setRowType(
+              StructType.newBuilder()
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("relname")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .build())
+          .build();
+
+  protected static final Statement SELECT_AR_INTERNAL_METADATA_TABLE =
+      getSelectRelNameStatement("ar_internal_metadata");
+  protected static final Statement SELECT_SCHEMA_MIGRATIONS_COLUMNS =
+      getSelectAttributesStatement("schema_migrations", true);
+  protected static final Statement SELECT_SCHEMA_MIGRATIONS_COLUMNS_V6 =
+      getSelectAttributesStatement("schema_migrations", false);
+  protected static final ResultSetMetadata SELECT_COLUMNS_METADATA =
+      ResultSetMetadata.newBuilder()
+          .setRowType(
+              StructType.newBuilder()
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("attname")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("format_type")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("pg_get_expr")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("attnotnull")
+                          .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("atttypid")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("atttypmod")
+                          .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("collname")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("comment")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .addFields(
+                      Field.newBuilder()
+                          .setName("attgenerated")
+                          .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                          .build())
+                  .build())
+          .build();
+  protected static final ResultSet SELECT_SCHEMA_MIGRATIONS_COLUMNS_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_COLUMNS_METADATA)
+          .addRows(
+              ListValue.newBuilder()
+                  .addValues(Value.newBuilder().setStringValue("version").build())
+                  .addValues(Value.newBuilder().setStringValue("character varying").build())
+                  .addValues(Value.newBuilder().setStringValue("").build())
+                  .addValues(Value.newBuilder().setBoolValue(true).build())
+                  .addValues(Value.newBuilder().setStringValue(String.valueOf(Oid.VARCHAR)).build())
+                  .addValues(Value.newBuilder().setStringValue("-1").build())
+                  .addValues(Value.newBuilder().setStringValue("").build())
+                  .addValues(Value.newBuilder().setStringValue("").build())
+                  .addValues(Value.newBuilder().setStringValue("").build())
+                  .build())
+          .build();
+
+  protected static final Statement SELECT_AR_INTERNAL_METADATA_COLUMNS =
+      getSelectAttributesStatement("ar_internal_metadata", true);
+  protected static final Statement SELECT_AR_INTERNAL_METADATA_COLUMNS_V6 =
+      getSelectAttributesStatement("ar_internal_metadata", false);
+  protected static final ResultSet SELECT_AR_INTERNAL_METADATA_COLUMNS_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(SELECT_COLUMNS_METADATA)
+          .addRows(createColumnRow("key", "character varying", true, Oid.VARCHAR))
+          .addRows(createColumnRow("value", "character varying", true, Oid.VARCHAR))
+          .addRows(createColumnRow("created_at", "timestamptz", true, Oid.TIMESTAMPTZ))
+          .addRows(createColumnRow("updated_at", "timestamptz", true, Oid.TIMESTAMPTZ))
+          .build();
+
+  private static final Statement SELECT_EXTENSIONS =
+      Statement.of(
+          "with pg_extension as (\n"
+              + "select * from (select 0::bigint as oid, ''::varchar as extname, 0::bigint as extowner, 0::bigint as extnamespace, false::bool as extrelocatable, ''::varchar as extversion, '{}'::bigint[] as extconfig, '{}'::text[] as extcondition\n"
+              + ") ext where false)\n"
+              + "SELECT extname FROM pg_extension");
+  private static final ResultSet SELECT_EXTENSIONS_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(
+              ResultSetMetadata.newBuilder()
+                  .setRowType(
+                      StructType.newBuilder()
+                          .addFields(
+                              Field.newBuilder()
+                                  .setName("extname")
+                                  .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                  .build())
+                          .build())
+                  .build())
+          .build();
+
+  private static final Statement SELECT_ENUMS =
+      Statement.of(
+          "with "
+              + EmptyPgEnum.PG_ENUM_CTE
+              + ",\n"
+              + PG_TYPE_PREFIX
+              + "\nSELECT\n"
+              + "  type.typname AS name,\n"
+              + "  ''::varchar AS value\n"
+              + "FROM pg_enum AS enum\n"
+              + "JOIN pg_type AS type\n"
+              + "  ON (type.oid = enum.enumtypid)\n"
+              + "GROUP BY type.typname;\n");
+  private static final ResultSet SELECT_ENUMS_RESULTSET =
+      ResultSet.newBuilder()
+          .setMetadata(
+              ResultSetMetadata.newBuilder()
+                  .setRowType(
+                      StructType.newBuilder()
+                          .addFields(
+                              Field.newBuilder()
+                                  .setName("name")
+                                  .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                  .build())
+                          .addFields(
+                              Field.newBuilder()
+                                  .setName("value")
+                                  .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                  .build())
+                          .build())
+                  .build())
+          .build();
+
+  @BeforeClass
+  public static void setupResults() {
+    mockSpanner.putStatementResult(StatementResult.query(SELECT_TYPES, SELECT_TYPES_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_TYPES_EXTENDED, SELECT_TYPES_EXTENDED_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES, SELECT_RANGE_TYPES_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_1043, SELECT_RANGE_TYPES_1043_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_16, SELECT_RANGE_TYPES_16_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_1184, SELECT_RANGE_TYPES_1184_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_20, SELECT_RANGE_TYPES_20_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_1700, SELECT_RANGE_TYPES_1700_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_1082, SELECT_RANGE_TYPES_1082_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_17, SELECT_RANGE_TYPES_17_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_RANGE_TYPES_V6_701, SELECT_RANGE_TYPES_701_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_ARRAY_TYPES, SELECT_ARRAY_TYPES_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_ARRAY_TYPES_V6, SELECT_ARRAY_TYPES_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(SELECT_EXTENSIONS, SELECT_EXTENSIONS_RESULTSET));
+    mockSpanner.putStatementResult(StatementResult.query(SELECT_ENUMS, SELECT_ENUMS_RESULTSET));
+  }
+
+  public static void createVirtualEnv(String directoryName) throws Exception {
+    run(new String[] {"bundle", "install"}, directoryName, ImmutableMap.of());
+  }
+
+  public static String run(String[] command, String directoryName, Map<String, String> environment)
+      throws Exception {
+    File directory = new File(directoryName);
+    ProcessBuilder builder = new ProcessBuilder();
+    builder.command(command);
+    builder.directory(directory);
+    builder.environment().putAll(environment);
+    Process process = builder.start();
+    Scanner scanner = new Scanner(process.getInputStream());
+    Scanner errorScanner = new Scanner(process.getErrorStream());
+
+    StringBuilder output = new StringBuilder();
+    while (scanner.hasNextLine()) {
+      output.append(scanner.nextLine()).append("\n");
+    }
+    StringBuilder error = new StringBuilder();
+    while (errorScanner.hasNextLine()) {
+      error.append(errorScanner.nextLine()).append("\n");
+    }
+    int result = process.waitFor();
+    assertEquals(output + "\n\n\n" + error, 0, result);
+
+    return output.toString();
+  }
+
+  static void addMigrationsTableResults() {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_MIGRATIONS_TABLE, createSelectRelNameResultSet("schema_migrations")));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_AR_INTERNAL_METADATA_TABLE,
+            createSelectRelNameResultSet("ar_internal_metadata")));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_SCHEMA_MIGRATIONS_COLUMNS, SELECT_SCHEMA_MIGRATIONS_COLUMNS_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_SCHEMA_MIGRATIONS_COLUMNS_V6, SELECT_SCHEMA_MIGRATIONS_COLUMNS_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_AR_INTERNAL_METADATA_COLUMNS, SELECT_AR_INTERNAL_METADATA_COLUMNS_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_AR_INTERNAL_METADATA_COLUMNS_V6, SELECT_AR_INTERNAL_METADATA_COLUMNS_RESULTSET));
+
+    addMigrationResults("1");
+  }
+
+  static Statement getSelectRelNameStatement(String table) {
+    return getSelectRelNameStatement(table, "'r','p'");
+  }
+
+  static Statement getSelectRelNameStatementAllRelKinds(String table) {
+    return getSelectRelNameStatement(table, "'r','v','m','p','f'");
+  }
+
+  static Statement getSelectRelNameStatement(String table, String relKinds) {
+    return Statement.of(
+        String.format(
+            "with "
+                + EMULATED_PG_CLASS_PREFIX
+                + ",\n"
+                + "pg_namespace as (\n"
+                + "  select case schema_name when 'pg_catalog' then 11 when 'public' then 2200 else 0 end as oid,\n"
+                + "        schema_name as nspname, null as nspowner, null as nspacl\n"
+                + "  from information_schema.schemata\n"
+                + ")\n"
+                + "SELECT c.relname "
+                + "FROM pg_class c "
+                + "LEFT JOIN pg_namespace n ON n.oid = c.relnamespace "
+                + "WHERE n.nspname  IN ('pg_catalog', 'public') "
+                + "AND c.relname = '%s' "
+                + "AND c.relkind IN (%s)",
+            table,
+            relKinds));
+  }
+
+  static Statement getSelectAttributesStatement(String table, boolean withGenerated) {
+    Statement statement =
+        Statement.of(
+            String.format(
+                "with "
+                    + EMULATED_PG_ATTRIBUTE_PREFIX
+                    + ",\n"
+                    + EMULATED_PG_ATTRDEF_PREFIX
+                    + ",\n"
+                    + PG_TYPE_PREFIX
+                    + ",\n"
+                    + PgCollation.PG_COLLATION_CTE
+                    + "\n"
+                    + "SELECT a.attname, a.spanner_type as format_type,\n"
+                    + "       d.adbin as pg_get_expr, a.attnotnull, a.atttypid, a.atttypmod,\n"
+                    + "       c.collname, ''::varchar AS comment,\n"
+                    + "       attgenerated as attgenerated\n"
+                    + "  FROM pg_attribute a\n"
+                    + "  LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum\n"
+                    + "  LEFT JOIN pg_type t ON a.atttypid = t.oid\n"
+                    + "  LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation\n"
+                    + " WHERE a.attrelid = '''\"public\".\"%s\"'''\n"
+                    + "   AND a.attnum > 0 AND NOT a.attisdropped\n"
+                    + " ORDER BY a.attnum\n",
+                table));
+    return withGenerated
+        ? statement
+        : Statement.of(
+            statement.getSql().replace(",\n       attgenerated as attgenerated\n", "\n"));
+  }
+
+  static ListValue createColumnRow(String name, String type, boolean notNull, int oid) {
+    return ListValue.newBuilder()
+        .addValues(Value.newBuilder().setStringValue(name).build())
+        .addValues(Value.newBuilder().setStringValue(type).build())
+        .addValues(Value.newBuilder().setStringValue("").build())
+        .addValues(Value.newBuilder().setBoolValue(notNull).build())
+        .addValues(Value.newBuilder().setStringValue(String.valueOf(oid)).build())
+        .addValues(Value.newBuilder().setStringValue("-1").build())
+        .addValues(Value.newBuilder().setStringValue("").build())
+        .addValues(Value.newBuilder().setStringValue("").build())
+        .addValues(Value.newBuilder().setStringValue("").build())
+        .build();
+  }
+
+  static ResultSet createSelectRelNameResultSet(String table) {
+    return ResultSet.newBuilder()
+        .setMetadata(SELECT_RELNAME_METADATA)
+        .addRows(
+            ListValue.newBuilder()
+                .addValues(Value.newBuilder().setStringValue(table).build())
+                .build())
+        .build();
+  }
+
+  static void addMigrationResults(String... versions) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                "SELECT \"schema_migrations\".\"version\" "
+                    + "FROM \"schema_migrations\" "
+                    + "ORDER BY \"schema_migrations\".\"version\" ASC"),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("version")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addAllRows(
+                    Arrays.stream(versions)
+                        .map(
+                            version ->
+                                ListValue.newBuilder()
+                                    .addValues(Value.newBuilder().setStringValue(version).build())
+                                    .build())
+                        .collect(Collectors.toList()))
+                .build()));
+  }
+
+  static void addSelectTablesResult(String... tables) {
+    addSelectTablesResultWithRelKinds("'r','v','m','p','f'", tables);
+  }
+
+  static void addSelectTablesResultWithRelKinds(String relKinds, String... tables) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                String.format(
+                    "with "
+                        + EMULATED_PG_CLASS_PREFIX
+                        + ",\n"
+                        + "pg_namespace as (\n"
+                        + "  select case schema_name when 'pg_catalog' then 11 when 'public' then 2200 else 0 end as oid,\n"
+                        + "        schema_name as nspname, null as nspowner, null as nspacl\n"
+                        + "  from information_schema.schemata\n"
+                        + ")\n"
+                        + "SELECT c.relname "
+                        + "FROM pg_class c "
+                        + "LEFT JOIN pg_namespace n ON n.oid = c.relnamespace "
+                        + "WHERE n.nspname  IN ('pg_catalog', 'public') "
+                        + "AND c.relkind IN (%s)",
+                    relKinds)),
+            ResultSet.newBuilder()
+                .setMetadata(SELECT_RELNAME_METADATA)
+                .addAllRows(
+                    Arrays.stream(tables)
+                        .map(
+                            table ->
+                                ListValue.newBuilder()
+                                    .addValues(Value.newBuilder().setStringValue(table).build())
+                                    .build())
+                        .collect(Collectors.toList()))
+                .build()));
+  }
+
+  static void addSelectPrimaryKeyResult(String table, String... columns) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                String.format(
+                    "SELECT ic.column_name as attname\n"
+                        + "FROM information_schema.index_columns ic\n"
+                        + "INNER JOIN information_schema.indexes i using (table_catalog, table_schema, table_name, index_name)\n"
+                        + "WHERE ic.table_schema='public' and ic.table_name='%s'\n"
+                        + "AND i.index_type='PRIMARY_KEY'\n"
+                        + "ORDER BY ordinal_position\n",
+                    table)),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("attname")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addAllRows(
+                    Arrays.stream(columns)
+                        .map(
+                            column ->
+                                ListValue.newBuilder()
+                                    .addValues(Value.newBuilder().setStringValue(column).build())
+                                    .build())
+                        .collect(Collectors.toList()))
+                .build()));
+  }
+
+  static void addSelectColumnsResult(String table, ListValue... columns) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            getSelectAttributesStatement(table, true),
+            ResultSet.newBuilder()
+                .setMetadata(SELECT_COLUMNS_METADATA)
+                .addAllRows(Arrays.stream(columns).collect(Collectors.toList()))
+                .build()));
+  }
+
+  static void addInsertMigrationResult(String version) {
+    String sql =
+        "INSERT INTO \"schema_migrations\" (\"version\") VALUES ($1) RETURNING \"version\"";
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("version")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .build();
+
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(version).build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(version).build())
+                        .build())
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+  }
+
+  static void addSelectArInternalMetadataResult(String key, long limit) {
+    String sql =
+        "SELECT \"ar_internal_metadata\".* "
+            + "FROM \"ar_internal_metadata\" "
+            + "WHERE \"ar_internal_metadata\".\"key\" = $1 LIMIT $2";
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("key")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("value")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("created_at")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("updated_at")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .build();
+
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql), ResultSet.newBuilder().setMetadata(metadata).build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(key).bind("p2").to(limit).build(),
+            ResultSet.newBuilder().setMetadata(metadata).build()));
+  }
+
+  static void addInsertArInternalMetadataResult(String key, String value) {
+    String sql =
+        "INSERT INTO \"ar_internal_metadata\" "
+            + "(\"key\", \"value\", \"created_at\", \"updated_at\") "
+            + "VALUES ($1, $2, $3, $4) RETURNING \"key\"";
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("key")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p3")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p4")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+    // INSERT INTO "ar_internal_metadata" ("key", "value", "created_at", "updated_at")
+    // VALUES ($1, $2, $3, $4) RETURNING "key"
+    mockSpanner.putPartialStatementResult(
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(key).bind("p2").to(value).build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(key).build())
+                        .build())
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .build()));
+  }
+
+  static void addSelectTableDescriptionResult(String table, String description) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                String.format(
+                    "with "
+                        + EMULATED_PG_CLASS_PREFIX
+                        + ",\n"
+                        + "pg_namespace as (\n"
+                        + "  select case schema_name when 'pg_catalog' then 11 when 'public' then 2200 else 0 end as oid,\n"
+                        + "        schema_name as nspname, null as nspowner, null as nspacl\n"
+                        + "  from information_schema.schemata\n"
+                        + ")\n"
+                        + "SELECT ''::varchar AS obj_description\n"
+                        + "FROM pg_class c\n"
+                        + "  LEFT JOIN pg_namespace n ON n.oid = c.relnamespace\n"
+                        + "WHERE c.relname = '%s'\n"
+                        + "  AND c.relkind IN ('r','p')\n"
+                        + "  AND n.nspname  IN ('pg_catalog', 'public')\n",
+                    table)),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("obj_description")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .build())
+                        .build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(description).build())
+                        .build())
+                .build()));
+  }
+
+  static void addSelectTableIndexesResult(String table) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                String.format(
+                    "with "
+                        + EMULATED_PG_CLASS_PREFIX
+                        + ",\n"
+                        + PgIndex.PG_INDEX_CTE
+                        + ",\n"
+                        + PgNamespace.PG_NAMESPACE_CTE
+                        + "\n"
+                        + "SELECT i.relname, d.indisunique, d.indkey, 'CREATE INDEX ON USING btree ( )'::varchar AS pg_get_indexdef, t.oid,\n"
+                        + "                ''::varchar AS comment\n"
+                        + "FROM pg_class t\n"
+                        + "INNER JOIN pg_index d ON t.oid = d.indrelid\n"
+                        + "INNER JOIN pg_class i ON d.indexrelid = i.oid\n"
+                        + "LEFT JOIN pg_namespace n ON n.oid = i.relnamespace\n"
+                        + "WHERE i.relkind IN ('i', 'I')\n"
+                        + "  AND d.indisprimary = 'f'\n"
+                        + "  AND t.relname = '%s'\n"
+                        + "  AND n.nspname  IN ('pg_catalog', 'public')\n"
+                        + "ORDER BY i.relname\n",
+                    table)),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("relname")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("indisunique")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("indkey")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("pg_get_indexdef")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("oid")
+                                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("comment")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .build())
+                        .build())
+                .build()));
+  }
+
+  static void addSelectTableConstraintsResult(String table) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                String.format(
+                    "with "
+                        + PgConstraint.PG_CONSTRAINT_CTE
+                        + ",\n"
+                        + EMULATED_PG_CLASS_PREFIX
+                        + ",\n"
+                        + PgNamespace.PG_NAMESPACE_CTE
+                        + "\n"
+                        + "            SELECT conname, conbin AS constraintdef, c.convalidated AS valid\n"
+                        + "            FROM pg_constraint c\n"
+                        + "            JOIN pg_class t ON c.conrelid = t.oid\n"
+                        + "            JOIN pg_namespace n ON n.oid = c.connamespace\n"
+                        + "            WHERE c.contype = 'c'\n"
+                        + "              AND t.relname = '%s'\n"
+                        + "              AND n.nspname  IN ('pg_catalog', 'public')\n",
+                    table)),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("conname")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("constraintdef")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("valid")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .build())
+                        .build())
+                .build()));
+  }
+
+  static void addSelectTableForeignKeysResult(String table, boolean withDeferrable) {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                String.format(
+                    "with "
+                        + PgConstraint.PG_CONSTRAINT_CTE
+                        + ",\n"
+                        + EMULATED_PG_CLASS_PREFIX
+                        + ",\n"
+                        + EMULATED_PG_ATTRIBUTE_PREFIX
+                        + ",\n"
+                        + PgNamespace.PG_NAMESPACE_CTE
+                        + "\nSELECT substr( t2.oid, 12, length( t2.oid) - 13) AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid"
+                        + (withDeferrable
+                            ? ", c.condeferrable AS deferrable, c.condeferred AS deferred\n"
+                            : "\n")
+                        + "FROM pg_constraint c\n"
+                        + "JOIN pg_class t1 ON c.conrelid = t1.oid\n"
+                        + "JOIN pg_class t2 ON c.confrelid = t2.oid\n"
+                        + "JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid\n"
+                        + "JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid\n"
+                        + "JOIN pg_namespace t3 ON c.connamespace = t3.oid\n"
+                        + "WHERE c.contype = 'f'\n"
+                        + "  AND t1.relname = '%s'\n"
+                        + "  AND t3.nspname  IN ('pg_catalog', 'public')\n"
+                        + "ORDER BY c.conname\n",
+                    table)),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    ResultSetMetadata.newBuilder()
+                        .setRowType(
+                            StructType.newBuilder()
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("to_table")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("column")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("name")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("on_update")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("on_delete")
+                                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("valid")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("deferrable")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .addFields(
+                                    Field.newBuilder()
+                                        .setName("deferred")
+                                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                                        .build())
+                                .build())
+                        .build())
+                .build()));
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ruby/ActiveRecordMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/ActiveRecordMockServerTest.java
new file mode 100644
index 00000000..a28362b6
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/ActiveRecordMockServerTest.java
@@ -0,0 +1,1526 @@
+// 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.pgadapter.ruby;
+
+import static org.junit.Assert.assertEquals;
+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.ByteArray;
+import com.google.cloud.Date;
+import com.google.cloud.Timestamp;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Statement;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.protobuf.ListValue;
+import com.google.protobuf.Value;
+import com.google.spanner.admin.database.v1.UpdateDatabaseDdlRequest;
+import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.ExecuteSqlRequest.QueryMode;
+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.TypeCode;
+import io.grpc.Status;
+import java.math.BigDecimal;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.postgresql.PGProperty;
+import org.postgresql.core.Oid;
+import org.postgresql.util.PSQLException;
+
+@Category(RubyTest.class)
+@RunWith(JUnit4.class)
+public class ActiveRecordMockServerTest extends AbstractRubyMockServerTest {
+  private abstract static class Model {
+    Timestamp createdAt;
+    Timestamp updatedAt;
+    long version;
+
+    Model(Timestamp createdAt, Timestamp updatedAt, long version) {
+      this.createdAt = createdAt;
+      this.updatedAt = updatedAt;
+      this.version = version;
+    }
+  }
+
+  private static final class Singer extends Model {
+    final String singerId;
+    String firstName;
+    String lastName;
+    boolean active;
+
+    Singer(
+        String singerId,
+        String firstName,
+        String lastName,
+        boolean active,
+        Timestamp createdAt,
+        Timestamp updatedAt,
+        long version) {
+      super(createdAt, updatedAt, version);
+      this.singerId = singerId;
+      this.firstName = firstName;
+      this.lastName = lastName;
+      this.active = active;
+    }
+  }
+
+  private static final class Album extends Model {
+    final String albumId;
+    String title;
+    BigDecimal marketingBudget;
+    Date releaseDate;
+    ByteArray coverPicture;
+    String singerId;
+
+    Album(
+        String albumId,
+        String title,
+        BigDecimal marketingBudget,
+        Date releaseDate,
+        ByteArray coverPicture,
+        String singerId,
+        Timestamp createdAt,
+        Timestamp updatedAt,
+        long version) {
+      super(createdAt, updatedAt, version);
+      this.albumId = albumId;
+      this.title = title;
+      this.marketingBudget = marketingBudget;
+      this.releaseDate = releaseDate;
+      this.coverPicture = coverPicture;
+      this.singerId = singerId;
+    }
+  }
+
+  private static final class Track extends Model {
+    final String albumId;
+    final long trackNumber;
+    String title;
+    Double sampleRate;
+
+    Track(
+        String albumId,
+        long trackNumber,
+        String title,
+        Double sampleRate,
+        Timestamp createdAt,
+        Timestamp updatedAt,
+        long version) {
+      super(createdAt, updatedAt, version);
+      this.albumId = albumId;
+      this.trackNumber = trackNumber;
+      this.title = title;
+      this.sampleRate = sampleRate;
+    }
+  }
+
+  private static final class Venue extends Model {
+    final String venueId;
+    String name;
+    String description;
+
+    Venue(
+        String venueId,
+        String name,
+        String description,
+        Timestamp createdAt,
+        Timestamp updatedAt,
+        long version) {
+      super(createdAt, updatedAt, version);
+      this.venueId = venueId;
+      this.name = name;
+      this.description = description;
+    }
+  }
+
+  private static final class Concert extends Model {
+    final String concertId;
+    String venueId;
+    String singerId;
+    String name;
+    Timestamp startTime;
+    Timestamp endTime;
+
+    Concert(
+        String concertId,
+        String venueId,
+        String singerId,
+        String name,
+        Timestamp startTime,
+        Timestamp endTime,
+        Timestamp createdAt,
+        Timestamp updatedAt,
+        long version) {
+      super(createdAt, updatedAt, version);
+      this.concertId = concertId;
+      this.venueId = venueId;
+      this.singerId = singerId;
+      this.name = name;
+      this.startTime = startTime;
+      this.endTime = endTime;
+    }
+  }
+
+  private static final String DIRECTORY_NAME = "./src/test/ruby/activerecord";
+
+  @BeforeClass
+  public static void installGems() throws Exception {
+    createVirtualEnv(DIRECTORY_NAME);
+  }
+
+  @Test
+  public void testErrorHints() throws SQLException {
+    // Emulate an ActiveRecord connection.
+    String url =
+        String.format(
+            "jdbc:postgresql://localhost:%d/db?%s=rails&%s=090000",
+            pgServer.getLocalPort(),
+            PGProperty.APPLICATION_NAME.getName(),
+            PGProperty.ASSUME_MIN_SERVER_VERSION.getName());
+    try (Connection connection = DriverManager.getConnection(url)) {
+      connection.createStatement().execute("begin transaction");
+      PSQLException exception =
+          assertThrows(
+              PSQLException.class,
+              () ->
+                  connection.createStatement().execute("create table foo (id bigint primary key)"));
+      assertNotNull(exception.getServerErrorMessage());
+      assertEquals(
+          "Using Ruby ActiveRecord migrations requires that the option 'spanner.ddl_transaction_mode=AutocommitExplicitTransaction' has been set. "
+              + "Please add \"spanner.ddl_transaction_mode\": \"AutocommitExplicitTransaction\" to the \"variables\" section of your database.yml file.\n"
+              + "See https://github.com/GoogleCloudPlatform/pgadapter/blob/-/samples/ruby/activerecord/README.md for more information.",
+          exception.getServerErrorMessage().getHint());
+      connection.createStatement().execute("rollback");
+
+      mockSpanner.putStatementResult(
+          StatementResult.exception(
+              Statement.of("SELECT pg_try_advisory_lock(12345)"),
+              Status.INVALID_ARGUMENT
+                  .withDescription("Advisory locks are not supported")
+                  .asRuntimeException()));
+      exception =
+          assertThrows(
+              PSQLException.class,
+              () -> connection.createStatement().execute("SELECT pg_try_advisory_lock(12345)"));
+      assertNotNull(exception.getServerErrorMessage());
+      assertEquals(
+          "PGAdapter does not support advisory locks. Please 'add advisory_locks: false' to your database.yml file. "
+              + "See https://edgeguides.rubyonrails.org/configuring.html#configuring-a-postgresql-database for more information.",
+          exception.getServerErrorMessage().getHint());
+
+      // Other errors should not return any hints.
+      mockSpanner.putStatementResult(
+          StatementResult.exception(
+              Statement.of("SELECT * FROM foo"),
+              Status.NOT_FOUND.withDescription("Table foo not found").asRuntimeException()));
+      exception =
+          assertThrows(
+              PSQLException.class, () -> connection.createStatement().execute("SELECT * FROM foo"));
+      assertNotNull(exception.getServerErrorMessage());
+      assertEquals(
+          exception.getServerErrorMessage().getMessage(),
+          "Table foo not found - Statement: 'SELECT * FROM foo'");
+      assertNull(exception.getServerErrorMessage().getHint());
+    }
+  }
+
+  @Test
+  public void testDbMigrate() throws Exception {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_MIGRATIONS_TABLE,
+            ResultSet.newBuilder().setMetadata(SELECT_RELNAME_METADATA).build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_AR_INTERNAL_METADATA_TABLE,
+            ResultSet.newBuilder().setMetadata(SELECT_RELNAME_METADATA).build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_SCHEMA_MIGRATIONS_COLUMNS, SELECT_SCHEMA_MIGRATIONS_COLUMNS_RESULTSET));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_AR_INTERNAL_METADATA_COLUMNS, SELECT_AR_INTERNAL_METADATA_COLUMNS_RESULTSET));
+    // Add an empty migrations result.
+    addMigrationResults();
+
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_MIGRATIONS_TABLE_EXTENDED,
+            ResultSet.newBuilder()
+                .setMetadata(SELECT_RELNAME_METADATA)
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue("schema_migrations").build())
+                        .build())
+                .build()));
+    addInsertMigrationResult("1");
+    addSelectArInternalMetadataResult("environment", 1L);
+    addSelectTablesResult("ar_internal_metadata", "schema_migrations", "albums", "singers");
+    addSelectTablesResultWithRelKinds(
+        "'r','p'", "ar_internal_metadata", "schema_migrations", "albums", "singers");
+    addInsertArInternalMetadataResult("environment", "default_env");
+    addSelectSampleTablesResult();
+
+    // For initializing the DDL executor.
+    addDdlResponseToSpannerAdmin();
+    // CREATE TABLE schema_migrations
+    addDdlResponseToSpannerAdmin();
+    // CREATE TABLE ar_internal_metadata
+    addDdlResponseToSpannerAdmin();
+    // The actual migration.
+    addDdlResponseToSpannerAdmin();
+
+    run(
+        new String[] {"bundle", "exec", "rake", "db:migrate"},
+        DIRECTORY_NAME,
+        ImmutableMap.of("PGHOST", "localhost", "PGPORT", String.valueOf(pgServer.getLocalPort())));
+
+    assertEquals(3, mockDatabaseAdmin.getRequests().size());
+    UpdateDatabaseDdlRequest request =
+        (UpdateDatabaseDdlRequest) mockDatabaseAdmin.getRequests().get(2);
+    assertEquals(5, request.getStatementsCount());
+    assertEquals(
+        "CREATE TABLE \"singers\" ("
+            + "\"singer_id\" character varying(36) NOT NULL PRIMARY KEY, "
+            + "\"first_name\" character varying(100), "
+            + "\"last_name\" character varying(200) NOT NULL, "
+            + "\"full_name\" character varying GENERATED ALWAYS AS (coalesce(concat(first_name, ' '::varchar, last_name), last_name)) STORED, "
+            + "\"active\" boolean, "
+            + "\"created_at\" timestamptz, "
+            + "\"updated_at\" timestamptz, "
+            + "\"lock_version\" integer NOT NULL)",
+        request.getStatements(0));
+    assertEquals(
+        "CREATE TABLE \"albums\" ("
+            + "\"album_id\" character varying(36) NOT NULL PRIMARY KEY, "
+            + "\"title\" character varying, "
+            + "\"marketing_budget\" decimal, "
+            + "\"release_date\" date, "
+            + "\"cover_picture\" bytea, "
+            + "\"singer_id\" character varying(36), "
+            + "\"created_at\" timestamptz, "
+            + "\"updated_at\" timestamptz, "
+            + "\"lock_version\" integer NOT NULL, "
+            + "CONSTRAINT \"fk_rails_df791b93c8\"\n"
+            + "FOREIGN KEY (\"singer_id\")\n"
+            + "  REFERENCES \"singers\" (\"singer_id\")\n"
+            + ")",
+        request.getStatements(1));
+    assertEquals(
+        "create table tracks (\n"
+            + "        album_id     varchar(36) not null,\n"
+            + "        track_number bigint not null,\n"
+            + "        title        varchar not null,\n"
+            + "        sample_rate  float8 not null,\n"
+            + "        created_at   timestamptz,\n"
+            + "        updated_at   timestamptz,\n"
+            + "        lock_version bigint not null,\n"
+            + "        primary key (album_id, track_number)\n"
+            + "    ) interleave in parent albums on delete cascade",
+        request.getStatements(2));
+    assertEquals(
+        "CREATE TABLE \"venues\" ("
+            + "\"venue_id\" character varying(36) NOT NULL PRIMARY KEY, "
+            + "\"name\" character varying, "
+            + "\"description\" jsonb, "
+            + "\"created_at\" timestamptz, "
+            + "\"updated_at\" timestamptz, "
+            + "\"lock_version\" integer NOT NULL)",
+        request.getStatements(3));
+    assertEquals(
+        "CREATE TABLE \"concerts\" ("
+            + "\"concert_id\" character varying(36) NOT NULL PRIMARY KEY, "
+            + "\"venue_id\" character varying(36), "
+            + "\"singer_id\" character varying(36), "
+            + "\"name\" character varying, "
+            + "\"start_time\" timestamptz NOT NULL, "
+            + "\"end_time\" timestamptz NOT NULL, "
+            + "\"created_at\" timestamptz, "
+            + "\"updated_at\" timestamptz, "
+            + "\"lock_version\" integer NOT NULL, "
+            + "CONSTRAINT \"fk_rails_a72d62761d\"\n"
+            + "FOREIGN KEY (\"venue_id\")\n"
+            + "  REFERENCES \"venues\" (\"venue_id\")\n"
+            + ", CONSTRAINT \"fk_rails_20a8d5418f\"\n"
+            + "FOREIGN KEY (\"singer_id\")\n"
+            + "  REFERENCES \"singers\" (\"singer_id\")\n"
+            + ", CONSTRAINT chk_end_time_after_start_time CHECK (end_time > start_time))",
+        request.getStatements(4));
+  }
+
+  @Test
+  public void testRakeRun() throws Exception {
+    addMigrationsTableResults();
+    addSelectSampleTablesResult();
+    mockSpanner.putStatementResult(
+        StatementResult.update(Statement.of("DELETE FROM \"albums\""), 0L));
+    mockSpanner.putStatementResult(
+        StatementResult.update(Statement.of("DELETE FROM \"singers\""), 0L));
+    mockSpanner.putStatementResult(
+        StatementResult.update(Statement.of("DELETE FROM \"concerts\""), 0L));
+    mockSpanner.putStatementResult(
+        StatementResult.update(Statement.of("DELETE FROM \"venues\""), 0L));
+    addInspectSingerResult(ImmutableList.of());
+
+    Singer singer =
+        new Singer(
+            randomUuid(),
+            "some-first-name",
+            "some-last-name",
+            true,
+            Timestamp.now(),
+            Timestamp.now(),
+            1);
+    addInsertSingerResult(singer, false);
+    addSelectSingersResult(ImmutableList.of(singer));
+
+    Album album =
+        new Album(
+            randomUuid(),
+            "some-title",
+            new BigDecimal("200000.12"),
+            Date.fromYearMonthDay(2000, 1, 1),
+            ByteArray.copyFrom("test"),
+            singer.singerId,
+            Timestamp.now(),
+            Timestamp.now(),
+            1);
+    addInsertAlbumResult(album, false);
+    addSelectAlbumsOfSingerResult(
+        singer.singerId,
+        ImmutableList.of(
+            new Album(
+                randomUuid(),
+                "some-title-1",
+                new BigDecimal("200000.12"),
+                Date.fromYearMonthDay(2000, 1, 1),
+                ByteArray.copyFrom("test"),
+                singer.singerId,
+                Timestamp.now(),
+                Timestamp.now(),
+                1),
+            new Album(
+                randomUuid(),
+                "some-title-2",
+                new BigDecimal("200000.12"),
+                Date.fromYearMonthDay(2000, 1, 1),
+                ByteArray.copyFrom("test"),
+                singer.singerId,
+                Timestamp.now(),
+                Timestamp.now(),
+                1),
+            new Album(
+                randomUuid(),
+                "some-title-3",
+                new BigDecimal("200000.12"),
+                Date.fromYearMonthDay(2000, 1, 1),
+                ByteArray.copyFrom("test"),
+                singer.singerId,
+                Timestamp.now(),
+                Timestamp.now(),
+                1)),
+        true);
+    Track track =
+        new Track(album.albumId, 1L, "some-title", 3.14d, Timestamp.now(), Timestamp.now(), 1);
+    addInsertTrackResult(track, false);
+    addSelectTracksOfAlbumResult(
+        album.albumId,
+        ImmutableList.of(
+            new Track(
+                track.albumId,
+                1L,
+                "some-track-title-1",
+                3.14d,
+                Timestamp.now(),
+                Timestamp.now(),
+                1),
+            new Track(
+                track.albumId,
+                2L,
+                "some-track-title-2",
+                99.9d,
+                Timestamp.now(),
+                Timestamp.now(),
+                1),
+            new Track(
+                track.albumId,
+                3L,
+                "some-track-title-3",
+                -1.2d,
+                Timestamp.now(),
+                Timestamp.now(),
+                1)),
+        false);
+
+    Venue venue =
+        new Venue(
+            randomUuid(),
+            "some-venue",
+            "{\"Capacity\": 1000}",
+            Timestamp.now(),
+            Timestamp.now(),
+            1);
+    addInsertVenueResult(venue, false);
+    Concert concert =
+        new Concert(
+            randomUuid(),
+            venue.venueId,
+            singer.singerId,
+            "some-concert",
+            Timestamp.parseTimestamp("2022-04-01T10:00:00Z"),
+            Timestamp.parseTimestamp("2022-04-01T15:00:00Z"),
+            Timestamp.now(),
+            Timestamp.now(),
+            1);
+    addInsertConcertResult(concert, false);
+
+    singer.firstName = "Dave";
+    singer.lastName = "Anderson";
+    addUpdateSingerResult(singer, false);
+    addSelectSingerResult(singer);
+
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(
+                "SELECT \"singers\".* FROM \"singers\" WHERE \"singers\".\"last_name\" LIKE 'A%'"),
+            ResultSet.newBuilder()
+                .setMetadata(createSelectSingersMetadata())
+                .addRows(createSingerRecord(singer))
+                .build()));
+
+    String output =
+        run(
+            new String[] {"bundle", "exec", "rake", "run"},
+            DIRECTORY_NAME,
+            ImmutableMap.of(
+                "PGHOST",
+                "localhost",
+                "PGPORT",
+                String.valueOf(pgServer.getLocalPort()),
+                "RAILS_ENV",
+                "development"));
+
+    assertTrue(output, output.contains("some-track-title-1"));
+
+    List<ExecuteSqlRequest> selectSingerRequests =
+        mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+            .filter(request -> request.getSql().equals(getSelectSingerSql()))
+            .collect(Collectors.toList());
+    assertEquals(2, selectSingerRequests.size());
+    assertTrue(selectSingerRequests.get(0).getTransaction().hasSingleUse());
+    assertEquals(QueryMode.PLAN, selectSingerRequests.get(0).getQueryMode());
+    assertTrue(selectSingerRequests.get(1).getTransaction().hasSingleUse());
+    assertEquals(QueryMode.NORMAL, selectSingerRequests.get(1).getQueryMode());
+
+    List<ExecuteSqlRequest> updateRequests =
+        mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+            .filter(request -> request.getSql().startsWith("UPDATE"))
+            .collect(Collectors.toList());
+    assertEquals(2, updateRequests.size());
+    assertEquals(getUpdateSingerStatement(), updateRequests.get(0).getSql());
+    assertEquals(QueryMode.PLAN, updateRequests.get(0).getQueryMode());
+    assertTrue(updateRequests.get(0).getTransaction().hasBegin());
+    assertEquals(getUpdateSingerStatement(), updateRequests.get(1).getSql());
+    assertEquals(QueryMode.NORMAL, updateRequests.get(1).getQueryMode());
+    assertTrue(updateRequests.get(1).getTransaction().hasId());
+
+    List<ExecuteSqlRequest> insertSingersRequests =
+        mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+            .filter(request -> request.getSql().equals(getInsertSingerStatement()))
+            .collect(Collectors.toList());
+    assertEquals(11, insertSingersRequests.size());
+    assertEquals(QueryMode.PLAN, insertSingersRequests.get(0).getQueryMode());
+    assertTrue(insertSingersRequests.get(0).getTransaction().hasId());
+    for (int i = 1; i < insertSingersRequests.size(); i++) {
+      assertEquals(QueryMode.NORMAL, insertSingersRequests.get(i).getQueryMode());
+      assertTrue(insertSingersRequests.get(i).getTransaction().hasId());
+      assertEquals(
+          insertSingersRequests.get(i - 1).getTransaction().getId(),
+          insertSingersRequests.get(i).getTransaction().getId());
+    }
+
+    List<ExecuteSqlRequest> insertAlbumRequests =
+        mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream()
+            .filter(request -> request.getSql().equals(getInsertAlbumStatement()))
+            .collect(Collectors.toList());
+    assertEquals(31, insertAlbumRequests.size());
+    assertEquals(QueryMode.PLAN, insertAlbumRequests.get(0).getQueryMode());
+    assertTrue(insertAlbumRequests.get(0).getTransaction().hasId());
+    for (int i = 1; i < insertAlbumRequests.size(); i++) {
+      assertEquals(QueryMode.NORMAL, insertAlbumRequests.get(i).getQueryMode());
+      assertTrue(insertAlbumRequests.get(i).getTransaction().hasId());
+      assertEquals(
+          insertAlbumRequests.get(i - 1).getTransaction().getId(),
+          insertAlbumRequests.get(i).getTransaction().getId());
+    }
+    assertEquals(
+        insertAlbumRequests.get(0).getTransaction().getId(),
+        insertSingersRequests.get(0).getTransaction().getId());
+    assertEquals(
+        1,
+        mockSpanner.getRequestsOfType(CommitRequest.class).stream()
+            .filter(
+                request ->
+                    request
+                        .getTransactionId()
+                        .equals(insertSingersRequests.get(0).getTransaction().getId()))
+            .count());
+  }
+
+  static String getInsertSingerStatement() {
+    return "INSERT INTO \"singers\" (\"singer_id\", \"first_name\", \"last_name\", \"active\", \"created_at\", \"updated_at\", \"lock_version\") "
+        + "VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING \"singer_id\"";
+  }
+
+  static void addInsertSingerResult(Singer singer, boolean exact) {
+    String sql = getInsertSingerStatement();
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("singer_id")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p3")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p4")
+                            .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p5")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p6")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p7")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql)
+                .bind("p1")
+                .to(singer.singerId)
+                .bind("p2")
+                .to(singer.firstName)
+                .bind("p3")
+                .to(singer.lastName)
+                .bind("p4")
+                .to(singer.active)
+                .bind("p5")
+                .to(singer.createdAt)
+                .bind("p6")
+                .to(singer.updatedAt)
+                .bind("p7")
+                .to(singer.version)
+                .build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(singer.singerId).build())
+                        .build())
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static void addSelectSingersResult(ImmutableList<Singer> singers) {
+    String sql = "SELECT \"singers\".* FROM \"singers\"";
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(createSelectSingersMetadata())
+                .addAllRows(
+                    singers.stream()
+                        .map(ActiveRecordMockServerTest::createSingerRecord)
+                        .collect(Collectors.toList()))
+                .build()));
+  }
+
+  static void addInspectSingerResult(ImmutableList<Singer> singers) {
+    String sql = "SELECT \"singers\".* FROM \"singers\" /* loading for inspect */ LIMIT $1";
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder().setMetadata(createSelectSingersMetadata()).build()));
+  }
+
+  static String getSelectSingerSql() {
+    return "SELECT \"singers\".* FROM \"singers\" WHERE \"singers\".\"singer_id\" = $1 LIMIT $2";
+  }
+
+  static void addSelectSingerResult(Singer singer) {
+    String sql = getSelectSingerSql();
+    ResultSetMetadata metadata = createSelectSingerMetadata();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql), ResultSet.newBuilder().setMetadata(metadata).build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(singer.singerId).bind("p2").to(1L).build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .addRows(createSingerRecord(singer))
+                .build()));
+  }
+
+  static ResultSetMetadata createSelectSingersMetadata() {
+    return ResultSetMetadata.newBuilder()
+        .setRowType(
+            StructType.newBuilder()
+                .addFields(
+                    Field.newBuilder()
+                        .setName("singer_id")
+                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                        .build())
+                .addFields(
+                    Field.newBuilder()
+                        .setName("first_name")
+                        .setType(Type.newBuilder().setCode(TypeCode.STRING).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("active")
+                        .setType(Type.newBuilder().setCode(TypeCode.BOOL).build())
+                        .build())
+                .addFields(
+                    Field.newBuilder()
+                        .setName("created_at")
+                        .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                        .build())
+                .addFields(
+                    Field.newBuilder()
+                        .setName("updated_at")
+                        .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                        .build())
+                .addFields(
+                    Field.newBuilder()
+                        .setName("lock_version")
+                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                        .build())
+                .build())
+        .build();
+  }
+
+  static ResultSetMetadata createSelectSingerMetadata() {
+    return addSelectOneParameters(createSelectSingersMetadata());
+  }
+
+  static ListValue createSingerRecord(Singer singer) {
+    return ListValue.newBuilder()
+        .addValues(Value.newBuilder().setStringValue(singer.singerId).build())
+        .addValues(Value.newBuilder().setStringValue(singer.firstName).build())
+        .addValues(Value.newBuilder().setStringValue(singer.lastName).build())
+        .addValues(
+            Value.newBuilder().setStringValue(singer.firstName + " " + singer.lastName).build())
+        .addValues(Value.newBuilder().setBoolValue(singer.active).build())
+        .addValues(Value.newBuilder().setStringValue(singer.createdAt.toString()).build())
+        .addValues(Value.newBuilder().setStringValue(singer.updatedAt.toString()).build())
+        .addValues(Value.newBuilder().setStringValue(String.valueOf(singer.version)).build())
+        .build();
+  }
+
+  static String getUpdateSingerStatement() {
+    return "UPDATE \"singers\" SET \"first_name\" = $1, \"last_name\" = $2, \"updated_at\" = $3, \"lock_version\" = $4 "
+        + "WHERE \"singers\".\"singer_id\" = $5 AND \"singers\".\"lock_version\" = $6";
+  }
+
+  static void addUpdateSingerResult(Singer singer, boolean exact) {
+    String sql = getUpdateSingerStatement();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(
+                    createParameterTypesMetadata(
+                        ImmutableList.of(
+                            TypeCode.STRING,
+                            TypeCode.STRING,
+                            TypeCode.TIMESTAMP,
+                            TypeCode.INT64,
+                            TypeCode.STRING,
+                            TypeCode.INT64)))
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+    StatementResult result =
+        StatementResult.update(
+            Statement.newBuilder(sql)
+                .bind("p1")
+                .to(singer.firstName)
+                .bind("p2")
+                .to(singer.lastName)
+                .bind("p3")
+                .to(singer.updatedAt)
+                .bind("p4")
+                .to(singer.version + 1)
+                .bind("p5")
+                .to(singer.singerId)
+                .bind("p6")
+                .to(singer.version)
+                .build(),
+            1L);
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static String getInsertAlbumStatement() {
+    return "INSERT INTO \"albums\" (\"album_id\", \"title\", \"marketing_budget\", \"release_date\", \"cover_picture\", \"singer_id\", \"created_at\", \"updated_at\", \"lock_version\") "
+        + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING \"album_id\"";
+  }
+
+  static void addInsertAlbumResult(Album album, boolean exact) {
+    String sql = getInsertAlbumStatement();
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("album_id")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p3")
+                            .setType(Type.newBuilder().setCode(TypeCode.NUMERIC).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p4")
+                            .setType(Type.newBuilder().setCode(TypeCode.DATE).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p5")
+                            .setType(Type.newBuilder().setCode(TypeCode.BYTES).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p6")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p7")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p8")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p9")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql)
+                .bind("p1")
+                .to(album.albumId)
+                .bind("p2")
+                .to(album.title)
+                .bind("p3")
+                .to(album.marketingBudget)
+                .bind("p4")
+                .to(album.releaseDate)
+                .bind("p5")
+                .to(album.coverPicture)
+                .bind("p6")
+                .to(album.singerId)
+                .bind("p7")
+                .to(album.createdAt)
+                .bind("p8")
+                .to(album.updatedAt)
+                .bind("p9")
+                .to(album.version)
+                .build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(album.albumId).build())
+                        .build())
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static void addSelectAlbumsOfSingerResult(
+      String singerId, ImmutableList<Album> albums, boolean exact) {
+    String sql = "SELECT \"albums\".* FROM \"albums\" WHERE \"albums\".\"singer_id\" = $1";
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("album_id")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).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.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql), ResultSet.newBuilder().setMetadata(metadata).build()));
+
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(singerId).build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .addAllRows(
+                    albums.stream()
+                        .map(ActiveRecordMockServerTest::createAlbumRecord)
+                        .collect(Collectors.toList()))
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static ListValue createAlbumRecord(Album album) {
+    return ListValue.newBuilder()
+        .addValues(Value.newBuilder().setStringValue(album.albumId).build())
+        .addValues(Value.newBuilder().setStringValue(album.title).build())
+        .addValues(Value.newBuilder().setStringValue(album.singerId).build())
+        .build();
+  }
+
+  static void addSelectTracksOfAlbumResult(
+      String albumId, ImmutableList<Track> tracks, boolean exact) {
+    String sql = "SELECT \"tracks\".* FROM \"tracks\" WHERE \"tracks\".\"album_id\" = $1";
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("album_id")
+                            .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("title")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("sample_rate")
+                            .setType(Type.newBuilder().setCode(TypeCode.FLOAT64).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("created_at")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("updated_at")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("lock_version")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql), ResultSet.newBuilder().setMetadata(metadata).build()));
+
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(albumId).build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .addAllRows(
+                    tracks.stream()
+                        .map(ActiveRecordMockServerTest::createTrackRecord)
+                        .collect(Collectors.toList()))
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static ListValue createTrackRecord(Track track) {
+    return ListValue.newBuilder()
+        .addValues(Value.newBuilder().setStringValue(track.albumId).build())
+        .addValues(Value.newBuilder().setStringValue(String.valueOf(track.trackNumber)).build())
+        .addValues(Value.newBuilder().setStringValue(track.title).build())
+        .addValues(Value.newBuilder().setNumberValue(track.sampleRate).build())
+        .addValues(Value.newBuilder().setStringValue(track.createdAt.toString()).build())
+        .addValues(Value.newBuilder().setStringValue(track.updatedAt.toString()).build())
+        .addValues(Value.newBuilder().setStringValue(String.valueOf(track.version)).build())
+        .build();
+  }
+
+  static String getInsertTrackStatement() {
+    return "INSERT INTO \"tracks\" (\"album_id\", \"track_number\", \"title\", \"sample_rate\", \"created_at\", \"updated_at\", \"lock_version\") "
+        + "VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING \"album_id\",\"track_number\"";
+  }
+
+  static void addInsertTrackResult(Track album, boolean exact) {
+    String sql = getInsertTrackStatement();
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("album_id")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("track_number")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p3")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p4")
+                            .setType(Type.newBuilder().setCode(TypeCode.FLOAT64).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p5")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p6")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p7")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql)
+                .bind("p1")
+                .to(album.albumId)
+                .bind("p2")
+                .to(album.trackNumber)
+                .bind("p3")
+                .to(album.title)
+                .bind("p4")
+                .to(album.sampleRate)
+                .bind("p5")
+                .to(album.createdAt)
+                .bind("p6")
+                .to(album.updatedAt)
+                .bind("p7")
+                .to(album.version)
+                .build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(album.albumId).build())
+                        .addValues(
+                            Value.newBuilder()
+                                .setStringValue(String.valueOf(album.trackNumber))
+                                .build())
+                        .build())
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static String getInsertVenueStatement() {
+    return "INSERT INTO \"venues\" (\"venue_id\", \"name\", \"description\", \"created_at\", \"updated_at\", \"lock_version\") "
+        + "VALUES ($1, $2, $3, $4, $5, $6) RETURNING \"venue_id\"";
+  }
+
+  static void addInsertVenueResult(Venue venue, boolean exact) {
+    String sql = getInsertVenueStatement();
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("venue_id")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p3")
+                            .setType(Type.newBuilder().setCode(TypeCode.JSON).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p4")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p5")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p6")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql)
+                .bind("p1")
+                .to(venue.venueId)
+                .bind("p2")
+                .to(venue.name)
+                .bind("p3")
+                .to(venue.description)
+                .bind("p4")
+                .to(venue.createdAt)
+                .bind("p5")
+                .to(venue.updatedAt)
+                .bind("p6")
+                .to(venue.version)
+                .build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(venue.venueId).build())
+                        .build())
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static String getInsertConcertStatement() {
+    return "INSERT INTO \"concerts\" (\"concert_id\", \"venue_id\", \"singer_id\", \"name\", \"start_time\", \"end_time\", \"created_at\", \"updated_at\", \"lock_version\") "
+        + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING \"concert_id\"";
+  }
+
+  static void addInsertConcertResult(Concert concert, boolean exact) {
+    String sql = getInsertConcertStatement();
+    ResultSetMetadata metadata =
+        ResultSetMetadata.newBuilder()
+            .setRowType(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("concert_id")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .build())
+            .setUndeclaredParameters(
+                StructType.newBuilder()
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p1")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p2")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p3")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p4")
+                            .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p5")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p6")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p7")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p8")
+                            .setType(Type.newBuilder().setCode(TypeCode.TIMESTAMP).build())
+                            .build())
+                    .addFields(
+                        Field.newBuilder()
+                            .setName("p9")
+                            .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                            .build())
+                    .build())
+            .build();
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().build())
+                .build()));
+
+    StatementResult result =
+        StatementResult.query(
+            Statement.newBuilder(sql)
+                .bind("p1")
+                .to(concert.concertId)
+                .bind("p2")
+                .to(concert.venueId)
+                .bind("p3")
+                .to(concert.singerId)
+                .bind("p4")
+                .to(concert.name)
+                .bind("p5")
+                .to(concert.startTime)
+                .bind("p6")
+                .to(concert.endTime)
+                .bind("p7")
+                .to(concert.createdAt)
+                .bind("p8")
+                .to(concert.updatedAt)
+                .bind("p9")
+                .to(concert.version)
+                .build(),
+            ResultSet.newBuilder()
+                .setMetadata(metadata)
+                .setStats(ResultSetStats.newBuilder().setRowCountExact(1L).build())
+                .addRows(
+                    ListValue.newBuilder()
+                        .addValues(Value.newBuilder().setStringValue(concert.concertId).build())
+                        .build())
+                .build());
+    if (exact) {
+      mockSpanner.putStatementResult(result);
+    } else {
+      mockSpanner.putPartialStatementResult(result);
+    }
+  }
+
+  static ResultSetMetadata addSelectOneParameters(ResultSetMetadata metadata) {
+    return metadata
+        .toBuilder()
+        .setUndeclaredParameters(
+            StructType.newBuilder()
+                .addFields(
+                    Field.newBuilder()
+                        .setName("p1")
+                        .setType(Type.newBuilder().setCode(TypeCode.STRING).build())
+                        .build())
+                .addFields(
+                    Field.newBuilder()
+                        .setName("p2")
+                        .setType(Type.newBuilder().setCode(TypeCode.INT64).build())
+                        .build())
+                .build())
+        .build();
+  }
+
+  static void addSelectSampleTablesResult() {
+    addSelectTablesResult(
+        "ar_internal_metadata",
+        "schema_migrations",
+        "albums",
+        "concerts",
+        "singers",
+        "tracks",
+        "venues");
+
+    addSelectColumnsResult(
+        "singers",
+        createColumnRow("singer_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("first_name", "character varying", false, Oid.VARCHAR),
+        createColumnRow("last_name", "character varying", true, Oid.VARCHAR),
+        createColumnRow("full_name", "character varying", false, Oid.VARCHAR),
+        createColumnRow("active", "boolean", false, Oid.BOOL),
+        createColumnRow("created_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("updated_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("lock_version", "bigint", false, Oid.INT8));
+    addSelectPrimaryKeyResult("singers", "singer_id");
+    addSelectTableDescriptionResult("singers", "create table singers");
+    // TODO: Actually add indexes.
+    addSelectTableIndexesResult("singers");
+    addSelectTableConstraintsResult("singers");
+
+    addSelectColumnsResult(
+        "albums",
+        createColumnRow("album_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("title", "character varying", true, Oid.VARCHAR),
+        createColumnRow("marketing_budget", "numeric", false, Oid.NUMERIC),
+        createColumnRow("release_date", "date", false, Oid.DATE),
+        createColumnRow("cover_picture", "bytea", false, Oid.BYTEA),
+        createColumnRow("singer_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("created_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("updated_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("lock_version", "bigint", false, Oid.INT8));
+    addSelectPrimaryKeyResult("albums", "album_id");
+    addSelectTableDescriptionResult("albums", "create table albums");
+    // TODO: Actually add indexes.
+    addSelectTableIndexesResult("albums");
+    addSelectTableConstraintsResult("albums");
+
+    addSelectColumnsResult(
+        "tracks",
+        createColumnRow("album_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("track_number", "bigint", true, Oid.INT8),
+        createColumnRow("title", "character varying", true, Oid.VARCHAR),
+        createColumnRow("sample_rate", "double precision", false, Oid.FLOAT8),
+        createColumnRow("created_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("updated_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("lock_version", "bigint", false, Oid.INT8));
+    addSelectPrimaryKeyResult("tracks", "album_id", "track_number");
+    addSelectTableDescriptionResult("tracks", "create table tracks");
+    // TODO: Actually add indexes.
+    addSelectTableIndexesResult("tracks");
+    addSelectTableConstraintsResult("tracks");
+
+    addSelectColumnsResult(
+        "venues",
+        createColumnRow("venue_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("name", "character varying", false, Oid.VARCHAR),
+        createColumnRow("description", "jsonb", false, Oid.VARCHAR),
+        createColumnRow("created_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("updated_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("lock_version", "bigint", false, Oid.INT8));
+    addSelectPrimaryKeyResult("venues", "venue_id");
+    addSelectTableDescriptionResult("venues", "create table venues");
+    // TODO: Actually add indexes.
+    addSelectTableIndexesResult("venues");
+    addSelectTableConstraintsResult("venues");
+
+    addSelectColumnsResult(
+        "concerts",
+        createColumnRow("concert_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("venue_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("singer_id", "character varying", true, Oid.VARCHAR),
+        createColumnRow("name", "character varying", true, Oid.VARCHAR),
+        createColumnRow("start_time", "timestamp with time zone", true, Oid.TIMESTAMPTZ),
+        createColumnRow("end_time", "timestamp with time zone", true, Oid.TIMESTAMPTZ),
+        createColumnRow("created_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("updated_at", "timestamp with time zone", false, Oid.TIMESTAMPTZ),
+        createColumnRow("lock_version", "bigint", false, Oid.INT8));
+    addSelectPrimaryKeyResult("concerts", "concert_id");
+    addSelectTableDescriptionResult("concerts", "create table concerts");
+    // TODO: Actually add indexes.
+    addSelectTableIndexesResult("concerts");
+    addSelectTableConstraintsResult("concerts");
+
+    // TODO: Actually add foreign keys.
+    addSelectTableForeignKeysResult("albums", true);
+    addSelectTableForeignKeysResult("singers", true);
+    addSelectTableForeignKeysResult("tracks", true);
+    addSelectTableForeignKeysResult("venues", true);
+    addSelectTableForeignKeysResult("concerts", true);
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ruby/ITActiveRecordTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/ITActiveRecordTest.java
new file mode 100644
index 00000000..a04adba0
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/ITActiveRecordTest.java
@@ -0,0 +1,107 @@
+// 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.pgadapter.ruby;
+
+import static com.google.cloud.spanner.pgadapter.ruby.AbstractRubyMockServerTest.createVirtualEnv;
+import static com.google.cloud.spanner.pgadapter.ruby.AbstractRubyMockServerTest.run;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.Database;
+import com.google.cloud.spanner.pgadapter.IntegrationTest;
+import com.google.cloud.spanner.pgadapter.PgAdapterTestEnv;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.util.Collections;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@Category(IntegrationTest.class)
+@RunWith(JUnit4.class)
+public class ITActiveRecordTest {
+  private static final PgAdapterTestEnv testEnv = new PgAdapterTestEnv();
+
+  private static Database database;
+
+  private static final String DIRECTORY_NAME = "./src/test/ruby/activerecord";
+
+  @BeforeClass
+  public static void installGems() throws Exception {}
+
+  @BeforeClass
+  public static void setup() throws Exception {
+    createVirtualEnv(DIRECTORY_NAME);
+    testEnv.setUp();
+    database = testEnv.createDatabase(ImmutableList.of());
+    testEnv.startPGAdapterServerWithDefaultDatabase(database.getId(), Collections.emptyList());
+  }
+
+  @AfterClass
+  public static void teardown() {
+    testEnv.stopPGAdapterServer();
+    testEnv.cleanUp();
+  }
+
+  @Test
+  public void testMigrateAndRun() throws Exception {
+    run(
+        new String[] {"bundle", "exec", "rake", "db:migrate"},
+        DIRECTORY_NAME,
+        ImmutableMap.of(
+            "PGHOST", "localhost", "PGPORT", String.valueOf(testEnv.getServer().getLocalPort())));
+    run(
+        new String[] {"bundle", "exec", "rake", "run"},
+        DIRECTORY_NAME,
+        ImmutableMap.of(
+            "PGHOST",
+            "localhost",
+            "PGPORT",
+            String.valueOf(testEnv.getServer().getLocalPort()),
+            "RAILS_ENV",
+            "development"));
+    try (Connection connection =
+        DriverManager.getConnection(
+            String.format(
+                "jdbc:postgresql://localhost:%d/%s",
+                testEnv.getServer().getLocalPort(), database.getId().getDatabase()))) {
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery("select count(1) from singers")) {
+        assertTrue(resultSet.next());
+        assertEquals(10, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery("select count(1) from albums")) {
+        assertTrue(resultSet.next());
+        assertEquals(30, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery("select count(1) from tracks")) {
+        assertTrue(resultSet.next());
+        assertEquals(300, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ruby/RubyTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/RubyTest.java
new file mode 100644
index 00000000..bfa34072
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ruby/RubyTest.java
@@ -0,0 +1,18 @@
+// 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.pgadapter.ruby;
+
+/** Ruby test interface. */
+public interface RubyTest {}
diff --git a/src/test/ruby/activerecord/Gemfile b/src/test/ruby/activerecord/Gemfile
new file mode 100644
index 00000000..48643d2d
--- /dev/null
+++ b/src/test/ruby/activerecord/Gemfile
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+gem 'pg'
+gem 'rake'
+gem 'activerecord', '~> 7.0'
+gem 'sinatra-activerecord'
+gem 'composite_primary_keys', '~> 14'
diff --git a/src/test/ruby/activerecord/Rakefile b/src/test/ruby/activerecord/Rakefile
new file mode 100644
index 00000000..d2950b38
--- /dev/null
+++ b/src/test/ruby/activerecord/Rakefile
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'config/environment'
+require 'sinatra/activerecord/rake'
+
+Bundler.require
+
+desc 'Runs a simple ActiveRecord tutorial for Google Cloud Spanner PostgreSQL.'
+task :run do
+  begin
+    sh 'rake db:seed'
+    sh 'ruby application.rb'
+  end
+end
diff --git a/src/test/ruby/activerecord/application.rb b/src/test/ruby/activerecord/application.rb
new file mode 100644
index 00000000..80796fd0
--- /dev/null
+++ b/src/test/ruby/activerecord/application.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require 'active_record'
+require 'io/console'
+require_relative 'config/environment'
+require_relative 'models/singer'
+require_relative 'models/album'
+
+# This sample application shows the basic features of
+# ActiveRecord with Google Cloud Spanner PostgreSQL-dialect.
+class Application
+  def self.run
+    # Query the current singers in the database.
+    query_singers
+
+    # Update a random singer.
+    update_singer
+
+    # Execute a query on the singers table.
+    query_singers_by_name
+  end
+
+  def self.query_singers
+    # Fetch all singers and albums from the database.
+    # The database has been pre-filled by the `db/seeds.rb` script.
+    puts 'Known singers and their albums:'
+    puts ''
+    Singer.all.each do |singer|
+      puts "#{singer.first_name} #{singer.last_name}"
+      singer.albums.each do |album|
+        puts "   #{album.title}"
+        album.tracks.each do |track|
+          puts "       #{track.track_number} #{track.title}"
+        end
+      end
+    end
+  end
+
+  def self.update_singer
+    # Select a random singer and update the name of this singer.
+    puts ''
+    singer = Singer.all.sample
+    puts "Current name of singer #{singer.id} is '#{singer.first_name} #{singer.last_name}'"
+    puts "Updating name to 'Dave Anderson'"
+    singer.first_name = 'Dave'
+    singer.last_name = 'Anderson'
+    singer.save!
+    singer.reload
+    puts "New name of singer #{singer.id}: #{singer.first_name} #{singer.last_name}"
+  end
+
+  def self.query_singers_by_name
+    # Select all singers whose last name start with 'A'.
+    # This should include at least the singer that was updated
+    # in the previous step, but probably also a number of other singers.
+    puts ''
+    puts "Getting all singers with a last name that starts with 'A'"
+
+    last_name = Singer.arel_table['last_name']
+    # The 'escape=nil, case_sensitive=true' is necessary to work around the lack of support
+    # for ILIKE in Cloud Spanner.
+    Singer.where(last_name.matches("A%", escape=nil, case_sensitive=true)).each do |s|
+      puts "#{s.first_name} #{s.last_name}"
+    end
+  end
+end
+
+Application.run
+
diff --git a/src/test/ruby/activerecord/config/database.yml b/src/test/ruby/activerecord/config/database.yml
new file mode 100644
index 00000000..64cf5297
--- /dev/null
+++ b/src/test/ruby/activerecord/config/database.yml
@@ -0,0 +1,9 @@
+development:
+  adapter: postgresql
+  pool: 5
+  # Advisory locks are not supported by PGAdapter
+  advisory_locks: false
+  # These settings ensure that migrations and schema inspections work.
+  variables:
+    "spanner.ddl_transaction_mode": "AutocommitExplicitTransaction"
+    "spanner.emulate_pg_class_tables": "true"
diff --git a/src/test/ruby/activerecord/config/environment.rb b/src/test/ruby/activerecord/config/environment.rb
new file mode 100644
index 00000000..8433cd8b
--- /dev/null
+++ b/src/test/ruby/activerecord/config/environment.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require 'active_record'
+require 'bundler'
+
+Bundler.require
+
+# Make sure that the PostgreSQL-adapter uses timestamptz by default, and without any type modifiers.
+
+# The following sets the default type that should be used by PostgreSQL for datetime.
+# This is only supported in ActiveRecord 7.0 and higher.
+ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
+
+# Remove the above line and uncomment the line below if you are using ActiveRecord 6.1 or lower.
+# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:datetime] = { name: "timestamptz" }
+
+# The following ensures that ActiveRecord does not use any type modifiers for timestamp types.
+# That is; Cloud Spanner only supports `timestamptz` and not for example `timestamptz(6)`.
+module ActiveRecord::ConnectionAdapters
+  class PostgreSQLAdapter
+    def supports_datetime_with_precision?
+      false
+    end
+  end
+end
diff --git a/src/test/ruby/activerecord/db/migrate/01_create_tables.rb b/src/test/ruby/activerecord/db/migrate/01_create_tables.rb
new file mode 100644
index 00000000..9d468616
--- /dev/null
+++ b/src/test/ruby/activerecord/db/migrate/01_create_tables.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+# Simple migration to the tables for the sample application.
+class CreateTables < ActiveRecord::Migration[7.0]
+  def change
+    create_table :singers, id: false, primary_key: :singer_id do |t|
+      t.string :singer_id, limit: 36, null: false, primary_key: true
+      t.string :first_name, limit: 100
+      t.string :last_name, limit: 200, null: false
+      t.virtual :full_name, type: :string, as: "coalesce(concat(first_name, ' '::varchar, last_name), last_name)", stored: true
+      t.boolean :active
+
+      t.column :created_at, :timestamptz
+      t.column :updated_at, :timestamptz
+      t.integer :lock_version, null: false
+    end
+
+    create_table :albums, id: false, primary_key: :album_id do |t|
+      t.string :album_id, limit: 36, null: false, primary_key: true
+      t.string :title
+      t.numeric :marketing_budget
+      t.date :release_date
+      t.binary :cover_picture
+      # We do not include an index on the foreign key because Cloud Spanner
+      # will automatically create a managed index for the foreign key
+      # constraint.
+      t.references :singer, foreign_key: {primary_key: :singer_id},
+                   type: :string, limit: 36, index: false
+
+      t.column :created_at, :timestamptz
+      t.column :updated_at, :timestamptz
+      t.integer :lock_version, null: false
+    end
+
+    # The ActiveRecord PostgreSQL provider does not know how to create an interleaved table.
+    # These tables must therefore be created using a hand-written SQL script.
+    # Note that interleaved tables require you to use a composite primary key.
+    # This also requires "gem 'composite_primary_keys', '~> 14'" to be part of your project.
+    execute "create table tracks (
+        album_id     varchar(36) not null,
+        track_number bigint not null,
+        title        varchar not null,
+        sample_rate  float8 not null,
+        created_at   timestamptz,
+        updated_at   timestamptz,
+        lock_version bigint not null,
+        primary key (album_id, track_number)
+    ) interleave in parent albums on delete cascade;
+    "
+
+    create_table :venues, id: false, primary_key: :venue_id do |t|
+      t.string :venue_id, limit: 36, null: false, primary_key: true
+      t.string :name
+      t.jsonb :description
+      t.column :created_at, :timestamptz
+      t.column :updated_at, :timestamptz
+      t.integer :lock_version, null: false
+    end
+
+    create_table :concerts, id: false, primary_key: :venue_id do |t|
+      t.string :concert_id, limit: 36, null: false, primary_key: true
+      t.references :venue, foreign_key: {primary_key: :venue_id},
+                   type: :string, limit: 36, index: false
+      t.references :singer, foreign_key: {primary_key: :singer_id},
+                   type: :string, limit: 36, index: false
+      t.string :name
+      t.column :start_time, :timestamptz, null: false
+      t.column :end_time, :timestamptz, null: false
+      t.column :created_at, :timestamptz
+      t.column :updated_at, :timestamptz
+      t.integer :lock_version, null: false
+      t.check_constraint "end_time > start_time", name: :chk_end_time_after_start_time
+    end
+
+  end
+end
diff --git a/src/test/ruby/activerecord/db/schema.rb b/src/test/ruby/activerecord/db/schema.rb
new file mode 100644
index 00000000..4775cc30
--- /dev/null
+++ b/src/test/ruby/activerecord/db/schema.rb
@@ -0,0 +1,35 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.0].define(version: 0) do
+  create_table "albums", primary_key: "album_id", id: { type: :string, collation: "" }, comment: "create table albums", force: :cascade do |t|
+    t.string "title", null: false, collation: ""
+    t.decimal "marketing_budget", collation: ""
+    t.date "release_date", collation: ""
+    t.binary "cover_picture", collation: ""
+    t.string "singer_id", null: false, collation: ""
+    t.datetime "created_at", precision: nil, collation: ""
+    t.datetime "updated_at", precision: nil, collation: ""
+    t.bigint "lock_version", collation: ""
+  end
+
+  create_table "singers", primary_key: "singer_id", id: { type: :string, collation: "" }, comment: "create table singers", force: :cascade do |t|
+    t.string "first_name", collation: ""
+    t.string "last_name", null: false, collation: ""
+    t.string "full_name", collation: ""
+    t.boolean "active", collation: ""
+    t.datetime "created_at", precision: nil, collation: ""
+    t.datetime "updated_at", precision: nil, collation: ""
+    t.bigint "lock_version", collation: ""
+  end
+
+end
diff --git a/src/test/ruby/activerecord/db/seeds.rb b/src/test/ruby/activerecord/db/seeds.rb
new file mode 100644
index 00000000..42b1995a
--- /dev/null
+++ b/src/test/ruby/activerecord/db/seeds.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require 'securerandom'
+require_relative '../config/environment.rb'
+require_relative '../models/album'
+require_relative '../models/concert'
+require_relative '../models/singer'
+require_relative '../models/track'
+require_relative '../models/venue'
+
+first_names = %w[Pete Alice John Ethel Trudy Naomi Wendy]
+last_names = %w[Wendelson Allison Peterson Johnson Henderson Ericsson Aronson]
+
+adjectives = %w[daily happy blue generous cooked bad open]
+nouns = %w[windows potatoes bank street tree glass bottle]
+
+adverbs = %w[swiftly surprisingly rather closely furiously politely currently unimpressively gracefully abnormally stealthily irritably]
+verbs = %w[poke rescue tick long soothe obtain fancy prick suffer encourage love own]
+
+rnd = Random.new
+
+Concert.delete_all
+Venue.delete_all
+Album.delete_all
+Singer.delete_all
+
+Singer.transaction do
+  singers = []
+  albums = []
+  tracks = []
+  venues = []
+  concerts = []
+  10.times do
+    singers.append({singer_id: SecureRandom.uuid,
+                    first_name: first_names.sample,
+                    last_name: last_names.sample,
+                    active: true})
+  end
+  Singer.create singers
+
+  30.times do
+    singer_id = singers.sample[:singer_id]
+    albums.append({album_id: SecureRandom.uuid,
+                   title: "#{adjectives.sample} #{nouns.sample}",
+                   singer_id: singer_id,
+                   marketing_budget: rand * 10000,
+                   release_date: Date.new(rand(100) + 1923, rand(12) + 1, rand(28) + 1),
+                   cover_picture: rnd.bytes(rand(200) + 10)})
+  end
+  Album.create albums
+
+  albums.each do |album|
+    10.times do |num|
+      tracks.append({album_id: album[:album_id],
+                     track_number: num,
+                     title: "#{adverbs.sample} #{verbs.sample}",
+                     sample_rate: rand})
+    end
+  end
+  Track.create tracks
+
+  10.times do
+    venues.append({venue_id: SecureRandom.uuid,
+                   name: "Venue #{rand(1000)}",
+                   description: {
+                     capacity: rand(20000),
+                     type: "Arena",
+                   }})
+  end
+  Venue.create venues
+
+  50.times do
+    singer = singers.sample
+    date = Date.new(rand(100) + 1923, rand(12) + 1, rand(28) + 1)
+    start_time = DateTime.new(date.year, date.month, date.day, rand(24), rand(60), 0)
+    end_time = start_time + (rand(5) + 1).hours
+    concerts.append({concert_id: SecureRandom.uuid,
+                     name: "#{singer[:first_name]} #{singer[:last_name]} Live #{date}",
+                     venue_id: venues.sample[:venue_id],
+                     singer_id: singer[:singer_id],
+                     start_time: start_time,
+                     end_time: end_time})
+  end
+  Concert.create concerts
+end
diff --git a/src/test/ruby/activerecord/models/album.rb b/src/test/ruby/activerecord/models/album.rb
new file mode 100644
index 00000000..ab0a6eb8
--- /dev/null
+++ b/src/test/ruby/activerecord/models/album.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'singer'
+require_relative 'track'
+
+# Model for an Album of a Singer.
+class Album < ActiveRecord::Base
+  belongs_to :singer
+  has_many :tracks
+end
diff --git a/src/test/ruby/activerecord/models/concert.rb b/src/test/ruby/activerecord/models/concert.rb
new file mode 100644
index 00000000..a5c0f57c
--- /dev/null
+++ b/src/test/ruby/activerecord/models/concert.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'singer'
+require_relative 'venue'
+
+# Model for Concert.
+class Concert < ActiveRecord::Base
+  belongs_to :singer
+  belongs_to :venue
+end
diff --git a/src/test/ruby/activerecord/models/singer.rb b/src/test/ruby/activerecord/models/singer.rb
new file mode 100644
index 00000000..0e1eb546
--- /dev/null
+++ b/src/test/ruby/activerecord/models/singer.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'album'
+require_relative 'concert'
+
+# Model for a Singer entity.
+class Singer < ActiveRecord::Base
+  has_many :albums
+  has_many :concerts
+end
diff --git a/src/test/ruby/activerecord/models/track.rb b/src/test/ruby/activerecord/models/track.rb
new file mode 100644
index 00000000..ce6a25ac
--- /dev/null
+++ b/src/test/ruby/activerecord/models/track.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+# Model for a Track on an Album.
+class Track < ActiveRecord::Base
+  self.primary_keys = :album_id, :track_number
+  belongs_to :album
+end
diff --git a/src/test/ruby/activerecord/models/venue.rb b/src/test/ruby/activerecord/models/venue.rb
new file mode 100644
index 00000000..df0051d0
--- /dev/null
+++ b/src/test/ruby/activerecord/models/venue.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Copyright 2023 Google LLC
+#
+# Use of this source code is governed by an MIT-style
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+require_relative 'concert'
+
+# Model for a Venue entity.
+class Venue < ActiveRecord::Base
+  has_many :concerts
+end