Skip to content

Commit 290d8a7

Browse files
committed
feat: add plpgsql-parser package for combined SQL + PL/pgSQL parsing
- Combined parse function that auto-detects PL/pgSQL functions and hydrates them - Transform API for parse -> modify -> deparse pipeline - Deparse function that handles dehydration and stitching - Re-exports underlying primitives for power users - 7 tests passing
1 parent 0afd8a5 commit 290d8a7

11 files changed

Lines changed: 631 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { parse, transformSync, deparseSync, loadModule } from '../src';
2+
3+
beforeAll(async () => {
4+
await loadModule();
5+
});
6+
7+
const simpleFunctionSql = `
8+
CREATE OR REPLACE FUNCTION test_func(p_id int)
9+
RETURNS void
10+
LANGUAGE plpgsql
11+
AS $$
12+
DECLARE
13+
v_count int;
14+
BEGIN
15+
v_count := 0;
16+
RAISE NOTICE 'Count: %', v_count;
17+
END;
18+
$$;
19+
`;
20+
21+
const multiStatementSql = `
22+
CREATE TABLE users (id int);
23+
24+
CREATE OR REPLACE FUNCTION get_user(p_id int)
25+
RETURNS int
26+
LANGUAGE plpgsql
27+
AS $$
28+
BEGIN
29+
RETURN p_id;
30+
END;
31+
$$;
32+
33+
CREATE INDEX idx_users_id ON users(id);
34+
`;
35+
36+
describe('plpgsql-parser', () => {
37+
describe('parse', () => {
38+
it('should parse a simple PL/pgSQL function', () => {
39+
const result = parse(simpleFunctionSql);
40+
41+
expect(result.sql).toBeDefined();
42+
expect(result.sql.stmts).toHaveLength(1);
43+
expect(result.items).toHaveLength(1);
44+
expect(result.functions).toHaveLength(1);
45+
46+
const fn = result.functions[0];
47+
expect(fn.kind).toBe('plpgsql-function');
48+
expect(fn.language).toBe('plpgsql');
49+
expect(fn.body.raw).toContain('v_count');
50+
expect(fn.plpgsql.hydrated).toBeDefined();
51+
expect(fn.plpgsql.stats.totalExpressions).toBeGreaterThan(0);
52+
});
53+
54+
it('should parse multi-statement SQL with mixed content', () => {
55+
const result = parse(multiStatementSql);
56+
57+
expect(result.sql.stmts).toHaveLength(3);
58+
expect(result.items).toHaveLength(3);
59+
expect(result.functions).toHaveLength(1);
60+
61+
expect(result.items[0].kind).toBe('stmt');
62+
expect(result.items[1].kind).toBe('plpgsql-function');
63+
expect(result.items[2].kind).toBe('stmt');
64+
});
65+
66+
it('should skip hydration when hydrate=false', () => {
67+
const result = parse(simpleFunctionSql, { hydrate: false });
68+
69+
expect(result.functions).toHaveLength(0);
70+
expect(result.items).toHaveLength(1);
71+
expect(result.items[0].kind).toBe('stmt');
72+
});
73+
});
74+
75+
describe('transformSync', () => {
76+
it('should transform function using callback', () => {
77+
const result = transformSync(simpleFunctionSql, (ctx) => {
78+
const fn = ctx.functions[0];
79+
fn.stmt.funcname[0].String.sval = 'renamed_func';
80+
});
81+
82+
expect(result).toContain('renamed_func');
83+
});
84+
85+
it('should transform function using visitor', () => {
86+
const result = transformSync(simpleFunctionSql, {
87+
onFunction: (fn) => {
88+
fn.stmt.funcname[0].String.sval = 'visitor_renamed';
89+
}
90+
});
91+
92+
expect(result).toContain('visitor_renamed');
93+
});
94+
});
95+
96+
describe('deparseSync', () => {
97+
it('should deparse parsed script back to SQL', () => {
98+
const parsed = parse(simpleFunctionSql);
99+
const result = deparseSync(parsed);
100+
101+
expect(result).toContain('CREATE');
102+
expect(result).toContain('FUNCTION');
103+
expect(result).toContain('test_func');
104+
expect(result).toContain('plpgsql');
105+
});
106+
107+
it('should support pretty printing', () => {
108+
const parsed = parse(simpleFunctionSql);
109+
const result = deparseSync(parsed, { pretty: true });
110+
111+
expect(result).toContain('\n');
112+
});
113+
});
114+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: "ts-jest",
4+
testEnvironment: "node",
5+
transform: {
6+
"^.+\\.tsx?$": [
7+
"ts-jest",
8+
{
9+
babelConfig: false,
10+
tsconfig: "tsconfig.json",
11+
},
12+
],
13+
},
14+
transformIgnorePatterns: [`/node_modules/*`],
15+
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
16+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
17+
modulePathIgnorePatterns: ["dist/*"]
18+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "plpgsql-parser",
3+
"version": "0.1.0",
4+
"author": "Constructive <developers@constructive.io>",
5+
"description": "Combined SQL + PL/pgSQL parser with hydrated ASTs and transform API",
6+
"main": "index.js",
7+
"module": "esm/index.js",
8+
"types": "index.d.ts",
9+
"homepage": "https://github.com/constructive-io/pgsql-parser",
10+
"license": "MIT",
11+
"publishConfig": {
12+
"access": "public",
13+
"directory": "dist"
14+
},
15+
"repository": {
16+
"type": "git",
17+
"url": "https://github.com/constructive-io/pgsql-parser"
18+
},
19+
"bugs": {
20+
"url": "https://github.com/constructive-io/pgsql-parser/issues"
21+
},
22+
"scripts": {
23+
"copy": "makage assets",
24+
"clean": "makage clean dist",
25+
"prepublishOnly": "npm run build",
26+
"build": "npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy",
27+
"build:dev": "npm run clean && tsc --declarationMap && tsc -p tsconfig.esm.json && npm run copy",
28+
"lint": "eslint . --fix",
29+
"test": "jest",
30+
"test:watch": "jest --watch"
31+
},
32+
"keywords": [
33+
"sql",
34+
"postgres",
35+
"postgresql",
36+
"pg",
37+
"plpgsql",
38+
"query",
39+
"ast",
40+
"parser",
41+
"deparser",
42+
"transform",
43+
"database"
44+
],
45+
"devDependencies": {
46+
"makage": "^0.1.8"
47+
},
48+
"dependencies": {
49+
"@libpg-query/parser": "^17.6.3",
50+
"@pgsql/types": "^17.6.2",
51+
"pgsql-deparser": "workspace:*",
52+
"plpgsql-deparser": "workspace:*"
53+
}
54+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { deparse as deparseSql } from 'pgsql-deparser';
2+
import {
3+
dehydratePlpgsqlAst,
4+
deparseSync as deparsePlpgsql
5+
} from 'plpgsql-deparser';
6+
import type {
7+
ParsedScript,
8+
TransformContext,
9+
DeparseOptions,
10+
ParsedFunction
11+
} from './types';
12+
13+
function stitchBodyIntoSqlAst(
14+
sqlAst: any,
15+
fn: ParsedFunction,
16+
newBody: string
17+
): void {
18+
const stmts = sqlAst.stmts;
19+
if (!stmts || !stmts[fn.stmtIndex]) return;
20+
21+
const rawStmt = stmts[fn.stmtIndex];
22+
const createFunctionStmt = rawStmt?.stmt?.CreateFunctionStmt;
23+
if (!createFunctionStmt?.options) return;
24+
25+
for (const opt of createFunctionStmt.options) {
26+
if (opt?.DefElem?.defname === 'as') {
27+
const arg = opt.DefElem.arg;
28+
if (arg?.List?.items?.[0]?.String) {
29+
arg.List.items[0].String.sval = newBody;
30+
return;
31+
}
32+
if (arg?.String) {
33+
arg.String.sval = newBody;
34+
return;
35+
}
36+
}
37+
}
38+
}
39+
40+
export async function deparse(
41+
input: ParsedScript | TransformContext,
42+
options: DeparseOptions = {}
43+
): Promise<string> {
44+
const { pretty = true } = options;
45+
46+
const sqlAst = input.sql;
47+
const functions = input.functions;
48+
49+
for (const fn of functions) {
50+
const dehydrated = dehydratePlpgsqlAst(fn.plpgsql.hydrated);
51+
const newBody = deparsePlpgsql(dehydrated);
52+
stitchBodyIntoSqlAst(sqlAst, fn, newBody);
53+
}
54+
55+
if (sqlAst.stmts && sqlAst.stmts.length > 0) {
56+
const results: string[] = [];
57+
for (const rawStmt of sqlAst.stmts) {
58+
if (rawStmt?.stmt) {
59+
const deparsed = await deparseSql(rawStmt.stmt, { pretty });
60+
results.push(deparsed);
61+
}
62+
}
63+
return results.join(';\n\n') + (results.length > 0 ? ';' : '');
64+
}
65+
66+
return '';
67+
}
68+
69+
export function deparseSync(
70+
input: ParsedScript | TransformContext,
71+
options: DeparseOptions = {}
72+
): string {
73+
const { pretty = true } = options;
74+
75+
const sqlAst = input.sql;
76+
const functions = input.functions;
77+
78+
for (const fn of functions) {
79+
const dehydrated = dehydratePlpgsqlAst(fn.plpgsql.hydrated);
80+
const newBody = deparsePlpgsql(dehydrated);
81+
stitchBodyIntoSqlAst(sqlAst, fn, newBody);
82+
}
83+
84+
if (sqlAst.stmts && sqlAst.stmts.length > 0) {
85+
const results: string[] = [];
86+
for (const rawStmt of sqlAst.stmts) {
87+
if (rawStmt?.stmt) {
88+
const { Deparser } = require('pgsql-deparser');
89+
const deparsed = Deparser.deparse(rawStmt.stmt, { pretty });
90+
results.push(deparsed);
91+
}
92+
}
93+
return results.join(';\n\n') + (results.length > 0 ? ';' : '');
94+
}
95+
96+
return '';
97+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export * from './types';
2+
export { parse, parseSync, loadModule } from './parse';
3+
export { deparse, deparseSync } from './deparse';
4+
export { transform, transformSync } from './transform';
5+
6+
export {
7+
hydratePlpgsqlAst,
8+
dehydratePlpgsqlAst,
9+
deparseSync as deparsePlpgsqlBody,
10+
isHydratedExpr,
11+
getOriginalQuery
12+
} from 'plpgsql-deparser';
13+
14+
export { deparse as deparseSql, Deparser } from 'pgsql-deparser';
15+
16+
export {
17+
parseSync as parseSql,
18+
parsePlPgSQLSync as parsePlpgsqlBody
19+
} from '@libpg-query/parser';

0 commit comments

Comments
 (0)