|
| 1 | +import { loadModule } from 'plpgsql-parser'; |
| 2 | + |
| 3 | +import { classifyStatements } from '../src/facts'; |
| 4 | +import { SchemaRouter } from '../src/router'; |
| 5 | +import { transformSqlStatement } from '../src/transform'; |
| 6 | + |
| 7 | +beforeAll(async () => { |
| 8 | + await loadModule(); |
| 9 | +}); |
| 10 | + |
| 11 | +const swap = new Map([ |
| 12 | + ['a', 'b'], |
| 13 | + ['b', 'a'] |
| 14 | +]); |
| 15 | + |
| 16 | +describe('single routing pass (claims)', () => { |
| 17 | + it('applies a cyclic schema mapping exactly once per site', () => { |
| 18 | + const cases: Array<[string, string]> = [ |
| 19 | + ['CREATE TABLE a.t (id int);', 'CREATE TABLE b.t (\n id int\n);'], |
| 20 | + ['SELECT * FROM a.t;', 'SELECT *\nFROM b.t;'], |
| 21 | + ['INSERT INTO a.t VALUES (1);', 'INSERT INTO b.t VALUES\n (1);'], |
| 22 | + ['ALTER TABLE a.t ADD COLUMN c b.mytype;', 'ALTER TABLE b.t\n ADD COLUMN c a.mytype;'], |
| 23 | + ['CREATE VIEW a.v AS SELECT * FROM b.t;', 'CREATE VIEW b.v AS SELECT * FROM a.t;'], |
| 24 | + ['SELECT a.f(NULL::b.tp);', 'SELECT b.f(CAST(NULL AS a.tp));'], |
| 25 | + ['DROP TABLE a.t;', 'DROP TABLE b.t;'], |
| 26 | + ['CREATE INDEX i ON a.t (c);', 'CREATE INDEX i ON b.t (c);'], |
| 27 | + [ |
| 28 | + 'CREATE TABLE t (o uuid REFERENCES a.pk (id));', |
| 29 | + 'CREATE TABLE t (\n o uuid REFERENCES b.pk (id)\n);' |
| 30 | + ] |
| 31 | + ]; |
| 32 | + const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); |
| 33 | + for (const [input, expected] of cases) { |
| 34 | + expect(norm(transformSqlStatement(input, swap).sql)).toBe(norm(expected)); |
| 35 | + } |
| 36 | + }); |
| 37 | + |
| 38 | + it('swaps a two-schema module without leftover-validation errors', () => { |
| 39 | + const sql = [ |
| 40 | + 'CREATE TABLE a.users (id uuid PRIMARY KEY);', |
| 41 | + 'CREATE TABLE b.posts (author uuid REFERENCES a.users (id));', |
| 42 | + 'CREATE FUNCTION a.author_of(p uuid) RETURNS uuid LANGUAGE sql AS $$ SELECT author FROM b.posts WHERE id = p $$;' |
| 43 | + ].join('\n'); |
| 44 | + const out = sql |
| 45 | + .split('\n') |
| 46 | + .map(stmt => transformSqlStatement(stmt, swap).sql) |
| 47 | + .join('\n'); |
| 48 | + expect(out).toContain('b.users'); |
| 49 | + expect(out).toContain('a.posts'); |
| 50 | + expect(out).toContain('b.author_of'); |
| 51 | + expect(out).toContain('REFERENCES b.users'); |
| 52 | + }); |
| 53 | + |
| 54 | + it('statement-level namespace context wins over generic visitors', () => { |
| 55 | + // Only a *function* route for a.f exists. The DropStmt handler routes with |
| 56 | + // ns 'function'; the generic ObjectWithArgs visitor (ns 'unknown') must |
| 57 | + // not route it a second time. |
| 58 | + const router = new SchemaRouter({ |
| 59 | + a: { functions: { f: 'fns' } } |
| 60 | + }); |
| 61 | + expect(transformSqlStatement('DROP FUNCTION a.f(int);', router).sql.trim()).toBe( |
| 62 | + 'DROP FUNCTION fns.f(int);' |
| 63 | + ); |
| 64 | + expect(transformSqlStatement('ALTER FUNCTION a.f(int) OWNER TO u;', router).sql.trim()).toBe( |
| 65 | + 'ALTER FUNCTION fns.f(int) OWNER TO u;' |
| 66 | + ); |
| 67 | + }); |
| 68 | + |
| 69 | + it('rebind with a cyclic name swap stays single-pass', () => { |
| 70 | + const router = new SchemaRouter({ |
| 71 | + auth: { functions: { uid: { schema: null, name: 'current_user_id' } } } |
| 72 | + }); |
| 73 | + expect( |
| 74 | + transformSqlStatement('SELECT auth.uid();', router).sql.trim() |
| 75 | + ).toBe('SELECT current_user_id();'); |
| 76 | + }); |
| 77 | +}); |
| 78 | + |
| 79 | +describe('StatementFacts spans', () => { |
| 80 | + it('reports each statement source span verbatim', () => { |
| 81 | + const sql = `CREATE SCHEMA app;\nCREATE TABLE app.users (id uuid);\n\nSELECT 1;`; |
| 82 | + const facts = classifyStatements(sql); |
| 83 | + expect(facts).toHaveLength(3); |
| 84 | + for (const f of facts) { |
| 85 | + const text = sql.slice(f.span.start, f.span.start + f.span.len); |
| 86 | + expect(text.trim().length).toBeGreaterThan(0); |
| 87 | + } |
| 88 | + const [schema, table, select] = facts; |
| 89 | + expect(sql.slice(schema.span.start, schema.span.start + schema.span.len).trim()).toBe( |
| 90 | + 'CREATE SCHEMA app' |
| 91 | + ); |
| 92 | + expect(sql.slice(table.span.start, table.span.start + table.span.len).trim()).toBe( |
| 93 | + 'CREATE TABLE app.users (id uuid)' |
| 94 | + ); |
| 95 | + expect(sql.slice(select.span.start, select.span.start + select.span.len).trim()).toBe( |
| 96 | + 'SELECT 1' |
| 97 | + ); |
| 98 | + }); |
| 99 | + |
| 100 | + it('covers the tail of the script for the final statement', () => { |
| 101 | + const sql = 'SELECT 1'; // no trailing semicolon |
| 102 | + const [f] = classifyStatements(sql); |
| 103 | + expect(f.span.start).toBe(0); |
| 104 | + expect(sql.slice(f.span.start, f.span.start + f.span.len)).toBe('SELECT 1'); |
| 105 | + }); |
| 106 | +}); |
0 commit comments