Skip to content

Commit 3624d33

Browse files
committed
test: add fixture-based round-trip CST tests
7 SQL fixture files covering: - PGPM headers with deploy/requires comments - Multi-statement schema setup (CREATE TABLE, INSERT) - RLS policies and GRANT statements - PL/pgSQL functions with dollar-quoted bodies - Views and triggers - ALTER/DROP statements - Edge cases (trailing comments, adjacent comments, dollar-quoted internals) Each fixture verifies: 1. parse→deparse→parse→deparse idempotency (CST round trip) 2. All top-level -- comments preserved 3. All SQL statements survive 4. CST node ordering matches source order 56 total tests (28 existing + 28 new).
1 parent aa70c09 commit 3624d33

8 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Add columns to existing table
2+
ALTER TABLE app.users ADD COLUMN bio text;
3+
ALTER TABLE app.users ADD COLUMN avatar_url text;
4+
5+
-- Rename a column
6+
ALTER TABLE app.users RENAME COLUMN username TO display_name;
7+
8+
-- Drop unused objects
9+
DROP INDEX IF EXISTS app.idx_old_index;
10+
11+
-- Recreate with new definition
12+
CREATE INDEX idx_users_display_name ON app.users (display_name);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Comments with special characters: don't break "parsing"
2+
SELECT 1;
3+
4+
-- Inline comment after statement
5+
SELECT 2; -- trailing note
6+
7+
-- Adjacent comments with no blank line
8+
-- first line
9+
-- second line
10+
SELECT 3;
11+
12+
-- Dollar-quoted body with internal comments (should NOT be extracted)
13+
CREATE FUNCTION app.noop() RETURNS void AS $$
14+
BEGIN
15+
-- this comment is inside the function body
16+
NULL;
17+
END;
18+
$$ LANGUAGE plpgsql;
19+
20+
-- String that looks like a comment
21+
SELECT '-- not a comment' AS val;
22+
23+
-- Empty statement list edge
24+
SELECT 4;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- RLS policies for the users table
2+
ALTER TABLE app.users ENABLE ROW LEVEL SECURITY;
3+
4+
-- Admins can see all rows
5+
CREATE POLICY admin_all ON app.users
6+
FOR ALL
7+
TO admin_role
8+
USING (true);
9+
10+
-- Users can only see their own row
11+
CREATE POLICY own_row ON app.users
12+
FOR SELECT
13+
TO authenticated
14+
USING (id = current_setting('app.current_user_id')::integer);
15+
16+
-- Grant basic access
17+
GRANT USAGE ON SCHEMA app TO authenticated;
18+
GRANT SELECT ON app.users TO authenticated;
19+
GRANT ALL ON app.users TO admin_role;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Schema setup
2+
CREATE SCHEMA IF NOT EXISTS app;
3+
4+
-- Users table
5+
CREATE TABLE app.users (
6+
id serial PRIMARY KEY,
7+
username text NOT NULL,
8+
created_at timestamptz DEFAULT now()
9+
);
10+
11+
-- Roles table
12+
CREATE TABLE app.roles (
13+
id serial PRIMARY KEY,
14+
name text UNIQUE NOT NULL
15+
);
16+
17+
-- Junction table
18+
CREATE TABLE app.user_roles (
19+
user_id integer REFERENCES app.users (id),
20+
role_id integer REFERENCES app.roles (id),
21+
PRIMARY KEY (user_id, role_id)
22+
);
23+
24+
-- Seed default roles
25+
INSERT INTO app.roles (name) VALUES ('admin'), ('viewer');
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Deploy schemas/my-app/tables/users to pg
2+
-- requires: schemas/my-app/schema
3+
4+
BEGIN;
5+
6+
-- Create the main users table
7+
CREATE TABLE my_app.users (
8+
id serial PRIMARY KEY,
9+
name text NOT NULL,
10+
email text UNIQUE
11+
);
12+
13+
-- Add an index for fast lookups
14+
CREATE INDEX idx_users_email ON my_app.users (email);
15+
16+
COMMIT;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Deploy schemas/app/functions/get_user to pg
2+
-- requires: schemas/app/tables/users
3+
4+
BEGIN;
5+
6+
-- Function to get a user by ID
7+
CREATE FUNCTION app.get_user(p_id integer)
8+
RETURNS TABLE (id integer, username text, created_at timestamptz) AS $$
9+
BEGIN
10+
-- Return the matching user
11+
RETURN QUERY
12+
SELECT u.id, u.username, u.created_at
13+
FROM app.users u
14+
WHERE u.id = p_id;
15+
END;
16+
$$ LANGUAGE plpgsql STABLE;
17+
18+
-- Grant execute to authenticated users
19+
GRANT EXECUTE ON FUNCTION app.get_user(integer) TO authenticated;
20+
21+
COMMIT;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Active users view
2+
CREATE VIEW app.active_users AS
3+
SELECT id, username, created_at
4+
FROM app.users
5+
WHERE created_at > now() - interval '90 days';
6+
7+
-- Audit trigger function
8+
CREATE FUNCTION app.audit_trigger() RETURNS trigger AS $$
9+
BEGIN
10+
INSERT INTO app.audit_log (table_name, action, row_id)
11+
VALUES (TG_TABLE_NAME, TG_OP, NEW.id);
12+
RETURN NEW;
13+
END;
14+
$$ LANGUAGE plpgsql;
15+
16+
-- Attach trigger to users table
17+
CREATE TRIGGER users_audit
18+
AFTER INSERT OR UPDATE ON app.users
19+
FOR EACH ROW
20+
EXECUTE FUNCTION app.audit_trigger();
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import {
4+
parseSync,
5+
deparseEnhanced,
6+
isRawComment,
7+
isRawWhitespace,
8+
isRawStmt,
9+
loadModule,
10+
} from '../src';
11+
12+
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
13+
14+
const fixtures = fs.readdirSync(FIXTURES_DIR)
15+
.filter(f => f.endsWith('.sql'))
16+
.sort()
17+
.map(f => ({
18+
name: f.replace('.sql', ''),
19+
sql: fs.readFileSync(path.join(FIXTURES_DIR, f), 'utf-8'),
20+
}));
21+
22+
beforeAll(async () => {
23+
await loadModule();
24+
});
25+
26+
describe('fixture round-trip (CST)', () => {
27+
for (const { name, sql } of fixtures) {
28+
describe(name, () => {
29+
it('parse→deparse→parse→deparse is idempotent', () => {
30+
// First round trip
31+
const result1 = parseSync(sql);
32+
const output1 = deparseEnhanced(result1);
33+
34+
// Second round trip
35+
const result2 = parseSync(output1);
36+
const output2 = deparseEnhanced(result2);
37+
38+
// The two deparses must produce identical output
39+
expect(output2).toBe(output1);
40+
});
41+
42+
it('preserves all -- comments from the original', () => {
43+
const result = parseSync(sql);
44+
const output = deparseEnhanced(result);
45+
46+
// Extract expected comments: lines starting with -- that are NOT
47+
// inside dollar-quoted blocks
48+
const expectedComments = extractTopLevelComments(sql);
49+
50+
for (const comment of expectedComments) {
51+
expect(output).toContain(comment);
52+
}
53+
});
54+
55+
it('preserves all SQL statements from the original', () => {
56+
const result = parseSync(sql);
57+
const stmts = result.stmts.filter(isRawStmt);
58+
59+
// Should have at least one real statement
60+
expect(stmts.length).toBeGreaterThan(0);
61+
62+
// Deparse should produce valid SQL for each statement
63+
const output = deparseEnhanced(result);
64+
expect(output.length).toBeGreaterThan(0);
65+
});
66+
67+
it('CST node ordering matches source order', () => {
68+
const result = parseSync(sql);
69+
const types = result.stmts.map(s => {
70+
if (isRawComment(s)) return 'comment';
71+
if (isRawWhitespace(s)) return 'whitespace';
72+
if (isRawStmt(s)) return 'stmt';
73+
return 'unknown';
74+
});
75+
76+
// No unknown node types
77+
expect(types).not.toContain('unknown');
78+
79+
// Should have at least one statement
80+
expect(types).toContain('stmt');
81+
});
82+
});
83+
}
84+
});
85+
86+
/**
87+
* Extract top-level -- comments from SQL source, skipping any
88+
* that appear inside dollar-quoted strings.
89+
*/
90+
function extractTopLevelComments(sql: string): string[] {
91+
const comments: string[] = [];
92+
let inDollarQuote = false;
93+
let dollarTag = '';
94+
95+
const lines = sql.split('\n');
96+
for (const line of lines) {
97+
const trimmed = line.trim();
98+
99+
// Check for dollar-quote boundaries
100+
const dollarMatch = trimmed.match(/\$([a-zA-Z_]*)\$/);
101+
if (dollarMatch) {
102+
const tag = dollarMatch[0];
103+
if (!inDollarQuote) {
104+
// Check if this line also closes the dollar quote
105+
const firstIdx = trimmed.indexOf(tag);
106+
const secondIdx = trimmed.indexOf(tag, firstIdx + tag.length);
107+
if (secondIdx === -1) {
108+
inDollarQuote = true;
109+
dollarTag = tag;
110+
}
111+
// If both open and close on same line, not entering a block
112+
} else if (tag === dollarTag) {
113+
inDollarQuote = false;
114+
dollarTag = '';
115+
}
116+
continue;
117+
}
118+
119+
if (!inDollarQuote && trimmed.startsWith('--')) {
120+
comments.push(trimmed);
121+
}
122+
}
123+
124+
return comments;
125+
}

0 commit comments

Comments
 (0)