Skip to content

Commit 3b061b8

Browse files
authored
Merge pull request #313 from constructive-io/fix/traverse-typed-field-dispatch
fix(traverse): dispatch visitors for untagged typed embedded fields
2 parents ff21b71 + 34fb02b commit 3b061b8

2 files changed

Lines changed: 396 additions & 43 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import type { Visitor } from '../src';
2+
import { NodePath, visit, walk } from '../src';
3+
4+
// AST literals below are real libpg_query (PG18) parse output. Concrete
5+
// typed embedded fields (e.g. CreatePolicyStmt.table: RangeVar) are bare
6+
// untagged objects in libpg_query JSON — no `{RangeVar: {...}}` wrapper.
7+
8+
const createPolicyAst = {
9+
CreatePolicyStmt: {
10+
policy_name: 'p',
11+
table: { schemaname: 'app', relname: 'posts', inh: true, relpersistence: 'p', location: 19 },
12+
cmd_name: 'select',
13+
permissive: true,
14+
roles: [{ RoleSpec: { roletype: 'ROLESPEC_PUBLIC', location: -1 } }],
15+
qual: { A_Const: { boolval: { boolval: true }, location: 47 } }
16+
}
17+
};
18+
19+
const indexStmtAst = {
20+
IndexStmt: {
21+
idxname: 'idx',
22+
relation: { schemaname: 'app', relname: 'posts', inh: true, relpersistence: 'p', location: 20 },
23+
accessMethod: 'btree',
24+
indexParams: [
25+
{ IndexElem: { name: 'id', ordering: 'SORTBY_DEFAULT', nulls_ordering: 'SORTBY_NULLS_DEFAULT' } }
26+
]
27+
}
28+
};
29+
30+
const createTrigAst = {
31+
CreateTrigStmt: {
32+
trigname: 'trg',
33+
relation: { schemaname: 'app', relname: 'posts', inh: true, relpersistence: 'p', location: 36 },
34+
funcname: [{ String: { sval: 'f' } }],
35+
timing: 2,
36+
events: 4
37+
}
38+
};
39+
40+
const alterTableAst = {
41+
AlterTableStmt: {
42+
relation: { schemaname: 'app', relname: 'posts', inh: true, relpersistence: 'p', location: 12 },
43+
cmds: [
44+
{
45+
AlterTableCmd: {
46+
subtype: 'AT_AddColumn',
47+
def: {
48+
ColumnDef: {
49+
colname: 'x',
50+
typeName: {
51+
names: [{ String: { sval: 'pg_catalog' } }, { String: { sval: 'int4' } }],
52+
typemod: -1,
53+
location: 35
54+
},
55+
is_local: true,
56+
location: 33
57+
}
58+
},
59+
behavior: 'DROP_RESTRICT'
60+
}
61+
}
62+
],
63+
objtype: 'OBJECT_TABLE'
64+
}
65+
};
66+
67+
describe('walk — typed embedded fields (untagged)', () => {
68+
it('visits RangeVar for CreatePolicyStmt.table', () => {
69+
const visited: NodePath[] = [];
70+
walk(createPolicyAst, { RangeVar: (path) => { visited.push(path); } });
71+
72+
expect(visited).toHaveLength(1);
73+
expect(visited[0].node.schemaname).toBe('app');
74+
expect(visited[0].node.relname).toBe('posts');
75+
expect(visited[0].tag).toBe('RangeVar');
76+
expect(visited[0].path).toEqual(['table']);
77+
expect(visited[0].parent?.tag).toBe('CreatePolicyStmt');
78+
});
79+
80+
it('visits RangeVar for IndexStmt.relation', () => {
81+
const visited: NodePath[] = [];
82+
walk(indexStmtAst, { RangeVar: (path) => { visited.push(path); } });
83+
84+
expect(visited).toHaveLength(1);
85+
expect(visited[0].node.relname).toBe('posts');
86+
expect(visited[0].path).toEqual(['relation']);
87+
});
88+
89+
it('visits RangeVar for CreateTrigStmt.relation', () => {
90+
const visited: NodePath[] = [];
91+
walk(createTrigAst, { RangeVar: (path) => { visited.push(path); } });
92+
93+
expect(visited).toHaveLength(1);
94+
expect(visited[0].node.relname).toBe('posts');
95+
expect(visited[0].path).toEqual(['relation']);
96+
});
97+
98+
it('visits RangeVar for AlterTableStmt.relation', () => {
99+
const visited: NodePath[] = [];
100+
walk(alterTableAst, { RangeVar: (path) => { visited.push(path); } });
101+
102+
expect(visited).toHaveLength(1);
103+
expect(visited[0].node.relname).toBe('posts');
104+
expect(visited[0].path).toEqual(['relation']);
105+
});
106+
107+
it('visits TypeName for ColumnDef.typeName (untagged typed field)', () => {
108+
const visited: NodePath[] = [];
109+
walk(alterTableAst, { TypeName: (path) => { visited.push(path); } });
110+
111+
expect(visited).toHaveLength(1);
112+
expect(visited[0].node.names).toHaveLength(2);
113+
expect(visited[0].parent?.tag).toBe('ColumnDef');
114+
});
115+
116+
it('visits RawStmt for the array-typed ParseResult.stmts field', () => {
117+
const parseResult = {
118+
ParseResult: {
119+
version: 180004,
120+
stmts: [
121+
{ stmt: createPolicyAst, stmt_len: 52 },
122+
{ stmt: indexStmtAst, stmt_len: 34 }
123+
]
124+
}
125+
};
126+
const rawStmts: NodePath[] = [];
127+
const rangeVars: NodePath[] = [];
128+
walk(parseResult, {
129+
RawStmt: (path) => { rawStmts.push(path); },
130+
RangeVar: (path) => { rangeVars.push(path); }
131+
});
132+
133+
expect(rawStmts).toHaveLength(2);
134+
expect(rawStmts[0].tag).toBe('RawStmt');
135+
expect(rawStmts[0].path).toEqual(['stmts', 0]);
136+
expect(rawStmts[1].path).toEqual(['stmts', 1]);
137+
expect(rangeVars).toHaveLength(2);
138+
});
139+
140+
it('returning false from a synthesized-tag visitor skips its children', () => {
141+
const ast = {
142+
IndexStmt: {
143+
idxname: 'idx',
144+
relation: {
145+
relname: 'posts',
146+
inh: true,
147+
relpersistence: 'p',
148+
alias: { aliasname: 'a' }
149+
},
150+
accessMethod: 'btree'
151+
}
152+
};
153+
const aliases: NodePath[] = [];
154+
walk(ast, {
155+
RangeVar: () => false,
156+
Alias: (path) => { aliases.push(path); }
157+
});
158+
expect(aliases).toHaveLength(0);
159+
160+
walk(ast, { Alias: (path) => { aliases.push(path); } });
161+
expect(aliases).toHaveLength(1);
162+
expect(aliases[0].node.aliasname).toBe('a');
163+
});
164+
165+
it('visits Query and its nested typed fields for IntoClause.viewQuery', () => {
166+
// IntoClause.viewQuery and RangeTblEntry.subquery are the two concrete
167+
// Query-typed fields in the PG18 runtime schema.
168+
const ast = {
169+
CreateTableAsStmt: {
170+
into: {
171+
rel: { relname: 'mv', inh: true, relpersistence: 'p' },
172+
viewQuery: {
173+
commandType: 'CMD_SELECT',
174+
rtable: [
175+
{
176+
RangeTblEntry: {
177+
rtekind: 'RTE_SUBQUERY',
178+
subquery: {
179+
commandType: 'CMD_SELECT',
180+
jointree: { fromlist: [] as any[] }
181+
},
182+
eref: { aliasname: 'inner_q' }
183+
}
184+
}
185+
],
186+
jointree: {
187+
fromlist: [{ RangeVar: { relname: 'src', inh: true, relpersistence: 'p' } }]
188+
}
189+
}
190+
},
191+
objtype: 'OBJECT_MATVIEW'
192+
}
193+
};
194+
195+
const tags: string[] = [];
196+
walk(ast, (path) => { tags.push(path.tag); });
197+
198+
// outer Query via IntoClause.viewQuery, inner Query via RangeTblEntry.subquery
199+
expect(tags.filter((t) => t === 'Query')).toHaveLength(2);
200+
// FromExpr synthesized for both Query.jointree fields
201+
expect(tags.filter((t) => t === 'FromExpr')).toHaveLength(2);
202+
// RangeVar: IntoClause.rel (untagged) + tagged one inside fromlist
203+
expect(tags.filter((t) => t === 'RangeVar')).toHaveLength(2);
204+
// Alias synthesized for RangeTblEntry.eref
205+
expect(tags).toContain('Alias');
206+
expect(tags).toContain('RangeTblEntry');
207+
expect(tags).toContain('IntoClause');
208+
});
209+
210+
it('handles empty untagged typed values and mixed tagged/untagged arrays', () => {
211+
const emptyRel = { IndexStmt: { relation: {} } };
212+
const visited: NodePath[] = [];
213+
walk(emptyRel, { RangeVar: (path) => { visited.push(path); } });
214+
expect(visited).toHaveLength(1);
215+
expect(visited[0].node).toEqual({});
216+
217+
// ParseResult.stmts with one bare RawStmt and one already-tagged entry
218+
const mixed = {
219+
ParseResult: {
220+
version: 180004,
221+
stmts: [
222+
{ stmt: indexStmtAst, stmt_len: 34 },
223+
{ RawStmt: { stmt: createTrigAst, stmt_len: 66 } }
224+
]
225+
}
226+
};
227+
const rawStmts: NodePath[] = [];
228+
walk(mixed, { RawStmt: (path) => { rawStmts.push(path); } });
229+
expect(rawStmts).toHaveLength(2);
230+
});
231+
232+
it('keeps existing behavior for tagged nodes', () => {
233+
const visited: string[] = [];
234+
const visitor: Visitor = {
235+
CreatePolicyStmt: (path) => { visited.push(`CreatePolicyStmt:${path.node.policy_name}`); },
236+
RoleSpec: (path) => { visited.push(`RoleSpec:${path.node.roletype}`); },
237+
A_Const: () => { visited.push('A_Const'); }
238+
};
239+
walk(createPolicyAst, visitor);
240+
expect(visited.sort()).toEqual([
241+
'A_Const',
242+
'CreatePolicyStmt:p',
243+
'RoleSpec:ROLESPEC_PUBLIC'
244+
]);
245+
});
246+
});
247+
248+
describe('visit — typed embedded fields (untagged)', () => {
249+
it('visits RangeVar for CreatePolicyStmt.table', () => {
250+
const visited: any[] = [];
251+
visit(createPolicyAst, {
252+
RangeVar: (node, ctx) => { visited.push({ node, ctx }); }
253+
});
254+
255+
expect(visited).toHaveLength(1);
256+
expect(visited[0].node.relname).toBe('posts');
257+
expect(visited[0].ctx.path).toEqual(['table']);
258+
});
259+
260+
it('visits RawStmt for the array-typed ParseResult.stmts field', () => {
261+
const parseResult = {
262+
ParseResult: {
263+
version: 180004,
264+
stmts: [{ stmt: indexStmtAst, stmt_len: 34 }]
265+
}
266+
};
267+
const rawStmts: any[] = [];
268+
const rangeVars: any[] = [];
269+
visit(parseResult, {
270+
RawStmt: (node) => { rawStmts.push(node); },
271+
RangeVar: (node) => { rangeVars.push(node); }
272+
});
273+
274+
expect(rawStmts).toHaveLength(1);
275+
expect(rangeVars).toHaveLength(1);
276+
expect(rangeVars[0].relname).toBe('posts');
277+
});
278+
279+
it('keeps existing behavior for tagged nodes', () => {
280+
const visited: string[] = [];
281+
visit(createPolicyAst, {
282+
CreatePolicyStmt: (node) => { visited.push(`CreatePolicyStmt:${node.policy_name}`); },
283+
RoleSpec: (node) => { visited.push(`RoleSpec:${node.roletype}`); }
284+
});
285+
expect(visited).toEqual(['CreatePolicyStmt:p', 'RoleSpec:ROLESPEC_PUBLIC']);
286+
});
287+
});

0 commit comments

Comments
 (0)