Skip to content

Commit ad4237b

Browse files
committed
test(plpgsql-parser): add fixture-based round-trip tests for full integration
1 parent af1a5b4 commit ad4237b

File tree

1 file changed

+291
-0
lines changed

1 file changed

+291
-0
lines changed
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { parse, deparseSync, loadModule } from '../src';
2+
import { parseSync } from '@libpg-query/parser';
3+
import { readFileSync, existsSync } from 'fs';
4+
import * as path from 'path';
5+
6+
const FIXTURES_DIR = path.join(__dirname, '../../../__fixtures__/plpgsql');
7+
const GENERATED_JSON = path.join(__dirname, '../../../__fixtures__/plpgsql-generated/generated.json');
8+
9+
const noop = (): undefined => undefined;
10+
11+
const transform = (obj: any, props: any): any => {
12+
let copy: any = null;
13+
if (obj == null || typeof obj !== 'object') {
14+
return obj;
15+
}
16+
17+
if (obj instanceof Date) {
18+
copy = new Date();
19+
copy.setTime(obj.getTime());
20+
return copy;
21+
}
22+
23+
if (obj instanceof Array) {
24+
copy = [];
25+
for (let i = 0, len = obj.length; i < len; i++) {
26+
copy[i] = transform(obj[i], props);
27+
}
28+
return copy;
29+
}
30+
31+
if (obj instanceof Object || typeof obj === 'object') {
32+
copy = {};
33+
for (const attr in obj) {
34+
if (obj.hasOwnProperty(attr)) {
35+
let value: any;
36+
if (props.hasOwnProperty(attr)) {
37+
if (typeof props[attr] === 'function') {
38+
value = props[attr](obj[attr]);
39+
} else if (props[attr].hasOwnProperty(obj[attr])) {
40+
value = props[attr][obj[attr]];
41+
} else {
42+
value = transform(obj[attr], props);
43+
}
44+
} else {
45+
value = transform(obj[attr], props);
46+
}
47+
if (value !== undefined) {
48+
copy[attr] = value;
49+
}
50+
} else {
51+
const value = transform(obj[attr], props);
52+
if (value !== undefined) {
53+
copy[attr] = value;
54+
}
55+
}
56+
}
57+
return copy;
58+
}
59+
60+
throw new Error("Unable to copy obj! Its type isn't supported.");
61+
};
62+
63+
const cleanSqlTree = (tree: any) => {
64+
return transform(tree, {
65+
stmt_len: noop,
66+
stmt_location: noop,
67+
location: noop,
68+
});
69+
};
70+
71+
beforeAll(async () => {
72+
await loadModule();
73+
});
74+
75+
describe('plpgsql-parser round-trip tests', () => {
76+
describe('fixture-based integration tests', () => {
77+
const fixtureFile = path.join(FIXTURES_DIR, 'plpgsql_deparser_fixes.sql');
78+
79+
if (!existsSync(fixtureFile)) {
80+
it.skip('fixture file not found', () => {});
81+
return;
82+
}
83+
84+
const sql = readFileSync(fixtureFile, 'utf-8');
85+
const statements = sql.split(/;\s*\n/).filter(s => s.trim() && !s.trim().startsWith('--'));
86+
87+
it.each(statements.map((stmt, i) => [i + 1, stmt.trim() + ';']))
88+
('should round-trip statement %i', async (_, statement) => {
89+
const stmt = statement as string;
90+
91+
// Skip empty statements or comments
92+
if (!stmt.match(/CREATE\s+(FUNCTION|PROCEDURE)/i)) {
93+
return;
94+
}
95+
96+
// Parse with plpgsql-parser (auto-hydrates)
97+
const parsed = parse(stmt);
98+
99+
// Deparse with plpgsql-parser (auto-passes return info)
100+
const deparsed = deparseSync(parsed);
101+
102+
// Reparse the deparsed SQL
103+
const reparsed = parse(deparsed);
104+
105+
// Clean both ASTs for comparison
106+
const originalClean = cleanSqlTree(parsed.sql);
107+
const reparsedClean = cleanSqlTree(reparsed.sql);
108+
109+
// Compare SQL ASTs
110+
expect(reparsedClean).toEqual(originalClean);
111+
});
112+
});
113+
114+
describe('return info integration', () => {
115+
it('should handle SETOF function with bare RETURN correctly', () => {
116+
const sql = `
117+
CREATE FUNCTION get_items()
118+
RETURNS SETOF int
119+
LANGUAGE plpgsql AS $$
120+
BEGIN
121+
RETURN QUERY SELECT 1;
122+
RETURN;
123+
END;
124+
$$;
125+
`;
126+
127+
const parsed = parse(sql);
128+
const deparsed = deparseSync(parsed);
129+
130+
// SETOF functions should keep bare RETURN (not RETURN NULL)
131+
expect(deparsed).toMatch(/RETURN\s*;/);
132+
expect(deparsed).not.toMatch(/RETURN\s+NULL\s*;/);
133+
134+
// Verify round-trip
135+
const reparsed = parse(deparsed);
136+
expect(cleanSqlTree(reparsed.sql)).toEqual(cleanSqlTree(parsed.sql));
137+
});
138+
139+
it('should handle scalar function with empty RETURN correctly', () => {
140+
const sql = `
141+
CREATE FUNCTION get_value()
142+
RETURNS int
143+
LANGUAGE plpgsql AS $$
144+
BEGIN
145+
RETURN;
146+
END;
147+
$$;
148+
`;
149+
150+
const parsed = parse(sql);
151+
const deparsed = deparseSync(parsed);
152+
153+
// Scalar functions with empty RETURN should become RETURN NULL
154+
expect(deparsed).toMatch(/RETURN\s+NULL\s*;/);
155+
156+
// Verify round-trip (AST should match after normalization)
157+
const reparsed = parse(deparsed);
158+
expect(cleanSqlTree(reparsed.sql)).toEqual(cleanSqlTree(parsed.sql));
159+
});
160+
161+
it('should handle void function with bare RETURN correctly', () => {
162+
const sql = `
163+
CREATE FUNCTION do_nothing()
164+
RETURNS void
165+
LANGUAGE plpgsql AS $$
166+
BEGIN
167+
RETURN;
168+
END;
169+
$$;
170+
`;
171+
172+
const parsed = parse(sql);
173+
const deparsed = deparseSync(parsed);
174+
175+
// Void functions should keep bare RETURN
176+
expect(deparsed).toMatch(/RETURN\s*;/);
177+
expect(deparsed).not.toMatch(/RETURN\s+NULL\s*;/);
178+
179+
// Verify round-trip
180+
const reparsed = parse(deparsed);
181+
expect(cleanSqlTree(reparsed.sql)).toEqual(cleanSqlTree(parsed.sql));
182+
});
183+
184+
it('should handle OUT parameter function with bare RETURN correctly', () => {
185+
const sql = `
186+
CREATE FUNCTION get_info(OUT result text)
187+
RETURNS text
188+
LANGUAGE plpgsql AS $$
189+
BEGIN
190+
result := 'hello';
191+
RETURN;
192+
END;
193+
$$;
194+
`;
195+
196+
const parsed = parse(sql);
197+
const deparsed = deparseSync(parsed);
198+
199+
// OUT parameter functions should keep bare RETURN
200+
expect(deparsed).toMatch(/RETURN\s*;/);
201+
expect(deparsed).not.toMatch(/RETURN\s+NULL\s*;/);
202+
203+
// Verify round-trip
204+
const reparsed = parse(deparsed);
205+
expect(cleanSqlTree(reparsed.sql)).toEqual(cleanSqlTree(parsed.sql));
206+
});
207+
208+
it('should handle trigger function correctly', () => {
209+
const sql = `
210+
CREATE FUNCTION my_trigger()
211+
RETURNS trigger
212+
LANGUAGE plpgsql AS $$
213+
BEGIN
214+
RETURN NEW;
215+
END;
216+
$$;
217+
`;
218+
219+
const parsed = parse(sql);
220+
const deparsed = deparseSync(parsed);
221+
222+
// Trigger functions should work correctly
223+
expect(deparsed.toLowerCase()).toContain('return new');
224+
225+
// Verify round-trip
226+
const reparsed = parse(deparsed);
227+
expect(cleanSqlTree(reparsed.sql)).toEqual(cleanSqlTree(parsed.sql));
228+
});
229+
});
230+
231+
describe('generated fixtures round-trip', () => {
232+
if (!existsSync(GENERATED_JSON)) {
233+
it.skip('generated.json not found', () => {});
234+
return;
235+
}
236+
237+
const fixtures: Record<string, string> = JSON.parse(readFileSync(GENERATED_JSON, 'utf-8'));
238+
const entries = Object.entries(fixtures);
239+
240+
it('should have generated fixtures available', () => {
241+
expect(entries.length).toBeGreaterThan(0);
242+
});
243+
244+
it('should round-trip all generated fixtures through plpgsql-parser', async () => {
245+
const failures: { key: string; error: string }[] = [];
246+
247+
for (const [key, sql] of entries) {
248+
try {
249+
// Parse with plpgsql-parser
250+
const parsed = parse(sql);
251+
252+
// Only test if we found PL/pgSQL functions
253+
if (parsed.functions.length === 0) {
254+
continue;
255+
}
256+
257+
// Deparse with plpgsql-parser
258+
const deparsed = deparseSync(parsed);
259+
260+
// Reparse
261+
const reparsed = parse(deparsed);
262+
263+
// Compare cleaned ASTs
264+
const originalClean = cleanSqlTree(parsed.sql);
265+
const reparsedClean = cleanSqlTree(reparsed.sql);
266+
267+
expect(reparsedClean).toEqual(originalClean);
268+
} catch (err) {
269+
failures.push({
270+
key,
271+
error: err instanceof Error ? err.message : String(err),
272+
});
273+
}
274+
}
275+
276+
if (failures.length > 0) {
277+
const failureReport = failures
278+
.slice(0, 10)
279+
.map(f => ` - ${f.key}: ${f.error.substring(0, 100)}`)
280+
.join('\n');
281+
console.log(`\n${failures.length} fixture failures:\n${failureReport}`);
282+
}
283+
284+
// Allow some failures for now, but track them
285+
const failureRate = failures.length / entries.length;
286+
expect(failureRate).toBeLessThan(0.1); // Less than 10% failure rate
287+
288+
console.log(`\nRound-trip tested ${entries.length - failures.length} of ${entries.length} fixtures through plpgsql-parser`);
289+
}, 120000);
290+
});
291+
});

0 commit comments

Comments
 (0)