Skip to content

Commit 219df51

Browse files
authored
Merge pull request #255 from constructive-io/feat/plpgsql-parser
feat: add plpgsql-parser package for combined SQL + PL/pgSQL parsing
2 parents 0afd8a5 + 3c646eb commit 219df51

12 files changed

Lines changed: 742 additions & 0 deletions

File tree

packages/plpgsql-parser/README.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# plpgsql-parser
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/pgsql-parser/blob/main/LICENSE-MIT"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
<a href="https://www.npmjs.com/package/plpgsql-parser"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/pgsql-parser?filename=packages%2Fplpgsql-parser%2Fpackage.json"/></a>
13+
</p>
14+
15+
Combined SQL + PL/pgSQL parser with hydrated ASTs and transform API.
16+
17+
> **⚠️ Experimental:** This package is currently experimental. If you're looking for just SQL parsing, see [`pgsql-parser`](https://www.npmjs.com/package/pgsql-parser). For just PL/pgSQL deparsing, see [`plpgsql-deparser`](https://www.npmjs.com/package/plpgsql-deparser).
18+
19+
## Overview
20+
21+
This package provides a unified API for parsing SQL scripts containing PL/pgSQL functions. It combines the SQL parser and PL/pgSQL parser, automatically detecting and hydrating PL/pgSQL function bodies.
22+
23+
Key features:
24+
25+
- Auto-detects `CREATE FUNCTION` statements with `LANGUAGE plpgsql`
26+
- Hydrates PL/pgSQL function bodies into structured ASTs
27+
- Transform API for parse → modify → deparse workflows
28+
- Re-exports underlying primitives for power users
29+
30+
## Installation
31+
32+
```bash
33+
npm install plpgsql-parser
34+
```
35+
36+
## Usage
37+
38+
```typescript
39+
import { parse, transform, deparseSync, loadModule } from 'plpgsql-parser';
40+
41+
// Initialize the WASM module
42+
await loadModule();
43+
44+
// Parse SQL with PL/pgSQL functions - auto-detects and hydrates
45+
const result = parse(`
46+
CREATE FUNCTION my_func(p_id int)
47+
RETURNS void
48+
LANGUAGE plpgsql
49+
AS $$
50+
BEGIN
51+
RAISE NOTICE 'Hello %', p_id;
52+
END;
53+
$$;
54+
`);
55+
56+
console.log(result.functions.length); // 1
57+
console.log(result.functions[0].plpgsql.hydrated); // Hydrated AST
58+
59+
// Transform API for parse -> modify -> deparse pipeline
60+
const output = transformSync(sql, (ctx) => {
61+
// Modify the function name
62+
ctx.functions[0].stmt.funcname[0].String.sval = 'renamed_func';
63+
});
64+
65+
// Deparse back to SQL
66+
const sql = deparseSync(result, { pretty: true });
67+
```
68+
69+
## API
70+
71+
### `parse(sql, options?)`
72+
73+
Parses SQL and auto-detects PL/pgSQL functions, hydrating their bodies.
74+
75+
Options:
76+
- `hydrate` (default: `true`) - Whether to hydrate PL/pgSQL function bodies
77+
78+
Returns a `ParsedScript` with:
79+
- `sql` - The raw SQL parse result
80+
- `items` - Array of parsed items (statements and functions)
81+
- `functions` - Array of detected PL/pgSQL functions with hydrated ASTs
82+
83+
### `transform(sql, callback, options?)`
84+
85+
Async transform pipeline: parse -> modify -> deparse.
86+
87+
### `transformSync(sql, callback, options?)`
88+
89+
Sync version of transform.
90+
91+
### `deparseSync(parsed, options?)`
92+
93+
Converts a parsed script back to SQL.
94+
95+
Options:
96+
- `pretty` (default: `true`) - Whether to pretty-print the output
97+
98+
## Re-exports
99+
100+
For power users, the package re-exports underlying primitives:
101+
102+
- `parseSql` - SQL parser from `@libpg-query/parser`
103+
- `parsePlpgsqlBody` - PL/pgSQL parser from `@libpg-query/parser`
104+
- `deparseSql` - SQL deparser from `pgsql-deparser`
105+
- `deparsePlpgsqlBody` - PL/pgSQL deparser from `plpgsql-deparser`
106+
- `hydratePlpgsqlAst` - Hydration utility from `plpgsql-deparser`
107+
- `dehydratePlpgsqlAst` - Dehydration utility from `plpgsql-deparser`
108+
109+
## License
110+
111+
MIT
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+
}

0 commit comments

Comments
 (0)