Skip to content

Commit e3c9e38

Browse files
authored
Add Docker-based client compatibility test framework (#264)
* Document what postgres tables have been stubbed out for clients. * Initial client compat tester. * Add golang pgx test. * Add golang test. * Move per-client tests to their own directories. * Ready queries in as table, generate view, export final dudckdb database. * Add jdbc; some justfile improvements. * Add rust, sqlalchemy * task coordination is working. * Add README.md. * Rename rust, move clients to separate directory. * Add CLAUDE.md * Refactor justfile. * More results recipes. * Better logging. * Add overview recipe. * Add line to let CodeQL know this non secure connection is safe.
1 parent f18f04b commit e3c9e38

32 files changed

+4178
-6
lines changed

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ A PostgreSQL wire protocol compatible server backed by DuckDB. Connect with any
3131
- [Two-Tier Query Processing](#two-tier-query-processing)
3232
- [Supported Features](#supported-features)
3333
- [Limitations](#limitations)
34+
- [SQL Client Compatibility](#sql-client-compatibility)
3435
- [Dependencies](#dependencies)
3536
- [License](#license)
3637

@@ -645,6 +646,104 @@ The following DuckDB features work transparently through the fallback mechanism:
645646
- **Limited System Catalog**: Some `pg_*` system tables are stubs (return empty)
646647
- **Type OID Mapping**: Incomplete (some types show as "unknown")
647648

649+
## SQL Client Compatibility
650+
651+
Duckgres implements a subset of PostgreSQL's system catalog to satisfy introspection queries from common SQL clients, ORMs, and BI tools. The tables below document current coverage.
652+
653+
### pg_catalog Views
654+
655+
| View | Status | Notes |
656+
|------|--------|-------|
657+
| `pg_class` | Implemented | `pg_class_full` wrapper adding `relforcerowsecurity`; DuckLake variant sources from `duckdb_tables()`/`duckdb_views()` |
658+
| `pg_namespace` | Implemented | Maps `main` → `public`; DuckLake variant derives from `duckdb_tables()`/`duckdb_views()` |
659+
| `pg_attribute` | Implemented | Maps DuckDB internal type OIDs to PG OIDs via `duckdb_columns()` JOIN; fixes `atttypmod` for NUMERIC |
660+
| `pg_type` | Implemented | Fixes NULLs + adds synthetic entries for missing OIDs (json, jsonb, bpchar, text, record, array types) |
661+
| `pg_database` | Implemented | Hardcoded: postgres, template0, template1, testdb |
662+
| `pg_stat_user_tables` | Implemented | Uses `reltuples` from pg_class; zeros for scan/tuple stats |
663+
| `pg_roles` | Stub (empty) | Single hardcoded `duckdb` superuser |
664+
| `pg_constraint` | Stub (empty) | |
665+
| `pg_enum` | Stub (empty) | |
666+
| `pg_collation` | Stub (empty) | |
667+
| `pg_policy` | Stub (empty) | |
668+
| `pg_inherits` | Stub (empty) | |
669+
| `pg_statistic_ext` | Stub (empty) | |
670+
| `pg_publication` | Stub (empty) | |
671+
| `pg_publication_rel` | Stub (empty) | |
672+
| `pg_publication_tables` | Stub (empty) | |
673+
| `pg_rules` | Stub (empty) | |
674+
| `pg_matviews` | Stub (empty) | |
675+
| `pg_partitioned_table` | Stub (empty) | |
676+
| `pg_stat_activity` | Stub (empty) | Intercepted at query time for live data |
677+
| `pg_statio_user_tables` | Stub (empty) | |
678+
| `pg_stat_statements` | Stub (empty) | |
679+
| `pg_indexes` | Stub (empty) | |
680+
| `pg_settings` | Missing | `current_setting()` macro handles `server_version` and `server_encoding` only |
681+
| `pg_proc` | Missing | DuckDB has native `pg_catalog.pg_proc` but no wrapper |
682+
| `pg_description` | Missing | Handled via `obj_description()`/`col_description()` macros returning NULL |
683+
| `pg_depend` | Missing | |
684+
| `pg_am` | Missing | |
685+
| `pg_attrdef` | Missing | |
686+
| `pg_tablespace` | Missing | |
687+
688+
### information_schema Views
689+
690+
| View | Status | Notes |
691+
|------|--------|-------|
692+
| `tables` | Implemented | Filters internal views, normalizes `main` → `public` |
693+
| `columns` | Implemented | DuckDB → PG type name normalization, optional metadata overlay |
694+
| `schemata` | Implemented | Adds synthetic entries for `pg_catalog`, `information_schema`, `pg_toast` |
695+
| `views` | Implemented | Filters internal views |
696+
| `key_column_usage` | Missing | Used by ORMs for relationship discovery |
697+
| `table_constraints` | Missing | Used by ORMs for relationship discovery |
698+
| `referential_constraints` | Missing | Used by ORMs for FK introspection |
699+
700+
### Functions & Macros
701+
702+
| Function | Status | Notes |
703+
|----------|--------|-------|
704+
| `format_type(oid, int)` | Implemented | Comprehensive OID → name mapping |
705+
| `pg_get_expr(text, oid)` | Implemented | Returns NULL |
706+
| `pg_get_indexdef(oid)` | Implemented | Returns empty string |
707+
| `pg_get_constraintdef(oid)` | Implemented | Returns empty string |
708+
| `pg_get_serial_sequence(text, text)` | Implemented | Returns NULL |
709+
| `pg_table_is_visible(oid)` | Implemented | Always true |
710+
| `pg_get_userbyid(oid)` | Implemented | Maps OID 10 → `postgres`, 6171 → `pg_database_owner` |
711+
| `obj_description(oid, text)` | Implemented | Returns NULL |
712+
| `col_description(oid, int)` | Implemented | Returns NULL |
713+
| `shobj_description(oid, text)` | Implemented | Returns NULL |
714+
| `has_table_privilege(text, text)` | Implemented | Always true |
715+
| `has_schema_privilege(text, text)` | Implemented | Always true |
716+
| `pg_encoding_to_char(int)` | Implemented | Always `UTF8` |
717+
| `version()` | Implemented | Returns PG 15.0 compatible string |
718+
| `current_setting(text)` | Implemented | Handles `server_version` and `server_encoding` |
719+
| `pg_is_in_recovery()` | Implemented | Always false |
720+
| `pg_backend_pid()` | Implemented | Returns 0 |
721+
| `pg_size_pretty(bigint)` | Implemented | Full human-readable formatting |
722+
| `pg_total_relation_size(oid)` | Implemented | Returns 0 |
723+
| `pg_relation_size(oid)` | Implemented | Returns 0 |
724+
| `pg_table_size(oid)` | Implemented | Returns 0 |
725+
| `pg_indexes_size(oid)` | Implemented | Returns 0 |
726+
| `pg_database_size(text)` | Implemented | Returns 0 |
727+
| `quote_ident(text)` | Implemented | |
728+
| `quote_literal(text)` | Implemented | |
729+
| `quote_nullable(text)` | Implemented | |
730+
| `txid_current()` | Implemented | Epoch-based pseudo ID |
731+
| `current_schema()` | Missing | |
732+
| `current_schemas(bool)` | Missing | |
733+
734+
### Startup Parameters
735+
736+
| Parameter | Value |
737+
|-----------|-------|
738+
| `server_version` | `15.0 (Duckgres)` |
739+
| `server_encoding` | `UTF8` |
740+
| `client_encoding` | `UTF8` |
741+
| `DateStyle` | `ISO, MDY` |
742+
| `TimeZone` | `UTC` |
743+
| `integer_datetimes` | `on` |
744+
| `standard_conforming_strings` | `on` |
745+
| `IntervalStyle` | Missing |
746+
648747
## Dependencies
649748

650749
- [DuckDB Go Driver](https://github.com/duckdb/duckdb-go) - DuckDB database engine

duckgres_local_ducklake.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
host: "0.0.0.0"
2-
port: 5433
2+
port: 5432
33

44
data_dir: "./data"
55

@@ -11,16 +11,16 @@ extensions:
1111

1212
ducklake:
1313
# PostgreSQL metadata store
14-
metadata_store: "postgres:host=db.posthog.orb.local port=5432 user=posthog password=posthog dbname=ducklake"
14+
metadata_store: "host=localhost port=5433 user=ducklake password=ducklake dbname=ducklake"
1515

1616
# MinIO object storage
17-
object_store: "s3://ducklake-dev/"
17+
object_store: "s3://ducklake/"
1818

1919
# S3/MinIO credentials
2020
s3_provider: "config"
21-
s3_endpoint: "objectstorage.posthog.orb.local:19000"
22-
s3_access_key: "object_storage_root_user"
23-
s3_secret_key: "object_storage_root_password"
21+
s3_endpoint: "localhost:9000"
22+
s3_access_key: "minioadmin"
23+
s3_secret_key: "minioadmin"
2424
s3_region: "us-east-1"
2525
s3_use_ssl: false
2626
s3_url_style: "path"

scripts/client-compat/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
results/

scripts/client-compat/CLAUDE.md

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# Claude Code Context for Client Compatibility Tests
2+
3+
## What This Is
4+
5+
Docker Compose-based test framework that runs real PostgreSQL client libraries against duckgres and collects results into a DuckDB database. Lives under `scripts/client-compat/`.
6+
7+
## Directory Layout
8+
9+
```
10+
scripts/client-compat/
11+
├── CLAUDE.md # you are here
12+
├── README.md # human-facing docs
13+
├── docker-compose.yml # all services
14+
├── justfile # task runner
15+
├── Dockerfile.results # results-gatherer image
16+
├── results_gatherer.py # HTTP server that collects results into DuckDB
17+
├── report_client.py # Python helper for reporting (shared by Python clients)
18+
├── entrypoint.sh # log-tee wrapper (shared by all clients)
19+
├── queries.yaml # shared query catalog executed by every client
20+
├── clients/ # one directory per client
21+
│ ├── psycopg/
22+
│ ├── pgx/
23+
│ ├── psql/
24+
│ ├── jdbc/
25+
│ ├── tokio-postgres/
26+
│ ├── node-postgres/
27+
│ ├── sqlalchemy/
28+
│ └── harlequin/
29+
└── results/ # output volume (.gitignored)
30+
```
31+
32+
## Lifecycle / How It Works
33+
34+
1. `just all``docker compose up -d` starts duckgres, results-gatherer, and all clients
35+
2. Clients block on healthchecks (`duckgres` TCP + `results-gatherer` HTTP `/health`)
36+
3. Each client runs tests, POSTing `{client, suite, test_name, status, detail}` to `POST /result`
37+
4. The justfile runs `docker wait` on all client containers (handles crashes gracefully)
38+
5. Once all clients exit, the justfile `docker exec`s a `POST /shutdown` to the gatherer
39+
6. Gatherer prints report, exports `results_<ts>.duckdb` and `.json`, exits 0
40+
7. `docker compose down -v` tears everything down
41+
42+
**Key design point**: the gatherer does NOT track which clients are done. The justfile handles all lifecycle coordination via `docker wait`. This avoids hangs when clients crash before reporting.
43+
44+
## Reporting Protocol
45+
46+
Clients report results via HTTP to `$RESULTS_URL` (defaults to `http://results-gatherer:8080`):
47+
48+
```
49+
POST /result
50+
{
51+
"client": "psycopg", # must match the client name
52+
"suite": "catalog_views", # test grouping
53+
"test_name": "pg_database", # unique within suite
54+
"status": "pass", # "pass" or "fail"
55+
"detail": "4 rows" # optional context (row count, error message)
56+
}
57+
```
58+
59+
All reporting is fire-and-forget (errors silently swallowed) so clients also work standalone without the gatherer.
60+
61+
### Python clients
62+
63+
Use `report_client.py` (copied into the image):
64+
65+
```python
66+
from report_client import ReportClient
67+
68+
rc = ReportClient("my_client")
69+
rc.report("suite_name", "test_name", "pass", "detail")
70+
```
71+
72+
### Non-Python clients
73+
74+
Implement HTTP POST directly. See `clients/pgx/main.go` (Go), `clients/jdbc/.../JdbcCompatTest.java` (Java), `clients/tokio-postgres/src/main.rs` (Rust), or `clients/psql/test_psql.sh` (curl) for examples.
75+
76+
## Adding a New Client
77+
78+
Name the directory after the **client library**, not the language (e.g. `tokio-postgres` not `rust`, `pgx` not `go`).
79+
80+
### 1. Create the client directory
81+
82+
```
83+
clients/<name>/
84+
├── Dockerfile
85+
└── <test script>
86+
```
87+
88+
### 2. Write the Dockerfile
89+
90+
Build context is `scripts/client-compat/` (not the client dir). COPY paths must be relative to that:
91+
92+
```dockerfile
93+
FROM python:3.12-slim-bookworm
94+
95+
RUN pip install --no-cache-dir <deps> pyyaml
96+
97+
COPY entrypoint.sh /entrypoint.sh
98+
COPY queries.yaml /queries.yaml
99+
COPY report_client.py /report_client.py # Python clients only
100+
COPY clients/<name>/test_<name>.py /test_<name>.py
101+
102+
ENTRYPOINT ["/entrypoint.sh", "python", "/test_<name>.py"]
103+
```
104+
105+
For compiled languages, use a multi-stage build (see `clients/pgx/Dockerfile` or `clients/tokio-postgres/Dockerfile`).
106+
107+
### 3. Implement the test script
108+
109+
Every client must:
110+
111+
1. **Wait for duckgres** — poll with `SELECT 1`, retry up to 30 times with 1s sleep
112+
2. **Run shared queries** — load `/queries.yaml`, execute each entry, report pass/fail
113+
3. **Run client-specific tests** — whatever the library supports (DDL/DML, prepared statements, COPY, ORM, etc.)
114+
4. **Connect with TLS** — use `sslmode=require` and accept self-signed certs
115+
5. **Exit non-zero on failure** — so `docker wait` captures it
116+
117+
The shared queries YAML format:
118+
```yaml
119+
- suite: catalog_views
120+
name: pg_database
121+
sql: SELECT datname FROM pg_database WHERE datallowconn
122+
```
123+
124+
### 4. Register in docker-compose.yml
125+
126+
Add a service block (copy an existing one and change names/paths):
127+
128+
```yaml
129+
<name>:
130+
build:
131+
context: .
132+
dockerfile: clients/<name>/Dockerfile
133+
container_name: compat-<name>
134+
depends_on:
135+
duckgres:
136+
condition: service_healthy
137+
results-gatherer:
138+
condition: service_healthy
139+
volumes:
140+
- ./results:/results
141+
environment:
142+
PGHOST: duckgres
143+
PGPORT: "5432"
144+
PGUSER: postgres
145+
PGPASSWORD: postgres
146+
RESULTS_URL: http://results-gatherer:8080
147+
LOG_DIR: /results/<name>
148+
```
149+
150+
### 5. Register in justfile
151+
152+
Two changes:
153+
154+
1. Add the container name to the `clients` variable:
155+
```
156+
clients := "compat-psycopg compat-pgx ... compat-<name>"
157+
```
158+
159+
2. Add a standalone target:
160+
```
161+
# Run <name> compatibility tests (standalone, no report)
162+
[group('client')]
163+
<name>: build
164+
docker compose run --rm <name>
165+
docker compose down -v
166+
```
167+
168+
### 6. Update README.md
169+
170+
Add the client to the tables in the Clients, Client-Specific Suites, and available targets sections.
171+
172+
## Common Patterns
173+
174+
### Connection parameters
175+
176+
Always use env vars: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`. Defaults should match docker-compose values (`duckgres`, `5432`, `postgres`, `postgres`).
177+
178+
### TLS
179+
180+
Duckgres auto-generates a self-signed cert. Clients must accept it:
181+
- Python (psycopg2): `sslmode="require"` (no cert validation by default)
182+
- Go (pgx): `InsecureSkipVerify: true` in tls.Config
183+
- Java (JDBC): `sslmode=require&sslfactory=org.postgresql.ssl.NonValidatingFactory`
184+
- Rust (tokio-postgres): `danger_accept_invalid_certs(true)` on TlsConnector
185+
186+
### Suites
187+
188+
Group related tests under a suite name. Common conventions:
189+
- `connection` — TLS, auth, server version, driver info
190+
- `ddl_dml` / `core_ddl_dml` — CREATE, INSERT, UPDATE, DELETE, DROP
191+
- Library-specific features get their own suite (`batch`, `copy`, `orm`, `prepared`, etc.)
192+
193+
## Results Database
194+
195+
Exported to `results/results_<timestamp>.duckdb` with:
196+
197+
- `queries` table — the shared query catalog from `queries.yaml`
198+
- `results` table — all test outcomes (client, suite, test_name, status, detail, ts)
199+
- `coverage` view — LEFT JOIN of queries to results (shows gaps)
200+
201+
Open with `just query-last-run` or `duckdb results/results_*.duckdb`.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM python:3.12-slim-bookworm
2+
3+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
4+
5+
COPY entrypoint.sh /entrypoint.sh
6+
COPY results_gatherer.py /results_gatherer.py
7+
COPY queries.yaml /queries.yaml
8+
9+
EXPOSE 8080
10+
11+
ENTRYPOINT ["/entrypoint.sh", "uv", "run", "--with", "duckdb", "--with", "pyyaml", "python", "/results_gatherer.py"]

0 commit comments

Comments
 (0)