Skip to content

Commit 265914e

Browse files
committed
feat(traverse): derive walk context from the nearest enclosing statement
1 parent 9e3af40 commit 265914e

2 files changed

Lines changed: 91 additions & 9 deletions

File tree

packages/plpgsql-parser/__tests__/walk-sql.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,45 @@ describe('walkSql', () => {
9898
]);
9999
});
100100

101+
it('refines the context to the nearest enclosing statement', () => {
102+
const seen: Array<{ table: string; stmtTag: string | null; isWrite: boolean }> = [];
103+
walkSql(
104+
'WITH w AS (INSERT INTO a.written VALUES (1) RETURNING *) SELECT * FROM b.read',
105+
{
106+
RangeVar: (path, ctx) => {
107+
seen.push({ table: path.node.relname, stmtTag: ctx.stmtTag, isWrite: ctx.isWrite });
108+
},
109+
},
110+
);
111+
expect(seen).toEqual(
112+
expect.arrayContaining([
113+
{ table: 'written', stmtTag: 'InsertStmt', isWrite: true },
114+
{ table: 'read', stmtTag: 'SelectStmt', isWrite: false },
115+
]),
116+
);
117+
});
118+
119+
it('marks a write inside a function body as a write', () => {
120+
const seen: Array<{ table: string; isWrite: boolean; functionName: string | null }> = [];
121+
walkSql(
122+
`CREATE FUNCTION w() RETURNS void LANGUAGE plpgsql AS $$
123+
BEGIN
124+
INSERT INTO infra.servers (name) VALUES ('x');
125+
END;
126+
$$;`,
127+
{
128+
RangeVar: (path, ctx) => {
129+
seen.push({
130+
table: path.node.relname,
131+
isWrite: ctx.isWrite,
132+
functionName: ctx.functionName,
133+
});
134+
},
135+
},
136+
);
137+
expect(seen).toEqual([{ table: 'servers', isWrite: true, functionName: 'w' }]);
138+
});
139+
101140
it('numbers statements', () => {
102141
const indexes: number[] = [];
103142
walkSql('SELECT 1; SELECT 2; SELECT 3', {

packages/traverse/src/walk.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
* because they see one node at a time and never the statement it belongs to:
1313
*
1414
* 1. a {@link WalkContext} on every callback (`stmtTag`, `isWrite`, `isRead`,
15-
* `insideFunction`, `functionName`)
15+
* `insideFunction`, `functionName`), refined by the nearest enclosing
16+
* statement — so an `INSERT` nested in a CTE or in a PL/pgSQL body reports
17+
* `isWrite`, not the context of whatever statement contains it
1618
* 2. visitor composition — N visitors in a single pass, each free to mix SQL
1719
* and `PLpgSQL_*` tags in the same object
1820
* 3. `ctx.abort()`, which ends the whole walk (returning `false` from a
@@ -52,7 +54,7 @@ export const READ_STATEMENTS: ReadonlySet<string> = new Set([
5254
* inside a function body.
5355
*/
5456
export interface WalkContext {
55-
/** Tag of the enclosing top-level statement, or `null` when walking a bare node. */
57+
/** Tag of the nearest enclosing statement, or `null` when walking a bare node. */
5658
readonly stmtTag: string | null;
5759
/** Index of the enclosing statement within the script, or `-1` if unknown. */
5860
readonly stmtIndex: number;
@@ -151,6 +153,46 @@ export function walk(
151153
const makeContext = (fields: Omit<WalkContext, 'abort'>): WalkContext =>
152154
Object.assign({}, fields, ctxBase);
153155

156+
/**
157+
* Statement context per node, derived from the path chain and memoized.
158+
*
159+
* A node's context is its parent's, except when the node is itself a
160+
* statement: then it becomes the enclosing statement for its whole subtree.
161+
* Nesting is what makes this necessary — the write target of an `INSERT`
162+
* inside a CTE, a rule action, or a PL/pgSQL body is a write, even though
163+
* the statement the script starts with may only read.
164+
*/
165+
const contexts = new WeakMap<object, WalkContext>();
166+
167+
const contextFor = (
168+
path: NodePath | PlpgsqlNodePath,
169+
base: WalkContext,
170+
): WalkContext => {
171+
const cached = contexts.get(path);
172+
if (cached) return cached;
173+
174+
const parent = (path as { parent?: NodePath | PlpgsqlNodePath | null }).parent;
175+
const inherited = parent ? contextFor(parent, base) : base;
176+
const tag = path.tag;
177+
const isWrite = WRITE_STATEMENTS.has(tag);
178+
const isRead = READ_STATEMENTS.has(tag);
179+
180+
const ctx =
181+
isWrite || isRead
182+
? makeContext({
183+
stmtTag: tag,
184+
stmtIndex: inherited.stmtIndex,
185+
isWrite,
186+
isRead,
187+
insideFunction: inherited.insideFunction,
188+
functionName: inherited.functionName,
189+
})
190+
: inherited;
191+
192+
contexts.set(path, ctx);
193+
return ctx;
194+
};
195+
154196
/** Fire every visitor for one node. Returns false if any asks to skip children. */
155197
const fire = (path: NodePath | PlpgsqlNodePath, ctx: WalkContext): boolean => {
156198
let descend = true;
@@ -177,11 +219,11 @@ export function walk(
177219
return descend;
178220
};
179221

180-
const sqlCallback = (ctx: WalkContext): SqlWalker => (path: NodePath) =>
181-
fire(path, ctx) ? undefined : false;
222+
const sqlCallback = (base: WalkContext): SqlWalker => (path: NodePath) =>
223+
fire(path, contextFor(path, base)) ? undefined : false;
182224

183-
const plpgsqlCallback = (ctx: WalkContext): PlpgsqlWalker => (path: PlpgsqlNodePath) =>
184-
fire(path, ctx) ? undefined : false;
225+
const plpgsqlCallback = (base: WalkContext): PlpgsqlWalker => (path: PlpgsqlNodePath) =>
226+
fire(path, contextFor(path, base)) ? undefined : false;
185227

186228
const bareContext = (): WalkContext =>
187229
makeContext({
@@ -201,24 +243,25 @@ export function walk(
201243
* every node after a `RawStmt` belongs to that statement.
202244
*/
203245
const walkParseResult = (parseResult: any): void => {
204-
let ctx = bareContext();
246+
const base = bareContext();
205247

206248
const callback: SqlWalker = (path: NodePath) => {
207249
if (path.tag !== 'RawStmt') {
208-
return fire(path, ctx) ? undefined : false;
250+
return fire(path, contextFor(path, base)) ? undefined : false;
209251
}
210252

211253
const stmt = path.node?.stmt;
212254
const stmtTag = stmt ? Object.keys(stmt)[0] : null;
213255
const last = path.keyPath[path.keyPath.length - 1];
214-
ctx = makeContext({
256+
const ctx = makeContext({
215257
stmtTag,
216258
stmtIndex: typeof last === 'number' ? last : -1,
217259
isWrite: stmtTag != null && WRITE_STATEMENTS.has(stmtTag),
218260
isRead: stmtTag != null && READ_STATEMENTS.has(stmtTag),
219261
insideFunction: false,
220262
functionName: null,
221263
});
264+
contexts.set(path, ctx);
222265

223266
let descend = fire(path, ctx);
224267
if (stmt && stmtTag) {

0 commit comments

Comments
 (0)