diff --git a/docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md b/docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md new file mode 100644 index 0000000000..4dd5c20cfb --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md @@ -0,0 +1,249 @@ +# PostgreSQL SP-3 — Driver Matrix Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run the SP-2 driver-agnostic behavior contract (connect, transactions, prepared statements, session isolation) through three additional real-world driver stacks — **Go/pgx**, **Java/pgjdbc**, **Node.js/node-postgres + Prisma** — each with its own protocol implementation and prepared-statement strategy, orchestrated by the existing pytest harness so the xfail catalogue, junit report, and CI wiring apply unchanged. + +**Architecture:** Each language ships ONE self-contained behavior program implementing the 4-behavior contract behind a uniform CLI (` ` → exit 0/1, diagnostics on stderr). Programs are compiled/installed into the existing runner image via a multi-stage Dockerfile extension (the container is the only place toolchains are guaranteed — Java doesn't exist on the dev host). Thin pytest subprocess wrappers under `tests/` give each (language × behavior) pair a stable nodeid (`tests/test_behaviors_go.py::test_behavior_go[transactions]`) so the exact-nodeid xfail catalogue works as-is. **Scope decision (user-approved 2026-07-08): behaviors only** — the differential engine stays Python/psycopg (its comparison unit is psycopg's decode semantics; per-language differential runners are a possible SP-3b, not this plan). + +**Tech Stack:** Go 1.22 + pgx v5, Java 21 (Temurin) + pgjdbc 42.7.x, Node 22 + pg (node-postgres) 8.x + Prisma 5.x, multi-stage Docker on `python:3.11-slim`, pytest subprocess wrappers, existing `run-pg-compat.bash`/CI. + +## Global Constraints + +- **The behavior contract is FROZEN.** The four behaviors' semantics must match `test/pg-compat/behaviors/*.py` exactly (they are the cross-driver contract): same assertions, same trap adaptations. Do not change the Python behaviors. +- **Trap adaptations every port MUST reproduce** (from the SP-1/SP-2 findings — the Python behaviors' docstrings are the reference): + - Session-isolation probe = `SET TimeZone = 'Antarctica/Troll'` / `SHOW TimeZone` (NEVER `application_name` — it's in ProxySQL's `ignore_vars`). Close A **before** opening B; assert B ≠ the distinctive value. + - Transactions: every verification `SELECT count(*) ... AS verify_read` runs inside its own explicit BEGIN/COMMIT (pins to the writer; a bare `^SELECT` routes to a replica → replication-lag flake). + - Every connection string pins **`client_encoding=UTF8`** (backend DBs are SQL_ASCII; ProxySQL imposes UTF8 — recorded finding in `xfail.toml`). Driver syntax: pgx/node-pg DSN param `client_encoding=UTF8`; pgjdbc URL does NOT accept it directly — use `options=-c%20client_encoding=UTF8` in the JDBC URL (verify empirically; `SET client_encoding` after connect is the fallback). + - Placeholders are driver-native: pgx `$1,$2` · pgjdbc `?` · node-pg `$1,$2` (Python's `%s` is psycopg-specific). +- **Env contract (read, never invent):** programs read `PGCOMPAT_PROXY_HOST` (default `proxysql`) / `PGCOMPAT_PROXY_PORT` (default `6133`), connect as `testuser`/`testuser`, db `testuser`, sslmode/ssl disabled. No other env vars needed by behavior programs. +- **CLI contract (uniform across languages):** ` ` where `` ∈ {connect, transactions, prepared, session_isolation}; exit 0 = pass, exit 1 = behavior assertion failed (human-readable reason on stderr), exit 2 = usage/infra error. No output on stdout needed for pass. +- **Table names are per-language** to be parallel-safe: `behavior_tx_t_go`, `behavior_tx_t_java`, `behavior_tx_t_node`, `behavior_tx_t_prisma` (Python keeps `behavior_tx_t`). Each program drops its table in a finally-equivalent. +- **Nodeid stability:** pytest wrappers use `@pytest.mark.parametrize(..., ids=[...])` with the literal behavior names so xfail.toml keys are stable (`tests/test_behaviors_.py::test_behavior_[]`). +- **Docker builds need `--network=host` in this environment** (documented in `run-pg-compat.bash`); harmless on GitHub runners. All toolchains live in the IMAGE (multi-stage), not the host — Java does not exist on the dev host at all. +- **Discovery-phase (spec §2.1):** a driver behavior that genuinely fails through ProxySQL is a FINDING — never weaken the program's assertion; add an `[[xfail]]` entry with reason+ref (this is exactly what the catalogue is for; Prisma is the most likely candidate). +- **Verify runs:** `WORKSPACE=$(pwd) INFRA_ID=sdd-sp2 test/pg-compat/run-pg-compat.bash ` against the standing `sdd-sp2` infra (`ensure-infras.bash` first if down). Do NOT touch `sdd-pg1`, `dev-rene*`, `iss5883`. +- **Version pins:** pgx `v5.7.x`, pgjdbc `42.7.x` (exact jar version pinned in the Dockerfile), pg (node) `8.x`, Prisma `5.x` — record exact chosen versions in a comment + the README table. + +--- + +## File Structure + +**New (this plan):** +- `test/pg-compat/drivers/go/behaviors.go` + `go.mod`/`go.sum` — Go behavior program (pgx v5). +- `test/pg-compat/drivers/java/Behaviors.java` — Java behavior program (single file, pgjdbc on the classpath). +- `test/pg-compat/drivers/node/behaviors.js` + `package.json`/`package-lock.json` — Node behavior program (pg). +- `test/pg-compat/drivers/prisma/` — `behaviors.mjs`, `schema.prisma`, package files — Prisma behavior program. +- `test/pg-compat/tests/test_behaviors_go.py`, `test_behaviors_java.py`, `test_behaviors_node.py`, `test_behaviors_prisma.py` — subprocess wrappers. +- `test/pg-compat/tests/_subproc.py` — the one shared subprocess helper (run program, assert exit 0, surface stderr). + +**Modified:** +- `test/pg-compat/Dockerfile` — multi-stage: Go builder (static binary), Java builder (javac) + JRE in final, Node runtime + npm ci; final stage remains `python:3.11-slim`-based. +- `test/pg-compat/README.md` — driver matrix table (language, driver, version, prepared-statement strategy, placeholder syntax). +- `docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md` — §6 SP-3 stub updated to the approved behaviors-only scope (+ SP-3b stub for per-language differential runners). +- `.github/workflows/gh-actions-reusable/ci-pg-compat.yml` — timeout bump only if measured necessary (Task 6 decides on evidence). + +**Interfaces produced (consumed by every task):** +- CLI contract as in Global Constraints; binaries land in the image at `/pg-compat/bin/behaviors-go`, `/pg-compat/bin/Behaviors.class`+wrapper `behaviors-java`, `/pg-compat/bin/behaviors-node` (wrapper invoking `node /pg-compat/drivers/node/behaviors.js`), `/pg-compat/bin/behaviors-prisma`. +- `tests/_subproc.py`: `def run_behavior(program: str, behavior: str) -> None` — runs `[program, behavior]`, `pytest.fail` with captured stderr on nonzero exit; `pytest.skip(f"{program} not in image")` if the binary is absent (lets partial images run). + +--- + +## Task 1: Multi-language runner image + CLI/subprocess scaffolding + +Extend the Dockerfile with the three toolchains (multi-stage; final image stays lean), add the shared subprocess helper, and prove the wiring with stub programs that only implement `connect`. Real behaviors land per-language in Tasks 2–4 — this task makes the image+harness seam work end to end. + +**Files:** +- Modify: `test/pg-compat/Dockerfile` +- Create: `test/pg-compat/tests/_subproc.py`, `test/pg-compat/drivers/go/{behaviors.go,go.mod}`, `test/pg-compat/drivers/java/Behaviors.java`, `test/pg-compat/drivers/node/{behaviors.js,package.json}` (stubs: `connect` only, other behaviors exit 2 "not implemented") +- Create: `test/pg-compat/tests/test_behaviors_go.py` (+ java, node variants) with ONLY the `connect` param active this task (`BEHAVIORS = ["connect"]`; Tasks 2–4 extend the list per language) + +**Interfaces:** +- Produces: the Dockerfile stages + `/pg-compat/bin/behaviors-{go,java,node}` layout, `run_behavior()` helper, wrapper test files. Tasks 2–4 only edit their language's program + extend their `BEHAVIORS` list. + +- [ ] **Step 1: Extend the Dockerfile (multi-stage)** + +Replace `test/pg-compat/Dockerfile` with: + +```dockerfile +# ---- Go builder: static behavior binary (no runtime needed in final) ---- +FROM golang:1.22-bookworm AS gobuild +WORKDIR /src +COPY drivers/go/ . +RUN CGO_ENABLED=0 go build -o /out/behaviors-go . + +# ---- Java builder: compile against a pinned pgjdbc jar ---- +FROM eclipse-temurin:21-jdk AS javabuild +WORKDIR /src +# Pin the driver version explicitly; record bumps in README's driver table. +ARG PGJDBC_VERSION=42.7.4 +RUN curl -fsSLo /pgjdbc.jar "https://repo1.maven.org/maven2/org/postgresql/postgresql/${PGJDBC_VERSION}/postgresql-${PGJDBC_VERSION}.jar" +COPY drivers/java/Behaviors.java . +RUN javac -cp /pgjdbc.jar Behaviors.java -d /out + +# ---- Node deps: install node-postgres against the lockfile ---- +FROM node:22-bookworm-slim AS nodebuild +WORKDIR /app +COPY drivers/node/package.json drivers/node/package-lock.json* ./ +RUN npm ci --omit=dev || npm install --omit=dev +COPY drivers/node/behaviors.js . + +# ---- Final: python base + JRE + node runtime + artifacts ---- +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 curl default-jre-headless \ + && rm -rf /var/lib/apt/lists/* +# Node runtime copied from the official image (bookworm-glibc compatible). +COPY --from=nodebuild /usr/local/bin/node /usr/local/bin/node +WORKDIR /pg-compat +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +# Language artifacts under /pg-compat/bin with uniform CLI wrappers. +COPY --from=gobuild /out/behaviors-go /pg-compat/bin/behaviors-go +COPY --from=javabuild /out/ /pg-compat/bin/java-classes/ +COPY --from=javabuild /pgjdbc.jar /pg-compat/bin/pgjdbc.jar +COPY --from=nodebuild /app /pg-compat/node-app +RUN printf '#!/bin/sh\nexec java -cp /pg-compat/bin/java-classes:/pg-compat/bin/pgjdbc.jar Behaviors "$@"\n' > /pg-compat/bin/behaviors-java \ + && printf '#!/bin/sh\nexec node /pg-compat/node-app/behaviors.js "$@"\n' > /pg-compat/bin/behaviors-node \ + && chmod +x /pg-compat/bin/behaviors-* +ENTRYPOINT ["pytest", "-q"] +``` + +(If `COPY --from=nodebuild /usr/local/bin/node` misses shared libs at runtime, fall back to `apt-get install nodejs` from bookworm — decide empirically, document in the report.) + +- [ ] **Step 2: Shared subprocess helper** + +`test/pg-compat/tests/_subproc.py`: + +```python +"""Run a per-language behavior program and translate its exit code into +pytest semantics. The CLI contract: ` ` -> exit 0 pass, +exit 1 assertion-failure (reason on stderr), exit 2 usage/infra error.""" +import os +import subprocess + +import pytest + +def run_behavior(program, behavior): + if not os.path.exists(program): + pytest.skip(f"{program} not present in this image") + r = subprocess.run( + [program, behavior], capture_output=True, text=True, timeout=120, + env=os.environ.copy(), + ) + if r.returncode == 0: + return + detail = f"{program} {behavior} -> exit {r.returncode}\nstderr:\n{r.stderr}\nstdout:\n{r.stdout}" + if r.returncode == 2: + pytest.fail(f"infra/usage error (not a behavior failure): {detail}") + pytest.fail(detail) +``` + +- [ ] **Step 3: Stub programs (connect only) + wrapper tests** + +Each stub implements `connect` fully (open → `SELECT 1` → assert 1 → close) and exits 2 with "not implemented" for the other names. Wrapper test file pattern (`tests/test_behaviors_go.py`; java/node identical with names swapped): + +```python +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-go" +BEHAVIORS = ["connect"] # Tasks 2-4 extend per language + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_go(behavior): + run_behavior(PROGRAM, behavior) +``` + +Stub sources: keep the real connection code (it is Task-common): read `PGCOMPAT_PROXY_HOST`/`PGCOMPAT_PROXY_PORT`, user/pass/db `testuser`, ssl off, `client_encoding=UTF8`. (Full per-language programs land in Tasks 2–4 — write the stubs so extending = filling in function bodies, not restructuring.) + +- [ ] **Step 4: Build + run — expect 3 new `connect` passes** + +```bash +WORKSPACE=$(pwd) INFRA_ID=sdd-sp2 test/pg-compat/run-pg-compat.bash tests/test_behaviors_go.py tests/test_behaviors_java.py tests/test_behaviors_node.py -v +``` +Expected: 3 passed (go/java/node × connect). Full suite still 16+3 passed, 2 skipped. + +- [ ] **Step 5: Commit** + +```bash +git add test/pg-compat/Dockerfile test/pg-compat/tests/_subproc.py test/pg-compat/tests/test_behaviors_*.py test/pg-compat/drivers/go test/pg-compat/drivers/java test/pg-compat/drivers/node +git commit -m "test(pg-compat): multi-language runner image + behavior CLI scaffolding (connect x3)" +``` + +--- + +## Task 2: Go/pgx behavior program (full contract) + +**Files:** Modify `test/pg-compat/drivers/go/behaviors.go` (+`go.sum`), extend `BEHAVIORS` in `tests/test_behaviors_go.py` to all four. + +**Key driver facts to encode:** pgx v5 (`github.com/jackc/pgx/v5`) prepares statements automatically via its statement cache (`default_query_exec_mode=cache_statement` default) — the 50× parameterized loop (`SELECT $1::int + $2::int`) exercises real extended-protocol prepared statements. DSN: `postgres://testuser:testuser@$HOST:$PORT/testuser?sslmode=disable&client_encoding=UTF8`. Transactions via `conn.Begin(ctx)`/`tx.Commit(ctx)`; verify-reads inside their own tx (`AS verify_read` alias, table `behavior_tx_t_go`). Session isolation: conn A `SET TimeZone='Antarctica/Troll'` → `SHOW TimeZone` == it → `a.Close(ctx)` → conn B `SHOW TimeZone` != it. All four behaviors behind the CLI switch; cleanup via `defer` + explicit final `DROP TABLE IF EXISTS`. + +- [ ] **Step 1:** Extend `BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"]` in the wrapper; run → RED (exit 2 not-implemented for the three new ones). +- [ ] **Step 2:** Implement the three behaviors in `behaviors.go` per the frozen contract (mirror `behaviors/*.py` assertions exactly; the Python files are the spec — read them). +- [ ] **Step 3:** Rebuild image + run `tests/test_behaviors_go.py -v` → 4 passed. Run twice (idempotent). A genuine failure through ProxySQL = finding: keep it failing, add `[[xfail]]` with reason+ref, report it. +- [ ] **Step 4:** Full suite green (± catalogued xfails). Commit: `test(pg-compat): Go/pgx behavior program (full contract)`. + +--- + +## Task 3: Java/pgjdbc behavior program (full contract) + +**Files:** Modify `test/pg-compat/drivers/java/Behaviors.java`, extend `tests/test_behaviors_java.py`. + +**Key driver facts to encode:** pgjdbc placeholders are `?`; pgjdbc switches a reused `PreparedStatement` to a **server-side named statement after `prepareThreshold` (default 5) executions** — reuse ONE PreparedStatement object for the 50× loop so the back half runs real named statements through ProxySQL's multiplexing (this is the pgjdbc-specific value of the port). URL: `jdbc:postgresql://$HOST:$PORT/testuser?sslmode=disable&options=-c%20client_encoding%3DUTF8` — VERIFY the options form empirically; fallback: execute `SET client_encoding TO 'UTF8'` right after connect and document. Transactions: `setAutoCommit(false)` … `commit()` … `setAutoCommit(true)`; verify-reads in their own autocommit-off/commit pair (`AS verify_read`, table `behavior_tx_t_java`). Session isolation identical structure (close A before B). Exit codes per the CLI contract; single-file `Behaviors.java` with a `main` dispatching on args[0]. + +- [ ] **Step 1:** Extend BEHAVIORS → RED (exit 2). +- [ ] **Step 2:** Implement; mirror the Python behaviors exactly. +- [ ] **Step 3:** Rebuild + run → 4 passed ×2 runs. pgjdbc's named-statement path failing through ProxySQL would be a HIGH-VALUE finding (this is the classic pooler breaker): keep failing + xfail-catalogue + report prominently. +- [ ] **Step 4:** Full suite green (± catalogued). Commit: `test(pg-compat): Java/pgjdbc behavior program (full contract)`. + +--- + +## Task 4: Node/node-postgres behavior program (full contract) + +**Files:** Modify `test/pg-compat/drivers/node/behaviors.js` (+lockfile), extend `tests/test_behaviors_node.py`. + +**Key driver facts to encode:** `pg` 8.x; placeholders `$1,$2`; **named prepared statements** via `client.query({name: 'add', text: 'SELECT $1::int + $2::int AS sum', values: [i, 1]})` — reusing the same `name` for the 50× loop makes node-pg Parse once and Bind/Execute repeatedly (its distinct prepared-statement strategy). Connection config from env (`host`, `port`, user/pass/db `testuser`, `ssl: false`); pin encoding via connection string param `client_encoding=UTF8` (or `options`). Transactions via explicit `BEGIN`/`COMMIT`/`ROLLBACK` queries; verify-reads inside their own BEGIN/COMMIT (`AS verify_read`, table `behavior_tx_t_node`). Session isolation: A sets/asserts TZ, `await a.end()` BEFORE `new Client()` B. Exit codes per CLI; async main with try/finally cleanup. + +- [ ] **Step 1:** Extend BEHAVIORS → RED. +- [ ] **Step 2:** Implement (mirror Python behaviors). +- [ ] **Step 3:** Rebuild + run → 4 passed ×2. Findings → xfail catalogue + report. +- [ ] **Step 4:** Full suite green (± catalogued). Commit: `test(pg-compat): Node/node-postgres behavior program (full contract)`. + +--- + +## Task 5: Prisma behavior program (ORM tier — xfail-tolerant) + +Prisma is the notorious pooler-breaker (aggressive prepared statements, its own connection assumptions) — that's exactly why it's in scope. It may legitimately fail through ProxySQL: failures here are FINDINGS for the catalogue, not blockers. + +**Files:** Create `test/pg-compat/drivers/prisma/{behaviors.mjs,schema.prisma,package.json,package-lock.json}`, `tests/test_behaviors_prisma.py`; modify the Dockerfile (extend the node stage: `npx prisma generate` at build time against `schema.prisma`; `binaryTargets = ["debian-openssl-3.0.x"]`). + +**Key facts:** datasource url from `env("PGCOMPAT_PRISMA_URL")` — construct it in the wrapper test/conftest from the PGCOMPAT proxy vars (`postgresql://testuser:testuser@$HOST:$PORT/testuser?sslmode=disable`). Behaviors via `$queryRaw`/`$executeRaw` + `$transaction` (interactive transactions for the txn-wrapped verify reads): `connect` = `SELECT 1`; `transactions` = table `behavior_tx_t_prisma` with $transaction rollback/commit semantics (rollback = throw inside the interactive txn); `prepared` = 50× `$queryRaw\`SELECT ${i}::int + ${1}::int\`` (Prisma always uses prepared statements — the whole point); `session_isolation` = two PrismaClient instances, `SET TimeZone` via `$executeRawUnsafe`, disconnect A before creating B. Note Prisma pools internally (connection_limit=1 in the URL keeps it deterministic-ish; document). + +- [ ] **Step 1:** Dockerfile prisma-generate stage + stub `connect` → wrapper with `BEHAVIORS=["connect"]` → green. +- [ ] **Step 2:** Implement all four; extend BEHAVIORS → run. **Expected outcome is uncertain by design** — record per-behavior results honestly; catalogue genuine ProxySQL-vs-Prisma incompatibilities as `[[xfail]]` entries with precise reasons (these are the deliverable). +- [ ] **Step 3:** Full suite: passes + catalogued xfails only. Run ×2. Commit: `test(pg-compat): Prisma behavior program (ORM tier, findings catalogued)`. + +--- + +## Task 6: Docs, spec sync, CI budget check + +**Files:** Modify `test/pg-compat/README.md`, `docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md` (§6), possibly `.github/workflows/gh-actions-reusable/ci-pg-compat.yml` (timeout only). + +- [ ] **Step 1:** README driver-matrix table: language | driver+version | placeholder syntax | prepared-statement strategy (psycopg auto-prepare@5 / pgx statement-cache / pgjdbc prepareThreshold@5 named / node-pg named / Prisma always) | wrapper nodeid prefix. Plus how to run one language (`run-pg-compat.bash tests/test_behaviors_go.py`). +- [ ] **Step 2:** Spec §6: replace the SP-3 stub with the as-built scope (behaviors-only, subprocess orchestration, drivers list + versions) and add an **SP-3b** stub (per-language differential runners emitting normalized results for Python's compare — deferred pending nightly stability). +- [ ] **Step 3:** Measure the image-build delta (time the docker build before/after SP-3 stages) and the full-suite wall time; bump the reusable's `timeout-minutes` ONLY if evidence demands (report the numbers either way). +- [ ] **Step 4:** Full suite final run ×2 → record the final pass/skip/xfail tally. Commit: `docs(pg-compat): SP-3 driver matrix docs + spec sync (+ CI budget evidence)`. + +--- + +## Self-Review + +**Spec coverage:** SP-3 roadmap items → Java/pgjdbc (Task 3), Go/pgx (Task 2), Node node-postgres + Prisma (Tasks 4–5) — all against the SP-2 `behaviors/` contract (Task 1 seam). Scope deviation from the spec stub's "behaviors + differential cases" is user-approved (2026-07-08, behaviors-only) and gets written back into the spec in Task 6 with an SP-3b stub. CI fan-out from the roadmap ("one matrix job per language") deliberately simplified to the single fat-image job — same coverage, no matrix complexity; revisit at promote-to-gating. + +**Placeholder scan:** Tasks 2–5 say "mirror the Python behaviors" instead of embedding ~150 lines × 4 languages — this is deliberate, not a placeholder: the Python behavior modules ARE the frozen executable spec (Global Constraints), each task names the exact driver-specific deltas (placeholders, prepared-statement mechanism, txn API, encoding pin), and implementers must read the Python files first. The Dockerfile, helper, and wrapper code are complete. + +**Type consistency:** CLI contract, binary paths (`/pg-compat/bin/behaviors-*`), `run_behavior(program, behavior)`, `BEHAVIORS` list pattern, per-language table names, and nodeid shapes are used identically across Tasks 1–6. + +**Risks:** (1) node binary COPY missing shared libs — Task 1 names the fallback; (2) pgjdbc URL encoding-pin syntax — Task 3 mandates empirical verification with a stated fallback; (3) Prisma engine/binaryTarget in slim image — Task 5 pins `debian-openssl-3.0.x`; (4) image size/build time — Task 6 measures and decides the CI budget on evidence. diff --git a/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md index 8dbd422ed8..cb6a75366c 100644 --- a/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md +++ b/docs/superpowers/specs/2026-07-08-pgsql-protocol-testing-design.md @@ -205,7 +205,7 @@ Adapted from pgcat. `harness/oracle.py`: - **New workflow** (e.g. `.github/workflows/CI-pg-compat.yml` caller on `v3.0`, reusable on `GH-Actions` per the two-branch split in `doc/GH-Actions/README.md`). - **Triggers:** nightly `schedule` + `pull_request` gated on the `pg-compat` label. -- **Shape:** build proxysql (debug, `PROXYSQL31=1`) once → cache → job spins up `infra-dbdeployer-pgsql17-repl` (+ Toxiproxy) via the standard `test/infra/control/` runners → runs `pytest test/pg-compat` across both backend modes (§2.2). SP-3 will fan out per-language matrix jobs from the same cached binary. +- **Shape:** build proxysql (debug, `PROXYSQL31=1`) once → cache → job spins up `infra-dbdeployer-pgsql17-repl` (+ Toxiproxy) via the standard `test/infra/control/` runners → runs `pytest test/pg-compat` across both backend modes (§2.2). SP-3 (as built — see §6) runs all languages from one multi-language runner image in the same job; a per-language matrix fan-out remains an option at promote-to-gating. - **Not gating** on normal PRs (heavy, multi-toolchain) — and, per §2.1, **reporting-oriented** in the discovery phase: the job publishes the failure inventory / xfail summary rather than going red on expected divergences. Nightly failures triaged per `CLAUDE.md`'s "never dismiss as flaky" policy. --- @@ -220,9 +220,45 @@ Adapted from pgcat. `harness/oracle.py`: --- -## 6. Roadmap — SP-3 and SP-4 (not in this spec) - -- **SP-3 — Driver matrix expansion.** Add adapters under `test/pg-compat/drivers/`: **Java** (pgjdbc, +HikariCP), **Go** (pgx native), **Node.js** (node-postgres, postgres.js, Prisma). Each runs the existing `behaviors/` set + differential cases. CI fans out one matrix job per language from the cached binary. Prisma/pgjdbc are the highest-value targets (aggressive server-side prepared statements historically break poolers). +## 6. Roadmap — SP-3, SP-3b and SP-4 + +- **SP-3 — Driver matrix expansion (AS BUILT, complete 2026-07-08).** Ran the + existing SP-2 `behaviors/` contract (`connect`, `transactions`, `prepared`, + `session_isolation` — frozen, unchanged) through four more driver stacks: + **Go** (pgx v5.7.5), **Java** (pgjdbc 42.7.4), **Node.js** (pg/node-postgres + 8.13.1), and **Node.js/Prisma** (5.22.0, raw-query API only). Each ships one + self-contained CLI program (` `, exit 0/1/2) built into the + pg-compat image by a multi-stage `Dockerfile` extension, invoked from pytest + via subprocess wrappers (`tests/test_behaviors_.py` + + `tests/_subproc.py::run_behavior`) so the existing xfail catalogue, junit + report, and CI wiring apply unchanged. + - **Scope decision (user-approved 2026-07-08): behaviors only.** The + differential engine (§4.4) stays Python/psycopg-only — its comparison + unit is psycopg's row/type decode semantics, which the other languages + don't share — so it was NOT extended to the new drivers in SP-3. See + SP-3b below for that follow-up. + - CI fans out via a **single fat multi-language image**, not a one-job- + per-language matrix as originally sketched below: same coverage (all + five drivers run every CI invocation), no matrix-job complexity. Revisit + the split if/when this suite is promoted to gating. + - **Result: all five driver stacks pass the full behavior contract with + zero `xfail.toml` entries added** — four distinct prepared-statement + strategies (psycopg auto-prepare@5, pgx's default statement-cache, + pgjdbc's server-side NAMED statements after `prepareThreshold=5` — the + classic connection-pooler breaker — and Prisma's always-prepared Rust + engine) all stay transparent through ProxySQL's connection multiplexing. + See `test/pg-compat/README.md`'s "Driver matrix (SP-3)" section for the + full per-language table (versions, placeholder syntax, encoding-pin + mechanism) and the Prisma raw-vs-ORM caveat. +- **SP-3b — Per-language differential runners (stub, deferred).** Extend each + non-Python driver's behavior program with a differential-case runner that + executes the same case files as §4.4 and emits a normalized result + (status, column names, OIDs/type tags, decoded row values) on stdout for + Python's `compare()` to consume — so the differential engine's comparisons + gain Go/Java/Node/Prisma coverage without reimplementing the comparator + once per language. Deferred pending nightly stability of the SP-3 + behaviors-only suite (see `ci-pg-compat.yml`'s non-gating `|| true`); not + scheduled against a specific SP number yet. - **SP-4 — Chaos & resilience suite.** Build on SP-2's Toxiproxy layer: failover/shunning (1-byte `limit_data` slow-loris), latency toxics, reset-peer, health-check detection and auto-recovery — with **bounded-error-rate assertions** (pgdog/pgcat style: "≤N errors of M", "reroute within T"), exercising the automatic `pgsql_replication_hostgroups` monitor path from §4.1. --- diff --git a/test/pg-compat/.dockerignore b/test/pg-compat/.dockerignore new file mode 100644 index 0000000000..81d48988e7 --- /dev/null +++ b/test/pg-compat/.dockerignore @@ -0,0 +1,7 @@ +# Keep dependency trees and generated Prisma artifacts out of the build +# context: every driver's node_modules (and Prisma's generated client) are +# produced INSIDE their multi-stage build steps, so shipping a host-built +# copy in via `COPY . .` would bloat the image and risk a wrong-platform +# query-engine binary. Report output is host-only too. +**/node_modules +pg-compat-reports diff --git a/test/pg-compat/Dockerfile b/test/pg-compat/Dockerfile index 5441775525..9840519b4d 100644 --- a/test/pg-compat/Dockerfile +++ b/test/pg-compat/Dockerfile @@ -1,7 +1,76 @@ +# ---- Go builder: static behavior binary (no runtime needed in final) ---- +FROM golang:1.23-bookworm AS gobuild +WORKDIR /src +COPY drivers/go/ . +RUN CGO_ENABLED=0 go build -o /out/behaviors-go . + +# ---- Java builder: compile against a pinned pgjdbc jar ---- +FROM eclipse-temurin:21-jdk AS javabuild +WORKDIR /src +# Pin the driver version explicitly; record bumps in README's driver table. +ARG PGJDBC_VERSION=42.7.4 +RUN curl -fsSLo /pgjdbc.jar "https://repo1.maven.org/maven2/org/postgresql/postgresql/${PGJDBC_VERSION}/postgresql-${PGJDBC_VERSION}.jar" +COPY drivers/java/Behaviors.java . +RUN javac --release 17 -cp /pgjdbc.jar Behaviors.java -d /out + +# ---- Node deps: install node-postgres against the lockfile ---- +FROM node:22-bookworm-slim AS nodebuild +WORKDIR /app +COPY drivers/node/package.json drivers/node/package-lock.json* ./ +# The `npm install` fallback must never be reached in normal operation +# (package-lock.json is committed, so `npm ci` succeeds); it exists only +# for first-bootstrap before a lockfile exists. +RUN npm ci --omit=dev || npm install --omit=dev +COPY drivers/node/behaviors.js . + +# ---- Prisma deps + client generation (ORM tier, SP3-Task 5) ---- +# npm ci pulls prisma (CLI, a devDependency) AND @prisma/client, then +# `prisma generate` produces the client + downloads the query-engine binary +# for binaryTargets=["debian-openssl-3.0.x"] (matching the bookworm/OpenSSL-3 +# final image). This stage is bookworm/OpenSSL-3 too, so the engine it +# fetches is the exact one the final stage runs. The engine download needs +# network egress -> the build runs with --network=host (see +# run-pg-compat.bash). PGCOMPAT_PRISMA_URL only has to EXIST for `generate` +# (it does not connect); behaviors.mjs overwrites it at runtime. +FROM node:22-bookworm-slim AS prismabuild +WORKDIR /app +COPY drivers/prisma/package.json drivers/prisma/package-lock.json* ./ +# devDependencies (the prisma CLI) are REQUIRED here for `prisma generate`, +# so this is a full install, not --omit=dev. The `npm install` fallback must +# never be reached in normal operation (package-lock.json is committed). +RUN npm ci || npm install +COPY drivers/prisma/schema.prisma drivers/prisma/behaviors.mjs ./ +ENV PGCOMPAT_PRISMA_URL="postgresql://build:build@localhost:5432/build?sslmode=disable" +RUN npx prisma generate + +# ---- Final: python base + JRE + node runtime + artifacts ---- FROM python:3.11-slim -RUN apt-get update && apt-get install -y --no-install-recommends libpq5 curl && rm -rf /var/lib/apt/lists/* +# openssl/libssl3: the Prisma query engine (debian-openssl-3.0.x) links +# against libssl.so.3 / libcrypto.so.3 at load time. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 curl default-jre-headless openssl \ + && rm -rf /var/lib/apt/lists/* +# Node runtime copied from the official image (bookworm-glibc compatible). +COPY --from=nodebuild /usr/local/bin/node /usr/local/bin/node WORKDIR /pg-compat COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . +# Language artifacts under /pg-compat/bin with uniform CLI wrappers. +COPY --from=gobuild /out/behaviors-go /pg-compat/bin/behaviors-go +COPY --from=javabuild /out/ /pg-compat/bin/java-classes/ +COPY --from=javabuild /pgjdbc.jar /pg-compat/bin/pgjdbc.jar +COPY --from=nodebuild /app /pg-compat/node-app +# Prisma app: node_modules (with the generated @prisma/client + .prisma +# client + query-engine binary), schema.prisma, behaviors.mjs. +# Known size trade-off: this copies the whole prismabuild dev tree, +# including the prisma CLI devDependency and its non-query engines +# (schema/format engines), not just what behaviors.mjs needs at runtime -- +# simple and correct over minimal. Task 6's image-size measurement accounts +# for it; prune here if the numbers demand it. +COPY --from=prismabuild /app /pg-compat/prisma-app +RUN printf '#!/bin/sh\nexec java -cp /pg-compat/bin/java-classes:/pg-compat/bin/pgjdbc.jar Behaviors "$@"\n' > /pg-compat/bin/behaviors-java \ + && printf '#!/bin/sh\nexec node /pg-compat/node-app/behaviors.js "$@"\n' > /pg-compat/bin/behaviors-node \ + && printf '#!/bin/sh\nexec node /pg-compat/prisma-app/behaviors.mjs "$@"\n' > /pg-compat/bin/behaviors-prisma \ + && chmod +x /pg-compat/bin/behaviors-* ENTRYPOINT ["pytest", "-q"] diff --git a/test/pg-compat/README.md b/test/pg-compat/README.md index f2a0b7aaf2..861b243436 100644 --- a/test/pg-compat/README.md +++ b/test/pg-compat/README.md @@ -49,6 +49,100 @@ WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash \ # report lands at: ${WORKSPACE}/pg-compat-reports/pg-compat.xml ``` +## Driver matrix (SP-3) + +Beyond the reference Python/psycopg3 harness, the suite runs the same +4-behavior contract (`connect`, `transactions`, `prepared`, +`session_isolation` — see `behaviors/*.py`, the FROZEN cross-driver +contract) through four more real-world driver stacks, each its own +self-contained CLI program compiled/installed into the pg-compat image by +the multi-stage `Dockerfile` (`drivers//`). All five stacks pass the +full behavior contract through ProxySQL with **zero `xfail.toml` entries +added** — every pass below is a genuine pass, not a catalogued divergence. + +| Language | Driver | Version | Placeholders | Prepared-statement strategy | Encoding pin | +|---|---|---|---|---|---| +| Python | psycopg3 | 3.2.* | `%s` (client-side) | auto-prepare after `prepare_threshold=5` (driver default) | DSN `client_encoding=UTF8` | +| Go | pgx | v5.7.5 | `$1, $2` | default `QueryExecMode=cache_statement` — Parse once per distinct SQL text (extended protocol), then Bind/Execute-only on every subsequent call via the server-side statement cache | DSN param `client_encoding=UTF8` | +| Java | pgjdbc | 42.7.4 | `?` | server-side NAMED statement after `prepareThreshold=5` (driver default); one `PreparedStatement` object reused for all 50 iterations | `options=-c client_encoding=UTF8` connection property | +| Node | pg (node-postgres) | 8.13.1 | `$1, $2` | UNCONDITIONAL named statements — `Parse` sent once at iteration 0 via `{name, text, values}`, every later call is `Bind`/`Execute` only | `client_encoding` config key | +| Node | Prisma | 5.22.0 | tagged-template (`$queryRaw`) | always-prepared — the Rust query engine has no simple-query mode; every `$queryRaw`/`$executeRawUnsafe` call is a real Parse/Bind/Execute; `connection_limit=1` pins the client to one backend connection | URL param `client_encoding` is accepted but IGNORED by the Rust engine (verified: `LATIN1` in the URL still yields UTF8) — the factory issues an explicit `SET client_encoding TO 'UTF8'` instead | + +**Headline finding:** four distinct prepared-statement strategies — including +pgjdbc's server-side NAMED statements (the classic connection-pooler +breaker: `prepared statement "S_1" does not exist`) and Prisma's +always-prepared Rust engine — all stay transparent through ProxySQL's +connection multiplexing. + +**Prisma caveat:** the Prisma behavior program (`drivers/prisma/behaviors.mjs`) +exercises only the **raw-query API** (`$queryRaw`/`$executeRawUnsafe`/ +`$transaction`), not Prisma's model/ORM query path (`prisma.model.findMany()` +etc.) — there are no real models in `schema.prisma` (a single unused dummy +model exists only to satisfy `prisma generate`). The ORM query path is a +possible future extension, not covered here. + +### Running one language + +Extra arguments to `run-pg-compat.bash` are forwarded to `pytest`, so a +single language's wrapper file (or `-k`) selects just that driver, e.g.: + +```bash +WORKSPACE=$(pwd) INFRA_ID= test/pg-compat/run-pg-compat.bash tests/test_behaviors_go.py -v +``` + +Per-language wrapper files: `tests/test_behaviors_go.py`, +`tests/test_behaviors_java.py`, `tests/test_behaviors_node.py`, +`tests/test_behaviors_prisma.py` (Python's own behaviors run via +`tests/test_behaviors.py`, in-process rather than as a subprocess). + +### Behavior-CLI contract + +Every language ships ONE compiled/installed binary at +`/pg-compat/bin/behaviors-` implementing the same CLI: + +``` +behaviors- # ∈ {connect, transactions, prepared, session_isolation} +``` + +- **exit 0** — behavior passed. +- **exit 1** — behavior assertion failed; a human-readable reason on stderr. +- **exit 2** — usage or infra error (unknown behavior name, not-yet-implemented + behavior, missing/invalid env); never a behavior-contract failure. +- No stdout output is required on pass. + +`tests/_subproc.py::run_behavior(program, behavior)` runs +`[program, behavior]`, translates exit 0/1/2 into pytest pass/fail, and +`pytest.skip`s if the binary is absent from the image (so a partial image +still runs the languages it does have). + +### Adding a language + +1. Implement the 4 behaviors (`connect`, `transactions`, `prepared`, + `session_isolation`) against `behaviors/*.py` as the frozen reference — + same assertions, same trap adaptations (session-isolation probe is + `SET TimeZone = 'Antarctica/Troll'` / `SHOW TimeZone`, **never** + `application_name` — it's in ProxySQL's `ignore_vars`; every transaction + verification read runs inside its own `BEGIN`/`COMMIT` so it pins to the + writer instead of racing replica lag; every connection pins + `client_encoding=UTF8`; placeholders are driver-native, not psycopg's `%s`). +2. Expose them behind the uniform CLI contract above, in its own + `drivers//` directory. Use a per-language table name for the + transactions behavior (`behavior_tx_t_`) so runs never collide + with another language's. +3. Add a build stage to `Dockerfile` that produces + `/pg-compat/bin/behaviors-` (a compiled binary, or a thin shell + wrapper invoking an interpreter — see the Java/Node stages for both + patterns) and pin the exact driver version in both the Dockerfile + (`ARG`/lockfile) and this table. +4. Add `tests/test_behaviors_.py` — a thin subprocess wrapper using + `tests/_subproc.py::run_behavior` and + `@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS)` so + nodeids stay stable (`tests/test_behaviors_.py::test_behavior_[]`) + for the `xfail.toml` exact-nodeid catalogue. +5. A behavior that genuinely fails through ProxySQL is a FINDING, not a bug + to hide — add an `[[xfail]]` entry (or a `[[finding]]` if nothing fails + but a divergence was neutralized), never weaken the assertion. + ## CI The suite is wired into CI as `CI-pg-compat` (`.github/workflows/CI-pg-compat.yml` diff --git a/test/pg-compat/behaviors/connect.py b/test/pg-compat/behaviors/connect.py index 4fb36709ef..4109205af4 100644 --- a/test/pg-compat/behaviors/connect.py +++ b/test/pg-compat/behaviors/connect.py @@ -9,5 +9,10 @@ def run(Adapter): a = Adapter() try: assert a.exec_simple("SELECT 1")[0][0] == 1 + # Uniform with the four SP-3 driver ports (go/java/node/prisma): + # assert the client_encoding=UTF8 pin took effect -- see + # harness/targets.py's encoding rationale (backend DBs default to + # SQL_ASCII; ProxySQL imposes UTF8). + assert a.exec_simple("SHOW client_encoding")[0][0] == "UTF8" finally: a.close() diff --git a/test/pg-compat/conftest.py b/test/pg-compat/conftest.py index dbc56ee680..4e3701e800 100644 --- a/test/pg-compat/conftest.py +++ b/test/pg-compat/conftest.py @@ -1,4 +1,5 @@ import os +import warnings import psycopg import pytest @@ -48,11 +49,26 @@ def proxy_conn(): def pytest_collection_modifyitems(config, items): + matched_ids = set() for item in items: entry = _XFAILS.get(item.nodeid) if entry: + matched_ids.add(item.nodeid) item.add_marker( pytest.mark.xfail( reason=f'{entry["reason"]} ({entry["ref"]})', strict=False ) ) + + # Catalogue hygiene: a [[xfail]] entry whose test_id matched NO collected + # item is currently a silent no-op (e.g. a typo'd nodeid, or a test that + # was renamed/removed without updating xfail.toml). Warn -- don't fail + # collection -- so a stale/typo'd entry is visible in the run instead of + # quietly doing nothing forever. + for test_id in _XFAILS: + if test_id not in matched_ids: + warnings.warn( + f"xfail.toml entry test_id={test_id!r} matched no collected " + f"test item -- stale or typo'd entry?", + stacklevel=1, + ) diff --git a/test/pg-compat/drivers/go/behaviors.go b/test/pg-compat/drivers/go/behaviors.go new file mode 100644 index 0000000000..c92b2a9598 --- /dev/null +++ b/test/pg-compat/drivers/go/behaviors.go @@ -0,0 +1,321 @@ +// behaviors-go: Go/pgx behavior CLI stub. +// +// CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, +// Global Constraints): `behaviors-go ` where is one of +// connect, transactions, prepared, session_isolation. +// +// exit 0 -> behavior passed +// exit 1 -> behavior assertion failed (reason on stderr) +// exit 2 -> usage/infra error (unknown behavior name, not-yet-implemented +// behavior, missing/invalid env, etc.) +// +// No stdout output is required on pass. +// +// This is the SP3-Task-1 scaffold: only `connect` is implemented end to end +// (open -> SELECT 1 -> assert first col == 1 -> assert client_encoding is +// UTF8 -> close). The other three behaviors are stubbed to exit 2 with +// "not implemented: " on stderr so Tasks 2-4 can fill in the function +// bodies below without restructuring dispatch(). +// +// Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default +// "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is +// "testuser"/"testuser"/"testuser"; sslmode disabled; client_encoding +// pinned to UTF8 (backend DBs default to SQL_ASCII; ProxySQL imposes +// UTF8 -- see xfail.toml finding referenced in the plan). +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/jackc/pgx/v5" +) + +// errNotImplemented is the sentinel distinguishing "behavior not yet wired +// up" (exit 2, infra/usage error) from a genuine assertion failure +// (exit 1). Stub bodies wrap it with %w; dispatch() checks errors.Is, so +// Task 2 replaces a stub body with a real implementation returning +// ordinary errors and gets exit-1 semantics automatically -- a pure +// body-fill, no dispatch changes. +var errNotImplemented = errors.New("not implemented") + +func dsn() string { + host := os.Getenv("PGCOMPAT_PROXY_HOST") + if host == "" { + host = "proxysql" + } + port := os.Getenv("PGCOMPAT_PROXY_PORT") + if port == "" { + port = "6133" + } + return fmt.Sprintf( + "postgres://testuser:testuser@%s:%s/testuser?sslmode=disable&client_encoding=UTF8", + host, port, + ) +} + +// connect: a fresh connection can run a trivial query. The simplest +// possible contract -- if this fails, nothing else is meaningful for this +// driver/target. Mirrors behaviors/connect.py, plus an explicit assertion +// that the DSN's client_encoding=UTF8 pin actually took effect (the pin is +// a recorded SP-2 finding; asserting it here keeps any encoding-pin +// regression visible in every run). +func connect() error { + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + var one int + if err := conn.QueryRow(ctx, "SELECT 1").Scan(&one); err != nil { + return fmt.Errorf("SELECT 1: %w", err) + } + if one != 1 { + return fmt.Errorf("SELECT 1 returned %d, want 1", one) + } + + var enc string + if err := conn.QueryRow(ctx, "SHOW client_encoding").Scan(&enc); err != nil { + return fmt.Errorf("SHOW client_encoding: %w", err) + } + if enc != "UTF8" { + return fmt.Errorf("client_encoding is %q, want \"UTF8\" (DSN pin did not take effect)", enc) + } + return nil +} + +// txTable is per-language (parallel-safe with the other drivers' behavior +// programs, which each use their own behavior_tx_t_ table; see the +// plan's Global Constraints). +const txTable = "behavior_tx_t_go" + +// transactions: BEGIN/COMMIT/ROLLBACK are honored end-to-end through +// ProxySQL. Mirrors behaviors/transactions.py exactly, including its +// RW-split trap fix: every verification read runs inside its own explicit +// BEGIN/COMMIT (via conn.Begin(ctx)/tx.Commit(ctx)) so it is pinned to the +// same (writer) backend connection as the preceding INSERT/COMMIT, instead +// of racing replication lag on a bare SELECT routed to a reader hostgroup. +// The verify-read carries the same "AS verify_read" alias as the Python +// behavior for pg_stat_statements traceability. +func transactions() error { + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + // Cleanup runs on success AND on failure (defer), leaving no state + // behind, same as the Python behavior's try/finally. Parity with + // Java/Node/Prisma: best-effort ROLLBACK first (error ignored) restores + // a usable session state before the DROP -- if a non-assertion error + // above left the connection mid-transaction, an aborted implicit + // transaction would otherwise reject the DROP. + defer func() { + conn.Exec(ctx, "ROLLBACK") + conn.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", txTable)) + }() + + if _, err := conn.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", txTable)); err != nil { + return fmt.Errorf("DROP TABLE IF EXISTS: %w", err) + } + if _, err := conn.Exec(ctx, fmt.Sprintf("CREATE TABLE %s (id int)", txTable)); err != nil { + return fmt.Errorf("CREATE TABLE: %w", err) + } + + tx1, err := conn.Begin(ctx) + if err != nil { + return fmt.Errorf("BEGIN (insert 1): %w", err) + } + if _, err := tx1.Exec(ctx, fmt.Sprintf("INSERT INTO %s VALUES (1)", txTable)); err != nil { + return fmt.Errorf("INSERT (1): %w", err) + } + if err := tx1.Rollback(ctx); err != nil { + return fmt.Errorf("ROLLBACK: %w", err) + } + + count, err := verifyCount(ctx, conn) + if err != nil { + return err + } + if count != 0 { + return fmt.Errorf("rollback did not discard the insert: count=%d, want 0", count) + } + + tx2, err := conn.Begin(ctx) + if err != nil { + return fmt.Errorf("BEGIN (insert 2): %w", err) + } + if _, err := tx2.Exec(ctx, fmt.Sprintf("INSERT INTO %s VALUES (2)", txTable)); err != nil { + return fmt.Errorf("INSERT (2): %w", err) + } + if err := tx2.Commit(ctx); err != nil { + return fmt.Errorf("COMMIT (insert 2): %w", err) + } + + count, err = verifyCount(ctx, conn) + if err != nil { + return err + } + if count != 1 { + return fmt.Errorf("commit did not persist the insert: count=%d, want 1", count) + } + + return nil +} + +// verifyCount runs the RW-split-safe verification read described in the +// transactions() comment above: its own explicit BEGIN...COMMIT wrapping a +// single "SELECT count(*) AS verify_read" against txTable. +func verifyCount(ctx context.Context, conn *pgx.Conn) (int, error) { + vtx, err := conn.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("BEGIN (verify): %w", err) + } + var count int + if err := vtx.QueryRow(ctx, fmt.Sprintf("SELECT count(*) AS verify_read FROM %s", txTable)).Scan(&count); err != nil { + return 0, fmt.Errorf("verify SELECT: %w", err) + } + if err := vtx.Commit(ctx); err != nil { + return 0, fmt.Errorf("COMMIT (verify): %w", err) + } + return count, nil +} + +// prepared: a parameterized statement, reused many times, keeps working +// across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. +// +// Exec mode in play: pgx v5's default QueryExecMode is +// QueryExecModeCacheStatement ("cache_statement") -- pgx.Connect does not +// override it here, so this is the mode used. Under cache_statement, pgx +// consults its per-connection statement cache (keyed by SQL text) FIRST: +// only a cache miss -- the first occurrence of a given SQL text -- sends an +// extended-protocol Parse (via Prepare); every subsequent call with the +// same SQL text goes through execPrepared, i.e. Bind/Execute only against +// the already-parsed server-side statement (pgx v5.7.5 conn.go, the +// QueryExecModeCacheStatement branches; also its QueryExecMode doc comment: +// "Queries are executed in a single round trip after the statement is +// cached"). In the 50x loop below that means iteration 0 Parses once and +// iterations 1-49 are Bind/Execute-only reuse of one server-side prepared +// statement -- so every iteration exercises real extended-protocol prepared +// statements multiplexed by ProxySQL, with no warm-up threshold to cross +// (unlike psycopg3's prepare_threshold -- see prepared.py's docstring); +// the loop's job is to prove that cached server-side statement keeps +// resolving correctly across many round trips through the proxy. +// Placeholders are pgx-native ($1, $2), unlike Python's psycopg %s -- see +// the plan's Global Constraints on driver-native placeholder syntax. +func prepared() error { + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + for i := 0; i < 50; i++ { + var sum int + if err := conn.QueryRow(ctx, "SELECT $1::int + $2::int", i, 1).Scan(&sum); err != nil { + return fmt.Errorf("iteration %d: %w", i, err) + } + if sum != i+1 { + return fmt.Errorf("iteration %d: got %d, want %d", i, sum, i+1) + } + } + return nil +} + +// distinctiveTZ is the session-isolation probe value. NEVER application_name +// -- ProxySQL lists it in ignore_vars, so it can never reflect a client SET +// through the proxy (see session_isolation.py's docstring). TimeZone is a +// tracked/forwarded/reset variable, so it is a valid probe. +const distinctiveTZ = "Antarctica/Troll" + +// sessionIsolation: session state set on one connection must not leak to a +// different connection. Mirrors behaviors/session_isolation.py exactly, +// including closing connection A before opening B (see the module's +// docstring for why: it makes it possible, not guaranteed, for B to reuse +// A's just-freed backend connection, which is what makes this a real test +// of ProxySQL resetting/not-inheriting session state on reuse). +func sessionIsolation() error { + ctx := context.Background() + a, err := pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect A: %w", err) + } + var b *pgx.Conn + defer func() { + // Idempotent-safe backstop, matching the Python finally: closing A + // again after the deliberate early close below is a safe no-op. + if a != nil { + a.Close(ctx) + } + if b != nil { + b.Close(ctx) + } + }() + + if _, err := a.Exec(ctx, fmt.Sprintf("SET TimeZone = '%s'", distinctiveTZ)); err != nil { + return fmt.Errorf("SET TimeZone (A): %w", err) + } + var tzA string + if err := a.QueryRow(ctx, "SHOW TimeZone").Scan(&tzA); err != nil { + return fmt.Errorf("SHOW TimeZone (A): %w", err) + } + if tzA != distinctiveTZ { + return fmt.Errorf("SHOW TimeZone (A) = %q, want %q", tzA, distinctiveTZ) + } + // Close A before B opens (deliberate -- see the doc comment above). + if err := a.Close(ctx); err != nil { + return fmt.Errorf("close A: %w", err) + } + + b, err = pgx.Connect(ctx, dsn()) + if err != nil { + return fmt.Errorf("connect B: %w", err) + } + var tzB string + if err := b.QueryRow(ctx, "SHOW TimeZone").Scan(&tzB); err != nil { + return fmt.Errorf("SHOW TimeZone (B): %w", err) + } + if tzB == distinctiveTZ { + return fmt.Errorf("session state leaked across connections: B's TimeZone is %q", tzB) + } + return nil +} + +func dispatch(behavior string) int { + var fn func() error + switch behavior { + case "connect": + fn = connect + case "transactions": + fn = transactions + case "prepared": + fn = prepared + case "session_isolation": + fn = sessionIsolation + default: + fmt.Fprintf(os.Stderr, "unknown behavior: %q\n", behavior) + return 2 + } + if err := fn(); err != nil { + fmt.Fprintln(os.Stderr, err) + if errors.Is(err, errNotImplemented) { + return 2 + } + return 1 + } + return 0 +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: behaviors-go ") + os.Exit(2) + } + os.Exit(dispatch(os.Args[1])) +} diff --git a/test/pg-compat/drivers/go/go.mod b/test/pg-compat/drivers/go/go.mod new file mode 100644 index 0000000000..9a995e90d6 --- /dev/null +++ b/test/pg-compat/drivers/go/go.mod @@ -0,0 +1,12 @@ +module proxysql-pg-compat/behaviors-go + +go 1.23.0 + +require github.com/jackc/pgx/v5 v5.7.5 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/test/pg-compat/drivers/go/go.sum b/test/pg-compat/drivers/go/go.sum new file mode 100644 index 0000000000..85a678b1fa --- /dev/null +++ b/test/pg-compat/drivers/go/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/pg-compat/drivers/java/Behaviors.java b/test/pg-compat/drivers/java/Behaviors.java new file mode 100644 index 0000000000..9d71a6ae4c --- /dev/null +++ b/test/pg-compat/drivers/java/Behaviors.java @@ -0,0 +1,350 @@ +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.Properties; + +/** + * behaviors-java: Java/pgjdbc behavior CLI stub. + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, + * Global Constraints): {@code behaviors-java } where + * {@code } is one of connect, transactions, prepared, + * session_isolation. + *
    + *
  • exit 0 -> behavior passed
  • + *
  • exit 1 -> behavior assertion failed (reason on stderr)
  • + *
  • exit 2 -> usage/infra error (unknown behavior name, + * not-yet-implemented behavior, missing/invalid env, etc.)
  • + *
+ * No stdout output is required on pass. + * + * This is the SP3-Task-1 scaffold: only {@code connect} is implemented end + * to end (open -> SELECT 1 -> assert first col == 1 -> assert + * client_encoding is UTF8 -> close). The + * other three behaviors are stubbed to exit 2 with "not implemented: + * <name>" on stderr so Task 3 can fill in the method bodies below + * without restructuring {@code dispatch()}. + * + * Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default + * "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is + * "testuser"/"testuser"/"testuser"; sslmode disabled; client_encoding + * pinned to UTF8. + * + * Encoding-pin finding (verified empirically for SP3-Task-1, see report): + * pgjdbc 42.7.4 accepts the {@code options} connection property set to + * {@code -c client_encoding=UTF8} and forwards it as a libpq-style startup + * option -- confirmed by running {@code SHOW client_encoding} through this + * exact stub against the ProxySQL PG frontend, which returned {@code UTF8}. + * No post-connect {@code SET client_encoding} statement is needed. + */ +public class Behaviors { + + private static String proxyHost() { + String h = System.getenv("PGCOMPAT_PROXY_HOST"); + return (h == null || h.isEmpty()) ? "proxysql" : h; + } + + private static String proxyPort() { + String p = System.getenv("PGCOMPAT_PROXY_PORT"); + return (p == null || p.isEmpty()) ? "6133" : p; + } + + private static Connection openConnection() throws Exception { + String url = "jdbc:postgresql://" + proxyHost() + ":" + proxyPort() + "/testuser"; + Properties props = new Properties(); + props.setProperty("user", "testuser"); + props.setProperty("password", "testuser"); + props.setProperty("sslmode", "disable"); + // Encoding pin: pgjdbc does not accept client_encoding as a direct + // connection property, but does forward `options` as libpq-style + // startup options -- this is the form ProxySQL/postgres accepts. + props.setProperty("options", "-c client_encoding=UTF8"); + return DriverManager.getConnection(url, props); + } + + // connect: a fresh connection can run a trivial query. The simplest + // possible contract -- if this fails, nothing else is meaningful for + // this driver/target. Mirrors behaviors/connect.py, plus an explicit + // assertion that the options=-c client_encoding=UTF8 pin actually took + // effect (recorded SP-2 finding; asserting it here keeps any + // encoding-pin regression visible in every run). + private static void connect() throws Exception { + try (Connection conn = openConnection(); + Statement st = conn.createStatement()) { + try (ResultSet rs = st.executeQuery("SELECT 1")) { + if (!rs.next()) { + throw new AssertionError("SELECT 1 returned no rows"); + } + int one = rs.getInt(1); + if (one != 1) { + throw new AssertionError("SELECT 1 returned " + one + ", want 1"); + } + } + try (ResultSet rs = st.executeQuery("SHOW client_encoding")) { + if (!rs.next()) { + throw new AssertionError("SHOW client_encoding returned no rows"); + } + String enc = rs.getString(1); + if (!"UTF8".equals(enc)) { + throw new AssertionError("client_encoding is \"" + enc + + "\", want \"UTF8\" (options pin did not take effect)"); + } + } + } + } + + // txTable is per-language (parallel-safe with the other drivers' behavior + // programs, which each use their own behavior_tx_t_ table -- see + // the plan's Global Constraints). + private static final String TX_TABLE = "behavior_tx_t_java"; + + // transactions: BEGIN/COMMIT/ROLLBACK are honored end-to-end through + // ProxySQL. Mirrors behaviors/transactions.py exactly, including its + // RW-split trap fix: every verification read runs inside its own + // explicit setAutoCommit(false).../commit() pair (see verifyCount()) + // so it is pinned to the same (writer) backend connection as the + // preceding INSERT/COMMIT, instead of racing replication lag on a bare + // SELECT routed to a reader hostgroup. The verify-read carries the same + // "AS verify_read" alias as the Python/Go behaviors for + // pg_stat_statements traceability. + private static void transactions() throws Exception { + try (Connection conn = openConnection()) { + try { + try (Statement st = conn.createStatement()) { + st.execute("DROP TABLE IF EXISTS " + TX_TABLE); + st.execute("CREATE TABLE " + TX_TABLE + " (id int)"); + } + + conn.setAutoCommit(false); + try (Statement st = conn.createStatement()) { + st.execute("INSERT INTO " + TX_TABLE + " VALUES (1)"); + } + conn.rollback(); + conn.setAutoCommit(true); + + int count0 = verifyCount(conn); + if (count0 != 0) { + throw new AssertionError("rollback did not discard the insert"); + } + + conn.setAutoCommit(false); + try (Statement st = conn.createStatement()) { + st.execute("INSERT INTO " + TX_TABLE + " VALUES (2)"); + } + conn.commit(); + conn.setAutoCommit(true); + + int count1 = verifyCount(conn); + if (count1 != 1) { + throw new AssertionError("commit did not persist the insert"); + } + } finally { + // Leave no state behind whether or not the assertions above + // passed (mirrors the Python/Go finally-equivalent cleanup), + // using a table name distinct from other languages/behaviors + // so runs never collide. If an exception above left + // autocommit off mid-transaction, restore it (rollback + + // setAutoCommit(true)) before the DROP so the connection is + // usable; a cleanup failure here is caught and only printed + // -- it must never mask the real error propagating out of + // this try block. + cleanupTable(conn, TX_TABLE); + } + } + } + + // verifyCount runs the RW-split-safe verification read described in the + // transactions() comment above: its own explicit + // setAutoCommit(false)/commit() pair wrapping a single + // "SELECT count(*) AS verify_read" against TX_TABLE. + private static int verifyCount(Connection conn) throws Exception { + conn.setAutoCommit(false); + int count; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery( + "SELECT count(*) AS verify_read FROM " + TX_TABLE)) { + if (!rs.next()) { + throw new AssertionError("verify_read returned no rows"); + } + count = rs.getInt(1); + } + conn.commit(); + conn.setAutoCommit(true); + return count; + } + + // cleanupTable restores the connection to a usable autocommit state (in + // case an exception left a transaction open) and drops the table. It + // never throws -- any failure here is printed to stderr and swallowed + // so it cannot mask a real assertion/exception already propagating out + // of the caller's try block. + private static void cleanupTable(Connection conn, String table) { + try { + if (!conn.getAutoCommit()) { + try { + conn.rollback(); + } catch (Exception ignore) { + // best effort; setAutoCommit below still runs + } + conn.setAutoCommit(true); + } + try (Statement st = conn.createStatement()) { + st.execute("DROP TABLE IF EXISTS " + table); + } + } catch (Exception e) { + System.err.println("cleanup failed (suppressed, not the real error): " + e); + } + } + + // prepared: a parameterized statement, reused many times, keeps working + // across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. + // + // pgjdbc-specific mechanism (the value of this port): pgjdbc starts every + // PreparedStatement as a client-side-substituted "simple" query and only + // promotes it to a real server-side NAMED statement (extended-protocol + // Parse-once/Bind+Execute-many) once the SAME PreparedStatement object has + // been executed more than `prepareThreshold` times (default 5; see + // org.postgresql.jdbc.PgConnection / PGProperty.PREPARE_THRESHOLD). We + // therefore prepare ONCE outside the loop and reuse that single + // PreparedStatement object for all 50 executions -- re-preparing per + // iteration would reset the threshold counter and the back half of the + // loop would never leave simple-query mode. Past iteration 5, this test + // is genuinely exercising a real named prepared statement multiplexed by + // ProxySQL across its backend connection pool -- the classic + // connection-pooler trap ("prepared statement \"S_1\" does not exist") + // that this port exists to probe. + private static void prepared() throws Exception { + try (Connection conn = openConnection(); + PreparedStatement ps = conn.prepareStatement("SELECT ?::int + ?::int AS sum")) { + for (int i = 0; i < 50; i++) { + ps.setInt(1, i); + ps.setInt(2, 1); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new AssertionError("iteration " + i + ": no rows returned"); + } + int sum = rs.getInt(1); + if (sum != i + 1) { + throw new AssertionError( + "iteration " + i + ": got " + sum + ", want " + (i + 1)); + } + } + } + } + } + + // distinctiveTz is the session-isolation probe value. NEVER + // application_name -- ProxySQL lists it in ignore_vars, so it can never + // reflect a client SET through the proxy (see session_isolation.py's + // docstring). TimeZone is a tracked/forwarded/reset variable, so it is a + // valid probe. + private static final String DISTINCTIVE_TZ = "Antarctica/Troll"; + + // sessionIsolation: session state set on one connection must not leak to + // a different connection. Mirrors behaviors/session_isolation.py + // exactly, including closing connection A before opening B (see the + // Python module's docstring for why: it makes it possible, not + // guaranteed, for B to reuse A's just-freed backend connection, which is + // what makes this a real test of ProxySQL resetting/not-inheriting + // session state on reuse). + private static void sessionIsolation() throws Exception { + Connection a = null; + Connection b = null; + try { + a = openConnection(); + try (Statement st = a.createStatement()) { + st.execute("SET TimeZone = '" + DISTINCTIVE_TZ + "'"); + } + try (Statement st = a.createStatement(); + ResultSet rs = st.executeQuery("SHOW TimeZone")) { + if (!rs.next()) { + throw new AssertionError("SHOW TimeZone (A) returned no rows"); + } + String tzA = rs.getString(1); + if (!DISTINCTIVE_TZ.equals(tzA)) { + throw new AssertionError( + "SHOW TimeZone (A) = \"" + tzA + "\", want \"" + DISTINCTIVE_TZ + "\""); + } + } + // Close A before B opens (deliberate -- see the doc comment + // above). The finally below closes A again as a + // resource-hygiene backstop on an assert failure above; + // closeQuietly() is idempotent-safe so that repeat call is a + // safe no-op. + a.close(); + + b = openConnection(); + try (Statement st = b.createStatement(); + ResultSet rs = st.executeQuery("SHOW TimeZone")) { + if (!rs.next()) { + throw new AssertionError("SHOW TimeZone (B) returned no rows"); + } + String tzB = rs.getString(1); + if (DISTINCTIVE_TZ.equals(tzB)) { + throw new AssertionError( + "session state leaked across connections: B's TimeZone is \"" + tzB + "\""); + } + } + } finally { + closeQuietly(a); + closeQuietly(b); + } + } + + // closeQuietly is the idempotent-safe backstop referenced above: closing + // an already-closed (or never-opened) connection is a safe no-op, same + // as the Python adapter's close(). + private static void closeQuietly(Connection c) { + if (c == null) { + return; + } + try { + if (!c.isClosed()) { + c.close(); + } + } catch (Exception ignore) { + // best-effort cleanup only + } + } + + private static int dispatch(String behavior) { + try { + switch (behavior) { + case "connect": + connect(); + return 0; + case "transactions": + transactions(); + return 0; + case "prepared": + prepared(); + return 0; + case "session_isolation": + sessionIsolation(); + return 0; + default: + System.err.println("unknown behavior: " + behavior); + return 2; + } + } catch (UnsupportedOperationException e) { + System.err.println(e.getMessage()); + return 2; + } catch (AssertionError e) { + System.err.println(e.getMessage()); + return 1; + } catch (Exception e) { + System.err.println(e.toString()); + return 1; + } + } + + public static void main(String[] args) { + if (args.length != 1) { + System.err.println("usage: behaviors-java "); + System.exit(2); + } + System.exit(dispatch(args[0])); + } +} diff --git a/test/pg-compat/drivers/node/behaviors.js b/test/pg-compat/drivers/node/behaviors.js new file mode 100644 index 0000000000..f23e5f3b16 --- /dev/null +++ b/test/pg-compat/drivers/node/behaviors.js @@ -0,0 +1,311 @@ +#!/usr/bin/env node +/** + * behaviors-node: node-postgres (pg) behavior CLI stub. + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, + * Global Constraints): `behaviors-node ` where is one + * of connect, transactions, prepared, session_isolation. + * exit 0 -> behavior passed + * exit 1 -> behavior assertion failed (reason on stderr) + * exit 2 -> usage/infra error (unknown behavior name, not-yet-implemented + * behavior, missing/invalid env, etc.) + * No stdout output is required on pass. + * + * This is the SP3-Task-1 scaffold: only `connect` is implemented end to + * end (open -> SELECT 1 -> assert first col == 1 -> assert client_encoding + * is UTF8 -> close). The other three behaviors throw NotImplementedError + * (exit 2) so Task 4 can fill in the function bodies below without + * restructuring dispatch(). + * + * Env contract (read, never invent): PGCOMPAT_PROXY_HOST (default + * "proxysql"), PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is + * "testuser"/"testuser"/"testuser"; ssl disabled; client_encoding pinned + * to UTF8. + * + * Encoding-pin finding (verified empirically for SP3-Task-1, see report): + * node-postgres's ConnectionParameters reads a `client_encoding` key + * straight off the config object (lib/connection-parameters.js) and, if + * set, sends it as a startup-packet parameter -- confirmed by inspecting + * pg@8.13.1's source AND by `SHOW client_encoding` returning `UTF8` + * through this exact stub against the ProxySQL PG frontend (the connect + * behavior below asserts this on every run). No post-connect + * `SET client_encoding` is needed for this driver. + */ +'use strict'; + +const { Client } = require('pg'); + +// Sentinel distinguishing "behavior not yet wired up" (exit 2, infra/usage +// error) from a genuine assertion failure (exit 1). Stub bodies throw it; +// dispatch()'s catch checks `instanceof`, so Task 4 replaces a stub body +// with a real implementation throwing ordinary Errors and gets exit-1 +// semantics automatically -- a pure body-fill, no dispatch changes. +class NotImplementedError extends Error {} + +function clientConfig() { + return { + host: process.env.PGCOMPAT_PROXY_HOST || 'proxysql', + port: parseInt(process.env.PGCOMPAT_PROXY_PORT || '6133', 10), + user: 'testuser', + password: 'testuser', + database: 'testuser', + ssl: false, + client_encoding: 'UTF8', + }; +} + +// connect: a fresh connection can run a trivial query. The simplest +// possible contract -- if this fails, nothing else is meaningful for this +// driver/target. Mirrors behaviors/connect.py, plus an explicit assertion +// that the client_encoding=UTF8 pin actually took effect (recorded SP-2 +// finding; asserting it here keeps any encoding-pin regression visible in +// every run). +async function connect() { + const client = new Client(clientConfig()); + await client.connect(); + try { + const res = await client.query('SELECT 1'); + const one = res.rows[0]['?column?'] !== undefined ? res.rows[0]['?column?'] : Object.values(res.rows[0])[0]; + if (one !== 1) { + throw new Error(`SELECT 1 returned ${one}, want 1`); + } + const encRes = await client.query('SHOW client_encoding'); + const enc = encRes.rows[0].client_encoding; + if (enc !== 'UTF8') { + throw new Error(`client_encoding is ${JSON.stringify(enc)}, want "UTF8" (config pin did not take effect)`); + } + } finally { + await client.end(); + } +} + +// txTable is per-language (parallel-safe with the other drivers' behavior +// programs, which each use their own behavior_tx_t_ table -- see the +// plan's Global Constraints). +const TX_TABLE = 'behavior_tx_t_node'; + +// transactions: BEGIN/COMMIT/ROLLBACK are honored end-to-end through +// ProxySQL. Mirrors behaviors/transactions.py exactly, including its +// RW-split trap fix: every verification read runs inside its own explicit +// BEGIN/COMMIT so it is pinned to the same (writer) backend connection as +// the preceding INSERT/COMMIT, instead of racing replication lag on a bare +// SELECT routed to a reader hostgroup. The verify-read carries the same +// "AS verify_read" alias as the other language ports for pg_stat_statements +// traceability. +// +// node-pg trap note: `count(*)` returns PostgreSQL's int8/bigint type, which +// node-pg deliberately returns as a STRING (not a JS number) by default -- +// JS numbers cannot losslessly represent the full int8 range, so pg's +// built-in type parser leaves int8 as text unless the app opts into a +// custom parser (pg-types). Comparing count to a number with `===` would +// therefore always be false even when the value is correct. This behavior +// compares against the string "0"/"1" deliberately, to reflect exactly what +// the driver hands back rather than silently coercing it away. +async function transactions() { + const client = new Client(clientConfig()); + await client.connect(); + try { + await client.query(`DROP TABLE IF EXISTS ${TX_TABLE}`); + await client.query(`CREATE TABLE ${TX_TABLE} (id int)`); + + await client.query('BEGIN'); + await client.query(`INSERT INTO ${TX_TABLE} VALUES (1)`); + await client.query('ROLLBACK'); + + let count = await verifyCount(client); + if (count !== '0') { + throw new Error(`rollback did not discard the insert: count=${count}, want "0"`); + } + + await client.query('BEGIN'); + await client.query(`INSERT INTO ${TX_TABLE} VALUES (2)`); + await client.query('COMMIT'); + + count = await verifyCount(client); + if (count !== '1') { + throw new Error(`commit did not persist the insert: count=${count}, want "1"`); + } + } finally { + // Leave no state behind whether or not the assertions above passed + // (mirrors the Python/Go/Java finally-equivalent cleanup), using a + // table name distinct from other languages/behaviors so runs never + // collide. If an exception above left the connection mid-transaction, + // best-effort ROLLBACK first (catching/ignoring its own error) so the + // DROP below is not itself rejected by an aborted transaction; a + // cleanup failure is caught and only printed -- it must never mask the + // original error propagating out of this try block. + try { + await client.query('ROLLBACK'); + } catch (e) { + // no open/aborted transaction to roll back -- expected on the happy + // path, ignored. + } + try { + await client.query(`DROP TABLE IF EXISTS ${TX_TABLE}`); + } catch (e) { + process.stderr.write(`cleanup failed (suppressed, not the real error): ${e}\n`); + } + await client.end(); + } +} + +// verifyCount runs the RW-split-safe verification read described in the +// transactions() comment above: its own explicit BEGIN/COMMIT wrapping a +// single "SELECT count(*) AS verify_read" against TX_TABLE. Returns the raw +// string node-pg hands back for int8 (see transactions()'s docstring). +async function verifyCount(client) { + await client.query('BEGIN'); + const res = await client.query(`SELECT count(*) AS verify_read FROM ${TX_TABLE}`); + await client.query('COMMIT'); + return res.rows[0].verify_read; +} + +// prepared: a parameterized statement, reused many times, keeps working +// across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. +// +// node-pg-specific mechanism (the value of this port): node-pg's distinct +// strategy is explicit NAMED prepared statements -- passing a `name` on the +// query config object makes node-pg send an extended-protocol Parse message +// with that statement name ONLY the first time that name is used on this +// connection; every subsequent query() call with the same `name` skips +// Parse and sends Bind+Execute only, reusing the already-parsed statement +// server-side (see pg/lib/client.js's query() -- it tracks previously +// parsed statement names per connection). Unlike psycopg3 (prepared.py, +// auto-prepares after a threshold) or pgjdbc (Behaviors.java, promotes +// after prepareThreshold executions), node-pg's named-statement reuse is +// unconditional and explicit from the very first call: every one of the 50 +// iterations below -- not just a "back half" past some warm-up count -- +// exercises a real extended-protocol Parse-once/Bind+Execute-many sequence +// multiplexed by ProxySQL, which is exactly the connection-pooler trap +// ("prepared statement ... does not exist") this port exists to probe. +// Placeholders are node-pg-native ($1, $2), same wire syntax as pgx -- +// unlike Python's psycopg %s (see prepared.py's docstring). +async function prepared() { + const client = new Client(clientConfig()); + await client.connect(); + try { + for (let i = 0; i < 50; i++) { + const res = await client.query({ + name: 'pgcompat_add', + text: 'SELECT $1::int + $2::int AS sum', + values: [i, 1], + }); + // node-pg parses int4 (the ::int cast's result type) as a JS number + // already -- unlike int8/count(*) above, no manual coercion is + // needed here. Verified: typeof res.rows[0].sum === 'number'. + const sum = res.rows[0].sum; + if (typeof sum !== 'number') { + throw new Error(`iteration ${i}: sum came back as ${typeof sum} (${JSON.stringify(sum)}), want a JS number`); + } + if (sum !== i + 1) { + throw new Error(`iteration ${i}: got ${sum}, want ${i + 1}`); + } + } + } finally { + await client.end(); + } +} + +// DISTINCTIVE_TZ is the session-isolation probe value. NEVER +// application_name -- ProxySQL lists it in ignore_vars, so it can never +// reflect a client SET through the proxy (see session_isolation.py's +// docstring). TimeZone is a tracked/forwarded/reset variable, so it is a +// valid probe. +const DISTINCTIVE_TZ = 'Antarctica/Troll'; + +// sessionIsolation: session state set on one connection must not leak to a +// different connection. Mirrors behaviors/session_isolation.py exactly, +// including closing connection A before opening B (see the Python module's +// docstring for why: it makes it possible, not guaranteed, for B to reuse +// A's just-freed backend connection, which is what makes this a real test +// of ProxySQL resetting/not-inheriting session state on reuse). Never +// application_name -- see DISTINCTIVE_TZ's comment above. +async function sessionIsolation() { + const a = new Client(clientConfig()); + let b = null; + let aEnded = false; + let bEnded = false; + await a.connect(); + try { + await a.query(`SET TimeZone = '${DISTINCTIVE_TZ}'`); + const tzARes = await a.query('SHOW TimeZone'); + const tzA = tzARes.rows[0].TimeZone; + if (tzA !== DISTINCTIVE_TZ) { + throw new Error(`SHOW TimeZone (A) = ${JSON.stringify(tzA)}, want ${JSON.stringify(DISTINCTIVE_TZ)}`); + } + // Close A before B opens (deliberate -- see the doc comment above). + // The finally below ends A again as a resource-hygiene backstop on an + // assert failure above. end() is defensive, not strictly required -- + // it is a no-op on an already-ended client in pg@8.13.1 (verified via a + // mock server during Task 4 review); the aEnded flag is kept anyway so + // this does not depend on that no-op behavior continuing to hold across + // driver upgrades. + await a.end(); + aEnded = true; + + b = new Client(clientConfig()); + await b.connect(); + const tzBRes = await b.query('SHOW TimeZone'); + const tzB = tzBRes.rows[0].TimeZone; + if (tzB === DISTINCTIVE_TZ) { + throw new Error(`session state leaked across connections: B's TimeZone is ${JSON.stringify(tzB)}`); + } + await b.end(); + bEnded = true; + } finally { + if (!aEnded) { + try { + await a.end(); + } catch (e) { + // best-effort cleanup only + } + } + if (b !== null && !bEnded) { + try { + await b.end(); + } catch (e) { + // best-effort cleanup only + } + } + } +} + +const BEHAVIOR_FNS = { + connect, + transactions, + prepared, + session_isolation: sessionIsolation, +}; + +async function dispatch(behavior) { + // hasOwnProperty guard: a prototype-chain key ("constructor", "toString") + // must be an unknown behavior, not a callable. + const fn = Object.prototype.hasOwnProperty.call(BEHAVIOR_FNS, behavior) + ? BEHAVIOR_FNS[behavior] : undefined; + if (!fn) { + process.stderr.write(`unknown behavior: ${behavior}\n`); + return 2; + } + try { + await fn(); + } catch (err) { + if (err instanceof NotImplementedError) { + process.stderr.write(`${err.message}\n`); + return 2; + } + process.stderr.write(`${err && err.stack ? err.stack : err}\n`); + return 1; + } + return 0; +} + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 1) { + process.stderr.write('usage: behaviors-node \n'); + process.exit(2); + } + process.exit(await dispatch(args[0])); +} + +main(); diff --git a/test/pg-compat/drivers/node/package-lock.json b/test/pg-compat/drivers/node/package-lock.json new file mode 100644 index 0000000000..c0bc5b56df --- /dev/null +++ b/test/pg-compat/drivers/node/package-lock.json @@ -0,0 +1,149 @@ +{ + "name": "pg-compat-behaviors-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pg-compat-behaviors-node", + "version": "1.0.0", + "dependencies": { + "pg": "8.13.1" + } + }, + "node_modules/pg": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.6.tgz", + "integrity": "sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/test/pg-compat/drivers/node/package.json b/test/pg-compat/drivers/node/package.json new file mode 100644 index 0000000000..bc11a12efe --- /dev/null +++ b/test/pg-compat/drivers/node/package.json @@ -0,0 +1,10 @@ +{ + "name": "pg-compat-behaviors-node", + "private": true, + "version": "1.0.0", + "description": "node-postgres (pg) behavior CLI for the pg-compat driver matrix harness.", + "main": "behaviors.js", + "dependencies": { + "pg": "8.13.1" + } +} diff --git a/test/pg-compat/drivers/prisma/behaviors.mjs b/test/pg-compat/drivers/prisma/behaviors.mjs new file mode 100644 index 0000000000..91256a5c0a --- /dev/null +++ b/test/pg-compat/drivers/prisma/behaviors.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/** + * behaviors-prisma: Prisma ORM behavior CLI (SP3-Task 5, the ORM tier). + * + * CLI contract (see docs/superpowers/plans/2026-07-08-pgsql-sp3-driver-matrix.md, + * Global Constraints): `behaviors-prisma ` where is one + * of connect, transactions, prepared, session_isolation. + * exit 0 -> behavior passed + * exit 1 -> behavior assertion failed (reason on stderr) + * exit 2 -> usage error only (unknown behavior name / wrong arg count). + * All four behaviors are implemented, so there is no NotImplementedError / + * "not yet wired" exit-2 path -- exit 2 is reserved for CLI misuse. + * No stdout output is required on pass. + * + * Why Prisma is in scope (and why failures here are FINDINGS, not blockers): + * Prisma is the notorious connection-pooler breaker. Its query engine (a + * Rust binary) ALWAYS speaks the extended protocol with server-side prepared + * statements and makes its own connection-pooling assumptions, which is + * exactly the combination that trips proxies that multiplex client sessions + * across a smaller set of backend connections. A behavior that works direct + * but fails through ProxySQL is a catalogued [[xfail]], not a bug to hide. + * + * ---- Env / URL contract (deliberately unchanged from the other drivers) ---- + * The harness only ever sets PGCOMPAT_PROXY_HOST (default "proxysql") and + * PGCOMPAT_PROXY_PORT (default "6133"); user/pass/db is + * "testuser"/"testuser"/"testuser", ssl disabled. Prisma's datasource in + * schema.prisma reads url = env("PGCOMPAT_PRISMA_URL"), so this program + * CONSTRUCTS that URL from the two proxy vars and injects it into + * process.env BEFORE instantiating any PrismaClient. That keeps the env + * contract identical to the other language programs (no new required vars): + * the schema's env() simply reads what this program planted. + * + * connection_limit=1 is pinned in the URL on purpose (see session_isolation + * below): Prisma maintains an INTERNAL connection pool per PrismaClient, so + * without this pin a SET on one query and a SHOW on the next could land on + * two different pooled backend connections WITHIN THE SAME CLIENT -- a false + * "leak" that has nothing to do with ProxySQL. Pinning the client to exactly + * one connection makes every statement issued through a given PrismaClient + * hit the same connection, so the isolation probe measures cross-CLIENT + * (i.e. cross backend-connection) state, which is the contract under test. + */ +'use strict'; + +// Build PGCOMPAT_PRISMA_URL from the proxy env vars and plant it BEFORE the +// PrismaClient import is instantiated. (ESM import bindings are resolved +// first, but PrismaClient reads the datasource env only when a client is +// constructed, so setting it here -- top of the module body -- is in time.) +const HOST = process.env.PGCOMPAT_PROXY_HOST || 'proxysql'; +const PORT = process.env.PGCOMPAT_PROXY_PORT || '6133'; +process.env.PGCOMPAT_PRISMA_URL = + `postgresql://testuser:testuser@${HOST}:${PORT}/testuser` + + `?sslmode=disable&connection_limit=1`; + +import { PrismaClient } from '@prisma/client'; + +// TX_TABLE is per-language (parallel-safe with the other drivers' behavior +// programs, which each use their own behavior_tx_t_ table -- see the +// plan's Global Constraints). +const TX_TABLE = 'behavior_tx_t_prisma'; + +// DISTINCTIVE_TZ is the session-isolation probe value. NEVER +// application_name -- ProxySQL lists it in ignore_vars, so it can never +// reflect a client SET through the proxy (see session_isolation.py's +// docstring). TimeZone is a tracked/forwarded/reset variable, a valid probe. +const DISTINCTIVE_TZ = 'Antarctica/Troll'; + +// newClient: the shared client-factory every behavior uses. Besides +// constructing the PrismaClient it applies the client_encoding=UTF8 pin the +// Global Constraints mark MUST-reproduce for every port. +// +// Encoding-pin mechanism (empirical answer, Task 5 review; evidence in the +// SP3-Task 5 report): Prisma's PostgreSQL connector does NOT propagate a +// `client_encoding` URL param -- it is accepted but IGNORED. Probed direct +// against the SQL_ASCII primary (so ProxySQL's own UTF8-forcing could not +// confound the answer): with NO param `SHOW client_encoding` already +// returns UTF8 (the Rust query engine unconditionally sets UTF8 on its +// connections), and even `client_encoding=LATIN1` in the URL still yields +// UTF8 -- proof the param is discarded, while psycopg direct with no pin +// sees the true backend default SQL_ASCII. So the engine structurally +// guarantees UTF8 today; the explicit SET below is the pgjdbc-precedent +// fallback (URL param doesn't propagate -> SET right after construction), +// keeping this port's pin EXPLICIT like the other four instead of relying +// on an undocumented engine default. connection_limit=1 (module header) +// guarantees the SET lands on the same single connection every subsequent +// statement of this client uses. +async function newClient() { + const prisma = new PrismaClient(); + await prisma.$executeRawUnsafe("SET client_encoding TO 'UTF8'"); + return prisma; +} + +// firstVal: pull the single scalar out of a one-row/one-column raw result, +// independent of what Prisma named the column (SHOW returns "TimeZone", +// "client_encoding", etc.). Mirrors the other drivers' Object.values() dance. +function firstVal(rows) { + return Object.values(rows[0])[0]; +} + +// connect: a fresh client can run a trivial query, and the client_encoding +// pin ProxySQL imposes is visible. Mirrors behaviors/connect.py plus the +// UTF8 assertion the node port also carries (recorded SP-2 finding: the +// SQL_ASCII backend reports UTF8 through ProxySQL). +async function connect() { + const prisma = await newClient(); + try { + // int4 literal -> Prisma returns a JS number for `one`; coerce with + // Number() defensively and compare to 1. + const rows = await prisma.$queryRaw`SELECT 1 AS one`; + const one = Number(firstVal(rows)); + if (one !== 1) { + throw new Error(`SELECT 1 returned ${one}, want 1`); + } + const enc = firstVal(await prisma.$queryRawUnsafe('SHOW client_encoding')); + if (enc !== 'UTF8') { + throw new Error(`client_encoding is ${JSON.stringify(enc)}, want "UTF8" (ProxySQL pin did not take effect)`); + } + } finally { + await prisma.$disconnect(); + } +} + +// verifyCount runs the RW-split-safe verification read INSIDE its own +// $transaction (Prisma interactive transaction). Mirrors the other ports' +// "verify read inside its own BEGIN/COMMIT": BEGIN does not match ^SELECT so +// it takes the writer hostgroup, and ProxySQL pins the whole interactive +// transaction to that one backend connection -- so the count is read from +// the same node the INSERT/COMMIT hit, never a lagging replica. The +// count(*)::int cast is deliberate: count(*) is int8, which Prisma would +// return as a JS BigInt; casting to int4 makes Prisma hand back a plain JS +// number so the `=== 0` / `=== 1` comparisons below are apples-to-apples. +// The cast also preserves the distinctive `AS verify_read` alias used for +// pg_stat_statements traceability across all the language ports. +async function verifyCount(prisma) { + const rows = await prisma.$transaction(async (tx) => { + return tx.$queryRawUnsafe(`SELECT count(*)::int AS verify_read FROM ${TX_TABLE}`); + }); + return Number(rows[0].verify_read); +} + +// transactions: $transaction rollback/commit semantics honored end-to-end. +// Mirrors behaviors/transactions.py; the ORM adaptation of "rollback" is the +// documented one: in a Prisma INTERACTIVE transaction there is no explicit +// rollback() call -- THROWING out of the callback makes Prisma roll the +// transaction back. So the rollback leg wraps the INSERT in $transaction and +// deliberately throws "force-rollback", which we catch; the committing leg +// simply returns normally from the callback, so Prisma COMMITs. +async function transactions() { + const prisma = await newClient(); + try { + await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS ${TX_TABLE}`); + await prisma.$executeRawUnsafe(`CREATE TABLE ${TX_TABLE} (id int)`); + + // Rollback leg: throw inside the interactive txn -> Prisma rolls back. + let rolledBack = false; + try { + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`INSERT INTO ${TX_TABLE} VALUES (1)`); + throw new Error('force-rollback'); + }); + } catch (e) { + if (e && e.message === 'force-rollback') { + rolledBack = true; + } else { + throw e; // an UNEXPECTED error (e.g. proxy rejected the statement) + } + } + if (!rolledBack) { + throw new Error('interactive transaction did not throw as expected for the rollback leg'); + } + + let count = await verifyCount(prisma); + if (count !== 0) { + throw new Error(`rollback did not discard the insert: count=${count}, want 0`); + } + + // Commit leg: return normally -> Prisma commits. + await prisma.$transaction(async (tx) => { + await tx.$executeRawUnsafe(`INSERT INTO ${TX_TABLE} VALUES (2)`); + }); + + count = await verifyCount(prisma); + if (count !== 1) { + throw new Error(`commit did not persist the insert: count=${count}, want 1`); + } + } finally { + // Leave no state behind whether or not the assertions above passed + // (mirrors the other ports' finally-equivalent cleanup). A cleanup + // failure is caught and only printed -- it must never mask the original + // error propagating out of this try block. + try { + await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS ${TX_TABLE}`); + } catch (e) { + process.stderr.write(`cleanup failed (suppressed, not the real error): ${e}\n`); + } + await prisma.$disconnect(); + } +} + +// prepared: a parameterized statement, reused many times, keeps working +// across ProxySQL's connection multiplexing. Mirrors behaviors/prepared.py. +// +// Prisma's distinct (and most-hostile-to-poolers) mechanism: its Rust query +// engine ALWAYS uses the extended protocol with server-side prepared +// statements -- there is no "simple text substitution" mode and no +// threshold to cross (unlike psycopg3's auto-prepare@5 or pgjdbc's +// prepareThreshold). Every one of the 50 $queryRaw calls below therefore +// issues a real Parse/Bind/Execute the engine multiplexes over its single +// (connection_limit is 1 here) backend connection. The tagged-template +// interpolation ${i}/${1} becomes bound parameters $1/$2 -- NOT text +// splicing -- which is exactly the prepared-statement path this port exists +// to probe through the proxy. +async function prepared() { + const prisma = await newClient(); + try { + for (let i = 0; i < 50; i++) { + // ::int (int4) result -> Prisma returns a JS number for `sum` + // (int8/BigInt would only appear for an uncast count()/bigint column; + // verified empirically that int4 comes back as number). Number() makes + // the coercion explicit and tolerant if a build ever returns BigInt. + const rows = await prisma.$queryRaw`SELECT ${i}::int + ${1}::int AS sum`; + const sum = Number(rows[0].sum); + if (sum !== i + 1) { + throw new Error(`iteration ${i}: got ${sum}, want ${i + 1}`); + } + } + } finally { + await prisma.$disconnect(); + } +} + +// sessionIsolation: session state set on one CLIENT must not leak to a +// different CLIENT. Mirrors behaviors/session_isolation.py, including +// closing A before opening B (so B *can* reuse A's just-freed backend +// connection -- the reuse case ProxySQL must reset). Two PrismaClient +// instances, each pinned to connection_limit=1 (see the module header) so +// the SET and the SHOW on A are guaranteed to run on the SAME backend +// connection within A -- otherwise Prisma's internal pool could scatter them +// and produce a false negative unrelated to ProxySQL. +async function sessionIsolation() { + const a = await newClient(); + let b = null; + let aClosed = false; + try { + await a.$executeRawUnsafe(`SET TimeZone = '${DISTINCTIVE_TZ}'`); + const tzA = firstVal(await a.$queryRawUnsafe('SHOW TimeZone')); + if (tzA !== DISTINCTIVE_TZ) { + throw new Error(`SHOW TimeZone (A) = ${JSON.stringify(tzA)}, want ${JSON.stringify(DISTINCTIVE_TZ)}`); + } + // Disconnect A before B connects (deliberate -- see the doc comment). + await a.$disconnect(); + aClosed = true; + + b = await newClient(); + const tzB = firstVal(await b.$queryRawUnsafe('SHOW TimeZone')); + if (tzB === DISTINCTIVE_TZ) { + throw new Error(`session state leaked across connections: B's TimeZone is ${JSON.stringify(tzB)}`); + } + await b.$disconnect(); + b = null; + } finally { + if (!aClosed) { + try { await a.$disconnect(); } catch (e) { /* best-effort cleanup */ } + } + if (b !== null) { + try { await b.$disconnect(); } catch (e) { /* best-effort cleanup */ } + } + } +} + +const BEHAVIOR_FNS = { + connect, + transactions, + prepared, + session_isolation: sessionIsolation, +}; + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 1) { + process.stderr.write('usage: behaviors-prisma \n'); + process.exit(2); + } + const behavior = args[0]; + const fn = Object.prototype.hasOwnProperty.call(BEHAVIOR_FNS, behavior) + ? BEHAVIOR_FNS[behavior] : undefined; + if (!fn) { + process.stderr.write(`unknown behavior: ${behavior}\n`); + process.exit(2); + } + try { + await fn(); + } catch (err) { + // Any error out of a behavior body is a behavior assertion failure + // (exit 1) -- including a proxy rejecting a Prisma prepared statement, + // which is precisely the finding this program exists to surface. + process.stderr.write(`${err && err.stack ? err.stack : err}\n`); + process.exit(1); + } + process.exit(0); +} + +main(); diff --git a/test/pg-compat/drivers/prisma/package-lock.json b/test/pg-compat/drivers/prisma/package-lock.json new file mode 100644 index 0000000000..dec2ffe49c --- /dev/null +++ b/test/pg-compat/drivers/prisma/package-lock.json @@ -0,0 +1,120 @@ +{ + "name": "pg-compat-behaviors-prisma", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pg-compat-behaviors-prisma", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "5.22.0" + }, + "devDependencies": { + "prisma": "5.22.0" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + } + } +} diff --git a/test/pg-compat/drivers/prisma/package.json b/test/pg-compat/drivers/prisma/package.json new file mode 100644 index 0000000000..ad58580123 --- /dev/null +++ b/test/pg-compat/drivers/prisma/package.json @@ -0,0 +1,14 @@ +{ + "name": "pg-compat-behaviors-prisma", + "private": true, + "version": "1.0.0", + "description": "Prisma ORM behavior CLI for the pg-compat driver matrix harness (SP3-Task 5).", + "type": "module", + "main": "behaviors.mjs", + "dependencies": { + "@prisma/client": "5.22.0" + }, + "devDependencies": { + "prisma": "5.22.0" + } +} diff --git a/test/pg-compat/drivers/prisma/schema.prisma b/test/pg-compat/drivers/prisma/schema.prisma new file mode 100644 index 0000000000..88e6063168 --- /dev/null +++ b/test/pg-compat/drivers/prisma/schema.prisma @@ -0,0 +1,45 @@ +// Prisma schema for the pg-compat driver-matrix behavior program (SP3-Task 5). +// +// No REAL models are needed: every behavior uses Prisma's raw-query escape +// hatch ($queryRaw / $executeRawUnsafe / $transaction), so the generated +// client uses none of the model types. However, `prisma generate` (5.22.0) +// refuses to generate from a schema with zero models ("You don't have any +// models defined ... so nothing will be generated" -> non-zero exit), +// verified empirically on the runner toolchain. So we declare ONE dummy +// model `Unused`, @@map-ed to a table `pgcompat_prisma_unused` that is +// NEVER created and NEVER queried, purely to satisfy the generator. It has +// no runtime effect: behaviors.mjs never references `prisma.unused`. +// +// The datasource URL comes from PGCOMPAT_PRISMA_URL, which behaviors.mjs +// constructs at runtime from the PGCOMPAT_PROXY_HOST/PGCOMPAT_PROXY_PORT +// env contract and injects into process.env BEFORE instantiating +// PrismaClient (keeps the harness env contract unchanged -- no new required +// vars). At BUILD time `prisma generate` only needs the variable to exist +// (it does not connect), so the Dockerfile sets a throwaway value. + +datasource db { + provider = "postgresql" + url = env("PGCOMPAT_PRISMA_URL") +} + +generator client { + provider = "prisma-client-js" + // The runner final image is python:3.11-slim (Debian bookworm, OpenSSL 3), + // and the client is generated in node:22-bookworm-slim (also bookworm / + // OpenSSL 3). The query-engine binary target for that platform is + // debian-openssl-3.0.x. Verified empirically: the engine downloaded at + // build time for this target loads and runs under the python:3.11-slim + // final stage (see the SP3-Task 5 report). "native" would resolve to the + // same file when generating inside bookworm, but pinning the explicit + // target makes the build reproducible regardless of the generating host. + binaryTargets = ["debian-openssl-3.0.x"] +} + +// Dummy model to satisfy `prisma generate` (see the datasource comment +// above). Mapped to a table that is never created; behaviors use only raw +// queries and never touch this model. +model Unused { + id Int @id + + @@map("pgcompat_prisma_unused") +} diff --git a/test/pg-compat/tests/_subproc.py b/test/pg-compat/tests/_subproc.py new file mode 100644 index 0000000000..1f7b961b6c --- /dev/null +++ b/test/pg-compat/tests/_subproc.py @@ -0,0 +1,21 @@ +"""Run a per-language behavior program and translate its exit code into +pytest semantics. The CLI contract: ` ` -> exit 0 pass, +exit 1 assertion-failure (reason on stderr), exit 2 usage/infra error.""" +import os +import subprocess + +import pytest + +def run_behavior(program, behavior): + if not os.path.exists(program): + pytest.skip(f"{program} not present in this image") + r = subprocess.run( + [program, behavior], capture_output=True, text=True, timeout=120, + env=os.environ.copy(), + ) + if r.returncode == 0: + return + detail = f"{program} {behavior} -> exit {r.returncode}\nstderr:\n{r.stderr}\nstdout:\n{r.stdout}" + if r.returncode == 2: + pytest.fail(f"infra/usage error (not a behavior failure): {detail}") + pytest.fail(detail) diff --git a/test/pg-compat/tests/test_behaviors_go.py b/test/pg-compat/tests/test_behaviors_go.py new file mode 100644 index 0000000000..405be17968 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_go.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-go" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_go(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_behaviors_java.py b/test/pg-compat/tests/test_behaviors_java.py new file mode 100644 index 0000000000..1a84adcaa5 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_java.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-java" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_java(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_behaviors_node.py b/test/pg-compat/tests/test_behaviors_node.py new file mode 100644 index 0000000000..5c3a3cfd27 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_node.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-node" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_node(behavior): + run_behavior(PROGRAM, behavior) diff --git a/test/pg-compat/tests/test_behaviors_prisma.py b/test/pg-compat/tests/test_behaviors_prisma.py new file mode 100644 index 0000000000..200f0e7d38 --- /dev/null +++ b/test/pg-compat/tests/test_behaviors_prisma.py @@ -0,0 +1,9 @@ +import pytest +from tests._subproc import run_behavior + +PROGRAM = "/pg-compat/bin/behaviors-prisma" +BEHAVIORS = ["connect", "transactions", "prepared", "session_isolation"] + +@pytest.mark.parametrize("behavior", BEHAVIORS, ids=BEHAVIORS) +def test_behavior_prisma(behavior): + run_behavior(PROGRAM, behavior)