Skip to content

Commit 84eac19

Browse files
committed
feat: add pgsql-parse package with comment and whitespace preservation
New package that preserves SQL comments and vertical whitespace through parse→deparse round trips by scanning source text for comment tokens and interleaving synthetic RawComment and RawWhitespace AST nodes into the stmts array by byte position. Features: - Pure TypeScript scanner for -- line and /* block */ comments - Handles string literals, dollar-quoted strings, escape strings - RawWhitespace nodes for blank lines between statements - Enhanced deparseEnhanced() that emits comments and whitespace - Idempotent: parse→deparse→parse→deparse produces identical output - Drop-in replacement API (re-exports parse, deparse, loadModule) - 36 tests across scanner and integration test suites No changes to any existing packages.
1 parent cdd732b commit 84eac19

14 files changed

Lines changed: 3739 additions & 5563 deletions

packages/parse/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { parse, parseSync, deparseEnhanced, isRawComment, isRawWhitespace, isRawStmt, loadModule } from '../src';
2+
3+
beforeAll(async () => {
4+
await loadModule();
5+
});
6+
7+
describe('parse (enhanced)', () => {
8+
describe('parseSync', () => {
9+
it('parses simple SQL without comments', () => {
10+
const result = parseSync('SELECT 1;');
11+
expect(result.version).toBeDefined();
12+
const stmts = result.stmts.filter(isRawStmt);
13+
expect(stmts).toHaveLength(1);
14+
});
15+
16+
it('preserves line comments before statements', () => {
17+
const sql = '-- this is a comment\nSELECT 1;';
18+
const result = parseSync(sql);
19+
const comments = result.stmts.filter(isRawComment);
20+
expect(comments).toHaveLength(1);
21+
expect(comments[0].RawComment.type).toBe('line');
22+
expect(comments[0].RawComment.text).toBe(' this is a comment');
23+
});
24+
25+
it('preserves block comments before statements', () => {
26+
const sql = '/* block comment */\nSELECT 1;';
27+
const result = parseSync(sql);
28+
const comments = result.stmts.filter(isRawComment);
29+
expect(comments).toHaveLength(1);
30+
expect(comments[0].RawComment.type).toBe('block');
31+
expect(comments[0].RawComment.text).toBe(' block comment ');
32+
});
33+
34+
it('preserves vertical whitespace between statements', () => {
35+
const sql = 'SELECT 1;\n\n\nSELECT 2;';
36+
const result = parseSync(sql);
37+
const ws = result.stmts.filter(isRawWhitespace);
38+
expect(ws).toHaveLength(1);
39+
expect(ws[0].RawWhitespace.lines).toBeGreaterThanOrEqual(1);
40+
});
41+
42+
it('interleaves comments in correct position', () => {
43+
const sql = '-- header\nSELECT 1;\n-- middle\nSELECT 2;\n-- footer';
44+
const result = parseSync(sql);
45+
46+
expect(result.stmts.length).toBeGreaterThanOrEqual(5);
47+
expect(isRawComment(result.stmts[0])).toBe(true);
48+
expect(isRawStmt(result.stmts[1])).toBe(true);
49+
expect(isRawComment(result.stmts[2])).toBe(true);
50+
expect(isRawStmt(result.stmts[3])).toBe(true);
51+
expect(isRawComment(result.stmts[4])).toBe(true);
52+
});
53+
54+
it('handles PGPM header comments', () => {
55+
const sql = `-- Deploy schemas/my-schema/tables/users to pg
56+
-- requires: schemas/my-schema/schema
57+
58+
BEGIN;
59+
60+
CREATE TABLE my_schema.users (
61+
id serial PRIMARY KEY,
62+
name text NOT NULL
63+
);
64+
65+
COMMIT;`;
66+
const result = parseSync(sql);
67+
const comments = result.stmts.filter(isRawComment);
68+
expect(comments.length).toBeGreaterThanOrEqual(2);
69+
expect(comments[0].RawComment.text).toContain('Deploy');
70+
expect(comments[1].RawComment.text).toContain('requires');
71+
});
72+
73+
it('handles nested block comments', () => {
74+
const sql = '/* outer /* inner */ still outer */ SELECT 1;';
75+
const result = parseSync(sql);
76+
const comments = result.stmts.filter(isRawComment);
77+
expect(comments).toHaveLength(1);
78+
expect(comments[0].RawComment.text).toContain('inner');
79+
});
80+
81+
it('does not pick up comments inside string literals', () => {
82+
const sql = "SELECT '-- not a comment';";
83+
const result = parseSync(sql);
84+
const comments = result.stmts.filter(isRawComment);
85+
expect(comments).toHaveLength(0);
86+
});
87+
88+
it('does not pick up comments inside dollar-quoted strings', () => {
89+
const sql = `CREATE FUNCTION foo() RETURNS void AS $$
90+
BEGIN
91+
-- inside function body
92+
RAISE NOTICE 'hello';
93+
END;
94+
$$ LANGUAGE plpgsql;`;
95+
const result = parseSync(sql);
96+
const comments = result.stmts.filter(isRawComment);
97+
// Comment inside $$ should NOT be extracted
98+
expect(comments).toHaveLength(0);
99+
});
100+
});
101+
102+
describe('parse (async)', () => {
103+
it('parses with comments preserved', async () => {
104+
const sql = '-- async test\nSELECT 1;';
105+
const result = await parse(sql);
106+
const comments = result.stmts.filter(isRawComment);
107+
expect(comments).toHaveLength(1);
108+
expect(comments[0].RawComment.text).toBe(' async test');
109+
});
110+
});
111+
});
112+
113+
describe('deparseEnhanced', () => {
114+
it('deparses a simple statement', () => {
115+
const result = parseSync('SELECT 1;');
116+
const sql = deparseEnhanced(result);
117+
expect(sql).toContain('SELECT 1');
118+
});
119+
120+
it('deparses with line comments preserved', () => {
121+
const sql = '-- my comment\nSELECT 1;';
122+
const result = parseSync(sql);
123+
const output = deparseEnhanced(result);
124+
expect(output).toContain('-- my comment');
125+
expect(output).toContain('SELECT 1');
126+
});
127+
128+
it('deparses with block comments preserved', () => {
129+
const sql = '/* block */ SELECT 1;';
130+
const result = parseSync(sql);
131+
const output = deparseEnhanced(result);
132+
expect(output).toContain('/* block */');
133+
expect(output).toContain('SELECT 1');
134+
});
135+
136+
it('round-trips comments through parse→deparse', () => {
137+
const sql = `-- header comment
138+
SELECT 1;
139+
140+
-- section break
141+
SELECT 2;`;
142+
const result = parseSync(sql);
143+
const output = deparseEnhanced(result);
144+
expect(output).toContain('-- header comment');
145+
expect(output).toContain('-- section break');
146+
expect(output).toContain('SELECT 1');
147+
expect(output).toContain('SELECT 2');
148+
});
149+
150+
it('round-trips block comments through parse→deparse', () => {
151+
const sql = `/* header */
152+
SELECT 1;
153+
154+
/* footer */
155+
SELECT 2;`;
156+
const result = parseSync(sql);
157+
const output = deparseEnhanced(result);
158+
expect(output).toContain('/* header */');
159+
expect(output).toContain('/* footer */');
160+
});
161+
162+
it('preserves multiple comment types', () => {
163+
const sql = `-- line comment
164+
/* block comment */
165+
SELECT 1;`;
166+
const result = parseSync(sql);
167+
const output = deparseEnhanced(result);
168+
expect(output).toContain('-- line comment');
169+
expect(output).toContain('/* block comment */');
170+
});
171+
172+
it('idempotent: parse→deparse→parse→deparse produces same output', () => {
173+
const sql = `-- Deploy schemas/test/tables/foo to pg
174+
-- requires: schemas/test/schema
175+
176+
BEGIN;
177+
178+
CREATE TABLE test.foo (
179+
id serial PRIMARY KEY
180+
);
181+
182+
COMMIT;`;
183+
const result1 = parseSync(sql);
184+
const output1 = deparseEnhanced(result1);
185+
const result2 = parseSync(output1);
186+
const output2 = deparseEnhanced(result2);
187+
expect(output2).toBe(output1);
188+
});
189+
});
190+
191+
describe('kitchen sink', () => {
192+
it('handles a complex SQL file with all comment types', () => {
193+
const sql = `-- Deploy schemas/my-app/tables/users to pg
194+
-- requires: schemas/my-app/schema
195+
196+
/*
197+
* This file creates the users table
198+
* with all required columns.
199+
*/
200+
201+
BEGIN;
202+
203+
-- Create the main users table
204+
CREATE TABLE my_app.users (
205+
id serial PRIMARY KEY,
206+
name text NOT NULL,
207+
email text UNIQUE
208+
);
209+
210+
/* Add an index for lookups */
211+
CREATE INDEX idx_users_email ON my_app.users (email);
212+
213+
-- Grant permissions
214+
GRANT SELECT ON my_app.users TO app_reader;
215+
216+
COMMIT;`;
217+
218+
const result = parseSync(sql);
219+
const output = deparseEnhanced(result);
220+
221+
// All comments should survive
222+
expect(output).toContain('-- Deploy schemas/my-app/tables/users to pg');
223+
expect(output).toContain('-- requires: schemas/my-app/schema');
224+
expect(output).toContain('This file creates the users table');
225+
expect(output).toContain('-- Create the main users table');
226+
expect(output).toContain('/* Add an index for lookups */');
227+
expect(output).toContain('-- Grant permissions');
228+
229+
// All statements should survive
230+
expect(output).toContain('BEGIN');
231+
expect(output).toContain('CREATE TABLE');
232+
expect(output).toContain('CREATE INDEX');
233+
expect(output).toContain('GRANT');
234+
expect(output).toContain('COMMIT');
235+
});
236+
});

0 commit comments

Comments
 (0)