From ccba53c4947c5b36fe4e46603a7b17ce54fd12f4 Mon Sep 17 00:00:00 2001 From: Hein Date: Sat, 3 Oct 2026 10:01:48 +0200 Subject: [PATCH] test: add podman/docker dbtest tool for postgres, mssql and mysql --- tests/dbtest/README.md | 45 ++++++++++++ tests/dbtest/dbs/mssql.sh | 17 +++++ tests/dbtest/dbs/mysql.sh | 16 +++++ tests/dbtest/dbs/postgres.sh | 16 +++++ tests/dbtest/dbtest.sh | 130 +++++++++++++++++++++++++++++++++++ tests/dbtest/init/mysql.sql | 21 ++++++ 6 files changed, 245 insertions(+) create mode 100644 tests/dbtest/README.md create mode 100644 tests/dbtest/dbs/mssql.sh create mode 100644 tests/dbtest/dbs/mysql.sh create mode 100644 tests/dbtest/dbs/postgres.sh create mode 100755 tests/dbtest/dbtest.sh create mode 100644 tests/dbtest/init/mysql.sql diff --git a/tests/dbtest/README.md b/tests/dbtest/README.md new file mode 100644 index 0000000..5c1458f --- /dev/null +++ b/tests/dbtest/README.md @@ -0,0 +1,45 @@ +# dbtest: container test databases + +Podman (preferred) or Docker. One tool for postgres, mssql, mysql. + +## Commands + +| Command | Action | +|---|---| +| `./tests/dbtest/dbtest.sh up ` | Start, wait for ready, run init | +| `down ` | Stop and remove | +| `restart ` | Fresh container | +| `status ` | State and connection string | +| `env ` | Print `export` line for the test env var | +| `test [pkgs]` | up, `go test`, down | +| `shell ` | Interactive client | +| `exec ` | Run SQL file | +| `logs ` | Container logs | + +## Databases + +| db | Port | Env var | Init | Default test pkgs | +|---|---|---|---|---| +| postgres | 5439 | `RELSPEC_TEST_PG_CONN` | `tests/postgres/init.sql` | readers/pgsql, writers/pgsql, writers/sqlexec | +| mssql | 1439 | `RELSPEC_TEST_MSSQL_CONN` | `test_data/mssql/test_schema.sql` (creates `RelSpecTest`) | readers/mssql, writers/mssql | +| mysql | 3309 | `RELSPEC_TEST_MYSQL_CONN` | `tests/dbtest/init/mysql.sql` | none (no Go driver/reader yet) | + +## Env + +| Var | Effect | +|---|---| +| `DBTEST_RUNTIME` | Force `podman` or `docker` | +| `DBTEST_TIMEOUT` | Ready wait, seconds (default 120) | +| `DBTEST_KEEP=1` | Keep container after `test` | +| `DBTEST_GOFLAGS` | Extra `go test` flags | + +## Add a database + +1. Add `dbs/.sh` defining: `DB_NAME DB_IMAGE DB_CONTAINER DB_PORT DB_INTERNAL_PORT DB_ENV DB_INIT_MOUNT DB_CONN_VAR DB_CONN DB_DEFAULT_PKGS` and functions `db_ready db_post_init db_shell db_exec_file`. +2. Add the name to `ALL_DBS` in `dbtest.sh`. + +## Notes + +- Containers are named `relspec-test-`; existing `tests/postgres/*.sh` and `make docker-*` use the same postgres name/port 5439 and remain independent. +- mssql needs ~2GB RAM and takes longer to become ready. +- Tests skip when the env var is unset. diff --git a/tests/dbtest/dbs/mssql.sh b/tests/dbtest/dbs/mssql.sh new file mode 100644 index 0000000..59f5b15 --- /dev/null +++ b/tests/dbtest/dbs/mssql.sh @@ -0,0 +1,17 @@ +# SQL Server container definition (sourced by dbtest.sh) +DB_NAME=mssql +DB_IMAGE=mcr.microsoft.com/mssql/server:2022-latest +DB_CONTAINER=relspec-test-mssql +DB_PORT=1439 +DB_INTERNAL_PORT=1433 +DB_ENV=(-e ACCEPT_EULA=Y -e "MSSQL_SA_PASSWORD=StrongPassword123!" -e MSSQL_PID=Express) +DB_INIT_MOUNT="$ROOT/test_data/mssql/test_schema.sql:/init/test_schema.sql" +DB_CONN_VAR=RELSPEC_TEST_MSSQL_CONN +DB_CONN="sqlserver://sa:StrongPassword123!@localhost:1439?database=RelSpecTest" +DB_DEFAULT_PKGS="./pkg/readers/mssql/ ./pkg/writers/mssql/" + +_sqlcmd() { rt exec -i "$DB_CONTAINER" /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'StrongPassword123!' "$@"; } +db_ready() { _sqlcmd -Q "SELECT 1" >/dev/null 2>&1; } +db_post_init() { _sqlcmd -b -i /init/test_schema.sql >/dev/null; } +db_shell() { rt exec -it "$DB_CONTAINER" /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'StrongPassword123!' -d RelSpecTest "$@"; } +db_exec_file() { _sqlcmd -b -d RelSpecTest < "$1"; } diff --git a/tests/dbtest/dbs/mysql.sh b/tests/dbtest/dbs/mysql.sh new file mode 100644 index 0000000..e0ac00e --- /dev/null +++ b/tests/dbtest/dbs/mysql.sh @@ -0,0 +1,16 @@ +# MySQL container definition (sourced by dbtest.sh) +DB_NAME=mysql +DB_IMAGE=docker.io/library/mysql:8.4 +DB_CONTAINER=relspec-test-mysql +DB_PORT=3309 +DB_INTERNAL_PORT=3306 +DB_ENV=(-e MYSQL_ROOT_PASSWORD=relspec_root_password -e MYSQL_DATABASE=relspec_test -e MYSQL_USER=relspec -e MYSQL_PASSWORD=relspec_test_password) +DB_INIT_MOUNT="$ROOT/tests/dbtest/init/mysql.sql:/docker-entrypoint-initdb.d/init.sql" +DB_CONN_VAR=RELSPEC_TEST_MYSQL_CONN +DB_CONN="relspec:relspec_test_password@tcp(localhost:3309)/relspec_test" +DB_DEFAULT_PKGS="" + +db_ready() { rt exec "$DB_CONTAINER" mysqladmin ping -h 127.0.0.1 -urelspec -prelspec_test_password --silent >/dev/null 2>&1; } +db_post_init() { :; } +db_shell() { rt exec -it "$DB_CONTAINER" mysql -urelspec -prelspec_test_password relspec_test "$@"; } +db_exec_file() { rt exec -i "$DB_CONTAINER" mysql -urelspec -prelspec_test_password relspec_test < "$1"; } diff --git a/tests/dbtest/dbs/postgres.sh b/tests/dbtest/dbs/postgres.sh new file mode 100644 index 0000000..49e2fcd --- /dev/null +++ b/tests/dbtest/dbs/postgres.sh @@ -0,0 +1,16 @@ +# Postgres container definition (sourced by dbtest.sh) +DB_NAME=postgres +DB_IMAGE=docker.io/library/postgres:16-alpine +DB_CONTAINER=relspec-test-postgres +DB_PORT=5439 +DB_INTERNAL_PORT=5432 +DB_ENV=(-e POSTGRES_USER=relspec -e POSTGRES_PASSWORD=relspec_test_password -e POSTGRES_DB=relspec_test) +DB_INIT_MOUNT="$ROOT/tests/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql" +DB_CONN_VAR=RELSPEC_TEST_PG_CONN +DB_CONN="postgres://relspec:relspec_test_password@localhost:5439/relspec_test" +DB_DEFAULT_PKGS="./pkg/readers/pgsql/ ./pkg/writers/pgsql/ ./pkg/writers/sqlexec/" + +db_ready() { rt exec "$DB_CONTAINER" pg_isready -U relspec -d relspec_test >/dev/null 2>&1; } +db_post_init() { :; } +db_shell() { rt exec -it "$DB_CONTAINER" psql -U relspec -d relspec_test "$@"; } +db_exec_file() { rt exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U relspec -d relspec_test < "$1"; } diff --git a/tests/dbtest/dbtest.sh b/tests/dbtest/dbtest.sh new file mode 100755 index 0000000..cb9f829 --- /dev/null +++ b/tests/dbtest/dbtest.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Reusable podman/docker test database tool for postgres, mssql and mysql. +# Usage: dbtest.sh [args] +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +TIMEOUT="${DBTEST_TIMEOUT:-120}" +ALL_DBS=(postgres mssql mysql) + +usage() { + cat < [args] + +commands: + up start container, wait until ready, run init + down stop and remove container + restart down + up + status running state and connection string + env print 'export VAR=conn' (use: eval "\$(dbtest.sh env postgres)") + logs container logs + shell interactive client + exec run a SQL file against the database + test [pkgs] up, run go tests with conn env set, down (keep with DBTEST_KEEP=1) + list supported databases + +dbs: ${ALL_DBS[*]} +env: DBTEST_RUNTIME=podman|docker DBTEST_TIMEOUT=secs DBTEST_KEEP=1 DBTEST_GOFLAGS=... +USAGE +} + +die() { echo "error: $*" >&2; exit 1; } +log() { echo "[dbtest] $*" >&2; } + +detect_runtime() { + if [ -n "${DBTEST_RUNTIME:-}" ]; then echo "$DBTEST_RUNTIME"; return; fi + if command -v podman >/dev/null 2>&1; then echo podman + elif command -v docker >/dev/null 2>&1; then echo docker + else die "neither podman nor docker is installed"; fi +} +RUNTIME="$(detect_runtime)" +rt() { "$RUNTIME" "$@"; } + +load_db() { + local f="$HERE/dbs/${1:-}.sh" + [ -f "$f" ] || die "unknown db '${1:-}' (supported: ${ALL_DBS[*]})" + # shellcheck disable=SC1090 + source "$f" +} + +is_running() { [ "$(rt inspect -f '{{.State.Running}}' "$DB_CONTAINER" 2>/dev/null || true)" = "true" ]; } + +cmd_up() { + if is_running; then + log "$DB_NAME already running" + else + rt rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true + log "starting $DB_NAME ($DB_IMAGE) on port $DB_PORT using $RUNTIME" + rt run -d --name "$DB_CONTAINER" "${DB_ENV[@]}" \ + -p "$DB_PORT:$DB_INTERNAL_PORT" \ + -v "$DB_INIT_MOUNT:ro,Z" "$DB_IMAGE" >/dev/null + fi + log "waiting for $DB_NAME (max ${TIMEOUT}s)" + local i=0 + until db_ready; do + i=$((i + 1)) + if [ "$i" -ge "$TIMEOUT" ]; then + rt logs --tail 50 "$DB_CONTAINER" >&2 || true + die "$DB_NAME did not become ready" + fi + sleep 1 + done + db_post_init + log "$DB_NAME ready: $DB_CONN_VAR=$DB_CONN" +} + +cmd_down() { + rt rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true + log "$DB_NAME removed" +} + +cmd_status() { + if is_running; then echo "$DB_NAME: running ($DB_CONTAINER, port $DB_PORT)"; else echo "$DB_NAME: stopped"; fi + echo "$DB_CONN_VAR=$DB_CONN" +} + +cmd_test() { + local pkgs="${*:-$DB_DEFAULT_PKGS}" + [ -n "$pkgs" ] || die "no test packages for $DB_NAME; pass packages as arguments" + cmd_up + [ -n "${DBTEST_KEEP:-}" ] || trap cmd_down EXIT + export "$DB_CONN_VAR=$DB_CONN" + cd "$ROOT" + # shellcheck disable=SC2086 + go test -count=1 ${DBTEST_GOFLAGS:-} $pkgs +} + +cmd="${1:-}" +case "$cmd" in + ""|-h|--help|help) usage; exit 0 ;; + list) printf '%s\n' "${ALL_DBS[@]}"; exit 0 ;; +esac +shift +target="${1:-}" +[ -n "$target" ] || die "missing " +shift || true + +run_one() { + load_db "$1" + shift + case "$cmd" in + up) cmd_up ;; + down) cmd_down ;; + restart) cmd_down; cmd_up ;; + status) cmd_status ;; + env) echo "export $DB_CONN_VAR='$DB_CONN'" ;; + logs) rt logs "$DB_CONTAINER" ;; + shell) db_shell "$@" ;; + exec) [ -f "${1:-}" ] || die "usage: exec "; db_exec_file "$1" ;; + test) cmd_test "$@" ;; + *) usage; die "unknown command '$cmd'" ;; + esac +} + +if [ "$target" = "all" ]; then + case "$cmd" in up|down|restart|status|env) ;; *) die "'all' is only valid for up/down/restart/status/env" ;; esac + for d in "${ALL_DBS[@]}"; do (run_one "$d"); done +else + run_one "$target" "$@" +fi diff --git a/tests/dbtest/init/mysql.sql b/tests/dbtest/init/mysql.sql new file mode 100644 index 0000000..0eaf714 --- /dev/null +++ b/tests/dbtest/init/mysql.sql @@ -0,0 +1,21 @@ +-- Minimal MySQL fixture for relspec container tests. +-- Extend when a MySQL reader/writer is added. +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_email (email) +) ENGINE=InnoDB; + +CREATE TABLE posts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NULL, + KEY idx_posts_user (user_id), + CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE VIEW v_user_posts AS + SELECT u.id AS user_id, u.email, p.id AS post_id, p.title + FROM users u JOIN posts p ON p.user_id = u.id;