dolt-compat.integration.test.ts218 lines · main
| 1 | /** |
| 2 | * DoltGres compatibility alarm — the "smoke alarm" (sprint plan S0.2). |
| 3 | * |
| 4 | * Runs against a REAL DoltGres instance and pins exactly which SQL the data |
| 5 | * plane may and may not use. This is the regression net that stops the |
| 6 | * "little bugs keep coming back" cycle: every incompatibility is asserted |
| 7 | * here, so it fails in a test instead of in production / in ISY's face. |
| 8 | * |
| 9 | * Two halves: |
| 10 | * 1. ACCEPTED today — must keep working (a regression = real breakage). |
| 11 | * 2. REJECTED today — documents the constraint the data-plane code works |
| 12 | * around. If DoltGres starts SUPPORTING one of these, this test flips |
| 13 | * to failing and tells us "you can now delete the workaround". |
| 14 | * |
| 15 | * How to run (needs the local DoltGres container up on :5433): |
| 16 | * docker compose -f infra/test/docker-compose.dolt.yml up -d # or the dev container |
| 17 | * BRIVEN_DATA_PLANE_URL=postgres://postgres:password@127.0.0.1:5433/postgres \ |
| 18 | * bun test src/db/dolt-compat.integration.test.ts |
| 19 | * |
| 20 | * Without BRIVEN_DATA_PLANE_URL set, the whole suite SKIPS (so the normal |
| 21 | * no-database unit run stays green). |
| 22 | */ |
| 23 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; |
| 24 | import pg from 'pg'; |
| 25 | |
| 26 | const URL = process.env.BRIVEN_DATA_PLANE_URL ?? process.env.DOLT_TEST_URL; |
| 27 | const HAS_DB = Boolean(URL); |
| 28 | |
| 29 | // Unique-ish suffix so reruns don't collide (Date.now is fine in a test file). |
| 30 | const TAG = `cmp_${Date.now().toString(36)}`; |
| 31 | |
| 32 | let client: pg.Client; |
| 33 | |
| 34 | /** Run a statement, return rows. Throws on SQL the engine rejects. */ |
| 35 | async function run(sql: string, params?: unknown[]): Promise<pg.QueryResult> { |
| 36 | return client.query(sql, params as never); |
| 37 | } |
| 38 | |
| 39 | /** Assert a statement is ACCEPTED (no throw). */ |
| 40 | async function accepts(sql: string, params?: unknown[]): Promise<void> { |
| 41 | await run(sql, params); |
| 42 | } |
| 43 | |
| 44 | /** Assert a statement is REJECTED (throws). Returns the error message. */ |
| 45 | async function rejects(sql: string, params?: unknown[]): Promise<string> { |
| 46 | try { |
| 47 | await run(sql, params); |
| 48 | } catch (err) { |
| 49 | return (err as Error).message; |
| 50 | } |
| 51 | throw new Error(`Expected DoltGres to REJECT this SQL, but it was accepted:\n${sql}`); |
| 52 | } |
| 53 | |
| 54 | describe.skipIf(!HAS_DB)('DoltGres compatibility alarm', () => { |
| 55 | beforeAll(async () => { |
| 56 | client = new pg.Client({ connectionString: URL }); |
| 57 | await client.connect(); |
| 58 | await run(`DROP TABLE IF EXISTS "${TAG}"`); |
| 59 | await run( |
| 60 | `CREATE TABLE "${TAG}" (id text PRIMARY KEY, name text, score int, created_at timestamp, body jsonb)`, |
| 61 | ); |
| 62 | await run(`INSERT INTO "${TAG}" (id, name, score) VALUES ('a','Alice',10),('b','Bob',20)`); |
| 63 | }); |
| 64 | |
| 65 | afterAll(async () => { |
| 66 | if (client) { |
| 67 | await run(`DROP TABLE IF EXISTS "${TAG}"`).catch(() => {}); |
| 68 | await client.end(); |
| 69 | } |
| 70 | }); |
| 71 | |
| 72 | // ───────────────────────── ACCEPTED today — must keep working ───────────── |
| 73 | describe('accepted (must keep working)', () => { |
| 74 | test('ON CONFLICT DO NOTHING — schema-apply / idempotent inserts', async () => { |
| 75 | await accepts( |
| 76 | `INSERT INTO "${TAG}" (id, name, score) VALUES ('a','dup',0) ON CONFLICT (id) DO NOTHING`, |
| 77 | ); |
| 78 | }); |
| 79 | |
| 80 | test('jsonb cast + read', async () => { |
| 81 | await accepts(`UPDATE "${TAG}" SET body = '{"k":1}'::jsonb WHERE id = 'a'`); |
| 82 | const r = await run(`SELECT body->>'k' AS k FROM "${TAG}" WHERE id = 'a'`); |
| 83 | expect(r.rows[0].k).toBe('1'); |
| 84 | }); |
| 85 | |
| 86 | test('date_trunc + interval', async () => { |
| 87 | await accepts(`SELECT date_trunc('hour', now() - interval '1 hour') AS h`); |
| 88 | }); |
| 89 | |
| 90 | test('generate_series (dashboard buckets)', async () => { |
| 91 | const r = await run(`SELECT count(*)::int AS n FROM generate_series(1, 24) g`); |
| 92 | expect(r.rows[0].n).toBe(24); |
| 93 | }); |
| 94 | |
| 95 | test('COUNT(DISTINCT) + CTE', async () => { |
| 96 | const r = await run( |
| 97 | `WITH t AS (SELECT score FROM "${TAG}") SELECT count(DISTINCT score)::int AS n FROM t`, |
| 98 | ); |
| 99 | expect(r.rows[0].n).toBe(2); |
| 100 | }); |
| 101 | |
| 102 | test('::regclass + pg_total_relation_size (storage usage SQL)', async () => { |
| 103 | await accepts(`SELECT pg_total_relation_size('"${TAG}"'::regclass) AS bytes`); |
| 104 | }); |
| 105 | |
| 106 | test('gen_random_uuid()', async () => { |
| 107 | await accepts(`SELECT gen_random_uuid() AS u`); |
| 108 | }); |
| 109 | |
| 110 | test('information_schema 3-way FK join (Studio relations)', async () => { |
| 111 | await accepts( |
| 112 | `SELECT tc.constraint_name |
| 113 | FROM information_schema.table_constraints tc |
| 114 | JOIN information_schema.key_column_usage kcu |
| 115 | ON tc.constraint_name = kcu.constraint_name |
| 116 | JOIN information_schema.constraint_column_usage ccu |
| 117 | ON ccu.constraint_name = tc.constraint_name |
| 118 | WHERE tc.constraint_type = 'FOREIGN KEY'`, |
| 119 | ); |
| 120 | }); |
| 121 | |
| 122 | test('DELETE ... RETURNING (guards sprint S1.6 — inline runtime must match isolate)', async () => { |
| 123 | await run(`INSERT INTO "${TAG}" (id, name) VALUES ('del','tmp')`); |
| 124 | const r = await run(`DELETE FROM "${TAG}" WHERE id = 'del' RETURNING id`); |
| 125 | expect(r.rows[0].id).toBe('del'); |
| 126 | }); |
| 127 | |
| 128 | test('case-insensitive search via lower()+LIKE (the S1.1 ILIKE replacement)', async () => { |
| 129 | const r = await run( |
| 130 | `SELECT id FROM "${TAG}" WHERE lower(name) LIKE '%' || lower($1) || '%' ORDER BY id`, |
| 131 | ['ALI'], |
| 132 | ); |
| 133 | expect(r.rows.map((x) => x.id)).toEqual(['a']); |
| 134 | }); |
| 135 | |
| 136 | test('plain TRUNCATE (the S1.3 replacement) on a scratch table', async () => { |
| 137 | await run(`DROP TABLE IF EXISTS "${TAG}_t"`); |
| 138 | await run(`CREATE TABLE "${TAG}_t" (id int)`); |
| 139 | await run(`INSERT INTO "${TAG}_t" VALUES (1)`); |
| 140 | await accepts(`TRUNCATE "${TAG}_t"`); |
| 141 | const r = await run(`SELECT count(*)::int AS n FROM "${TAG}_t"`); |
| 142 | expect(r.rows[0].n).toBe(0); |
| 143 | await run(`DROP TABLE "${TAG}_t"`); |
| 144 | }); |
| 145 | |
| 146 | test('manual upsert via ON CONFLICT DO NOTHING + UPDATE (the S2.4 replacement)', async () => { |
| 147 | await run( |
| 148 | `INSERT INTO "${TAG}" (id, name) VALUES ('a','x') ON CONFLICT (id) DO NOTHING`, |
| 149 | ); |
| 150 | await run(`UPDATE "${TAG}" SET name = 'AliceUpdated' WHERE id = 'a'`); |
| 151 | const r = await run(`SELECT name FROM "${TAG}" WHERE id = 'a'`); |
| 152 | expect(r.rows[0].name).toBe('AliceUpdated'); |
| 153 | }); |
| 154 | |
| 155 | test('expanded keyset cursor a<$1 OR (a=$1 AND b<$2) (the S2.5 replacement)', async () => { |
| 156 | await accepts( |
| 157 | `SELECT id FROM "${TAG}" WHERE (score < $1 OR (score = $1 AND id < $2)) ORDER BY score DESC, id DESC`, |
| 158 | [20, 'b'], |
| 159 | ); |
| 160 | }); |
| 161 | }); |
| 162 | |
| 163 | // ───────────────────── REJECTED today — workaround required ─────────────── |
| 164 | // If one of these starts PASSING, the test fails on purpose → delete the |
| 165 | // matching workaround and update BRIVEN-BUGS-REPORT.md. |
| 166 | describe('rejected (workaround required — flip = good news)', () => { |
| 167 | test('ILIKE → unsupported (drives S1.1)', async () => { |
| 168 | const msg = await rejects(`SELECT id FROM "${TAG}" WHERE name ILIKE '%ali%'`); |
| 169 | expect(msg.toLowerCase()).toContain('ilike'); |
| 170 | }); |
| 171 | |
| 172 | test('SET LOCAL → unsupported (drives S1.2)', async () => { |
| 173 | await rejects(`SET LOCAL statement_timeout = '5s'`); |
| 174 | }); |
| 175 | |
| 176 | test('TRUNCATE ... RESTART IDENTITY → syntax error (drives S1.3)', async () => { |
| 177 | await run(`DROP TABLE IF EXISTS "${TAG}_r"`); |
| 178 | await run(`CREATE TABLE "${TAG}_r" (id int)`); |
| 179 | await rejects(`TRUNCATE "${TAG}_r" RESTART IDENTITY`); |
| 180 | await run(`DROP TABLE "${TAG}_r"`); |
| 181 | }); |
| 182 | |
| 183 | test('ON CONFLICT DO UPDATE (excluded) → unsupported (drives S2.4)', async () => { |
| 184 | await rejects( |
| 185 | `INSERT INTO "${TAG}" (id, name) VALUES ('a','z') ON CONFLICT (id) DO UPDATE SET name = excluded.name`, |
| 186 | ); |
| 187 | }); |
| 188 | |
| 189 | test('row-tuple comparison (a,b) < ($1,$2) → unsupported (drives S2.5)', async () => { |
| 190 | await rejects(`SELECT id FROM "${TAG}" WHERE (score, id) < ($1, $2)`, [20, 'b']); |
| 191 | }); |
| 192 | |
| 193 | test('citext type → does not exist (drives S2.3)', async () => { |
| 194 | await rejects(`CREATE TABLE "${TAG}_c" (email citext)`); |
| 195 | }); |
| 196 | |
| 197 | test('CREATE EXTENSION → unsupported (drives S2.3)', async () => { |
| 198 | await rejects(`CREATE EXTENSION IF NOT EXISTS citext`); |
| 199 | }); |
| 200 | |
| 201 | test('vector(N) column → syntax error (drives S1.5 gate)', async () => { |
| 202 | await rejects(`CREATE TABLE "${TAG}_v" (embedding vector(3))`); |
| 203 | }); |
| 204 | }); |
| 205 | |
| 206 | // ───────────── documented limitations (flip = good news) ────────────────── |
| 207 | // Accepted SQL that returns a degenerate result on DoltGres. If the value |
| 208 | // changes, the test fails on purpose → DoltGres gained the capability and we |
| 209 | // can rely on it (e.g. storage-byte metering — sprint S2.2). |
| 210 | describe('documented limitations', () => { |
| 211 | test('pg_total_relation_size returns 0 (no on-disk size accounting yet)', async () => { |
| 212 | const r = await run(`SELECT pg_total_relation_size('"${TAG}"'::regclass)::bigint AS bytes`); |
| 213 | // DoltGres does not report relation byte sizes today. getStorageUsage |
| 214 | // counts tables accurately but `bytes` stays 0 until this flips. |
| 215 | expect(Number(r.rows[0].bytes)).toBe(0); |
| 216 | }); |
| 217 | }); |
| 218 | }); |