Skip to content

Commit 7354e55

Browse files
committed
feat: add fixture generation and round-trip testing for plpgsql-deparser
- Add make-fixtures.ts script that generates fixtures from __fixtures__/plpgsql/ - Create generated.json with 176 valid PL/pgSQL statements (full SQL statements) - Add comprehensive test-utils with cleanPlpgsqlTree for AST comparison - Implement expectAstMatch for round-trip testing (parse -> deparse -> reparse) - Add FixtureTestUtils class for loading and running fixture tests - Update tests to use generated.json with proper round-trip validation - Add 'fixtures' npm script to package.json
1 parent 7c4d752 commit 7354e55

6 files changed

Lines changed: 673 additions & 34 deletions

File tree

__fixtures__/plpgsql-generated/generated.json

Lines changed: 178 additions & 0 deletions
Large diffs are not rendered by default.

packages/plpgsql-deparser/__tests__/plpgsql-deparser.test.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { loadModule } from '@libpg-query/parser';
22
import { PLpgSQLDeparser, deparseSync, PLpgSQLParseResult } from '../src';
3-
import { loadPLpgSQLFixtures, PLpgSQLTestUtils } from '../test-utils';
4-
5-
const testUtils = new PLpgSQLTestUtils();
3+
import { FixtureTestUtils } from '../test-utils';
64

75
describe('PLpgSQLDeparser', () => {
6+
let fixtureTestUtils: FixtureTestUtils;
7+
88
beforeAll(async () => {
99
await loadModule();
10+
fixtureTestUtils = new FixtureTestUtils();
1011
});
1112

1213
describe('empty results', () => {
@@ -20,32 +21,24 @@ describe('PLpgSQLDeparser', () => {
2021
});
2122
});
2223

23-
describe('fixture-based tests using @libpg-query/parser', () => {
24-
const fixtures = loadPLpgSQLFixtures();
25-
26-
if (fixtures.length > 0) {
27-
describe('PL/pgSQL fixtures from __fixtures__/plpgsql/', () => {
28-
const sampleFixtures = fixtures.slice(0, 50);
29-
30-
it.each(sampleFixtures)('should parse and deparse $name', (testCase) => {
31-
try {
32-
const parsed = testUtils.parsePLpgSQLSync(testCase.functionBody);
33-
34-
if (parsed.plpgsql_funcs && parsed.plpgsql_funcs.length > 0) {
35-
const deparsed = deparseSync(parsed);
36-
expect(deparsed).toBeTruthy();
37-
expect(deparsed.length).toBeGreaterThan(0);
38-
}
39-
} catch (err) {
40-
console.log(`Skipping ${testCase.name}: ${err instanceof Error ? err.message : String(err)}`);
41-
}
42-
});
43-
});
44-
}
45-
46-
it('should load fixtures from actual SQL files', () => {
47-
const fixtures = loadPLpgSQLFixtures();
48-
expect(fixtures.length).toBeGreaterThan(0);
24+
describe('generated fixtures', () => {
25+
it('should have generated fixtures available', () => {
26+
expect(fixtureTestUtils.getFixtureCount()).toBeGreaterThan(0);
27+
});
28+
29+
it('should have at least 100 valid fixtures', () => {
30+
expect(fixtureTestUtils.getFixtureCount()).toBeGreaterThanOrEqual(100);
31+
});
32+
});
33+
34+
describe('round-trip tests using generated.json', () => {
35+
it('should round-trip plpgsql_domain fixtures', async () => {
36+
const entries = fixtureTestUtils.getTestEntries(['plpgsql_domain']);
37+
expect(entries.length).toBeGreaterThan(0);
38+
39+
for (const [key] of entries) {
40+
await fixtureTestUtils.runSingleFixture(key);
41+
}
4942
});
5043
});
5144

packages/plpgsql-deparser/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"prepublishOnly": "npm run build",
2626
"build": "npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy",
2727
"build:dev": "npm run clean && tsc --declarationMap && tsc -p tsconfig.esm.json && npm run copy",
28+
"fixtures": "ts-node scripts/make-fixtures.ts",
2829
"lint": "eslint . --fix",
2930
"test": "jest",
3031
"test:watch": "jest --watch"
@@ -42,6 +43,7 @@
4243
],
4344
"devDependencies": {
4445
"@libpg-query/parser": "^17.6.3",
46+
"libpg-query": "17.7.3",
4547
"makage": "^0.1.8"
4648
},
4749
"dependencies": {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env ts-node
2+
import * as path from 'path';
3+
import * as fs from 'fs';
4+
import { sync as globSync } from 'glob';
5+
import { parse } from 'libpg-query';
6+
import { parsePlPgSQLSync, loadModule } from '@libpg-query/parser';
7+
8+
const FIXTURE_DIR = path.join(__dirname, '../../../__fixtures__/plpgsql');
9+
const OUT_DIR = path.join(__dirname, '../../../__fixtures__/plpgsql-generated');
10+
11+
function ensureDir(dir: string) {
12+
if (!fs.existsSync(dir)) {
13+
fs.mkdirSync(dir, { recursive: true });
14+
}
15+
}
16+
17+
interface ExtractedStatement {
18+
statement: string;
19+
index: number;
20+
location?: number;
21+
length?: number;
22+
}
23+
24+
function extractStatement(
25+
originalSQL: string,
26+
stmtLocation: number | undefined,
27+
stmtLen: number | undefined,
28+
isFirst: boolean = false
29+
): string | null {
30+
const sqlBuffer = Buffer.from(originalSQL, 'utf8');
31+
let extracted: string | null = null;
32+
33+
if (stmtLocation !== undefined && stmtLen !== undefined) {
34+
const startByte = stmtLocation;
35+
const endByte = stmtLocation + stmtLen;
36+
const extractedBuffer = sqlBuffer.slice(startByte, endByte);
37+
extracted = extractedBuffer.toString('utf8');
38+
} else if (stmtLocation !== undefined && stmtLen === undefined) {
39+
const extractedBuffer = sqlBuffer.slice(stmtLocation);
40+
extracted = extractedBuffer.toString('utf8');
41+
} else if (isFirst && stmtLen !== undefined) {
42+
const extractedBuffer = sqlBuffer.slice(0, stmtLen);
43+
extracted = extractedBuffer.toString('utf8');
44+
} else if (isFirst && stmtLocation === undefined && stmtLen === undefined) {
45+
extracted = originalSQL;
46+
}
47+
48+
if (extracted) {
49+
extracted = extracted.trim();
50+
}
51+
52+
return extracted;
53+
}
54+
55+
function isPLpgSQLStatement(stmt: any): boolean {
56+
if ('CreateFunctionStmt' in stmt) {
57+
const options = stmt.CreateFunctionStmt.options || [];
58+
for (const opt of options) {
59+
if ('DefElem' in opt && opt.DefElem.defname === 'language') {
60+
const lang = opt.DefElem.arg?.String?.sval?.toLowerCase();
61+
if (lang === 'plpgsql') {
62+
return true;
63+
}
64+
}
65+
}
66+
}
67+
if ('DoStmt' in stmt) {
68+
return true;
69+
}
70+
return false;
71+
}
72+
73+
function generateStatementKey(relativePath: string, statementIndex: number): string {
74+
return `${relativePath.replace(/\.sql$/, '')}-${statementIndex + 1}.sql`;
75+
}
76+
77+
async function main() {
78+
await loadModule();
79+
80+
ensureDir(OUT_DIR);
81+
82+
const fixtures = globSync(path.join(FIXTURE_DIR, '**/*.sql'));
83+
const results: Record<string, string> = {};
84+
let totalStatements = 0;
85+
let validStatements = 0;
86+
let skippedStatements = 0;
87+
88+
console.log(`Found ${fixtures.length} fixture files`);
89+
90+
for (const fixturePath of fixtures) {
91+
const relPath = path.relative(FIXTURE_DIR, fixturePath);
92+
const sql = fs.readFileSync(fixturePath, 'utf-8');
93+
94+
try {
95+
const parseResult = await parse(sql);
96+
97+
if (!parseResult.stmts) {
98+
continue;
99+
}
100+
101+
let stmtIndex = 0;
102+
for (let idx = 0; idx < parseResult.stmts.length; idx++) {
103+
const rawStmt = parseResult.stmts[idx];
104+
const stmt = rawStmt.stmt;
105+
106+
if (!isPLpgSQLStatement(stmt)) {
107+
continue;
108+
}
109+
110+
totalStatements++;
111+
112+
const extracted = extractStatement(
113+
sql,
114+
rawStmt.stmt_location,
115+
rawStmt.stmt_len,
116+
idx === 0
117+
);
118+
119+
if (!extracted) {
120+
console.error(`Failed to extract statement ${idx} from ${relPath}`);
121+
skippedStatements++;
122+
continue;
123+
}
124+
125+
try {
126+
parsePlPgSQLSync(extracted);
127+
128+
const key = generateStatementKey(relPath, stmtIndex);
129+
results[key] = extracted;
130+
validStatements++;
131+
stmtIndex++;
132+
} catch (parseErr: any) {
133+
console.warn(`Skipping ${relPath}:${idx} - PL/pgSQL parse failed: ${parseErr.message}`);
134+
skippedStatements++;
135+
}
136+
}
137+
} catch (err: any) {
138+
console.error(`Failed to parse ${relPath}:`, err.message);
139+
continue;
140+
}
141+
}
142+
143+
const outputFile = path.join(OUT_DIR, 'generated.json');
144+
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2));
145+
146+
console.log(`\nFixture generation complete:`);
147+
console.log(` Total PL/pgSQL statements found: ${totalStatements}`);
148+
console.log(` Valid statements (parseable): ${validStatements}`);
149+
console.log(` Skipped statements: ${skippedStatements}`);
150+
console.log(` Output: ${outputFile}`);
151+
}
152+
153+
main().catch(console.error);

0 commit comments

Comments
 (0)