Skip to content

Commit 9dc2659

Browse files
authored
Merge pull request #323 from constructive-io/feat/ast-single-routing-pass
fix(transform): single routing pass via claims; namespace-aware handlers; statement spans on facts
2 parents 057e427 + 537c88e commit 9dc2659

3 files changed

Lines changed: 197 additions & 205 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
});

‎packages/transform/src/facts.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ export interface StatementFacts {
117117
* are incomplete and slicing should treat it conservatively.
118118
*/
119119
dynamicSql: boolean;
120+
/**
121+
* The statement's source span in the classified script, as reported by the
122+
* parser: `start` is the byte offset of the statement's first token, `len`
123+
* runs to the end of the statement (the parser excludes the trailing `;`;
124+
* for the final statement the span extends to the end of the script).
125+
* `sql.slice(span.start, span.start + span.len)` is the statement's
126+
* verbatim source, so consumers can carry original text alongside the
127+
* facts without a second parse.
128+
*/
129+
span: StatementSpan;
130+
}
131+
132+
/** A statement's location in the source script (byte offsets). */
133+
export interface StatementSpan {
134+
start: number;
135+
len: number;
120136
}
121137

122138
const SECURITY_TAGS = new Set([
@@ -272,7 +288,8 @@ function classifyOne(nodeTag: string, node: any): StatementFacts {
272288
bodyReferences: [],
273289
securityRelevant: SECURITY_TAGS.has(nodeTag),
274290
securityDefiner: false,
275-
dynamicSql: false
291+
dynamicSql: false,
292+
span: { start: 0, len: 0 }
276293
};
277294

278295
switch (nodeTag) {
@@ -467,6 +484,8 @@ export function classifyStatements(sql: string): StatementFacts[] {
467484
const nodeTag = stmtNode ? Object.keys(stmtNode)[0] : 'other';
468485
const node = stmtNode?.[nodeTag] ?? {};
469486
const facts = classifyOne(nodeTag, node);
487+
const start = stmt?.stmt_location ?? 0;
488+
facts.span = { start, len: stmt?.stmt_len ?? Math.max(0, sql.length - start) };
470489

471490
if (stmtNode) {
472491
walkSql(stmtNode, createFactsVisitor(facts));

0 commit comments

Comments
 (0)