Skip to content

The real-database test bed

Most tests run on SQLite, DuckDB and mocks. Anything that depends on how a real server behaves (catalogs, quoting, permissions, read-only sessions, timeouts) is tested against real servers instead: PostgreSQL, MySQL and SQL Server, each loaded with a well-known sample database and a large generated schema. One compose file, testbed/docker-compose.yml, defines all of it, and the same file runs on your machine, on GitHub's runners in CI, and on one small GCP VM started on demand.

The design is in Phase 2, section 3.

Server Sample database Generated wide schema
PostgreSQL 16 Pagila, in database pagila database wide, schemas sales_01 ... ops_20 and audit
MySQL 8.0 Sakila, in database sakila databases wide_s01 ... wide_s20 and wide_audit (MySQL's schemas are databases)
SQL Server 2022 AdventureWorksLT, in database AdventureWorksLT2022 database wide, schemas sales_01 ... ops_20 and audit

Local use

You need Docker with the compose plugin.

python testbed/passwords.py > testbed/.env               # once: random passwords, ignored by version control
docker compose -f testbed/docker-compose.yml up -d --wait
set -a; . testbed/.env; set +a                           # the tests read the same variables
REALDB=compose pytest -m realdb
docker compose -f testbed/docker-compose.yml down        # add -v to drop the data

In PowerShell, load the variables with Get-Content testbed/.env | ForEach-Object { $name, $value = $_ -split '=', 2; Set-Item "env:$name" $value } and set $env:REALDB = "compose".

The compose file has no default password, so no password is written in the repository: TESTBED_POSTGRES_PASSWORD, TESTBED_MYSQL_PASSWORD and TESTBED_MSSQL_PASSWORD are required, and up stops naming the variable if one is missing. testbed/passwords.py prints three fresh random ones. Docker Compose reads testbed/.env by itself, because it sits beside the compose file. A server keeps the password its data volume was created with, so make a new .env only together with down -v.

The first up downloads the samples, generates the wide schema and loads everything, which takes a few minutes (SQL Server wants about 2 GB of memory). Loading happens once per data volume, so a later up is quick. --wait returns only when every server is loaded.

Install the drivers the tests use with pip install -e "packages/nl2sql[postgres,mysql,mssql,duckdb]". SQL Server also needs Microsoft's ODBC Driver for SQL Server (17 or 18) on the machine; the tests use the newest one installed.

The compose file

Service Image What it does
samples python:3.12-slim One-shot. Runs testbed/samples.py: downloads each sample from a pinned URL, checks its SHA-256, extracts Sakila from its archive, and writes the wide schema for the three servers with testbed/wide_schema.py, all into the samples volume. Skips what is already there.
postgres postgres:16 Starts after samples. On its first start, testbed/postgres/init.sh (the image's own init mechanism) loads Pagila and the wide schema and creates the reader login.
mysql mysql:8.0 The same with Sakila, through testbed/mysql/init.sh. The mysql client handles Sakila's DELIMITER blocks.
mssql mcr.microsoft.com/mssql/server:2022-latest The server. Its healthcheck passes only once mssql-init has finished. It has a 60-second stop_grace_period: after Docker's default 10 seconds a stop ends in SIGKILL, and the next start then recovers every database before any can be opened.
mssql-init the same image, for its sqlcmd Runs testbed/mssql/init.sh: waits up to five minutes until the server accepts logins and every sample database it already has is ONLINE in sys.databases (asked from master, since a database still in recovery cannot be opened), then restores AdventureWorksLT from the .bak, loads the wide schema and creates the reader login, skipping each database that already exists. Then it marks itself loaded for its healthcheck and idles instead of exiting.

Every server has a healthcheck that passes only once it is loaded, so up --wait is enough before running tests. --wait fails on an exited container unless another service waits for it to complete, as the servers wait for samples. Nothing waits for mssql-init, and on a later up it skips everything and would exit at once, so it stays running instead; that is what makes the same up -d --wait work on every start.

The file reads four variables:

Variable Default
TESTBED_BIND 127.0.0.1
TESTBED_POSTGRES_PASSWORD none: required (testbed/passwords.py)
TESTBED_MYSQL_PASSWORD none: required
TESTBED_MSSQL_PASSWORD none: required
Service Published port Admin user Sample database
postgres 15432 postgres pagila
mysql 13306 root sakila
mssql 11433 sa AdventureWorksLT2022

The ports avoid a clash with a database already installed locally.

Each server also has a login nl2sql_reader, with the server's password, that may read its sample database and nothing else: not the wide schema. Tests use it where a limited login matters.

The sample databases and their licences

Nothing is committed to the repository; the samples service downloads each file and refuses one whose SHA-256 differs from the pinned value, so we never redistribute the samples. If one is ever vendored, its licence file goes beside it.

Sample Files Source Licence
Pagila pagila-schema.sql, pagila-insert-data.sql github.com/devrimgunduz/pagila, pinned to commit 5ba5a57, from before Pagila moved to PostgreSQL 18 PostgreSQL License (copyright Devrim Gündüz)
Sakila sakila-schema.sql, sakila-data.sql, from sakila-db.tar.gz downloads.mysql.com New BSD
AdventureWorksLT 2022 AdventureWorksLT2022.bak github.com/microsoft/sql-server-samples, release adventureworks MIT

The server images have their own terms. The SQL Server image runs the free Developer edition, which needs ACCEPT_EULA=Y and is licensed for development and testing, which is all we do with it.

When an upstream file changes, samples stops with the expected and actual hashes. Check the new file, then pin its URL and hash in testbed/samples.py.

The generated wide schema

python testbed/wide_schema.py --dialect postgres|mysql|tsql --seed 7 writes one SQL file. The same seed always gives the same schema:

Feature Count What it tests
Schemas 20 (sales_01 ... ops_20), plus a small audit Index scope, policy globs
Tables 1,200 in the 20 schemas, with 5 to 30 columns each Reflection and retrieval at scale
Views 2 per schema Views are indexed
Odd column types boolean, uuid, json, binary, xml and array where the server has the type; the nearest plain type where it does not Profiling never aborts
Comments on about 30% of tables and columns Descriptions come from the database
Foreign keys within each schema Join paths
Mixed-case names 10 tables, such as CustomerAccounts Quoting
Rows 3 tables with 1,000,000 rows, 50 with 1,000, the rest empty Row estimates, profiling limits

The rows are made on the server (generate_series on PostgreSQL, a cross join of digit tables on MySQL and SQL Server), so the file stays small.

Routing is measured on it too: tests/realdb/test_routing_wide.py indexes Pagila and the whole wide schema side by side on PostgreSQL, and checks that questions about films go to Pagila and questions about payroll runs or ledger entries go to the wide schema, however many tables there are to vote (the routing suite). Indexing the wide schema takes about 17 minutes on CI's runner, so the test runs on PostgreSQL only, and only when REALDB_WIDE_ROUTING is true: the workflow sets it nightly, on demand, and on pull requests that change nl2sql/indexing/ (or the test itself). It skips otherwise.

The realdb marker and fixtures

Every test under packages/nl2sql/tests/realdb/ is marked realdb. Its fixtures find the servers from the environment:

Variable Meaning
REALDB=compose The compose servers on 127.0.0.1, at the published ports, with the passwords in the three TESTBED_*_PASSWORD variables
REALDB_POSTGRES, REALDB_MYSQL, REALDB_MSSQL A URL for one server, such as postgresql://postgres:<password>@127.0.0.1:15432/, with the password percent-encoded. It wins over REALDB=compose.
REALDB_REQUIRED=1 A missing server is a failure, not a skip

Without a server, a realdb test skips with the fix in its message. Under REALDB_REQUIRED=1 it fails instead: a green run that skipped the real databases proves nothing.

Fixture or helper What it gives
server Each of the three servers in turn (RealServer: its admin URL, its sample database, engine() for a SQLAlchemy engine)
datasource(server, database=None, **fields) A Datasource for one database on the server, the sample by default
engine_on(database=None, **fields) An NL2SQL engine on one database of server, with a fake LLM and no key

Test code may name dialects and use SQLAlchemy directly; the boundary tests cover packages/*/src, not the tests.

test_conformance.py runs the adapter conformance kit (nl2sql_sdk.testing) on each server. Its seed goes into schemas kit_a and kit_b of the sample database (databases of those names on MySQL), written with the admin login and dropped after each test. Its limited_login is a login nl2sql_kit the test makes, whose password holds @ : / # % and which may read kit_a only.

CI: the realdb workflow

.github/workflows/realdb.yml runs the test bed on GitHub's runners. No cloud credentials are involved.

Job Runs What it does
changes always Works out whether a pull request touches database-facing code (the paths below). On the nightly and on-demand runs the answer is always yes.
realdb on a pull request when changes says so; nightly on main; on demand (workflow_dispatch), which is run before every release generates fresh passwords with testbed/passwords.py into the job's environment, masked in the log; docker compose ... up -d --wait, installs Microsoft's msodbcsql18 for pyodbc and packages/nl2sql[postgres,mysql,mssql,duckdb], then pytest -m realdb with REALDB=compose and REALDB_REQUIRED=1
realdb-gate always, after the other two Passes when realdb passed, or was skipped because nothing database-facing changed; fails otherwise. The decision is testbed/gate.py. This is the check to require.

The paths that make realdb run on a pull request:

packages/sdk/**
packages/nl2sql/src/nl2sql/adapters/**
packages/nl2sql/src/nl2sql/indexing/**
packages/nl2sql/src/nl2sql/storage/**
packages/nl2sql/src/nl2sql/schema/**
packages/nl2sql/tests/realdb/**
testbed/**
.github/workflows/realdb.yml

To run it by hand: Actions, "Real databases", "Run workflow", or gh workflow run realdb.yml --ref main.

The unit job in test.yml runs pytest -m "not integration and not realdb".

The scripts under testbed/ have their own unit tests, which need no database: packages/nl2sql/tests/unit/test_testbed.py (the wide schema, the checksums, skipping what is there, and the gate), packages/nl2sql/tests/unit/test_realdb_fixtures.py (the skip, the failure under REALDB_REQUIRED=1, and the variables) and packages/nl2sql/tests/unit/test_testbed_vm.py (scripts/testbed.py, against a fake gcloud on PATH).

The GCP VM

The same compose file also runs on one small VM, nl2sql-testbed in project nl2sql-testbed-261014, zone us-central1-a. It is started for a session and stopped after it. There are no GitHub-to-GCP credentials: the owner, or an agent the owner asks, runs it by hand, at least once before each release.

Setting Value
Machine e2-standard-2 (2 vCPU, 8 GB: SQL Server alone wants 2 GB), Debian 12
Docker Installed with the compose plugin by the VM's startup script on its first boot
Disk 30 GB standard persistent disk. It keeps the images and the data volumes while the VM is stopped, so a later session skips the load.
External IP Ephemeral, for outbound traffic only (image pulls, sample downloads). It is released, and free, while the VM is stopped.
Network Its own VPC, nl2sql-testbed, so none of the default network's open rules apply. One firewall rule admits TCP 22, 15432, 13306 and 11433 from IAP's range 35.235.240.0/20 only: no database port is public.
Access IAP: gcloud compute ssh and scp with --tunnel-through-iap, and gcloud compute start-iap-tunnel for the three servers
Passwords Secret Manager: testbed-postgres-root-password, testbed-mysql-root-password and testbed-mssql-root-password

scripts/testbed.py

You need the Google Cloud CLI, logged in (gcloud auth login) as an account with the access listed below. The script is Python so that it runs the same on Windows and Linux; it finds gcloud on PATH.

python scripts/testbed.py run -- pytest -m realdb    # the usual way: up, the command, down
python scripts/testbed.py up                         # or step by step
python scripts/testbed.py status
python scripts/testbed.py down
Command What it does
up Reads the three passwords from Secret Manager into memory, starts the VM, copies testbed/ to it (never a local testbed/.env), waits up to five minutes for Docker (the first boot installs it), runs docker compose up -d --wait there with TESTBED_BIND=0.0.0.0, then opens the three IAP tunnels in the background and waits until localhost:15432, 13306 and 11433 answer. The first session loads the samples, which takes several minutes.
run -- <cmd> up, then <cmd> with REALDB_POSTGRES, REALDB_MYSQL and REALDB_MSSQL pointing at the tunnels, then down in a finally: also after Ctrl-C, a failing command or a failed up. It exits with the command's code.
down Closes the tunnels it opened, runs docker compose stop on the VM so every server shuts down cleanly, then stops the VM. Stopping the VM alone kills SQL Server, and the next session then waits for its databases to recover. If the servers cannot be stopped within three minutes (the VM is unreachable or already stopped), it says so and stops the VM anyway. Safe to run again.
status The VM's state, how long it has been running, and whether each tunnel answers. Never a password.

--project, --zone and --instance override the defaults above.

The passwords reach the VM on the stdin of the docker compose command, so they live only in that command's environment there, and reach <cmd> through its own environment. They are never written to a file, put on a command line or printed, and the script never changes its own environment. The tunnels' process ids are kept in nl2sql-testbed/tunnels.json in the system's temporary folder, so a later down can close them.

Every gcloud compute ssh and scp call passes --strict-host-key-checking=no and --tunnel-through-iap. Right after a start, the first connection can fail while the VM boots and its new SSH key propagates (plink says Remote side unexpectedly closed network connection); the copy and the Docker wait retry such a connection failure, and only that, up to five times about a minute in all, then give up, and run still stops the VM.

On Windows, gcloud uses PuTTY's plink and pscp, and by default answers their host-key question by feeding them y on stdin instead of passing the caller's stdin on. The copy and the Docker wait keep that default, which stores the VM's host key in PuTTY's cache; the docker compose step sets CLOUDSDK_SSH_PUTTY_FORCE_CONNECT=false in gcloud's environment only, so the passwords on its stdin reach the VM. OpenSSH ignores the setting.

The script writes that stdin as bytes, one password per line ending in a bare newline: in text mode Windows would end each line with a carriage return, and the servers would be initialised with every password plus \r while the tests use the password alone (MySQL then answers 1045 and SQL Server 18456). The command on the VM also drops a trailing carriage return from each password it reads. A server keeps the password its data volume was created with, so if one was ever initialised with a wrong password, drop the volumes once while the VM is up, then down and up again, which loads the samples afresh:

gcloud compute ssh nl2sql-testbed --project=nl2sql-testbed-261014 --zone=us-central1-a --tunnel-through-iap \
  --command="sudo TESTBED_POSTGRES_PASSWORD=unused TESTBED_MYSQL_PASSWORD=unused TESTBED_MSSQL_PASSWORD=unused docker compose -f testbed/docker-compose.yml down -v"

After up on its own, remember down: a running VM bills by the hour. If the script is killed hard or the VM is started by hand, the nightly stop job below stops it at 03:00 UTC. That stop does not stop the servers first, so SQL Server is killed; the next up then waits while it recovers its databases, which mssql-init allows for.

Cost controls

Control Why
The VM is stopped whenever no session runs A running VM bills by the hour
Ephemeral external IP only Released, and free, while stopped
No public database port; IAP's range only Nothing else can reach the servers
run always stops the VM in a finally A failed test cannot leave it running
The nightly stop at 03:00 UTC The backstop for everything above
A budget of $10 a month with alerts The last line
Item Approximate cost (check the billing report after the first month)
Stopped the 30 GB disk: about $1 to $2 a month
Running about $0.07 an hour
The scheduler job free: one job is inside Cloud Scheduler's free tier

Access

The owner's account holds roles/owner on the project, which covers both the set-up below and scripts/testbed.py. Anyone else who runs the script needs:

Role For
roles/compute.instanceAdmin.v1 starting, stopping and describing the VM, and the SSH key gcloud compute ssh adds to it
roles/iap.tunnelResourceAccessor --tunnel-through-iap and start-iap-tunnel
roles/secretmanager.secretAccessor on the three secrets the passwords

One-time set-up

Nothing in the repository creates cloud resources. The owner runs these once, in a bash shell (Git Bash on Windows) logged in to gcloud. The three secrets already exist.

PROJECT=nl2sql-testbed-261014
REGION=us-central1
ZONE=us-central1-a
VM=nl2sql-testbed
STOPPER=testbed-stopper@$PROJECT.iam.gserviceaccount.com

# 1. The APIs.
gcloud services enable compute.googleapis.com iap.googleapis.com cloudscheduler.googleapis.com \
    billingbudgets.googleapis.com --project=$PROJECT

# 2. A network of its own, so the default network's open SSH and RDP rules do not apply.
gcloud compute networks create nl2sql-testbed --subnet-mode=custom --project=$PROJECT
gcloud compute networks subnets create nl2sql-testbed --network=nl2sql-testbed \
    --region=$REGION --range=10.10.0.0/24 --project=$PROJECT

# 3. The one inbound rule: SSH and the three servers, from IAP's range only.
gcloud compute firewall-rules create nl2sql-testbed-allow-iap --network=nl2sql-testbed \
    --direction=INGRESS --action=ALLOW --rules=tcp:22,tcp:15432,tcp:13306,tcp:11433 \
    --source-ranges=35.235.240.0/20 --target-tags=nl2sql-testbed --project=$PROJECT

# 4. The VM: e2-standard-2, Debian 12, a 30 GB standard disk, an ephemeral external IP,
#    no service account. Its startup script installs Docker and the compose plugin once.
#    printf writes it with LF line endings, which an inline script typed in Git Bash on
#    Windows does not get: with CRLF the VM fails to run it (exit 127) and Docker never arrives.
printf '%s\n' '#!/bin/bash' \
    'command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh' > startup.sh
gcloud compute instances create $VM --project=$PROJECT --zone=$ZONE \
    --machine-type=e2-standard-2 --image-family=debian-12 --image-project=debian-cloud \
    --boot-disk-size=30GB --boot-disk-type=pd-standard \
    --network=nl2sql-testbed --subnet=nl2sql-testbed --tags=nl2sql-testbed \
    --no-service-account --no-scopes \
    --metadata-from-file=startup-script=startup.sh
gcloud compute instances stop $VM --zone=$ZONE --project=$PROJECT    # stopped until a session needs it

# 5. The nightly stop: a service account that may only stop this VM, and a scheduler job
#    that calls the Compute Engine API with its token at 03:00 UTC.
gcloud iam service-accounts create testbed-stopper --display-name="Stops the test-bed VM" --project=$PROJECT
gcloud iam roles create testbedStopper --project=$PROJECT --title="Test-bed stopper" \
    --permissions=compute.instances.stop --stage=GA
gcloud compute instances add-iam-policy-binding $VM --zone=$ZONE --project=$PROJECT \
    --member=serviceAccount:$STOPPER --role=projects/$PROJECT/roles/testbedStopper
gcloud scheduler jobs create http nl2sql-testbed-nightly-stop --project=$PROJECT --location=$REGION \
    --schedule="0 3 * * *" --time-zone=Etc/UTC --http-method=POST \
    --uri=https://compute.googleapis.com/compute/v1/projects/$PROJECT/zones/$ZONE/instances/$VM/stop \
    --oauth-service-account-email=$STOPPER \
    --oauth-token-scope=https://www.googleapis.com/auth/cloud-platform

# 6. The budget: $10 a month, with alerts at 50%, 90% and 100%.
BILLING=$(gcloud billing projects describe $PROJECT --format='value(billingAccountName)')
gcloud billing budgets create --billing-account=${BILLING#billingAccounts/} \
    --display-name="nl2sql test bed" --budget-amount=10USD \
    --filter-projects=projects/$PROJECT \
    --threshold-rule=percent=0.5 --threshold-rule=percent=0.9 --threshold-rule=percent=1.0

To check the set-up, run python scripts/testbed.py run -- pytest -m realdb. To check the nightly job, start the VM with python scripts/testbed.py up, run gcloud scheduler jobs run nl2sql-testbed-nightly-stop --location=$REGION --project=$PROJECT, and python scripts/testbed.py status should then show it stopped; finish with python scripts/testbed.py down to close the tunnels.