Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/plpgsql-parser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# plpgsql-parser

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<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>
<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>
</p>

Combined SQL + PL/pgSQL parser with hydrated ASTs and transform API.

> **⚠️ 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).

## Overview

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.

Key features:

- Auto-detects `CREATE FUNCTION` statements with `LANGUAGE plpgsql`
- Hydrates PL/pgSQL function bodies into structured ASTs
- Transform API for parse → modify → deparse workflows
- Re-exports underlying primitives for power users

## Installation

```bash
npm install plpgsql-parser
```

## Usage

```typescript
import { parse, transform, deparseSync, loadModule } from 'plpgsql-parser';

// Initialize the WASM module
await loadModule();

// Parse SQL with PL/pgSQL functions - auto-detects and hydrates
const result = parse(`
CREATE FUNCTION my_func(p_id int)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
RAISE NOTICE 'Hello %', p_id;
END;
$$;
`);

console.log(result.functions.length); // 1
console.log(result.functions[0].plpgsql.hydrated); // Hydrated AST

// Transform API for parse -> modify -> deparse pipeline
const output = transformSync(sql, (ctx) => {
// Modify the function name
ctx.functions[0].stmt.funcname[0].String.sval = 'renamed_func';
});

// Deparse back to SQL
const sql = deparseSync(result, { pretty: true });
```

## API

### `parse(sql, options?)`

Parses SQL and auto-detects PL/pgSQL functions, hydrating their bodies.

Options:
- `hydrate` (default: `true`) - Whether to hydrate PL/pgSQL function bodies

Returns a `ParsedScript` with:
- `sql` - The raw SQL parse result
- `items` - Array of parsed items (statements and functions)
- `functions` - Array of detected PL/pgSQL functions with hydrated ASTs

### `transform(sql, callback, options?)`

Async transform pipeline: parse -> modify -> deparse.

### `transformSync(sql, callback, options?)`

Sync version of transform.

### `deparseSync(parsed, options?)`

Converts a parsed script back to SQL.

Options:
- `pretty` (default: `true`) - Whether to pretty-print the output

## Re-exports

For power users, the package re-exports underlying primitives:

- `parseSql` - SQL parser from `@libpg-query/parser`
- `parsePlpgsqlBody` - PL/pgSQL parser from `@libpg-query/parser`
- `deparseSql` - SQL deparser from `pgsql-deparser`
- `deparsePlpgsqlBody` - PL/pgSQL deparser from `plpgsql-deparser`
- `hydratePlpgsqlAst` - Hydration utility from `plpgsql-deparser`
- `dehydratePlpgsqlAst` - Dehydration utility from `plpgsql-deparser`

## License

MIT
114 changes: 114 additions & 0 deletions packages/plpgsql-parser/__tests__/plpgsql-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { parse, transformSync, deparseSync, loadModule } from '../src';

beforeAll(async () => {
await loadModule();
});

const simpleFunctionSql = `
CREATE OR REPLACE FUNCTION test_func(p_id int)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_count int;
BEGIN
v_count := 0;
RAISE NOTICE 'Count: %', v_count;
END;
$$;
`;

const multiStatementSql = `
CREATE TABLE users (id int);

CREATE OR REPLACE FUNCTION get_user(p_id int)
RETURNS int
LANGUAGE plpgsql
AS $$
BEGIN
RETURN p_id;
END;
$$;

CREATE INDEX idx_users_id ON users(id);
`;

describe('plpgsql-parser', () => {
describe('parse', () => {
it('should parse a simple PL/pgSQL function', () => {
const result = parse(simpleFunctionSql);

expect(result.sql).toBeDefined();
expect(result.sql.stmts).toHaveLength(1);
expect(result.items).toHaveLength(1);
expect(result.functions).toHaveLength(1);

const fn = result.functions[0];
expect(fn.kind).toBe('plpgsql-function');
expect(fn.language).toBe('plpgsql');
expect(fn.body.raw).toContain('v_count');
expect(fn.plpgsql.hydrated).toBeDefined();
expect(fn.plpgsql.stats.totalExpressions).toBeGreaterThan(0);
});

it('should parse multi-statement SQL with mixed content', () => {
const result = parse(multiStatementSql);

expect(result.sql.stmts).toHaveLength(3);
expect(result.items).toHaveLength(3);
expect(result.functions).toHaveLength(1);

expect(result.items[0].kind).toBe('stmt');
expect(result.items[1].kind).toBe('plpgsql-function');
expect(result.items[2].kind).toBe('stmt');
});

it('should skip hydration when hydrate=false', () => {
const result = parse(simpleFunctionSql, { hydrate: false });

expect(result.functions).toHaveLength(0);
expect(result.items).toHaveLength(1);
expect(result.items[0].kind).toBe('stmt');
});
});

describe('transformSync', () => {
it('should transform function using callback', () => {
const result = transformSync(simpleFunctionSql, (ctx) => {
const fn = ctx.functions[0];
fn.stmt.funcname[0].String.sval = 'renamed_func';
});

expect(result).toContain('renamed_func');
});

it('should transform function using visitor', () => {
const result = transformSync(simpleFunctionSql, {
onFunction: (fn) => {
fn.stmt.funcname[0].String.sval = 'visitor_renamed';
}
});

expect(result).toContain('visitor_renamed');
});
});

describe('deparseSync', () => {
it('should deparse parsed script back to SQL', () => {
const parsed = parse(simpleFunctionSql);
const result = deparseSync(parsed);

expect(result).toContain('CREATE');
expect(result).toContain('FUNCTION');
expect(result).toContain('test_func');
expect(result).toContain('plpgsql');
});

it('should support pretty printing', () => {
const parsed = parse(simpleFunctionSql);
const result = deparseSync(parsed, { pretty: true });

expect(result).toContain('\n');
});
});
});
18 changes: 18 additions & 0 deletions packages/plpgsql-parser/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
babelConfig: false,
tsconfig: "tsconfig.json",
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
modulePathIgnorePatterns: ["dist/*"]
};
54 changes: 54 additions & 0 deletions packages/plpgsql-parser/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "plpgsql-parser",
"version": "0.1.0",
"author": "Constructive <developers@constructive.io>",
"description": "Combined SQL + PL/pgSQL parser with hydrated ASTs and transform API",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"homepage": "https://github.com/constructive-io/pgsql-parser",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/pgsql-parser"
},
"bugs": {
"url": "https://github.com/constructive-io/pgsql-parser/issues"
},
"scripts": {
"copy": "makage assets",
"clean": "makage clean dist",
"prepublishOnly": "npm run build",
"build": "npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy",
"build:dev": "npm run clean && tsc --declarationMap && tsc -p tsconfig.esm.json && npm run copy",
"lint": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"sql",
"postgres",
"postgresql",
"pg",
"plpgsql",
"query",
"ast",
"parser",
"deparser",
"transform",
"database"
],
"devDependencies": {
"makage": "^0.1.8"
},
"dependencies": {
"@libpg-query/parser": "^17.6.3",
"@pgsql/types": "^17.6.2",
"pgsql-deparser": "workspace:*",
"plpgsql-deparser": "workspace:*"
}
}
97 changes: 97 additions & 0 deletions packages/plpgsql-parser/src/deparse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { deparse as deparseSql } from 'pgsql-deparser';
import {
dehydratePlpgsqlAst,
deparseSync as deparsePlpgsql
} from 'plpgsql-deparser';
import type {
ParsedScript,
TransformContext,
DeparseOptions,
ParsedFunction
} from './types';

function stitchBodyIntoSqlAst(
sqlAst: any,
fn: ParsedFunction,
newBody: string
): void {
const stmts = sqlAst.stmts;
if (!stmts || !stmts[fn.stmtIndex]) return;

const rawStmt = stmts[fn.stmtIndex];
const createFunctionStmt = rawStmt?.stmt?.CreateFunctionStmt;
if (!createFunctionStmt?.options) return;

for (const opt of createFunctionStmt.options) {
if (opt?.DefElem?.defname === 'as') {
const arg = opt.DefElem.arg;
if (arg?.List?.items?.[0]?.String) {
arg.List.items[0].String.sval = newBody;
return;
}
if (arg?.String) {
arg.String.sval = newBody;
return;
}
}
}
}

export async function deparse(
input: ParsedScript | TransformContext,
options: DeparseOptions = {}
): Promise<string> {
const { pretty = true } = options;

const sqlAst = input.sql;
const functions = input.functions;

for (const fn of functions) {
const dehydrated = dehydratePlpgsqlAst(fn.plpgsql.hydrated);
const newBody = deparsePlpgsql(dehydrated);
stitchBodyIntoSqlAst(sqlAst, fn, newBody);
}

if (sqlAst.stmts && sqlAst.stmts.length > 0) {
const results: string[] = [];
for (const rawStmt of sqlAst.stmts) {
if (rawStmt?.stmt) {
const deparsed = await deparseSql(rawStmt.stmt, { pretty });
results.push(deparsed);
}
}
return results.join(';\n\n') + (results.length > 0 ? ';' : '');
}

return '';
}

export function deparseSync(
input: ParsedScript | TransformContext,
options: DeparseOptions = {}
): string {
const { pretty = true } = options;

const sqlAst = input.sql;
const functions = input.functions;

for (const fn of functions) {
const dehydrated = dehydratePlpgsqlAst(fn.plpgsql.hydrated);
const newBody = deparsePlpgsql(dehydrated);
stitchBodyIntoSqlAst(sqlAst, fn, newBody);
}

if (sqlAst.stmts && sqlAst.stmts.length > 0) {
const results: string[] = [];
for (const rawStmt of sqlAst.stmts) {
if (rawStmt?.stmt) {
const { Deparser } = require('pgsql-deparser');
const deparsed = Deparser.deparse(rawStmt.stmt, { pretty });
results.push(deparsed);
}
}
return results.join(';\n\n') + (results.length > 0 ? ';' : '');
}

return '';
}
Loading