Skip to content

Commit 4e08f99

Browse files
authored
Merge pull request #293 from constructive-io/devin/1775777114-plpgsql-parse-package
feat: add plpgsql-parse package with comment preservation for PL/pgSQL function bodies
2 parents 6571608 + a2d7615 commit 4e08f99

25 files changed

Lines changed: 4600 additions & 5578 deletions

packages/plpgsql-parse/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

packages/plpgsql-parse/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# plpgsql-parse
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+
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.
8+
9+
## Installation
10+
11+
```sh
12+
npm install plpgsql-parse
13+
```
14+
15+
## Features
16+
17+
* **Body Comment Preservation** -- Retains `--` line comments inside PL/pgSQL function bodies (`$$...$$`) through parse-deparse cycles
18+
* **Outer SQL Comment Preservation** -- Preserves comments and whitespace outside function definitions via `pgsql-parse`
19+
* **Idempotent Round-Trips** -- `parse -> deparse -> parse -> deparse` produces identical output
20+
* **Non-Invasive** -- Does not modify `plpgsql-parser`, `plpgsql-deparser`, or any other upstream packages
21+
22+
## How It Works
23+
24+
1. Uses `pgsql-parse` for outer SQL comment and whitespace preservation
25+
2. For each PL/pgSQL function, scans the `$$...$$` body to extract `--` comments with line numbers
26+
3. Associates each comment with the nearest following PL/pgSQL statement (anchor)
27+
4. On deparse, re-injects comments by matching statement keywords against the deparsed output
28+
29+
## API
30+
31+
### Parse
32+
33+
```typescript
34+
import { parseSync, deparseSync, loadModule } from 'plpgsql-parse';
35+
36+
await loadModule();
37+
38+
const result = parseSync(`
39+
-- Create a counter function
40+
CREATE FUNCTION get_count() RETURNS int LANGUAGE plpgsql AS $$
41+
BEGIN
42+
-- Count active users
43+
RETURN (SELECT count(*) FROM users WHERE active);
44+
END;
45+
$$;
46+
`);
47+
48+
// result.enhanced contains outer SQL comments/whitespace
49+
// result.functions contains body comments for each PL/pgSQL function
50+
const sql = deparseSync(result);
51+
// Output preserves both outer and body comments
52+
```
53+
54+
## Credits
55+
56+
Built on the excellent work of several contributors:
57+
58+
* **[Dan Lynch](https://github.com/pyramation)** -- official maintainer since 2018 and architect of the current implementation
59+
* **[Lukas Fittl](https://github.com/lfittl)** for [libpg_query](https://github.com/pganalyze/libpg_query) -- the core PostgreSQL parser that powers this project
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`fixture round-trip tests exception-handler.sql deparsed output matches snapshot 1`] = `
4+
"-- Function with comments in exception handler
5+
CREATE FUNCTION safe_divide(
6+
a numeric,
7+
b numeric
8+
) RETURNS numeric LANGUAGE plpgsql AS $$
9+
DECLARE
10+
v_result numeric;
11+
BEGIN
12+
-- Attempt the division
13+
v_result := a / b;
14+
RETURN v_result;
15+
EXCEPTION
16+
WHEN division_by_zero THEN
17+
-- Log the error and return null
18+
RAISE NOTICE 'Division by zero: % / %', a, b;
19+
RETURN NULL;
20+
END;
21+
$$;"
22+
`;
23+
24+
exports[`fixture round-trip tests loop-with-comments.sql deparsed output matches snapshot 1`] = `
25+
"-- Function with comments inside loops
26+
CREATE FUNCTION process_batch(
27+
p_batch_size int
28+
) RETURNS int LANGUAGE plpgsql AS $$
29+
DECLARE
30+
v_processed integer := 0;
31+
r RECORD;
32+
BEGIN
33+
-- Process items in batches
34+
FOR r IN SELECT id, data FROM pending_items LIMIT p_batch_size LOOP
35+
-- Process each item
36+
PERFORM process_item(r.id, r.data);
37+
v_processed := v_processed + 1;
38+
END LOOP;
39+
40+
-- Return the count of processed items
41+
RETURN v_processed;
42+
END;
43+
$$;"
44+
`;
45+
46+
exports[`fixture round-trip tests multi-function.sql deparsed output matches snapshot 1`] = `
47+
"-- Multiple functions in one file
48+
-- with outer SQL comments preserved
49+
50+
CREATE FUNCTION add_numbers(
51+
a int,
52+
b int
53+
) RETURNS int LANGUAGE plpgsql AS $$
54+
BEGIN
55+
-- Simple addition
56+
RETURN a + b;
57+
END;
58+
$$;
59+
60+
-- Second function with its own body comments
61+
CREATE FUNCTION multiply_numbers(
62+
a int,
63+
b int
64+
) RETURNS int LANGUAGE plpgsql AS $$
65+
BEGIN
66+
-- Multiply the inputs
67+
RETURN a * b;
68+
END;
69+
$$;"
70+
`;
71+
72+
exports[`fixture round-trip tests multiple-comments.sql deparsed output matches snapshot 1`] = `
73+
"-- Function with multiple comment groups
74+
CREATE FUNCTION process_order(
75+
p_order_id int
76+
) RETURNS void LANGUAGE plpgsql AS $$
77+
DECLARE
78+
v_total numeric;
79+
v_status text;
80+
BEGIN
81+
-- First, calculate the order total
82+
SELECT sum(amount) INTO v_total FROM order_items WHERE order_id = p_order_id;
83+
84+
-- Then update the order status
85+
-- based on the total amount
86+
IF v_total > 1000 THEN
87+
v_status := 'premium';
88+
ELSE
89+
v_status := 'standard';
90+
END IF;
91+
92+
-- Finally, record the result
93+
UPDATE orders SET status = v_status, total = v_total WHERE id = p_order_id;
94+
END;
95+
$$;"
96+
`;
97+
98+
exports[`fixture round-trip tests nested-blocks.sql deparsed output matches snapshot 1`] = `
99+
"-- Function with nested blocks and comments
100+
CREATE FUNCTION complex_logic(
101+
p_id int
102+
) RETURNS text LANGUAGE plpgsql AS $$
103+
DECLARE
104+
v_result text;
105+
BEGIN
106+
-- Initialize result
107+
v_result := 'unknown';
108+
109+
-- Try the main logic
110+
BEGIN
111+
-- Fetch and process
112+
SELECT status INTO v_result FROM items WHERE id = p_id;
113+
EXCEPTION
114+
WHEN no_data_found THEN
115+
-- Handle missing item
116+
v_result := 'not_found';
117+
END;
118+
119+
RETURN v_result;
120+
END;
121+
$$;"
122+
`;
123+
124+
exports[`fixture round-trip tests no-comments.sql deparsed output matches snapshot 1`] = `
125+
"CREATE FUNCTION get_one() RETURNS int LANGUAGE plpgsql AS $$
126+
BEGIN
127+
RETURN 1;
128+
END;
129+
$$;"
130+
`;
131+
132+
exports[`fixture round-trip tests simple-function.sql deparsed output matches snapshot 1`] = `
133+
"-- Simple function with body comments
134+
CREATE FUNCTION get_user_count() RETURNS int LANGUAGE plpgsql AS $$
135+
DECLARE
136+
v_count integer;
137+
BEGIN
138+
-- Count all active users
139+
SELECT count(*) INTO v_count FROM users WHERE is_active = true;
140+
RETURN v_count;
141+
END;
142+
$$;"
143+
`;
144+
145+
exports[`fixture round-trip tests trigger-function.sql deparsed output matches snapshot 1`] = `
146+
"-- Trigger function with comments in body
147+
CREATE FUNCTION audit_trigger() RETURNS trigger LANGUAGE plpgsql AS $$
148+
BEGIN
149+
-- Set the updated_at timestamp
150+
NEW.updated_at := now();
151+
152+
-- Record the change in audit log
153+
IF TG_OP = 'UPDATE' THEN
154+
INSERT INTO audit_log (table_name, operation, old_data, new_data)
155+
VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD), row_to_json(NEW));
156+
END IF;
157+
158+
RETURN NEW;
159+
END;
160+
$$;"
161+
`;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Function with comments in exception handler
2+
CREATE FUNCTION safe_divide(a numeric, b numeric) RETURNS numeric
3+
LANGUAGE plpgsql
4+
AS $$
5+
DECLARE
6+
v_result numeric;
7+
BEGIN
8+
-- Attempt the division
9+
v_result := a / b;
10+
RETURN v_result;
11+
EXCEPTION
12+
WHEN division_by_zero THEN
13+
-- Log the error and return null
14+
RAISE NOTICE 'Division by zero: % / %', a, b;
15+
RETURN NULL;
16+
END;
17+
$$;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Function with comments inside loops
2+
CREATE FUNCTION process_batch(p_batch_size integer) RETURNS integer
3+
LANGUAGE plpgsql
4+
AS $$
5+
DECLARE
6+
v_processed integer := 0;
7+
r RECORD;
8+
BEGIN
9+
-- Process items in batches
10+
FOR r IN SELECT id, data FROM pending_items LIMIT p_batch_size LOOP
11+
-- Process each item
12+
PERFORM process_item(r.id, r.data);
13+
v_processed := v_processed + 1;
14+
END LOOP;
15+
16+
-- Return the count of processed items
17+
RETURN v_processed;
18+
END;
19+
$$;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Multiple functions in one file
2+
-- with outer SQL comments preserved
3+
4+
CREATE FUNCTION add_numbers(a integer, b integer) RETURNS integer
5+
LANGUAGE plpgsql
6+
AS $$
7+
BEGIN
8+
-- Simple addition
9+
RETURN a + b;
10+
END;
11+
$$;
12+
13+
-- Second function with its own body comments
14+
CREATE FUNCTION multiply_numbers(a integer, b integer) RETURNS integer
15+
LANGUAGE plpgsql
16+
AS $$
17+
BEGIN
18+
-- Multiply the inputs
19+
RETURN a * b;
20+
END;
21+
$$;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Function with multiple comment groups
2+
CREATE FUNCTION process_order(p_order_id integer) RETURNS void
3+
LANGUAGE plpgsql
4+
AS $$
5+
DECLARE
6+
v_total numeric;
7+
v_status text;
8+
BEGIN
9+
-- First, calculate the order total
10+
SELECT sum(amount) INTO v_total FROM order_items WHERE order_id = p_order_id;
11+
12+
-- Then update the order status
13+
-- based on the total amount
14+
IF v_total > 1000 THEN
15+
v_status := 'premium';
16+
ELSE
17+
v_status := 'standard';
18+
END IF;
19+
20+
-- Finally, record the result
21+
UPDATE orders SET status = v_status, total = v_total WHERE id = p_order_id;
22+
END;
23+
$$;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Function with nested blocks and comments
2+
CREATE FUNCTION complex_logic(p_id integer) RETURNS text
3+
LANGUAGE plpgsql
4+
AS $$
5+
DECLARE
6+
v_result text;
7+
BEGIN
8+
-- Initialize result
9+
v_result := 'unknown';
10+
11+
-- Try the main logic
12+
BEGIN
13+
-- Fetch and process
14+
SELECT status INTO v_result FROM items WHERE id = p_id;
15+
EXCEPTION
16+
WHEN no_data_found THEN
17+
-- Handle missing item
18+
v_result := 'not_found';
19+
END;
20+
21+
RETURN v_result;
22+
END;
23+
$$;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE FUNCTION get_one() RETURNS integer
2+
LANGUAGE plpgsql
3+
AS $$
4+
BEGIN
5+
RETURN 1;
6+
END;
7+
$$;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Simple function with body comments
2+
CREATE FUNCTION get_user_count() RETURNS integer
3+
LANGUAGE plpgsql
4+
AS $$
5+
DECLARE
6+
v_count integer;
7+
BEGIN
8+
-- Count all active users
9+
SELECT count(*) INTO v_count FROM users WHERE is_active = true;
10+
RETURN v_count;
11+
END;
12+
$$;

0 commit comments

Comments
 (0)