Skip to content

Commit dcbce4d

Browse files
authored
Merge pull request #329 from constructive-io/feat/unified-walk
feat(traverse): one walk() for any AST; walkSql(text) in plpgsql-parser
2 parents 884ba25 + 265914e commit dcbce4d

28 files changed

Lines changed: 2061 additions & 1142 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
---
2+
name: ast-traversal
3+
description: How to walk PostgreSQL SQL and PL/pgSQL ASTs in this monorepo — choosing between walk, walkSql, walkSqlAst, walkPlpgsqlAst, and traverse; statement context, visitor composition, abort, and mutation. Use when reading, validating, or rewriting SQL/PL/pgSQL ASTs.
4+
---
5+
6+
# AST Traversal
7+
8+
Everything in this repo that inspects or rewrites SQL goes through one of five
9+
functions. Pick the right one first; the rest of the work follows from it.
10+
11+
## Choosing a function
12+
13+
| Function | Package | Walks | Mutates |
14+
|---|---|---|---|
15+
| `walk(ast, visitors, opts?)` | `@pgsql/traverse` | any AST — parsed script, `ParseResult`, SQL node, PL/pgSQL node | no |
16+
| `walkSql(text, visitors, opts?)` | `plpgsql-parser` | SQL **text**: parses, hydrates PL/pgSQL bodies, then `walk`s | no |
17+
| `walkSqlAst(ast, visitor)` | `@pgsql/traverse` | SQL AST only | no |
18+
| `walkPlpgsqlAst(ast, visitor, opts?)` | `@pgsql/traverse` | PL/pgSQL AST only | no |
19+
| `traverse(ast, mutableVisitor)` | `@pgsql/traverse` | SQL AST, with insert/remove/replace | yes |
20+
21+
Decision rules:
22+
23+
- **Have a string?** `walkSql`. It is the only entry point that parses.
24+
- **Have an AST and don't care which universe it is from?** `walk`.
25+
- **Want exactly one node universe and no statement context?** `walkSqlAst` or
26+
`walkPlpgsqlAst`. These are the primitives `walk` is built on; reach for them
27+
when writing a reusable visitor that some other walker will drive (this is why
28+
`@pgsql/transform` exports visitor *factories* rather than walkers).
29+
- **Need to change the tree structurally?** `traverse`. Note that read-only
30+
walkers hand you the real node objects, so field-level edits (renaming a
31+
schema, rewriting a name list) work under `walk` too — `traverse` is for
32+
inserting, removing, and replacing nodes.
33+
34+
Never hand-roll `transformSync(sql, ..., { hydrate: true })` plus a
35+
per-statement loop plus a PL/pgSQL walk. That harness *is* `walk`.
36+
37+
## Visitors
38+
39+
A visitor is either a callback (fires on every node) or an object keyed by node
40+
tag. SQL tags and `PLpgSQL_*` tags may be mixed in one object — `walk` routes
41+
each node to the right walker.
42+
43+
```ts
44+
import { walk } from '@pgsql/traverse';
45+
46+
walk(ast, {
47+
RangeVar: (path) => console.log(path.node.relname),
48+
PLpgSQL_stmt_dynexecute: (path) => console.log('dynamic EXECUTE', path.node)
49+
});
50+
```
51+
52+
Pass an **array** of visitors to run independent concerns in a single parse:
53+
54+
```ts
55+
walkSql(sql, [blockedSchemas, readOnlySchemas, blockedFunctions]);
56+
```
57+
58+
Each callback receives `(path, ctx)`:
59+
60+
- `path` — a `NodePath` (SQL) or `PlpgsqlNodePath` (PL/pgSQL): `tag`, `node`,
61+
`parent`, `keyPath`. This is *structure*: where the node sits.
62+
- `ctx` — a `WalkContext`: `stmtTag`, `stmtIndex`, `isWrite`, `isRead`,
63+
`insideFunction`, `functionName`, `abort()`. This is *situation*: what the
64+
node is part of.
65+
66+
The reserved `statement` key fires once per top-level statement, before its
67+
children — the hook for per-statement setup or classification.
68+
69+
## Control flow
70+
71+
Two distinct mechanisms, do not confuse them:
72+
73+
- `return false` — skip this node's children, keep walking siblings.
74+
- `ctx.abort(reason?)` — end the whole walk. `walk`/`walkSql` return
75+
`{ aborted, reason, reasons }`. This is what a validator wants: the first
76+
rejection ends the work.
77+
78+
```ts
79+
const result = walkSql(sql, {
80+
RangeVar: (path, ctx) => {
81+
if (ctx.isWrite && path.node.schemaname === 'audit') {
82+
ctx.abort(`cannot write to ${path.node.schemaname}`);
83+
}
84+
}
85+
});
86+
if (result.aborted) reject(result.reason);
87+
```
88+
89+
Unparseable input from `walkSql` comes back as `{ aborted: true, reason }`
90+
rather than a thrown error, so "rejected" and "not understood" are one code path.
91+
92+
## Worked examples
93+
94+
### Collect every table a script touches, including inside function bodies
95+
96+
```ts
97+
import { loadModule, walkSql } from 'plpgsql-parser';
98+
99+
await loadModule(); // once per process: libpg-query is WASM
100+
101+
const tables = new Set<string>();
102+
walkSql(sql, {
103+
RangeVar: (path, ctx) => {
104+
const name = path.node.schemaname
105+
? `${path.node.schemaname}.${path.node.relname}`
106+
: path.node.relname;
107+
tables.add(ctx.insideFunction ? `${name} (via ${ctx.functionName})` : name);
108+
}
109+
});
110+
```
111+
112+
### Classify statements without a second parse
113+
114+
```ts
115+
walkSql(sql, {
116+
statement: (path, ctx) => {
117+
console.log(ctx.stmtIndex, path.tag, ctx.isWrite ? 'write' : 'read');
118+
}
119+
});
120+
```
121+
122+
### Rewrite schema names on a parsed script
123+
124+
Field-level rewrites work through the read-only walker because `path.node` is the
125+
live node. This is exactly how `@pgsql/transform` renames schemas:
126+
127+
```ts
128+
import { transformSync } from 'plpgsql-parser';
129+
import { walk } from '@pgsql/traverse';
130+
131+
const out = transformSync(sql, (ctx) => {
132+
walk(ctx, {
133+
RangeVar: (path) => {
134+
const to = mapping.get(path.node.schemaname);
135+
if (to) path.node.schemaname = to;
136+
}
137+
});
138+
}, { hydrate: true, pretty: true });
139+
```
140+
141+
`transformSync` gives the callback a parsed script (`{ sql, functions }`), which
142+
`walk` dispatches over directly — statements first, then every hydrated body.
143+
144+
### Restructure the tree
145+
146+
```ts
147+
import { traverse } from '@pgsql/traverse';
148+
149+
traverse(ast, {
150+
RawStmt: {
151+
enter: (path) => {
152+
if (isRedundant(path.node)) path.remove();
153+
}
154+
}
155+
});
156+
```
157+
158+
### Drive a reusable visitor from a primitive
159+
160+
When a caller already owns the traversal loop (per-statement state, custom
161+
ordering), build the visitor separately and hand it to the primitive:
162+
163+
```ts
164+
import { walkSqlAst } from '@pgsql/traverse';
165+
166+
for (const stmt of parseResult.stmts) {
167+
walkSqlAst(stmt.stmt, createFactsVisitor(factsFor(stmt)));
168+
}
169+
```
170+
171+
## Options
172+
173+
`walk` and `walkSql` share:
174+
175+
- `walkFunctionBodies` (default `true`) — walk hydrated PL/pgSQL bodies. Under
176+
`walkSql`, `false` also skips the PL/pgSQL parse, so turn it off when the
177+
visitors only care about top-level SQL.
178+
- `walkSqlExpressions` (default `true`) — recurse into the SQL expressions inside
179+
those bodies.
180+
- `sqlVisitor` — override the visitor used for those SQL expressions.
181+
182+
## Gotchas
183+
184+
- **Call `loadModule()` before any parse.** `libpg-query` is WASM; `parseSync` /
185+
`walkSql` throw `WASM module not initialized` otherwise.
186+
- **PL/pgSQL bodies are opaque until hydrated.** Without `{ hydrate: true }` a
187+
function body is a query string, and no SQL visitor will ever fire inside it.
188+
- **Untagged typed fields exist.** `CreatePolicyStmt.table` is a bare `RangeVar`
189+
with no `{ RangeVar: ... }` wrapper. `walkSqlAst` handles this via the runtime
190+
schema, which is why hand-written recursion over `Object.keys` misses nodes.
191+
- **`@pgsql/traverse` must stay parser-free.** It depends on types only; adding a
192+
parser dependency would cycle and pull WASM into the leaf package. Anything
193+
that needs to parse belongs in `plpgsql-parser` or above.
194+
- **Fixtures are the regression gate for transform work.** After changing any
195+
traversal in `@pgsql/transform`, `pnpm --filter @pgsql/transform test` output
196+
must be byte-identical; see the `testing-fixtures` skill.

.agents/skills/code-generation/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Three packages generate TypeScript from the PostgreSQL protobuf definition at `_
1313
| Package | Script | What it generates |
1414
|---------|--------|-------------------|
1515
| `@pgsql/utils` | `npm run build:proto` | AST helper functions (`src/`), wrapped helpers (`wrapped.ts`), runtime schema (`runtime-schema.ts`) |
16-
| `@pgsql/traverse` | `npm run build:proto` | Visitor-pattern traversal utilities |
16+
| `@pgsql/traverse` | `npm run build:proto` | Runtime schema driving the SQL walker (`walk` / `walkSqlAst`); see the `ast-traversal` skill |
1717
| `@pgsql/transform-ast` | `npm run build:proto` | Multi-version AST transformer utilities |
1818

1919
Each package has a `scripts/pg-proto-parser.ts` that configures `PgProtoParser` with package-specific options (which features to enable, output paths, type sources).

.agents/skills/testing-fixtures/SKILL.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,30 @@ npm run kitchen-sink # generate transform-specific kitchen-sink tests
168168
npm run test:ast # run AST round-trip validation
169169
```
170170

171+
## Changing a Walker
172+
173+
`@pgsql/transform` drives its whole pipeline (schema mapping, routing, qualify,
174+
role and extension transforms, round-trip validation) through the walkers in
175+
`@pgsql/traverse` — see the `ast-traversal` skill. Its fixtures are therefore the
176+
regression gate for any traversal change, including ones made in another package:
177+
178+
```bash
179+
pnpm --filter @pgsql/traverse test
180+
pnpm --filter plpgsql-parser test
181+
pnpm --filter @pgsql/transform test # fixtures + snapshots must be unchanged
182+
```
183+
184+
A traversal change that alters fixture output is a behavior change, not a
185+
refactor. Two failure modes to look for specifically:
186+
187+
- **Nodes no longer reached** — untagged typed fields (e.g. `CreatePolicyStmt.table`)
188+
are only found via the runtime schema, so a hand-rolled recursion silently drops
189+
them and a fixture loses a rename.
190+
- **Nodes reached twice** — a visitor fired both by an outer walk and by a nested
191+
one double-applies edits.
192+
193+
Never edit a fixture or snapshot to make a walker change pass.
194+
171195
## Package Scripts Reference
172196

173197
### `packages/deparser` (primary fixture pipeline)

.github/workflows/run-tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
- pg-proto-parser
2424
- '@pgsql/quotes'
2525
- '@pgsql/transform-ast'
26+
- '@pgsql/traverse'
2627
- '@pgsql/transform'
2728
- '@pgsql/scripts'
2829
steps:

AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ A pnpm monorepo for PostgreSQL AST parsing, deparsing, and code generation. All
1010
|---------|-----------|---------|
1111
| `pgsql-parser` | `packages/parser` | Parse SQL to AST (wraps `libpg-query` WASM) |
1212
| `pgsql-deparser` | `packages/deparser` | Convert AST back to SQL (pure TypeScript) |
13-
| `plpgsql-parser` | `packages/plpgsql-parser` | Parse PL/pgSQL to AST |
13+
| `plpgsql-parser` | `packages/plpgsql-parser` | Parse PL/pgSQL to AST; `walkSql(text, ...)` for text-in traversal |
1414
| `plpgsql-deparser` | `packages/plpgsql-deparser` | Convert PL/pgSQL AST back to SQL |
1515
| `pgsql-types` | `packages/pgsql-types` | Narrowed TypeScript types inferred from SQL fixtures |
1616
| `@pgsql/types` | (published from proto-parser codegen) | Core TypeScript type definitions for PostgreSQL AST nodes |
1717
| `@pgsql/utils` | `packages/utils` | Type-safe AST node creation utilities |
18-
| `@pgsql/traverse` | `packages/traverse` | Visitor-pattern AST traversal |
18+
| `@pgsql/traverse` | `packages/traverse` | Visitor-pattern traversal of SQL and PL/pgSQL ASTs: `walk`, `walkSqlAst`, `walkPlpgsqlAst`, `traverse` |
1919
| `@pgsql/transform-ast` | `packages/transform-ast` | Multi-version AST transformer (PG 13-17) |
2020
| `@pgsql/transform` | `packages/transform` | SQL schema transformation, statement classification (AST facts), qualification, round-trip validation |
2121
| `@pgsql/quotes` | `packages/quotes` | SQL identifier/string quoting and keyword classification |
@@ -37,6 +37,7 @@ Detailed workflow documentation lives in `.agents/skills/`:
3737

3838
| Skill | Path | Covers |
3939
|-------|------|--------|
40+
| **AST Traversal** | `.agents/skills/ast-traversal/SKILL.md` | Walking SQL and PL/pgSQL ASTs: choosing `walk` / `walkSql` / `walkSqlAst` / `walkPlpgsqlAst` / `traverse`, statement context, visitor composition, abort, mutation |
4041
| **Testing & Fixtures** | `.agents/skills/testing-fixtures/SKILL.md` | Fixture-based testing pipeline, adding new test fixtures, kitchen-sink workflow, PL/pgSQL fixtures, transform tests |
4142
| **Code Generation** | `.agents/skills/code-generation/SKILL.md` | Protobuf codegen (`build:proto`), type inference/generation (`pgsql-types`), keyword generation (`@pgsql/quotes`), version-specific deparsers |
4243

@@ -115,6 +116,12 @@ Version configuration lives in `config/versions.json` — maps PG versions (13-1
115116

116117
- TypeScript throughout, compiled to both CJS and ESM
117118
- `@pgsql/types` provides all AST node types — use them for type safety
119+
- Traversal: reach for `walk` from `@pgsql/traverse` (any AST: SQL, PL/pgSQL, or a
120+
parsed script) or `walkSql` from `plpgsql-parser` (SQL text). Use the
121+
`walkSqlAst` / `walkPlpgsqlAst` primitives only when you deliberately want a
122+
single node universe with no statement context, and `traverse` when you need to
123+
mutate. Never hand-roll a `transformSync(..., { hydrate: true })` +
124+
per-statement loop harness — that is what `walk` is for
118125
- `@pgsql/quotes` handles SQL identifier quoting — use `QuoteUtils` methods
119126
- Test files go in `__tests__/` within each package
120127
- Fixture SQL files go in `__fixtures__/kitchen-sink/` (see testing-fixtures skill)

README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,30 @@ const walker: Walker = (path: NodePath) => {
149149

150150
walk(ast, walker);
151151

152-
// Using a visitor object (recommended for multiple node types)
153-
const visitor: Visitor = {
152+
// Using a visitor object (recommended for multiple node types).
153+
// SQL and PL/pgSQL node tags may be mixed; the second argument to each
154+
// callback describes the statement the node belongs to.
155+
walk(ast, {
154156
SelectStmt: (path) => {
155157
console.log('SELECT statement:', path.node);
156158
},
157-
RangeVar: (path) => {
159+
RangeVar: (path, ctx) => {
158160
console.log('Table:', path.node.relname);
159-
console.log('Path to table:', path.path);
160-
console.log('Parent node:', path.parent?.tag);
161-
}
162-
};
161+
console.log('Is a write target:', ctx.isWrite);
162+
console.log('Inside function:', ctx.functionName);
163+
},
164+
PLpgSQL_stmt_dynexecute: (_path, ctx) => ctx.abort('dynamic EXECUTE')
165+
});
166+
```
167+
168+
Starting from SQL **text** instead of an AST? Use `walkSql` from
169+
`plpgsql-parser`, which parses, hydrates PL/pgSQL bodies, and then walks:
170+
171+
```typescript
172+
import { loadModule, walkSql } from 'plpgsql-parser';
163173

164-
walk(ast, visitor);
174+
await loadModule();
175+
walkSql('SELECT * FROM users', { RangeVar: (path) => console.log(path.node.relname) });
165176
```
166177

167178
## 📦 Packages
@@ -175,7 +186,7 @@ walk(ast, visitor);
175186
| [**pg-proto-parser**](./packages/proto-parser) | PostgreSQL protobuf parser and code generator | • Generate TypeScript interfaces from protobuf<br>• Create enum mappings and utilities<br>• AST helper generation |
176187
| [**@pgsql/transform-ast**](./packages/transform-ast) | Multi-version PostgreSQL AST transformer | • Transform ASTs between PostgreSQL versions (13→17)<br>• Single source of truth deparser pipeline<br>• Backward compatibility for legacy SQL |
177188
| [**@pgsql/transform**](./packages/transform) | SQL transformation & classification | • Schema-name rewriting (incl. PL/pgSQL bodies)<br>• Per-statement AST facts (`classifyStatements`)<br>• Qualification & round-trip validation |
178-
| [**@pgsql/traverse**](./packages/traverse) | PostgreSQL AST traversal utilities |Visitor pattern for traversing PostgreSQL AST nodes<br>• NodePath context with parent/path information<br>• Runtime schema-based precise traversal |
189+
| [**@pgsql/traverse**](./packages/traverse) | SQL + PL/pgSQL AST traversal |One `walk()` for SQL ASTs, PL/pgSQL ASTs, and parsed scripts<br>• `NodePath` structure plus statement context (`isWrite`, `insideFunction`, ...)<br>• Visitor composition, skip-children, and whole-walk abort |
179190

180191
## 🛠️ Development
181192

0 commit comments

Comments
 (0)