Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/plpgsql-parse/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
59 changes: 59 additions & 0 deletions packages/plpgsql-parse/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# plpgsql-parse

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

Comment preserving PL/pgSQL parser. A wrapper around `plpgsql-parser` and `plpgsql-deparser` that preserves `--` line comments inside PL/pgSQL function bodies through parse-deparse round trips.

## Installation

```sh
npm install plpgsql-parse
```

## Features

* **Body Comment Preservation** -- Retains `--` line comments inside PL/pgSQL function bodies (`$$...$$`) through parse-deparse cycles
* **Outer SQL Comment Preservation** -- Preserves comments and whitespace outside function definitions via `pgsql-parse`
* **Idempotent Round-Trips** -- `parse -> deparse -> parse -> deparse` produces identical output
* **Non-Invasive** -- Does not modify `plpgsql-parser`, `plpgsql-deparser`, or any other upstream packages

## How It Works

1. Uses `pgsql-parse` for outer SQL comment and whitespace preservation
2. For each PL/pgSQL function, scans the `$$...$$` body to extract `--` comments with line numbers
3. Associates each comment with the nearest following PL/pgSQL statement (anchor)
4. On deparse, re-injects comments by matching statement keywords against the deparsed output

## API

### Parse

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

await loadModule();

const result = parseSync(`
-- Create a counter function
CREATE FUNCTION get_count() RETURNS int LANGUAGE plpgsql AS $$
BEGIN
-- Count active users
RETURN (SELECT count(*) FROM users WHERE active);
END;
$$;
`);

// result.enhanced contains outer SQL comments/whitespace
// result.functions contains body comments for each PL/pgSQL function
const sql = deparseSync(result);
// Output preserves both outer and body comments
```

## Credits

Built on the excellent work of several contributors:

* **[Dan Lynch](https://github.com/pyramation)** -- official maintainer since 2018 and architect of the current implementation
* **[Lukas Fittl](https://github.com/lfittl)** for [libpg_query](https://github.com/pganalyze/libpg_query) -- the core PostgreSQL parser that powers this project
161 changes: 161 additions & 0 deletions packages/plpgsql-parse/__tests__/__snapshots__/roundtrip.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`fixture round-trip tests exception-handler.sql deparsed output matches snapshot 1`] = `
"-- Function with comments in exception handler
CREATE FUNCTION safe_divide(
a numeric,
b numeric
) RETURNS numeric LANGUAGE plpgsql AS $$
DECLARE
v_result numeric;
BEGIN
-- Attempt the division
v_result := a / b;
RETURN v_result;
EXCEPTION
WHEN division_by_zero THEN
-- Log the error and return null
RAISE NOTICE 'Division by zero: % / %', a, b;
RETURN NULL;
END;
$$;"
`;

exports[`fixture round-trip tests loop-with-comments.sql deparsed output matches snapshot 1`] = `
"-- Function with comments inside loops
CREATE FUNCTION process_batch(
p_batch_size int
) RETURNS int LANGUAGE plpgsql AS $$
DECLARE
v_processed integer := 0;
r RECORD;
BEGIN
-- Process items in batches
FOR r IN SELECT id, data FROM pending_items LIMIT p_batch_size LOOP
-- Process each item
PERFORM process_item(r.id, r.data);
v_processed := v_processed + 1;
END LOOP;

-- Return the count of processed items
RETURN v_processed;
END;
$$;"
`;

exports[`fixture round-trip tests multi-function.sql deparsed output matches snapshot 1`] = `
"-- Multiple functions in one file
-- with outer SQL comments preserved

CREATE FUNCTION add_numbers(
a int,
b int
) RETURNS int LANGUAGE plpgsql AS $$
BEGIN
-- Simple addition
RETURN a + b;
END;
$$;

-- Second function with its own body comments
CREATE FUNCTION multiply_numbers(
a int,
b int
) RETURNS int LANGUAGE plpgsql AS $$
BEGIN
-- Multiply the inputs
RETURN a * b;
END;
$$;"
`;

exports[`fixture round-trip tests multiple-comments.sql deparsed output matches snapshot 1`] = `
"-- Function with multiple comment groups
CREATE FUNCTION process_order(
p_order_id int
) RETURNS void LANGUAGE plpgsql AS $$
DECLARE
v_total numeric;
v_status text;
BEGIN
-- First, calculate the order total
SELECT sum(amount) INTO v_total FROM order_items WHERE order_id = p_order_id;

-- Then update the order status
-- based on the total amount
IF v_total > 1000 THEN
v_status := 'premium';
ELSE
v_status := 'standard';
END IF;

-- Finally, record the result
UPDATE orders SET status = v_status, total = v_total WHERE id = p_order_id;
END;
$$;"
`;

exports[`fixture round-trip tests nested-blocks.sql deparsed output matches snapshot 1`] = `
"-- Function with nested blocks and comments
CREATE FUNCTION complex_logic(
p_id int
) RETURNS text LANGUAGE plpgsql AS $$
DECLARE
v_result text;
BEGIN
-- Initialize result
v_result := 'unknown';

-- Try the main logic
BEGIN
-- Fetch and process
SELECT status INTO v_result FROM items WHERE id = p_id;
EXCEPTION
WHEN no_data_found THEN
-- Handle missing item
v_result := 'not_found';
END;

RETURN v_result;
END;
$$;"
`;

exports[`fixture round-trip tests no-comments.sql deparsed output matches snapshot 1`] = `
"CREATE FUNCTION get_one() RETURNS int LANGUAGE plpgsql AS $$
BEGIN
RETURN 1;
END;
$$;"
`;

exports[`fixture round-trip tests simple-function.sql deparsed output matches snapshot 1`] = `
"-- Simple function with body comments
CREATE FUNCTION get_user_count() RETURNS int LANGUAGE plpgsql AS $$
DECLARE
v_count integer;
BEGIN
-- Count all active users
SELECT count(*) INTO v_count FROM users WHERE is_active = true;
RETURN v_count;
END;
$$;"
`;

exports[`fixture round-trip tests trigger-function.sql deparsed output matches snapshot 1`] = `
"-- Trigger function with comments in body
CREATE FUNCTION audit_trigger() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
-- Set the updated_at timestamp
NEW.updated_at := now();

-- Record the change in audit log
IF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, operation, old_data, new_data)
VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD), row_to_json(NEW));
END IF;

RETURN NEW;
END;
$$;"
`;
17 changes: 17 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/exception-handler.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Function with comments in exception handler
CREATE FUNCTION safe_divide(a numeric, b numeric) RETURNS numeric
LANGUAGE plpgsql
AS $$
DECLARE
v_result numeric;
BEGIN
-- Attempt the division
v_result := a / b;
RETURN v_result;
EXCEPTION
WHEN division_by_zero THEN
-- Log the error and return null
RAISE NOTICE 'Division by zero: % / %', a, b;
RETURN NULL;
END;
$$;
19 changes: 19 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/loop-with-comments.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Function with comments inside loops
CREATE FUNCTION process_batch(p_batch_size integer) RETURNS integer
LANGUAGE plpgsql
AS $$
DECLARE
v_processed integer := 0;
r RECORD;
BEGIN
-- Process items in batches
FOR r IN SELECT id, data FROM pending_items LIMIT p_batch_size LOOP
-- Process each item
PERFORM process_item(r.id, r.data);
v_processed := v_processed + 1;
END LOOP;

-- Return the count of processed items
RETURN v_processed;
END;
$$;
21 changes: 21 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/multi-function.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Multiple functions in one file
-- with outer SQL comments preserved

CREATE FUNCTION add_numbers(a integer, b integer) RETURNS integer
LANGUAGE plpgsql
AS $$
BEGIN
-- Simple addition
RETURN a + b;
END;
$$;

-- Second function with its own body comments
CREATE FUNCTION multiply_numbers(a integer, b integer) RETURNS integer
LANGUAGE plpgsql
AS $$
BEGIN
-- Multiply the inputs
RETURN a * b;
END;
$$;
23 changes: 23 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/multiple-comments.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Function with multiple comment groups
CREATE FUNCTION process_order(p_order_id integer) RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
v_total numeric;
v_status text;
BEGIN
-- First, calculate the order total
SELECT sum(amount) INTO v_total FROM order_items WHERE order_id = p_order_id;

-- Then update the order status
-- based on the total amount
IF v_total > 1000 THEN
v_status := 'premium';
ELSE
v_status := 'standard';
END IF;

-- Finally, record the result
UPDATE orders SET status = v_status, total = v_total WHERE id = p_order_id;
END;
$$;
23 changes: 23 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/nested-blocks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Function with nested blocks and comments
CREATE FUNCTION complex_logic(p_id integer) RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
v_result text;
BEGIN
-- Initialize result
v_result := 'unknown';

-- Try the main logic
BEGIN
-- Fetch and process
SELECT status INTO v_result FROM items WHERE id = p_id;
EXCEPTION
WHEN no_data_found THEN
-- Handle missing item
v_result := 'not_found';
END;

RETURN v_result;
END;
$$;
7 changes: 7 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/no-comments.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE FUNCTION get_one() RETURNS integer
LANGUAGE plpgsql
AS $$
BEGIN
RETURN 1;
END;
$$;
12 changes: 12 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/simple-function.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Simple function with body comments
CREATE FUNCTION get_user_count() RETURNS integer
LANGUAGE plpgsql
AS $$
DECLARE
v_count integer;
BEGIN
-- Count all active users
SELECT count(*) INTO v_count FROM users WHERE is_active = true;
RETURN v_count;
END;
$$;
17 changes: 17 additions & 0 deletions packages/plpgsql-parse/__tests__/fixtures/trigger-function.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Trigger function with comments in body
CREATE FUNCTION audit_trigger() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Set the updated_at timestamp
NEW.updated_at := now();

-- Record the change in audit log
IF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, operation, old_data, new_data)
VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD), row_to_json(NEW));
END IF;

RETURN NEW;
END;
$$;
Loading
Loading