From 1659a27b92e31b684e140df01ce78f26dc2305e9 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 22 Jun 2025 22:45:20 -0700 Subject: [PATCH 01/17] chore(release): publish - pgsql-deparser@17.7.2 - pgsql-parser@17.6.2 - @pgsql/cli@1.30.2 - @pgsql/utils@17.6.2 --- packages/deparser/CHANGELOG.md | 8 ++++++++ packages/deparser/package.json | 2 +- packages/parser/CHANGELOG.md | 8 ++++++++ packages/parser/package.json | 4 ++-- packages/pgsql-cli/CHANGELOG.md | 8 ++++++++ packages/pgsql-cli/package.json | 6 +++--- packages/utils/CHANGELOG.md | 8 ++++++++ packages/utils/package.json | 4 ++-- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/deparser/CHANGELOG.md b/packages/deparser/CHANGELOG.md index 12bdd35f..47c9bcb8 100644 --- a/packages/deparser/CHANGELOG.md +++ b/packages/deparser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [17.7.2](https://github.com/launchql/pgsql-parser/compare/pgsql-deparser@17.7.1...pgsql-deparser@17.7.2) (2025-06-23) + +**Note:** Version bump only for package pgsql-deparser + + + + + ## [17.7.1](https://github.com/launchql/pgsql-parser/compare/pgsql-deparser@17.7.0...pgsql-deparser@17.7.1) (2025-06-23) **Note:** Version bump only for package pgsql-deparser diff --git a/packages/deparser/package.json b/packages/deparser/package.json index fd6570d7..a3792d35 100644 --- a/packages/deparser/package.json +++ b/packages/deparser/package.json @@ -1,6 +1,6 @@ { "name": "pgsql-deparser", - "version": "17.7.1", + "version": "17.7.2", "author": "Dan Lynch ", "description": "PostgreSQL AST Deparser", "main": "index.js", diff --git a/packages/parser/CHANGELOG.md b/packages/parser/CHANGELOG.md index cfdfbf8b..b9c003fc 100644 --- a/packages/parser/CHANGELOG.md +++ b/packages/parser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [17.6.2](https://github.com/launchql/pgsql-parser/compare/pgsql-parser@17.6.1...pgsql-parser@17.6.2) (2025-06-23) + +**Note:** Version bump only for package pgsql-parser + + + + + ## [17.6.1](https://github.com/launchql/pgsql-parser/compare/pgsql-parser@17.6.0...pgsql-parser@17.6.1) (2025-06-23) **Note:** Version bump only for package pgsql-parser diff --git a/packages/parser/package.json b/packages/parser/package.json index 775efde6..c6f01d83 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "pgsql-parser", - "version": "17.6.1", + "version": "17.6.2", "author": "Dan Lynch ", "description": "The real PostgreSQL query parser", "main": "index.js", @@ -41,6 +41,6 @@ "dependencies": { "@pgsql/types": "^17.6.1", "libpg-query": "17.5.2", - "pgsql-deparser": "^17.7.1" + "pgsql-deparser": "^17.7.2" } } diff --git a/packages/pgsql-cli/CHANGELOG.md b/packages/pgsql-cli/CHANGELOG.md index 777b6770..f19009bf 100644 --- a/packages/pgsql-cli/CHANGELOG.md +++ b/packages/pgsql-cli/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.30.2](https://github.com/launchql/pgsql-parser/compare/@pgsql/cli@1.30.1...@pgsql/cli@1.30.2) (2025-06-23) + +**Note:** Version bump only for package @pgsql/cli + + + + + ## [1.30.1](https://github.com/launchql/pgsql-parser/compare/@pgsql/cli@1.30.0...@pgsql/cli@1.30.1) (2025-06-23) **Note:** Version bump only for package @pgsql/cli diff --git a/packages/pgsql-cli/package.json b/packages/pgsql-cli/package.json index 28cfaa04..88b973ca 100644 --- a/packages/pgsql-cli/package.json +++ b/packages/pgsql-cli/package.json @@ -1,6 +1,6 @@ { "name": "@pgsql/cli", - "version": "1.30.1", + "version": "1.30.2", "description": "Unified CLI for PostgreSQL AST parsing, deparsing, and code generation", "author": "Dan Lynch ", "main": "index.js", @@ -53,7 +53,7 @@ "mkdirp": "3.0.1", "nested-obj": "^0.0.1", "pg-proto-parser": "^1.29.0", - "pgsql-deparser": "^17.7.1", - "pgsql-parser": "^17.6.1" + "pgsql-deparser": "^17.7.2", + "pgsql-parser": "^17.6.2" } } diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index fa79eba9..963d1979 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [17.6.2](https://github.com/launchql/pgsql-parser/compare/@pgsql/utils@17.6.1...@pgsql/utils@17.6.2) (2025-06-23) + +**Note:** Version bump only for package @pgsql/utils + + + + + ## [17.6.1](https://github.com/launchql/pgsql-parser/compare/@pgsql/utils@17.6.0...@pgsql/utils@17.6.1) (2025-06-23) **Note:** Version bump only for package @pgsql/utils diff --git a/packages/utils/package.json b/packages/utils/package.json index 30bffc35..e92d3b32 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@pgsql/utils", - "version": "17.6.1", + "version": "17.6.2", "author": "Dan Lynch ", "description": "PostgreSQL AST utils for pgsql-parser", "main": "index.js", @@ -32,7 +32,7 @@ }, "devDependencies": { "pg-proto-parser": "^1.29.0", - "pgsql-deparser": "^17.7.1" + "pgsql-deparser": "^17.7.2" }, "dependencies": { "@pgsql/types": "^17.6.1", From 159961b8d7a4f2347919b9b2bcce03dc63d3104c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 05:50:53 +0000 Subject: [PATCH 02/17] Implement pretty formatting option for CREATE TABLE statements - Add pretty option to DeparserOptions interface - Enhance SqlFormatter with proper indentation support - Modify CreateStmt to format with newlines and tabs when pretty=true - Create new pretty/ test folder with snapshot tests - Maintain backward compatibility (pretty=false by default) - All existing tests continue to pass Co-Authored-By: Dan Lynch --- __fixtures__/pretty/create_table.sql | 39 +++++++++++++++ .../create-table-pretty.test.ts.snap | 32 ++++++++++++ .../pretty/create-table-pretty.test.ts | 49 +++++++++++++++++++ packages/deparser/src/deparser.ts | 13 ++++- packages/deparser/src/utils/sql-formatter.ts | 16 +++++- 5 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 __fixtures__/pretty/create_table.sql create mode 100644 packages/deparser/__tests__/pretty/__snapshots__/create-table-pretty.test.ts.snap create mode 100644 packages/deparser/__tests__/pretty/create-table-pretty.test.ts diff --git a/__fixtures__/pretty/create_table.sql b/__fixtures__/pretty/create_table.sql new file mode 100644 index 00000000..cbfff60c --- /dev/null +++ b/__fixtures__/pretty/create_table.sql @@ -0,0 +1,39 @@ + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE +); + +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10,2) CHECK (price > 0), + category_id INTEGER, + description TEXT, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP, + UNIQUE (name, category_id), + FOREIGN KEY (category_id) REFERENCES categories(id) +); + +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + subtotal DECIMAL(10,2) NOT NULL, + tax_rate DECIMAL(5,4) DEFAULT 0.0825, + tax_amount DECIMAL(10,2) GENERATED ALWAYS AS (subtotal * tax_rate) STORED, + total DECIMAL(10,2) GENERATED ALWAYS AS (subtotal + tax_amount) STORED +); + +CREATE TABLE sales ( + id SERIAL, + sale_date DATE NOT NULL, + amount DECIMAL(10,2), + region VARCHAR(50) +) PARTITION BY RANGE (sale_date); + +CREATE TEMPORARY TABLE temp_calculations ( + id INTEGER, + value DECIMAL(15,5), + result TEXT +); diff --git a/packages/deparser/__tests__/pretty/__snapshots__/create-table-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/create-table-pretty.test.ts.snap new file mode 100644 index 00000000..a4053291 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/create-table-pretty.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pretty CREATE TABLE formatting should format basic CREATE TABLE with pretty option enabled 1`] = ` +"CREATE TABLE users ( + id serial PRIMARY KEY, + name text NOT NULL, + email text UNIQUE +);" +`; + +exports[`Pretty CREATE TABLE formatting should format complex CREATE TABLE with pretty option enabled 1`] = ` +"CREATE TABLE orders ( + id serial PRIMARY KEY, + user_id int NOT NULL, + total numeric(10, 2) CHECK (total > 0), + status varchar(20) DEFAULT 'pending', + created_at pg_catalog.timestamp DEFAULT now(), + FOREIGN KEY (user_id) REFERENCES users (id) +);" +`; + +exports[`Pretty CREATE TABLE formatting should maintain single-line format for complex table when pretty disabled 1`] = `"CREATE TABLE orders (id serial PRIMARY KEY, user_id int NOT NULL, total numeric(10, 2) CHECK (total > 0), status varchar(20) DEFAULT 'pending', created_at pg_catalog.timestamp DEFAULT now(), FOREIGN KEY (user_id) REFERENCES users (id));"`; + +exports[`Pretty CREATE TABLE formatting should maintain single-line format when pretty option disabled 1`] = `"CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL, email text UNIQUE);"`; + +exports[`Pretty CREATE TABLE formatting should use custom newline and tab characters in pretty mode 1`] = ` +"CREATE TABLE users ( + id serial PRIMARY KEY, + name text NOT NULL, + email text UNIQUE +);" +`; diff --git a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts new file mode 100644 index 00000000..d2250782 --- /dev/null +++ b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts @@ -0,0 +1,49 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; + +describe('Pretty CREATE TABLE formatting', () => { + const basicTableSql = `CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE);`; + + const complexTableSql = `CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + total DECIMAL(10,2) CHECK (total > 0), + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT now(), + FOREIGN KEY (user_id) REFERENCES users(id) + );`; + + it('should format basic CREATE TABLE with pretty option enabled', async () => { + const parsed = await parse(basicTableSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const parsed = await parse(basicTableSql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex CREATE TABLE with pretty option enabled', async () => { + const parsed = await parse(complexTableSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex table when pretty disabled', async () => { + const parsed = await parse(complexTableSql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const parsed = await parse(basicTableSql); + const result = deparseSync(parsed, { + pretty: true, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index fa7807c3..69247e79 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -12,6 +12,7 @@ export interface DeparserOptions { functionDelimiter?: string; // Default: '$$' // Alternative delimiter when the default is found in the body functionDelimiterFallback?: string; // Default: '$EOFCODE$' + pretty?: boolean; // Default: false } // Type guards for better type safety @@ -64,7 +65,7 @@ export class Deparser implements DeparserVisitor { private options: DeparserOptions; constructor(tree: Node | Node[] | t.ParseResult, opts: DeparserOptions = {}) { - this.formatter = new SqlFormatter(opts.newline, opts.tab); + this.formatter = new SqlFormatter(opts.newline, opts.tab, opts.pretty); // Set default options this.options = { @@ -2139,7 +2140,15 @@ export class Deparser implements DeparserVisitor { const elementStrs = elements.map(el => { return this.deparse(el, context); }); - output.push(this.formatter.parens(elementStrs.join(', '))); + + if (this.formatter.isPretty()) { + const formattedElements = elementStrs.map(el => + this.formatter.indent(el) + ).join(',' + this.formatter.newline()); + output.push('(' + this.formatter.newline() + formattedElements + this.formatter.newline() + ')'); + } else { + output.push(this.formatter.parens(elementStrs.join(', '))); + } } else if (!node.partbound) { output.push(this.formatter.parens('')); } diff --git a/packages/deparser/src/utils/sql-formatter.ts b/packages/deparser/src/utils/sql-formatter.ts index 8d0386b3..0f56504f 100644 --- a/packages/deparser/src/utils/sql-formatter.ts +++ b/packages/deparser/src/utils/sql-formatter.ts @@ -1,10 +1,12 @@ export class SqlFormatter { private newlineChar: string; private tabChar: string; + private prettyMode: boolean; - constructor(newlineChar: string = '\n', tabChar: string = ' ') { + constructor(newlineChar: string = '\n', tabChar: string = ' ', prettyMode: boolean = false) { this.newlineChar = newlineChar; this.tabChar = tabChar; + this.prettyMode = prettyMode; } format(parts: string[], separator: string = ' '): string { @@ -12,7 +14,13 @@ export class SqlFormatter { } indent(text: string, count: number = 1): string { - return text; + if (!this.prettyMode) { + return text; + } + const indentation = this.tabChar.repeat(count); + return text.split(this.newlineChar).map(line => + line.trim() ? indentation + line : line + ).join(this.newlineChar); } parens(content: string): string { @@ -26,4 +34,8 @@ export class SqlFormatter { tab(): string { return this.tabChar; } + + isPretty(): boolean { + return this.prettyMode; + } } From e31178f7b039081b159ad9d6b8bd2a35785f3412 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 06:06:25 +0000 Subject: [PATCH 03/17] Extend pretty formatting to SELECT, constraints, and CREATE POLICY statements - Add pretty formatting for SELECT statements with proper clause indentation - Add pretty formatting for constraint statements with multi-line support - Add pretty formatting for CREATE POLICY statements with clause formatting - Create comprehensive test fixtures for all new statement types - Add snapshot tests for SELECT, constraints, and CREATE POLICY formatting - Maintain backward compatibility with single-line formatting when pretty=false - All tests passing with proper snapshot validation Co-Authored-By: Dan Lynch --- __fixtures__/pretty/constraints.sql | 21 ++ __fixtures__/pretty/create_policy.sql | 22 ++ __fixtures__/pretty/select_statements.sql | 29 +++ .../constraints-pretty.test.ts.snap | 34 ++++ .../create-policy-pretty.test.ts.snap | 38 ++++ .../__snapshots__/select-pretty.test.ts.snap | 89 ++++++++ .../pretty/constraints-pretty.test.ts | 56 +++++ .../pretty/create-policy-pretty.test.ts | 50 +++++ .../__tests__/pretty/select-pretty.test.ts | 58 ++++++ packages/deparser/src/deparser.ts | 191 ++++++++++++++---- 10 files changed, 545 insertions(+), 43 deletions(-) create mode 100644 __fixtures__/pretty/constraints.sql create mode 100644 __fixtures__/pretty/create_policy.sql create mode 100644 __fixtures__/pretty/select_statements.sql create mode 100644 packages/deparser/__tests__/pretty/__snapshots__/constraints-pretty.test.ts.snap create mode 100644 packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap create mode 100644 packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap create mode 100644 packages/deparser/__tests__/pretty/constraints-pretty.test.ts create mode 100644 packages/deparser/__tests__/pretty/create-policy-pretty.test.ts create mode 100644 packages/deparser/__tests__/pretty/select-pretty.test.ts diff --git a/__fixtures__/pretty/constraints.sql b/__fixtures__/pretty/constraints.sql new file mode 100644 index 00000000..1ba8adee --- /dev/null +++ b/__fixtures__/pretty/constraints.sql @@ -0,0 +1,21 @@ +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + total DECIMAL(10,2) CHECK (total > 0), + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT now(), + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT unique_user_date UNIQUE (user_id, created_at), + CONSTRAINT check_status CHECK (status IN ('pending', 'completed', 'cancelled')) +); + +ALTER TABLE products ADD CONSTRAINT fk_category + FOREIGN KEY (category_id) + REFERENCES categories(id) + ON UPDATE CASCADE + ON DELETE SET NULL + DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE products ADD CONSTRAINT check_price CHECK (price > 0); + +ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email); diff --git a/__fixtures__/pretty/create_policy.sql b/__fixtures__/pretty/create_policy.sql new file mode 100644 index 00000000..57f4f788 --- /dev/null +++ b/__fixtures__/pretty/create_policy.sql @@ -0,0 +1,22 @@ +CREATE POLICY user_policy ON users FOR ALL TO authenticated_users USING (user_id = current_user_id()); + +CREATE POLICY admin_policy ON sensitive_data + AS RESTRICTIVE + FOR SELECT + TO admin_role + USING (department = current_user_department()) + WITH CHECK (approved = true); + +CREATE POLICY complex_policy ON documents + FOR UPDATE + TO document_editors + USING ( + owner_id = current_user_id() OR + (shared = true AND permissions @> '{"edit": true}') + ) + WITH CHECK ( + status != 'archived' AND + last_modified > now() - interval '1 day' + ); + +CREATE POLICY simple_policy ON posts FOR SELECT TO public USING (published = true); diff --git a/__fixtures__/pretty/select_statements.sql b/__fixtures__/pretty/select_statements.sql new file mode 100644 index 00000000..3d215de6 --- /dev/null +++ b/__fixtures__/pretty/select_statements.sql @@ -0,0 +1,29 @@ +SELECT id, name, email FROM users WHERE active = true; + +SELECT + u.id, + u.name, + u.email, + p.title as profile_title +FROM users u +JOIN profiles p ON u.id = p.user_id +WHERE u.active = true + AND u.created_at > '2023-01-01' +GROUP BY u.id, u.name, u.email, p.title +HAVING COUNT(*) > 1 +ORDER BY u.created_at DESC, u.name ASC +LIMIT 10 +OFFSET 5; + +SELECT id, name FROM users WHERE id IN ( + SELECT user_id FROM orders WHERE total > 100 +); + +SELECT name FROM customers +UNION ALL +SELECT name FROM suppliers +ORDER BY name; + +SELECT name, email FROM users WHERE status = 'active'; + +SELECT u.name, o.total FROM users u, orders o WHERE u.id = o.user_id; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/constraints-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/constraints-pretty.test.ts.snap new file mode 100644 index 00000000..89615060 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/constraints-pretty.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pretty constraint formatting should format check constraint with pretty option enabled 1`] = `"ALTER TABLE products ADD CONSTRAINT check_price CHECK (price > 0);"`; + +exports[`Pretty constraint formatting should format complex table with constraints with pretty option enabled 1`] = ` +"CREATE TABLE orders ( + id serial PRIMARY KEY, + user_id int NOT NULL, + total numeric(10, 2) CHECK (total > 0), + status varchar(20) DEFAULT 'pending', + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) + ON DELETE CASCADE +);" +`; + +exports[`Pretty constraint formatting should format foreign key constraint with pretty option enabled 1`] = ` +"ALTER TABLE products ADD CONSTRAINT fk_category FOREIGN KEY (category_id) REFERENCES categories (id) + ON UPDATE CASCADE + ON DELETE SET NULL + DEFERRABLE + INITIALLY DEFERRED;" +`; + +exports[`Pretty constraint formatting should maintain single-line format for complex table when pretty disabled 1`] = `"CREATE TABLE orders (id serial PRIMARY KEY, user_id int NOT NULL, total numeric(10, 2) CHECK (total > 0), status varchar(20) DEFAULT 'pending', CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE);"`; + +exports[`Pretty constraint formatting should maintain single-line format when pretty option disabled 1`] = `"ALTER TABLE products ADD CONSTRAINT fk_category FOREIGN KEY (category_id) REFERENCES categories (id) ON UPDATE CASCADE ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;"`; + +exports[`Pretty constraint formatting should use custom newline and tab characters in pretty mode 1`] = ` +"ALTER TABLE products ADD CONSTRAINT fk_category FOREIGN KEY (category_id) REFERENCES categories (id) + ON UPDATE CASCADE + ON DELETE SET NULL + DEFERRABLE + INITIALLY DEFERRED;" +`; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap new file mode 100644 index 00000000..62b86ca2 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pretty CREATE POLICY formatting should format basic CREATE POLICY with pretty option enabled 1`] = ` +"CREATEPOLICY"user_policy"ONusers + AS PERMISSIVE + FOR ALL + TO authenticated_users + USING (user_id = current_user_id());" +`; + +exports[`Pretty CREATE POLICY formatting should format complex CREATE POLICY with pretty option enabled 1`] = ` +"CREATEPOLICY"admin_policy"ONsensitive_data + AS RESTRICTIVE + FOR SELECT + TO admin_role + USING (department = current_user_department()) + WITH CHECK (approved = true);" +`; + +exports[`Pretty CREATE POLICY formatting should format simple CREATE POLICY with pretty option enabled 1`] = ` +"CREATEPOLICY"simple_policy"ONposts + AS PERMISSIVE + FOR SELECT + TO public + USING (published = true);" +`; + +exports[`Pretty CREATE POLICY formatting should maintain single-line format for complex policy when pretty disabled 1`] = `"CREATE POLICY "admin_policy" ON sensitive_data AS RESTRICTIVE FOR SELECT TO admin_role USING (department = current_user_department()) WITH CHECK (approved = true);"`; + +exports[`Pretty CREATE POLICY formatting should maintain single-line format when pretty option disabled 1`] = `"CREATE POLICY "user_policy" ON users AS PERMISSIVE FOR ALL TO authenticated_users USING (user_id = current_user_id());"`; + +exports[`Pretty CREATE POLICY formatting should use custom newline and tab characters in pretty mode 1`] = ` +"CREATEPOLICY"user_policy"ONusers + AS PERMISSIVE + FOR ALL + TO authenticated_users + USING (user_id = current_user_id());" +`; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap new file mode 100644 index 00000000..633d7c6a --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pretty SELECT formatting should format SELECT with UNION with pretty option enabled 1`] = ` +"SELECT +name +FROM +customers +UNION +ALL +SELECT +name +FROM +suppliers +ORDER BY +name;" +`; + +exports[`Pretty SELECT formatting should format SELECT with subquery with pretty option enabled 1`] = ` +"SELECT + + id, + name +FROM +users +WHERE + id IN (SELECT + user_id + FROM + orders + WHERE + total > 100);" +`; + +exports[`Pretty SELECT formatting should format basic SELECT with pretty option enabled 1`] = ` +"SELECT + + id, + name, + email +FROM +users +WHERE + active = true;" +`; + +exports[`Pretty SELECT formatting should format complex SELECT with pretty option enabled 1`] = ` +"SELECT + + u.id, + u.name, + u.email, + p.title +FROM +users AS u JOIN profiles AS p ON u.id = p.user_id +WHERE + u.active = true AND u.created_at > '2023-01-01' +GROUP BY + + u.id, + u.name, + u.email, + p.title +HAVING + count(*) > 1 +ORDER BY + + u.created_at DESC, + u.name ASC +LIMIT +10 +OFFSET +5;" +`; + +exports[`Pretty SELECT formatting should maintain single-line format for complex SELECT when pretty disabled 1`] = `"SELECT u.id, u.name, u.email, p.title FROM users AS u JOIN profiles AS p ON u.id = p.user_id WHERE u.active = true AND u.created_at > '2023-01-01' GROUP BY u.id, u.name, u.email, p.title HAVING count(*) > 1 ORDER BY u.created_at DESC, u.name ASC LIMIT 10 OFFSET 5;"`; + +exports[`Pretty SELECT formatting should maintain single-line format when pretty option disabled 1`] = `"SELECT id, name, email FROM users WHERE active = true;"`; + +exports[`Pretty SELECT formatting should use custom newline and tab characters in pretty mode 1`] = ` +"SELECT + + id, + name, + email +FROM +users +WHERE + active = true;" +`; diff --git a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts new file mode 100644 index 00000000..f47167d0 --- /dev/null +++ b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts @@ -0,0 +1,56 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; + +describe('Pretty constraint formatting', () => { + const foreignKeyConstraintSql = `ALTER TABLE products ADD CONSTRAINT fk_category FOREIGN KEY (category_id) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;`; + + const checkConstraintSql = `ALTER TABLE products ADD CONSTRAINT check_price CHECK (price > 0);`; + + const complexTableSql = `CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + total DECIMAL(10,2) CHECK (total > 0), + status VARCHAR(20) DEFAULT 'pending', + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );`; + + it('should format foreign key constraint with pretty option enabled', async () => { + const parsed = await parse(foreignKeyConstraintSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const parsed = await parse(foreignKeyConstraintSql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format check constraint with pretty option enabled', async () => { + const parsed = await parse(checkConstraintSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex table with constraints with pretty option enabled', async () => { + const parsed = await parse(complexTableSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex table when pretty disabled', async () => { + const parsed = await parse(complexTableSql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const parsed = await parse(foreignKeyConstraintSql); + const result = deparseSync(parsed, { + pretty: true, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts new file mode 100644 index 00000000..0254e424 --- /dev/null +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -0,0 +1,50 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; + +describe('Pretty CREATE POLICY formatting', () => { + const basicPolicySql = `CREATE POLICY user_policy ON users FOR ALL TO authenticated_users USING (user_id = current_user_id());`; + + const complexPolicySql = `CREATE POLICY admin_policy ON sensitive_data AS RESTRICTIVE FOR SELECT TO admin_role USING (department = current_user_department()) WITH CHECK (approved = true);`; + + const simplePolicySql = `CREATE POLICY simple_policy ON posts FOR SELECT TO public USING (published = true);`; + + it('should format basic CREATE POLICY with pretty option enabled', async () => { + const parsed = await parse(basicPolicySql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const parsed = await parse(basicPolicySql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex CREATE POLICY with pretty option enabled', async () => { + const parsed = await parse(complexPolicySql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex policy when pretty disabled', async () => { + const parsed = await parse(complexPolicySql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format simple CREATE POLICY with pretty option enabled', async () => { + const parsed = await parse(simplePolicySql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const parsed = await parse(basicPolicySql); + const result = deparseSync(parsed, { + pretty: true, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts new file mode 100644 index 00000000..b41cafd9 --- /dev/null +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -0,0 +1,58 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; + +describe('Pretty SELECT formatting', () => { + const basicSelectSql = `SELECT id, name, email FROM users WHERE active = true;`; + + const complexSelectSql = `SELECT u.id, u.name, u.email, p.title FROM users u JOIN profiles p ON u.id = p.user_id WHERE u.active = true AND u.created_at > '2023-01-01' GROUP BY u.id, u.name, u.email, p.title HAVING COUNT(*) > 1 ORDER BY u.created_at DESC, u.name ASC LIMIT 10 OFFSET 5;`; + + const selectWithSubquerySql = `SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100);`; + + const selectUnionSql = `SELECT name FROM customers UNION ALL SELECT name FROM suppliers ORDER BY name;`; + + it('should format basic SELECT with pretty option enabled', async () => { + const parsed = await parse(basicSelectSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const parsed = await parse(basicSelectSql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex SELECT with pretty option enabled', async () => { + const parsed = await parse(complexSelectSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex SELECT when pretty disabled', async () => { + const parsed = await parse(complexSelectSql); + const result = deparseSync(parsed, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format SELECT with subquery with pretty option enabled', async () => { + const parsed = await parse(selectWithSubquerySql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format SELECT with UNION with pretty option enabled', async () => { + const parsed = await parse(selectUnionSql); + const result = deparseSync(parsed, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const parsed = await parse(basicSelectSql); + const result = deparseSync(parsed, { + pretty: true, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 69247e79..a3b968af 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -287,10 +287,17 @@ export class Deparser implements DeparserVisitor { if (node.targetList) { const targetList = ListUtils.unwrapList(node.targetList); - const targets = targetList - .map(e => this.visit(e as Node, { ...context, select: true })) - .join(', '); - output.push(targets); + if (this.formatter.isPretty() && targetList.length > 1) { + const targets = targetList + .map(e => this.formatter.indent(this.visit(e as Node, { ...context, select: true }))) + .join(',' + this.formatter.newline()); + output.push(this.formatter.newline() + targets); + } else { + const targets = targetList + .map(e => this.visit(e as Node, { ...context, select: true })) + .join(', '); + output.push(targets); + } } if (node.intoClause) { @@ -301,15 +308,26 @@ export class Deparser implements DeparserVisitor { if (node.fromClause) { output.push('FROM'); const fromList = ListUtils.unwrapList(node.fromClause); - const fromItems = fromList - .map(e => this.deparse(e as Node, { ...context, from: true })) - .join(', '); - output.push(fromItems); + if (this.formatter.isPretty() && fromList.length > 1) { + const fromItems = fromList + .map(e => this.formatter.indent(this.deparse(e as Node, { ...context, from: true }))) + .join(',' + this.formatter.newline()); + output.push(this.formatter.newline() + fromItems); + } else { + const fromItems = fromList + .map(e => this.deparse(e as Node, { ...context, from: true })) + .join(', '); + output.push(fromItems); + } } if (node.whereClause) { output.push('WHERE'); - output.push(this.visit(node.whereClause as Node, context)); + if (this.formatter.isPretty()) { + output.push(this.formatter.indent(this.visit(node.whereClause as Node, context))); + } else { + output.push(this.visit(node.whereClause as Node, context)); + } } if (node.valuesLists) { @@ -324,15 +342,26 @@ export class Deparser implements DeparserVisitor { if (node.groupClause) { output.push('GROUP BY'); const groupList = ListUtils.unwrapList(node.groupClause); - const groupItems = groupList - .map(e => this.visit(e as Node, { ...context, group: true })) - .join(', '); - output.push(groupItems); + if (this.formatter.isPretty() && groupList.length > 1) { + const groupItems = groupList + .map(e => this.formatter.indent(this.visit(e as Node, { ...context, group: true }))) + .join(',' + this.formatter.newline()); + output.push(this.formatter.newline() + groupItems); + } else { + const groupItems = groupList + .map(e => this.visit(e as Node, { ...context, group: true })) + .join(', '); + output.push(groupItems); + } } if (node.havingClause) { output.push('HAVING'); - output.push(this.visit(node.havingClause as Node, context)); + if (this.formatter.isPretty()) { + output.push(this.formatter.indent(this.visit(node.havingClause as Node, context))); + } else { + output.push(this.visit(node.havingClause as Node, context)); + } } if (node.windowClause) { @@ -347,10 +376,17 @@ export class Deparser implements DeparserVisitor { if (node.sortClause) { output.push('ORDER BY'); const sortList = ListUtils.unwrapList(node.sortClause); - const sortItems = sortList - .map(e => this.visit(e as Node, { ...context, sort: true })) - .join(', '); - output.push(sortItems); + if (this.formatter.isPretty() && sortList.length > 1) { + const sortItems = sortList + .map(e => this.formatter.indent(this.visit(e as Node, { ...context, sort: true }))) + .join(',' + this.formatter.newline()); + output.push(this.formatter.newline() + sortItems); + } else { + const sortItems = sortList + .map(e => this.visit(e as Node, { ...context, sort: true })) + .join(', '); + output.push(sortItems); + } } if (node.limitCount) { @@ -371,6 +407,9 @@ export class Deparser implements DeparserVisitor { output.push(lockingClauses); } + if (this.formatter.isPretty()) { + return output.join(this.formatter.newline()); + } return output.join(' '); } @@ -2441,38 +2480,50 @@ export class Deparser implements DeparserVisitor { } } if (node.fk_upd_action && node.fk_upd_action !== 'a') { - output.push('ON UPDATE'); + let updateClause = 'ON UPDATE '; switch (node.fk_upd_action) { case 'r': - output.push('RESTRICT'); + updateClause += 'RESTRICT'; break; case 'c': - output.push('CASCADE'); + updateClause += 'CASCADE'; break; case 'n': - output.push('SET NULL'); + updateClause += 'SET NULL'; break; case 'd': - output.push('SET DEFAULT'); + updateClause += 'SET DEFAULT'; break; } + if (this.formatter.isPretty()) { + output.push('\n' + this.formatter.indent(updateClause)); + } else { + output.push('ON UPDATE'); + output.push(updateClause.replace('ON UPDATE ', '')); + } } if (node.fk_del_action && node.fk_del_action !== 'a') { - output.push('ON DELETE'); + let deleteClause = 'ON DELETE '; switch (node.fk_del_action) { case 'r': - output.push('RESTRICT'); + deleteClause += 'RESTRICT'; break; case 'c': - output.push('CASCADE'); + deleteClause += 'CASCADE'; break; case 'n': - output.push('SET NULL'); + deleteClause += 'SET NULL'; break; case 'd': - output.push('SET DEFAULT'); + deleteClause += 'SET DEFAULT'; break; } + if (this.formatter.isPretty()) { + output.push('\n' + this.formatter.indent(deleteClause)); + } else { + output.push('ON DELETE'); + output.push(deleteClause.replace('ON DELETE ', '')); + } } // Handle NOT VALID for foreign key constraints - only for table constraints, not domain constraints if (node.skip_validation && !context.isDomainConstraint) { @@ -2529,17 +2580,44 @@ export class Deparser implements DeparserVisitor { // Handle deferrable constraints for all constraint types that support it if (node.contype === 'CONSTR_PRIMARY' || node.contype === 'CONSTR_UNIQUE' || node.contype === 'CONSTR_FOREIGN') { if (node.deferrable) { - output.push('DEFERRABLE'); - if (node.initdeferred === true) { - output.push('INITIALLY DEFERRED'); - } else if (node.initdeferred === false) { - output.push('INITIALLY IMMEDIATE'); + if (this.formatter.isPretty() && node.contype === 'CONSTR_FOREIGN') { + output.push('\n' + this.formatter.indent('DEFERRABLE')); + if (node.initdeferred === true) { + output.push('\n' + this.formatter.indent('INITIALLY DEFERRED')); + } else if (node.initdeferred === false) { + output.push('\n' + this.formatter.indent('INITIALLY IMMEDIATE')); + } + } else { + output.push('DEFERRABLE'); + if (node.initdeferred === true) { + output.push('INITIALLY DEFERRED'); + } else if (node.initdeferred === false) { + output.push('INITIALLY IMMEDIATE'); + } } } else if (node.deferrable === false) { - output.push('NOT DEFERRABLE'); + if (this.formatter.isPretty() && node.contype === 'CONSTR_FOREIGN') { + output.push('\n' + this.formatter.indent('NOT DEFERRABLE')); + } else { + output.push('NOT DEFERRABLE'); + } } } + if (this.formatter.isPretty() && node.contype === 'CONSTR_FOREIGN') { + let result = ''; + for (let i = 0; i < output.length; i++) { + if (output[i].startsWith('\n')) { + result += output[i]; + } else { + if (i > 0 && !output[i-1].startsWith('\n')) { + result += ' '; + } + result += output[i]; + } + } + return result; + } return output.join(' '); } @@ -6328,31 +6406,58 @@ export class Deparser implements DeparserVisitor { // Handle AS RESTRICTIVE/PERMISSIVE clause if (node.permissive === undefined) { - output.push('AS', 'RESTRICTIVE'); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent('AS RESTRICTIVE')); + } else { + output.push('AS', 'RESTRICTIVE'); + } } else if (node.permissive === true) { - output.push('AS', 'PERMISSIVE'); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent('AS PERMISSIVE')); + } else { + output.push('AS', 'PERMISSIVE'); + } } if (node.cmd_name) { - output.push('FOR', node.cmd_name.toUpperCase()); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent(`FOR ${node.cmd_name.toUpperCase()}`)); + } else { + output.push('FOR', node.cmd_name.toUpperCase()); + } } if (node.roles && node.roles.length > 0) { - output.push('TO'); const roles = ListUtils.unwrapList(node.roles).map(role => this.visit(role, context)); - output.push(roles.join(', ')); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent(`TO ${roles.join(', ')}`)); + } else { + output.push('TO'); + output.push(roles.join(', ')); + } } if (node.qual) { - output.push('USING'); - output.push(`(${this.visit(node.qual, context)})`); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent(`USING (${this.visit(node.qual, context)})`)); + } else { + output.push('USING'); + output.push(`(${this.visit(node.qual, context)})`); + } } if (node.with_check) { - output.push('WITH CHECK'); - output.push(`(${this.visit(node.with_check, context)})`); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent(`WITH CHECK (${this.visit(node.with_check, context)})`)); + } else { + output.push('WITH CHECK'); + output.push(`(${this.visit(node.with_check, context)})`); + } } + if (this.formatter.isPretty()) { + return output.join(''); + } return output.join(' '); } From 5f08d72983553941d9f0f12d55230c769a870aaa Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 06:35:18 +0000 Subject: [PATCH 04/17] Fix SELECT statement pretty formatting - Remove extra blank lines after SELECT keyword - Fix indentation levels for FROM, LIMIT, OFFSET clauses - Add AST validation test to ensure pretty-formatted SQL maintains semantic equivalence - Update snapshots to match correct formatting pattern - All pretty formatting tests now pass (SELECT, CREATE TABLE, constraints, CREATE POLICY) Co-Authored-By: Dan Lynch --- .../__snapshots__/select-pretty.test.ts.snap | 14 +-- .../__tests__/pretty/select-pretty.test.ts | 30 +++++++ packages/deparser/src/deparser.ts | 88 +++++++++++++------ 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap index 633d7c6a..cb175447 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap @@ -2,29 +2,28 @@ exports[`Pretty SELECT formatting should format SELECT with UNION with pretty option enabled 1`] = ` "SELECT -name + name FROM customers UNION ALL SELECT -name + name FROM suppliers ORDER BY -name;" + name;" `; exports[`Pretty SELECT formatting should format SELECT with subquery with pretty option enabled 1`] = ` "SELECT - id, name FROM users WHERE id IN (SELECT - user_id + user_id FROM orders WHERE @@ -33,7 +32,6 @@ WHERE exports[`Pretty SELECT formatting should format basic SELECT with pretty option enabled 1`] = ` "SELECT - id, name, email @@ -45,7 +43,6 @@ WHERE exports[`Pretty SELECT formatting should format complex SELECT with pretty option enabled 1`] = ` "SELECT - u.id, u.name, u.email, @@ -55,7 +52,6 @@ users AS u JOIN profiles AS p ON u.id = p.user_id WHERE u.active = true AND u.created_at > '2023-01-01' GROUP BY - u.id, u.name, u.email, @@ -63,7 +59,6 @@ GROUP BY HAVING count(*) > 1 ORDER BY - u.created_at DESC, u.name ASC LIMIT @@ -78,7 +73,6 @@ exports[`Pretty SELECT formatting should maintain single-line format when pretty exports[`Pretty SELECT formatting should use custom newline and tab characters in pretty mode 1`] = ` "SELECT - id, name, email diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts index b41cafd9..bc16b5f7 100644 --- a/packages/deparser/__tests__/pretty/select-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -55,4 +55,34 @@ describe('Pretty SELECT formatting', () => { }); expect(result).toMatchSnapshot(); }); + + it('should validate AST equivalence between original and pretty-formatted SQL', async () => { + const testCases = [ + basicSelectSql, + complexSelectSql, + selectWithSubquerySql, + selectUnionSql + ]; + + for (const sql of testCases) { + const originalParsed = await parse(sql); + const prettyFormatted = deparseSync(originalParsed, { pretty: true }); + const reparsed = await parse(prettyFormatted); + + const removeLocations = (obj: any): any => { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(removeLocations); + + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (key !== 'location' && key !== 'stmt_location' && key !== 'stmt_len') { + result[key] = removeLocations(value); + } + } + return result; + }; + + expect(removeLocations(reparsed)).toEqual(removeLocations(originalParsed)); + } + }); }); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index a3b968af..3f6f9c7e 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -219,9 +219,11 @@ export class Deparser implements DeparserVisitor { if (!node.op || node.op === 'SETOP_NONE') { if (node.valuesLists == null) { - output.push('SELECT'); + if (!this.formatter.isPretty() || !node.targetList) { + output.push('SELECT'); + } } - } else { + }else { const leftStmt = this.SelectStmt(node.larg as t.SelectStmt, context); const rightStmt = this.SelectStmt(node.rarg as t.SelectStmt, context); @@ -272,26 +274,40 @@ export class Deparser implements DeparserVisitor { } } + // Handle DISTINCT clause - in pretty mode, we'll include it in the SELECT clause + let distinctPart = ''; if (node.distinctClause) { const distinctClause = ListUtils.unwrapList(node.distinctClause); if (distinctClause.length > 0 && Object.keys(distinctClause[0]).length > 0) { - output.push('DISTINCT ON'); const clause = distinctClause .map(e => this.visit(e as Node, { ...context, select: true })) .join(', '); - output.push(this.formatter.parens(clause)); + distinctPart = ' DISTINCT ON ' + this.formatter.parens(clause); } else { - output.push('DISTINCT'); + distinctPart = ' DISTINCT'; + } + + if (!this.formatter.isPretty()) { + if (distinctClause.length > 0 && Object.keys(distinctClause[0]).length > 0) { + output.push('DISTINCT ON'); + const clause = distinctClause + .map(e => this.visit(e as Node, { ...context, select: true })) + .join(', '); + output.push(this.formatter.parens(clause)); + } else { + output.push('DISTINCT'); + } } } if (node.targetList) { const targetList = ListUtils.unwrapList(node.targetList); - if (this.formatter.isPretty() && targetList.length > 1) { - const targets = targetList - .map(e => this.formatter.indent(this.visit(e as Node, { ...context, select: true }))) - .join(',' + this.formatter.newline()); - output.push(this.formatter.newline() + targets); + if (this.formatter.isPretty()) { + const targetStrings = targetList + .map(e => this.formatter.indent(this.visit(e as Node, { ...context, select: true }))); + const formattedTargets = targetStrings.join(',' + this.formatter.newline()); + output.push('SELECT' + distinctPart); + output.push(formattedTargets); } else { const targets = targetList .map(e => this.visit(e as Node, { ...context, select: true })) @@ -306,14 +322,15 @@ export class Deparser implements DeparserVisitor { } if (node.fromClause) { - output.push('FROM'); const fromList = ListUtils.unwrapList(node.fromClause); - if (this.formatter.isPretty() && fromList.length > 1) { + if (this.formatter.isPretty()) { + output.push('FROM'); const fromItems = fromList - .map(e => this.formatter.indent(this.deparse(e as Node, { ...context, from: true }))) - .join(',' + this.formatter.newline()); - output.push(this.formatter.newline() + fromItems); + .map(e => this.deparse(e as Node, { ...context, from: true })) + .join(', '); + output.push(fromItems); } else { + output.push('FROM'); const fromItems = fromList .map(e => this.deparse(e as Node, { ...context, from: true })) .join(', '); @@ -322,10 +339,11 @@ export class Deparser implements DeparserVisitor { } if (node.whereClause) { - output.push('WHERE'); if (this.formatter.isPretty()) { + output.push('WHERE'); output.push(this.formatter.indent(this.visit(node.whereClause as Node, context))); } else { + output.push('WHERE'); output.push(this.visit(node.whereClause as Node, context)); } } @@ -340,14 +358,15 @@ export class Deparser implements DeparserVisitor { } if (node.groupClause) { - output.push('GROUP BY'); const groupList = ListUtils.unwrapList(node.groupClause); - if (this.formatter.isPretty() && groupList.length > 1) { + if (this.formatter.isPretty()) { const groupItems = groupList .map(e => this.formatter.indent(this.visit(e as Node, { ...context, group: true }))) .join(',' + this.formatter.newline()); - output.push(this.formatter.newline() + groupItems); + output.push('GROUP BY'); + output.push(groupItems); } else { + output.push('GROUP BY'); const groupItems = groupList .map(e => this.visit(e as Node, { ...context, group: true })) .join(', '); @@ -356,10 +375,11 @@ export class Deparser implements DeparserVisitor { } if (node.havingClause) { - output.push('HAVING'); if (this.formatter.isPretty()) { + output.push('HAVING'); output.push(this.formatter.indent(this.visit(node.havingClause as Node, context))); } else { + output.push('HAVING'); output.push(this.visit(node.havingClause as Node, context)); } } @@ -374,14 +394,15 @@ export class Deparser implements DeparserVisitor { } if (node.sortClause) { - output.push('ORDER BY'); const sortList = ListUtils.unwrapList(node.sortClause); - if (this.formatter.isPretty() && sortList.length > 1) { + if (this.formatter.isPretty()) { const sortItems = sortList .map(e => this.formatter.indent(this.visit(e as Node, { ...context, sort: true }))) .join(',' + this.formatter.newline()); - output.push(this.formatter.newline() + sortItems); + output.push('ORDER BY'); + output.push(sortItems); } else { + output.push('ORDER BY'); const sortItems = sortList .map(e => this.visit(e as Node, { ...context, sort: true })) .join(', '); @@ -390,13 +411,23 @@ export class Deparser implements DeparserVisitor { } if (node.limitCount) { - output.push('LIMIT'); - output.push(this.visit(node.limitCount as Node, context)); + if (this.formatter.isPretty()) { + output.push('LIMIT'); + output.push(this.visit(node.limitCount as Node, context)); + } else { + output.push('LIMIT'); + output.push(this.visit(node.limitCount as Node, context)); + } } if (node.limitOffset) { - output.push('OFFSET'); - output.push(this.visit(node.limitOffset as Node, context)); + if (this.formatter.isPretty()) { + output.push('OFFSET'); + output.push(this.visit(node.limitOffset as Node, context)); + } else { + output.push('OFFSET'); + output.push(this.visit(node.limitOffset as Node, context)); + } } if (node.lockingClause) { @@ -408,7 +439,8 @@ export class Deparser implements DeparserVisitor { } if (this.formatter.isPretty()) { - return output.join(this.formatter.newline()); + const filteredOutput = output.filter(item => item.trim() !== ''); + return filteredOutput.join(this.formatter.newline()); } return output.join(' '); } From e75fb762fac7611e9560779ef42d776e89f5d6df Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 06:48:39 +0000 Subject: [PATCH 05/17] Implement robust AST validation using expectAstMatch utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom AST validation logic with proper expectAstMatch utility from test-utils - Update all pretty formatting tests (SELECT, CREATE TABLE, constraints, CREATE POLICY) to use expectAstMatch - Fix CreatePolicyStmt deparser bug that was removing spaces between keywords in pretty mode - Ensure round-trip validation: parse(sql1) โ†’ deparse(ast) โ†’ sql2 where parse(sql1) === parse(sql2) at AST level - Update CREATE POLICY snapshots to reflect corrected formatting - All tests pass with proper semantic equivalence validation Co-Authored-By: Dan Lynch --- .../create-policy-pretty.test.ts.snap | 34 +++++++++---------- .../pretty/constraints-pretty.test.ts | 16 +++++++++ .../pretty/create-policy-pretty.test.ts | 16 +++++++++ .../pretty/create-table-pretty.test.ts | 15 ++++++++ .../__tests__/pretty/select-pretty.test.ts | 31 +++++------------ packages/deparser/src/deparser.ts | 3 -- 6 files changed, 73 insertions(+), 42 deletions(-) diff --git a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap index 62b86ca2..be568ddb 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap @@ -1,27 +1,27 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Pretty CREATE POLICY formatting should format basic CREATE POLICY with pretty option enabled 1`] = ` -"CREATEPOLICY"user_policy"ONusers - AS PERMISSIVE - FOR ALL - TO authenticated_users +"CREATE POLICY "user_policy" ON users + AS PERMISSIVE + FOR ALL + TO authenticated_users USING (user_id = current_user_id());" `; exports[`Pretty CREATE POLICY formatting should format complex CREATE POLICY with pretty option enabled 1`] = ` -"CREATEPOLICY"admin_policy"ONsensitive_data - AS RESTRICTIVE - FOR SELECT - TO admin_role - USING (department = current_user_department()) +"CREATE POLICY "admin_policy" ON sensitive_data + AS RESTRICTIVE + FOR SELECT + TO admin_role + USING (department = current_user_department()) WITH CHECK (approved = true);" `; exports[`Pretty CREATE POLICY formatting should format simple CREATE POLICY with pretty option enabled 1`] = ` -"CREATEPOLICY"simple_policy"ONposts - AS PERMISSIVE - FOR SELECT - TO public +"CREATE POLICY "simple_policy" ON posts + AS PERMISSIVE + FOR SELECT + TO public USING (published = true);" `; @@ -30,9 +30,9 @@ exports[`Pretty CREATE POLICY formatting should maintain single-line format for exports[`Pretty CREATE POLICY formatting should maintain single-line format when pretty option disabled 1`] = `"CREATE POLICY "user_policy" ON users AS PERMISSIVE FOR ALL TO authenticated_users USING (user_id = current_user_id());"`; exports[`Pretty CREATE POLICY formatting should use custom newline and tab characters in pretty mode 1`] = ` -"CREATEPOLICY"user_policy"ONusers - AS PERMISSIVE - FOR ALL - TO authenticated_users +"CREATE POLICY "user_policy" ON users + AS PERMISSIVE + FOR ALL + TO authenticated_users USING (user_id = current_user_id());" `; diff --git a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts index f47167d0..40d326bf 100644 --- a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts @@ -1,5 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; +import { TestUtils } from '../../test-utils'; describe('Pretty constraint formatting', () => { const foreignKeyConstraintSql = `ALTER TABLE products ADD CONSTRAINT fk_category FOREIGN KEY (category_id) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;`; @@ -53,4 +54,19 @@ describe('Pretty constraint formatting', () => { }); expect(result).toMatchSnapshot(); }); + + it('should validate AST equivalence between original and pretty-formatted SQL', async () => { + const testUtils = new TestUtils(); + const testCases = [ + { name: 'foreign key constraint', sql: foreignKeyConstraintSql }, + { name: 'check constraint', sql: checkConstraintSql }, + { name: 'complex table with constraints', sql: complexTableSql } + ]; + + for (const testCase of testCases) { + const originalParsed = await parse(testCase.sql); + const prettyFormatted = deparseSync(originalParsed, { pretty: true }); + await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + } + }); }); diff --git a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts index 0254e424..92ba3cfa 100644 --- a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -1,5 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; +import { TestUtils } from '../../test-utils'; describe('Pretty CREATE POLICY formatting', () => { const basicPolicySql = `CREATE POLICY user_policy ON users FOR ALL TO authenticated_users USING (user_id = current_user_id());`; @@ -47,4 +48,19 @@ describe('Pretty CREATE POLICY formatting', () => { }); expect(result).toMatchSnapshot(); }); + + it('should validate AST equivalence between original and pretty-formatted SQL', async () => { + const testUtils = new TestUtils(); + const testCases = [ + { name: 'basic CREATE POLICY', sql: basicPolicySql }, + { name: 'complex CREATE POLICY', sql: complexPolicySql }, + { name: 'simple CREATE POLICY', sql: simplePolicySql } + ]; + + for (const testCase of testCases) { + const originalParsed = await parse(testCase.sql); + const prettyFormatted = deparseSync(originalParsed, { pretty: true }); + await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + } + }); }); diff --git a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts index d2250782..0fde0689 100644 --- a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts @@ -1,5 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; +import { TestUtils } from '../../test-utils'; describe('Pretty CREATE TABLE formatting', () => { const basicTableSql = `CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE);`; @@ -46,4 +47,18 @@ describe('Pretty CREATE TABLE formatting', () => { }); expect(result).toMatchSnapshot(); }); + + it('should validate AST equivalence between original and pretty-formatted SQL', async () => { + const testUtils = new TestUtils(); + const testCases = [ + { name: 'basic CREATE TABLE', sql: basicTableSql }, + { name: 'complex CREATE TABLE', sql: complexTableSql } + ]; + + for (const testCase of testCases) { + const originalParsed = await parse(testCase.sql); + const prettyFormatted = deparseSync(originalParsed, { pretty: true }); + await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + } + }); }); diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts index bc16b5f7..0eaef89c 100644 --- a/packages/deparser/__tests__/pretty/select-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -1,5 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; +import { TestUtils } from '../../test-utils'; describe('Pretty SELECT formatting', () => { const basicSelectSql = `SELECT id, name, email FROM users WHERE active = true;`; @@ -57,32 +58,18 @@ describe('Pretty SELECT formatting', () => { }); it('should validate AST equivalence between original and pretty-formatted SQL', async () => { + const testUtils = new TestUtils(); const testCases = [ - basicSelectSql, - complexSelectSql, - selectWithSubquerySql, - selectUnionSql + { name: 'basic SELECT', sql: basicSelectSql }, + { name: 'complex SELECT', sql: complexSelectSql }, + { name: 'SELECT with subquery', sql: selectWithSubquerySql }, + { name: 'SELECT with UNION', sql: selectUnionSql } ]; - for (const sql of testCases) { - const originalParsed = await parse(sql); + for (const testCase of testCases) { + const originalParsed = await parse(testCase.sql); const prettyFormatted = deparseSync(originalParsed, { pretty: true }); - const reparsed = await parse(prettyFormatted); - - const removeLocations = (obj: any): any => { - if (obj === null || typeof obj !== 'object') return obj; - if (Array.isArray(obj)) return obj.map(removeLocations); - - const result: any = {}; - for (const [key, value] of Object.entries(obj)) { - if (key !== 'location' && key !== 'stmt_location' && key !== 'stmt_len') { - result[key] = removeLocations(value); - } - } - return result; - }; - - expect(removeLocations(reparsed)).toEqual(removeLocations(originalParsed)); + await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); } }); }); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 3f6f9c7e..4267be68 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -6487,9 +6487,6 @@ export class Deparser implements DeparserVisitor { } } - if (this.formatter.isPretty()) { - return output.join(''); - } return output.join(' '); } From f29f08b3f83cac48a7eeb4e3f0a5205293e43c13 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 07:01:39 +0000 Subject: [PATCH 06/17] Replace expectAstMatch with expectParseDeparse helper for direct AST comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add standalone expectParseDeparse helper function in test-utils/index.ts (not on class) - Implement round-trip validation: parse(sql1) โ†’ deparse(sql2) โ†’ parse(sql2) โ†’ compare cleanTree(ast1) vs cleanTree(ast2) - Replace expectAstMatch usage with expectParseDeparse in all pretty formatting tests: - SELECT pretty tests - CREATE TABLE pretty tests - Constraints pretty tests - CREATE POLICY pretty tests - Use direct expect(ast2).toEqual(ast1) comparison for semantic validation - All tests pass with simplified, more direct AST validation approach Co-Authored-By: Dan Lynch --- .../__tests__/pretty/constraints-pretty.test.ts | 15 ++++++--------- .../pretty/create-policy-pretty.test.ts | 15 ++++++--------- .../pretty/create-table-pretty.test.ts | 13 +++++-------- .../__tests__/pretty/select-pretty.test.ts | 17 +++++++---------- packages/deparser/test-utils/index.ts | 11 +++++++++++ 5 files changed, 35 insertions(+), 36 deletions(-) diff --git a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts index 40d326bf..f74da66d 100644 --- a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts @@ -1,6 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; -import { TestUtils } from '../../test-utils'; +import { expectParseDeparse } from '../../test-utils'; describe('Pretty constraint formatting', () => { const foreignKeyConstraintSql = `ALTER TABLE products ADD CONSTRAINT fk_category FOREIGN KEY (category_id) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;`; @@ -56,17 +56,14 @@ describe('Pretty constraint formatting', () => { }); it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testUtils = new TestUtils(); const testCases = [ - { name: 'foreign key constraint', sql: foreignKeyConstraintSql }, - { name: 'check constraint', sql: checkConstraintSql }, - { name: 'complex table with constraints', sql: complexTableSql } + foreignKeyConstraintSql, + checkConstraintSql, + complexTableSql ]; - for (const testCase of testCases) { - const originalParsed = await parse(testCase.sql); - const prettyFormatted = deparseSync(originalParsed, { pretty: true }); - await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + for (const sql of testCases) { + await expectParseDeparse(sql, { pretty: true }); } }); }); diff --git a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts index 92ba3cfa..6902e68d 100644 --- a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -1,6 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; -import { TestUtils } from '../../test-utils'; +import { expectParseDeparse } from '../../test-utils'; describe('Pretty CREATE POLICY formatting', () => { const basicPolicySql = `CREATE POLICY user_policy ON users FOR ALL TO authenticated_users USING (user_id = current_user_id());`; @@ -50,17 +50,14 @@ describe('Pretty CREATE POLICY formatting', () => { }); it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testUtils = new TestUtils(); const testCases = [ - { name: 'basic CREATE POLICY', sql: basicPolicySql }, - { name: 'complex CREATE POLICY', sql: complexPolicySql }, - { name: 'simple CREATE POLICY', sql: simplePolicySql } + basicPolicySql, + complexPolicySql, + simplePolicySql ]; - for (const testCase of testCases) { - const originalParsed = await parse(testCase.sql); - const prettyFormatted = deparseSync(originalParsed, { pretty: true }); - await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + for (const sql of testCases) { + await expectParseDeparse(sql, { pretty: true }); } }); }); diff --git a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts index 0fde0689..bbead2ff 100644 --- a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts @@ -1,6 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; -import { TestUtils } from '../../test-utils'; +import { expectParseDeparse } from '../../test-utils'; describe('Pretty CREATE TABLE formatting', () => { const basicTableSql = `CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE);`; @@ -49,16 +49,13 @@ describe('Pretty CREATE TABLE formatting', () => { }); it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testUtils = new TestUtils(); const testCases = [ - { name: 'basic CREATE TABLE', sql: basicTableSql }, - { name: 'complex CREATE TABLE', sql: complexTableSql } + basicTableSql, + complexTableSql ]; - for (const testCase of testCases) { - const originalParsed = await parse(testCase.sql); - const prettyFormatted = deparseSync(originalParsed, { pretty: true }); - await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + for (const sql of testCases) { + await expectParseDeparse(sql, { pretty: true }); } }); }); diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts index 0eaef89c..74cd3f0c 100644 --- a/packages/deparser/__tests__/pretty/select-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -1,6 +1,6 @@ import { deparseSync } from '../../src'; import { parse } from 'libpg-query'; -import { TestUtils } from '../../test-utils'; +import { expectParseDeparse } from '../../test-utils'; describe('Pretty SELECT formatting', () => { const basicSelectSql = `SELECT id, name, email FROM users WHERE active = true;`; @@ -58,18 +58,15 @@ describe('Pretty SELECT formatting', () => { }); it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testUtils = new TestUtils(); const testCases = [ - { name: 'basic SELECT', sql: basicSelectSql }, - { name: 'complex SELECT', sql: complexSelectSql }, - { name: 'SELECT with subquery', sql: selectWithSubquerySql }, - { name: 'SELECT with UNION', sql: selectUnionSql } + basicSelectSql, + complexSelectSql, + selectWithSubquerySql, + selectUnionSql ]; - for (const testCase of testCases) { - const originalParsed = await parse(testCase.sql); - const prettyFormatted = deparseSync(originalParsed, { pretty: true }); - await testUtils.expectAstMatch(`pretty-${testCase.name}`, prettyFormatted); + for (const sql of testCases) { + await expectParseDeparse(sql, { pretty: true }); } }); }); diff --git a/packages/deparser/test-utils/index.ts b/packages/deparser/test-utils/index.ts index 6efb4b4e..1fd7cbc4 100644 --- a/packages/deparser/test-utils/index.ts +++ b/packages/deparser/test-utils/index.ts @@ -6,6 +6,17 @@ import * as path from 'path'; import { expect } from '@jest/globals'; import { diff } from 'jest-diff' +export async function expectParseDeparse(sql1: string, options = { pretty: false }) { + const parsed = await parse(sql1); + + const sql2 = deparse(parsed, options); + + const ast1 = cleanTree(parsed); + const ast2 = cleanTree(await parse(sql2)); + + expect(ast2).toEqual(ast1); +} + type ParseErrorType = | 'PARSE_FAILED' | 'INVALID_STATEMENT' From e7b5ff2c81759e756f34f1e8493d292a4c3efcb1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 07:18:55 +0000 Subject: [PATCH 07/17] Consolidate pretty formatting tests with expectParseDeparse helper - Update expectParseDeparse to return deparsed SQL for snapshot testing - Add DeparserOptions type support for newline and tab options - Replace all deparseSync calls with expectParseDeparse in pretty tests - Remove separate AST validation tests - now consolidated into snapshot tests - Each test now gets both AST validation and snapshot testing in single call - All 4 pretty test suites (24 tests, 24 snapshots) pass with consolidated approach Co-Authored-By: Dan Lynch --- .../pretty/constraints-pretty.test.ts | 30 ++++------------ .../pretty/create-policy-pretty.test.ts | 30 ++++------------ .../pretty/create-table-pretty.test.ts | 26 +++----------- .../__tests__/pretty/select-pretty.test.ts | 34 ++++--------------- packages/deparser/test-utils/index.ts | 6 ++-- 5 files changed, 28 insertions(+), 98 deletions(-) diff --git a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts index f74da66d..6600739a 100644 --- a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts @@ -16,54 +16,36 @@ describe('Pretty constraint formatting', () => { );`; it('should format foreign key constraint with pretty option enabled', async () => { - const parsed = await parse(foreignKeyConstraintSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(foreignKeyConstraintSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format when pretty option disabled', async () => { - const parsed = await parse(foreignKeyConstraintSql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(foreignKeyConstraintSql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should format check constraint with pretty option enabled', async () => { - const parsed = await parse(checkConstraintSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(checkConstraintSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should format complex table with constraints with pretty option enabled', async () => { - const parsed = await parse(complexTableSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(complexTableSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format for complex table when pretty disabled', async () => { - const parsed = await parse(complexTableSql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(complexTableSql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should use custom newline and tab characters in pretty mode', async () => { - const parsed = await parse(foreignKeyConstraintSql); - const result = deparseSync(parsed, { + const result = await expectParseDeparse(foreignKeyConstraintSql, { pretty: true, newline: '\r\n', tab: ' ' }); expect(result).toMatchSnapshot(); }); - - it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testCases = [ - foreignKeyConstraintSql, - checkConstraintSql, - complexTableSql - ]; - - for (const sql of testCases) { - await expectParseDeparse(sql, { pretty: true }); - } - }); }); diff --git a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts index 6902e68d..8fbf19c9 100644 --- a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -10,54 +10,36 @@ describe('Pretty CREATE POLICY formatting', () => { const simplePolicySql = `CREATE POLICY simple_policy ON posts FOR SELECT TO public USING (published = true);`; it('should format basic CREATE POLICY with pretty option enabled', async () => { - const parsed = await parse(basicPolicySql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(basicPolicySql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format when pretty option disabled', async () => { - const parsed = await parse(basicPolicySql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(basicPolicySql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should format complex CREATE POLICY with pretty option enabled', async () => { - const parsed = await parse(complexPolicySql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(complexPolicySql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format for complex policy when pretty disabled', async () => { - const parsed = await parse(complexPolicySql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(complexPolicySql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should format simple CREATE POLICY with pretty option enabled', async () => { - const parsed = await parse(simplePolicySql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(simplePolicySql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should use custom newline and tab characters in pretty mode', async () => { - const parsed = await parse(basicPolicySql); - const result = deparseSync(parsed, { + const result = await expectParseDeparse(basicPolicySql, { pretty: true, newline: '\r\n', tab: ' ' }); expect(result).toMatchSnapshot(); }); - - it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testCases = [ - basicPolicySql, - complexPolicySql, - simplePolicySql - ]; - - for (const sql of testCases) { - await expectParseDeparse(sql, { pretty: true }); - } - }); }); diff --git a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts index bbead2ff..aed8ebc2 100644 --- a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts @@ -15,47 +15,31 @@ describe('Pretty CREATE TABLE formatting', () => { );`; it('should format basic CREATE TABLE with pretty option enabled', async () => { - const parsed = await parse(basicTableSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(basicTableSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format when pretty option disabled', async () => { - const parsed = await parse(basicTableSql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(basicTableSql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should format complex CREATE TABLE with pretty option enabled', async () => { - const parsed = await parse(complexTableSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(complexTableSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format for complex table when pretty disabled', async () => { - const parsed = await parse(complexTableSql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(complexTableSql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should use custom newline and tab characters in pretty mode', async () => { - const parsed = await parse(basicTableSql); - const result = deparseSync(parsed, { + const result = await expectParseDeparse(basicTableSql, { pretty: true, newline: '\r\n', tab: ' ' }); expect(result).toMatchSnapshot(); }); - - it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testCases = [ - basicTableSql, - complexTableSql - ]; - - for (const sql of testCases) { - await expectParseDeparse(sql, { pretty: true }); - } - }); }); diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts index 74cd3f0c..b7ee7fba 100644 --- a/packages/deparser/__tests__/pretty/select-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -12,61 +12,41 @@ describe('Pretty SELECT formatting', () => { const selectUnionSql = `SELECT name FROM customers UNION ALL SELECT name FROM suppliers ORDER BY name;`; it('should format basic SELECT with pretty option enabled', async () => { - const parsed = await parse(basicSelectSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(basicSelectSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format when pretty option disabled', async () => { - const parsed = await parse(basicSelectSql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(basicSelectSql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should format complex SELECT with pretty option enabled', async () => { - const parsed = await parse(complexSelectSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(complexSelectSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should maintain single-line format for complex SELECT when pretty disabled', async () => { - const parsed = await parse(complexSelectSql); - const result = deparseSync(parsed, { pretty: false }); + const result = await expectParseDeparse(complexSelectSql, { pretty: false }); expect(result).toMatchSnapshot(); }); it('should format SELECT with subquery with pretty option enabled', async () => { - const parsed = await parse(selectWithSubquerySql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(selectWithSubquerySql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should format SELECT with UNION with pretty option enabled', async () => { - const parsed = await parse(selectUnionSql); - const result = deparseSync(parsed, { pretty: true }); + const result = await expectParseDeparse(selectUnionSql, { pretty: true }); expect(result).toMatchSnapshot(); }); it('should use custom newline and tab characters in pretty mode', async () => { - const parsed = await parse(basicSelectSql); - const result = deparseSync(parsed, { + const result = await expectParseDeparse(basicSelectSql, { pretty: true, newline: '\r\n', tab: ' ' }); expect(result).toMatchSnapshot(); }); - - it('should validate AST equivalence between original and pretty-formatted SQL', async () => { - const testCases = [ - basicSelectSql, - complexSelectSql, - selectWithSubquerySql, - selectUnionSql - ]; - - for (const sql of testCases) { - await expectParseDeparse(sql, { pretty: true }); - } - }); }); diff --git a/packages/deparser/test-utils/index.ts b/packages/deparser/test-utils/index.ts index 1fd7cbc4..bb55e761 100644 --- a/packages/deparser/test-utils/index.ts +++ b/packages/deparser/test-utils/index.ts @@ -1,12 +1,12 @@ import { parse } from 'libpg-query'; -import { deparseSync as deparse } from '../src'; +import { deparseSync as deparse, DeparserOptions } from '../src'; import { cleanTree } from '../src/utils'; import { readFileSync } from 'fs'; import * as path from 'path'; import { expect } from '@jest/globals'; import { diff } from 'jest-diff' -export async function expectParseDeparse(sql1: string, options = { pretty: false }) { +export async function expectParseDeparse(sql1: string, options: DeparserOptions = { pretty: false }) { const parsed = await parse(sql1); const sql2 = deparse(parsed, options); @@ -15,6 +15,8 @@ export async function expectParseDeparse(sql1: string, options = { pretty: false const ast2 = cleanTree(await parse(sql2)); expect(ast2).toEqual(ast1); + + return sql2; } type ParseErrorType = From 7b3f73e5bc7e9c0019a594318faa0055610d5722 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 07:36:43 +0000 Subject: [PATCH 08/17] Fix redundant OFFSET and LIMIT handling code - Remove unnecessary if/else conditionals where both branches do identical operations - Simplify LIMIT and OFFSET logic while maintaining functionality - Both pretty and non-pretty modes now use the same streamlined code path - All tests continue to pass with simplified logic Co-Authored-By: Dan Lynch --- packages/deparser/src/deparser.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 4267be68..42a9ad3d 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -411,23 +411,13 @@ export class Deparser implements DeparserVisitor { } if (node.limitCount) { - if (this.formatter.isPretty()) { - output.push('LIMIT'); - output.push(this.visit(node.limitCount as Node, context)); - } else { - output.push('LIMIT'); - output.push(this.visit(node.limitCount as Node, context)); - } + output.push('LIMIT'); + output.push(this.visit(node.limitCount as Node, context)); } if (node.limitOffset) { - if (this.formatter.isPretty()) { - output.push('OFFSET'); - output.push(this.visit(node.limitOffset as Node, context)); - } else { - output.push('OFFSET'); - output.push(this.visit(node.limitOffset as Node, context)); - } + output.push('OFFSET'); + output.push(this.visit(node.limitOffset as Node, context)); } if (node.lockingClause) { From 5b0ef0a1e296c3dc7d48837bfab4dff27f818358 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 08:01:54 +0000 Subject: [PATCH 09/17] Enhance pretty formatting for JOINs, CREATE POLICY, and CTEs - Add newline formatting for JOIN clauses in SELECT statements - Improve CREATE POLICY formatting with proper USING and WITH CHECK clause indentation - Implement CTE (Common Table Expressions) pretty formatting with proper indentation - Add comprehensive test cases for complex SQL formatting scenarios - Update snapshots to reflect improved formatting output - Maintain AST equivalence through expectParseDeparse validation Co-Authored-By: Dan Lynch --- .../create-policy-pretty.test.ts.snap | 14 +++ .../__snapshots__/cte-pretty.test.ts.snap | 109 ++++++++++++++++++ .../__snapshots__/select-pretty.test.ts.snap | 18 ++- .../pretty/create-policy-pretty.test.ts | 7 ++ .../__tests__/pretty/cte-pretty.test.ts | 47 ++++++++ .../__tests__/pretty/select-pretty.test.ts | 7 ++ packages/deparser/src/deparser.ts | 45 +++++--- 7 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap create mode 100644 packages/deparser/__tests__/pretty/cte-pretty.test.ts diff --git a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap index be568ddb..8d7b5de1 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap @@ -25,6 +25,20 @@ exports[`Pretty CREATE POLICY formatting should format simple CREATE POLICY with USING (published = true);" `; +exports[`Pretty CREATE POLICY formatting should format very complex CREATE POLICY with pretty option enabled 1`] = ` +"CREATE POLICY "complex_policy" ON sensitive_data + AS RESTRICTIVE + FOR SELECT + TO admin_role + USING (department = current_user_department() AND EXISTS (SELECT + 1 + FROM + user_permissions + WHERE + (user_id = current_user_id() AND permission = 'read_sensitive'))) + WITH CHECK (approved = true AND created_by = current_user_id());" +`; + exports[`Pretty CREATE POLICY formatting should maintain single-line format for complex policy when pretty disabled 1`] = `"CREATE POLICY "admin_policy" ON sensitive_data AS RESTRICTIVE FOR SELECT TO admin_role USING (department = current_user_department()) WITH CHECK (approved = true);"`; exports[`Pretty CREATE POLICY formatting should maintain single-line format when pretty option disabled 1`] = `"CREATE POLICY "user_policy" ON users AS PERMISSIVE FOR ALL TO authenticated_users USING (user_id = current_user_id());"`; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap new file mode 100644 index 00000000..c1f8dbb0 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pretty CTE (Common Table Expressions) formatting should format basic CTE with pretty option enabled 1`] = ` +"WITH + regional_sales AS (SELECT + region, + sum(sales_amount) AS total_sales + FROM + sales + GROUP BY + region) +SELECT + * +FROM +regional_sales;" +`; + +exports[`Pretty CTE (Common Table Expressions) formatting should format complex CTE with multiple CTEs with pretty option enabled 1`] = ` +"WITH + regional_sales AS (SELECT + region, + sum(sales_amount) AS total_sales + FROM + sales + GROUP BY + region), + top_regions AS (SELECT + region + FROM + regional_sales + WHERE + total_sales > 1000000) +SELECT + * +FROM +top_regions;" +`; + +exports[`Pretty CTE (Common Table Expressions) formatting should format nested CTE with complex joins with pretty option enabled 1`] = ` +"WITH + sales_summary AS (SELECT + region, + product_category, + sum(amount) AS total + FROM + sales + GROUP BY + region, + product_category), + regional_totals AS (SELECT + region, + sum(total) AS region_total + FROM + sales_summary + GROUP BY + region) +SELECT + s.region, + s.product_category, + s.total, + r.region_total +FROM +sales_summary AS s +JOIN regional_totals AS r ON s.region = r.region;" +`; + +exports[`Pretty CTE (Common Table Expressions) formatting should format recursive CTE with pretty option enabled 1`] = ` +"WITH RECURSIVE + employee_hierarchy AS (SELECT + id, + name, + manager_id, + 1 AS level + FROM + employees + WHERE + manager_id IS NULL + UNION + ALL + SELECT + e.id, + e.name, + e.manager_id, + eh.level + 1 + FROM + employees AS e + JOIN employee_hierarchy AS eh ON e.manager_id = eh.id) +SELECT + * +FROM +employee_hierarchy;" +`; + +exports[`Pretty CTE (Common Table Expressions) formatting should maintain single-line format when pretty option disabled 1`] = `"WITH regional_sales AS (SELECT region, sum(sales_amount) AS total_sales FROM sales GROUP BY region) SELECT * FROM regional_sales;"`; + +exports[`Pretty CTE (Common Table Expressions) formatting should use custom newline and tab characters in pretty mode 1`] = ` +"WITH + regional_sales AS (SELECT + region, + sum(sales_amount) AS total_sales + FROM + sales + GROUP BY + region) +SELECT + * +FROM +regional_sales;" +`; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap index cb175447..67ae6bc9 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap @@ -15,6 +15,21 @@ ORDER BY name;" `; +exports[`Pretty SELECT formatting should format SELECT with multiple JOINs with pretty option enabled 1`] = ` +"SELECT + u.id, + u.name, + u.email, + p.title +FROM +users AS u +JOIN profiles AS p ON u.id = p.user_id +LEFT JOIN orders AS o ON u.id = o.user_id +RIGHT JOIN addresses AS a ON u.id = a.user_id +WHERE + u.active = true;" +`; + exports[`Pretty SELECT formatting should format SELECT with subquery with pretty option enabled 1`] = ` "SELECT id, @@ -48,7 +63,8 @@ exports[`Pretty SELECT formatting should format complex SELECT with pretty optio u.email, p.title FROM -users AS u JOIN profiles AS p ON u.id = p.user_id +users AS u +JOIN profiles AS p ON u.id = p.user_id WHERE u.active = true AND u.created_at > '2023-01-01' GROUP BY diff --git a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts index 8fbf19c9..6ee6e655 100644 --- a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -7,6 +7,8 @@ describe('Pretty CREATE POLICY formatting', () => { const complexPolicySql = `CREATE POLICY admin_policy ON sensitive_data AS RESTRICTIVE FOR SELECT TO admin_role USING (department = current_user_department()) WITH CHECK (approved = true);`; + const veryComplexPolicySql = `CREATE POLICY complex_policy ON sensitive_data AS RESTRICTIVE FOR SELECT TO admin_role USING (department = current_user_department() AND EXISTS (SELECT 1 FROM user_permissions WHERE user_id = current_user_id() AND permission = 'read_sensitive')) WITH CHECK (approved = true AND created_by = current_user_id());`; + const simplePolicySql = `CREATE POLICY simple_policy ON posts FOR SELECT TO public USING (published = true);`; it('should format basic CREATE POLICY with pretty option enabled', async () => { @@ -34,6 +36,11 @@ describe('Pretty CREATE POLICY formatting', () => { expect(result).toMatchSnapshot(); }); + it('should format very complex CREATE POLICY with pretty option enabled', async () => { + const result = await expectParseDeparse(veryComplexPolicySql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + it('should use custom newline and tab characters in pretty mode', async () => { const result = await expectParseDeparse(basicPolicySql, { pretty: true, diff --git a/packages/deparser/__tests__/pretty/cte-pretty.test.ts b/packages/deparser/__tests__/pretty/cte-pretty.test.ts new file mode 100644 index 00000000..70e0d950 --- /dev/null +++ b/packages/deparser/__tests__/pretty/cte-pretty.test.ts @@ -0,0 +1,47 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +import { expectParseDeparse } from '../../test-utils'; + +describe('Pretty CTE (Common Table Expressions) formatting', () => { + const basicCteSql = `WITH regional_sales AS (SELECT region, SUM(sales_amount) as total_sales FROM sales GROUP BY region) SELECT * FROM regional_sales;`; + + const complexCteSql = `WITH regional_sales AS (SELECT region, SUM(sales_amount) as total_sales FROM sales GROUP BY region), top_regions AS (SELECT region FROM regional_sales WHERE total_sales > 1000000) SELECT * FROM top_regions;`; + + const recursiveCteSql = `WITH RECURSIVE employee_hierarchy AS (SELECT id, name, manager_id, 1 as level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, e.manager_id, eh.level + 1 FROM employees e JOIN employee_hierarchy eh ON e.manager_id = eh.id) SELECT * FROM employee_hierarchy;`; + + const nestedCteSql = `WITH sales_summary AS (SELECT region, product_category, SUM(amount) as total FROM sales GROUP BY region, product_category), regional_totals AS (SELECT region, SUM(total) as region_total FROM sales_summary GROUP BY region) SELECT s.region, s.product_category, s.total, r.region_total FROM sales_summary s JOIN regional_totals r ON s.region = r.region;`; + + it('should format basic CTE with pretty option enabled', async () => { + const result = await expectParseDeparse(basicCteSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const result = await expectParseDeparse(basicCteSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex CTE with multiple CTEs with pretty option enabled', async () => { + const result = await expectParseDeparse(complexCteSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format recursive CTE with pretty option enabled', async () => { + const result = await expectParseDeparse(recursiveCteSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format nested CTE with complex joins with pretty option enabled', async () => { + const result = await expectParseDeparse(nestedCteSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const result = await expectParseDeparse(basicCteSql, { + pretty: true, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts index b7ee7fba..81a6fa4f 100644 --- a/packages/deparser/__tests__/pretty/select-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -7,6 +7,8 @@ describe('Pretty SELECT formatting', () => { const complexSelectSql = `SELECT u.id, u.name, u.email, p.title FROM users u JOIN profiles p ON u.id = p.user_id WHERE u.active = true AND u.created_at > '2023-01-01' GROUP BY u.id, u.name, u.email, p.title HAVING COUNT(*) > 1 ORDER BY u.created_at DESC, u.name ASC LIMIT 10 OFFSET 5;`; + const multipleJoinsSql = `SELECT u.id, u.name, u.email, p.title FROM users AS u JOIN profiles AS p ON u.id = p.user_id LEFT JOIN orders AS o ON u.id = o.user_id RIGHT JOIN addresses AS a ON u.id = a.user_id WHERE u.active = true;`; + const selectWithSubquerySql = `SELECT id, name FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100);`; const selectUnionSql = `SELECT name FROM customers UNION ALL SELECT name FROM suppliers ORDER BY name;`; @@ -41,6 +43,11 @@ describe('Pretty SELECT formatting', () => { expect(result).toMatchSnapshot(); }); + it('should format SELECT with multiple JOINs with pretty option enabled', async () => { + const result = await expectParseDeparse(multipleJoinsSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + it('should use custom newline and tab characters in pretty mode', async () => { const result = await expectParseDeparse(basicSelectSql, { pretty: true, diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 42a9ad3d..3bb7c21d 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -970,9 +970,20 @@ export class Deparser implements DeparserVisitor { output.push('RECURSIVE'); } - const ctes = ListUtils.unwrapList(node.ctes); - const cteStrs = ctes.map(cte => this.visit(cte, context)); - output.push(cteStrs.join(', ')); + if (node.ctes && node.ctes.length > 0) { + const ctes = ListUtils.unwrapList(node.ctes); + if (this.formatter.isPretty()) { + const cteStrings = ctes.map(cte => { + const cteStr = this.visit(cte, context); + return this.formatter.newline() + this.formatter.indent(cteStr); + }); + return output.join(' ') + cteStrings.join(','); + } else { + const cteStrings = ctes.map(cte => this.visit(cte, context)); + output.push(cteStrings.join(', ')); + return output.join(' '); + } + } return output.join(' '); } @@ -3461,11 +3472,9 @@ export class Deparser implements DeparserVisitor { switch (node.jointype) { case 'JOIN_INNER': - // Handle NATURAL JOIN first - it has isNatural=true (NATURAL already added above) if (node.isNatural) { joinStr += 'JOIN'; } - // Handle CROSS JOIN case - when there's no quals, no usingClause, and not natural else if (!node.quals && (!node.usingClause || node.usingClause.length === 0)) { joinStr += 'CROSS JOIN'; } else { @@ -3485,7 +3494,11 @@ export class Deparser implements DeparserVisitor { joinStr += 'JOIN'; } - output.push(joinStr); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + joinStr); + } else { + output.push(joinStr); + } if (node.rarg) { let rargStr = this.visit(node.rarg, context); @@ -3498,18 +3511,25 @@ export class Deparser implements DeparserVisitor { } if (node.usingClause && node.usingClause.length > 0) { - output.push('USING'); const usingList = ListUtils.unwrapList(node.usingClause); const columnNames = usingList.map(col => this.visit(col, context)); - output.push(`(${columnNames.join(', ')})`); + if (this.formatter.isPretty()) { + output.push(`USING (${columnNames.join(', ')})`); + } else { + output.push('USING'); + output.push(`(${columnNames.join(', ')})`); + } } else if (node.quals) { - output.push('ON'); - output.push(this.visit(node.quals, context)); + if (this.formatter.isPretty()) { + output.push(`ON ${this.visit(node.quals, context)}`); + } else { + output.push('ON'); + output.push(this.visit(node.quals, context)); + } } - let result = output.join(' '); + let result = this.formatter.isPretty() ? output.join(' ') : output.join(' '); - // Handle join_using_alias first (for USING clause aliases like "AS x") if (node.join_using_alias && node.join_using_alias.aliasname) { let aliasStr = node.join_using_alias.aliasname; if (node.join_using_alias.colnames && node.join_using_alias.colnames.length > 0) { @@ -3520,7 +3540,6 @@ export class Deparser implements DeparserVisitor { result += ` AS ${aliasStr}`; } - // Handle regular alias (for outer table aliases like "y") if (node.alias && node.alias.aliasname) { let aliasStr = node.alias.aliasname; if (node.alias.colnames && node.alias.colnames.length > 0) { From d9e663fcbc7f5ab37861d9ebb7c562320c9817b1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 08:39:07 +0000 Subject: [PATCH 10/17] Fix JOIN formatting spacing issue - eliminate trailing spaces after table aliases - Fixed JoinExpr method to properly handle spaces in pretty formatting mode - Added leading space to ON and USING clauses to maintain proper SQL syntax - Updated snapshots to reflect improved formatting that matches Dan's requirements - All tests passing with enhanced JOIN, CREATE POLICY, and CTE formatting Co-Authored-By: Dan Lynch --- .../create-policy-pretty.test.ts.snap | 85 ++++++++----- .../__snapshots__/cte-pretty.test.ts.snap | 49 +++----- .../__snapshots__/select-pretty.test.ts.snap | 43 +++---- packages/deparser/src/deparser.ts | 118 +++++++++++------- 4 files changed, 160 insertions(+), 135 deletions(-) diff --git a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap index 8d7b5de1..e2e29089 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap @@ -1,42 +1,60 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Pretty CREATE POLICY formatting should format basic CREATE POLICY with pretty option enabled 1`] = ` -"CREATE POLICY "user_policy" ON users - AS PERMISSIVE - FOR ALL - TO authenticated_users - USING (user_id = current_user_id());" +"CREATE POLICY "user_policy" + ON users + AS PERMISSIVE + FOR ALL + TO authenticated_users + USING ( + user_id = current_user_id() + );" `; exports[`Pretty CREATE POLICY formatting should format complex CREATE POLICY with pretty option enabled 1`] = ` -"CREATE POLICY "admin_policy" ON sensitive_data - AS RESTRICTIVE - FOR SELECT - TO admin_role - USING (department = current_user_department()) - WITH CHECK (approved = true);" +"CREATE POLICY "admin_policy" + ON sensitive_data + AS RESTRICTIVE + FOR SELECT + TO admin_role + USING ( + department = current_user_department() + ) + WITH CHECK ( + approved = true + );" `; exports[`Pretty CREATE POLICY formatting should format simple CREATE POLICY with pretty option enabled 1`] = ` -"CREATE POLICY "simple_policy" ON posts - AS PERMISSIVE - FOR SELECT - TO public - USING (published = true);" +"CREATE POLICY "simple_policy" + ON posts + AS PERMISSIVE + FOR SELECT + TO public + USING ( + published = true + );" `; exports[`Pretty CREATE POLICY formatting should format very complex CREATE POLICY with pretty option enabled 1`] = ` -"CREATE POLICY "complex_policy" ON sensitive_data - AS RESTRICTIVE - FOR SELECT - TO admin_role - USING (department = current_user_department() AND EXISTS (SELECT - 1 - FROM - user_permissions - WHERE - (user_id = current_user_id() AND permission = 'read_sensitive'))) - WITH CHECK (approved = true AND created_by = current_user_id());" +"CREATE POLICY "complex_policy" + ON sensitive_data + AS RESTRICTIVE + FOR SELECT + TO admin_role + USING ( + department = current_user_department() + AND EXISTS (SELECT + 1 + FROM user_permissions + WHERE + (user_id = current_user_id() + AND permission = 'read_sensitive')) + ) + WITH CHECK ( + approved = true + AND created_by = current_user_id() + );" `; exports[`Pretty CREATE POLICY formatting should maintain single-line format for complex policy when pretty disabled 1`] = `"CREATE POLICY "admin_policy" ON sensitive_data AS RESTRICTIVE FOR SELECT TO admin_role USING (department = current_user_department()) WITH CHECK (approved = true);"`; @@ -44,9 +62,12 @@ exports[`Pretty CREATE POLICY formatting should maintain single-line format for exports[`Pretty CREATE POLICY formatting should maintain single-line format when pretty option disabled 1`] = `"CREATE POLICY "user_policy" ON users AS PERMISSIVE FOR ALL TO authenticated_users USING (user_id = current_user_id());"`; exports[`Pretty CREATE POLICY formatting should use custom newline and tab characters in pretty mode 1`] = ` -"CREATE POLICY "user_policy" ON users - AS PERMISSIVE - FOR ALL - TO authenticated_users - USING (user_id = current_user_id());" +"CREATE POLICY "user_policy" + ON users + AS PERMISSIVE + FOR ALL + TO authenticated_users + USING ( + user_id = current_user_id() + );" `; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap index c1f8dbb0..132770d7 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap @@ -1,57 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Pretty CTE (Common Table Expressions) formatting should format basic CTE with pretty option enabled 1`] = ` -"WITH +"WITH regional_sales AS (SELECT region, sum(sales_amount) AS total_sales - FROM - sales + FROM sales GROUP BY region) SELECT * -FROM -regional_sales;" +FROM regional_sales;" `; exports[`Pretty CTE (Common Table Expressions) formatting should format complex CTE with multiple CTEs with pretty option enabled 1`] = ` -"WITH +"WITH regional_sales AS (SELECT region, sum(sales_amount) AS total_sales - FROM - sales + FROM sales GROUP BY region), top_regions AS (SELECT region - FROM - regional_sales + FROM regional_sales WHERE total_sales > 1000000) SELECT * -FROM -top_regions;" +FROM top_regions;" `; exports[`Pretty CTE (Common Table Expressions) formatting should format nested CTE with complex joins with pretty option enabled 1`] = ` -"WITH +"WITH sales_summary AS (SELECT region, product_category, sum(amount) AS total - FROM - sales + FROM sales GROUP BY region, product_category), regional_totals AS (SELECT region, sum(total) AS region_total - FROM - sales_summary + FROM sales_summary GROUP BY region) SELECT @@ -59,20 +52,18 @@ SELECT s.product_category, s.total, r.region_total -FROM -sales_summary AS s +FROM sales_summary AS s JOIN regional_totals AS r ON s.region = r.region;" `; exports[`Pretty CTE (Common Table Expressions) formatting should format recursive CTE with pretty option enabled 1`] = ` -"WITH RECURSIVE +"WITH RECURSIVE employee_hierarchy AS (SELECT id, name, manager_id, 1 AS level - FROM - employees + FROM employees WHERE manager_id IS NULL UNION @@ -82,28 +73,24 @@ exports[`Pretty CTE (Common Table Expressions) formatting should format recursiv e.name, e.manager_id, eh.level + 1 - FROM - employees AS e + FROM employees AS e JOIN employee_hierarchy AS eh ON e.manager_id = eh.id) SELECT * -FROM -employee_hierarchy;" +FROM employee_hierarchy;" `; exports[`Pretty CTE (Common Table Expressions) formatting should maintain single-line format when pretty option disabled 1`] = `"WITH regional_sales AS (SELECT region, sum(sales_amount) AS total_sales FROM sales GROUP BY region) SELECT * FROM regional_sales;"`; exports[`Pretty CTE (Common Table Expressions) formatting should use custom newline and tab characters in pretty mode 1`] = ` -"WITH +"WITH regional_sales AS (SELECT region, sum(sales_amount) AS total_sales - FROM - sales + FROM sales GROUP BY region) SELECT * -FROM -regional_sales;" +FROM regional_sales;" `; diff --git a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap index 67ae6bc9..7f3e6932 100644 --- a/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap +++ b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap @@ -3,14 +3,12 @@ exports[`Pretty SELECT formatting should format SELECT with UNION with pretty option enabled 1`] = ` "SELECT name -FROM -customers +FROM customers UNION ALL SELECT name -FROM -suppliers +FROM suppliers ORDER BY name;" `; @@ -21,10 +19,9 @@ exports[`Pretty SELECT formatting should format SELECT with multiple JOINs with u.name, u.email, p.title -FROM -users AS u -JOIN profiles AS p ON u.id = p.user_id -LEFT JOIN orders AS o ON u.id = o.user_id +FROM users AS u +JOIN profiles AS p ON u.id = p.user_id +LEFT JOIN orders AS o ON u.id = o.user_id RIGHT JOIN addresses AS a ON u.id = a.user_id WHERE u.active = true;" @@ -34,15 +31,13 @@ exports[`Pretty SELECT formatting should format SELECT with subquery with pretty "SELECT id, name -FROM -users +FROM users WHERE id IN (SELECT - user_id - FROM - orders - WHERE - total > 100);" + user_id +FROM orders +WHERE + total > 100);" `; exports[`Pretty SELECT formatting should format basic SELECT with pretty option enabled 1`] = ` @@ -50,8 +45,7 @@ exports[`Pretty SELECT formatting should format basic SELECT with pretty option id, name, email -FROM -users +FROM users WHERE active = true;" `; @@ -62,11 +56,11 @@ exports[`Pretty SELECT formatting should format complex SELECT with pretty optio u.name, u.email, p.title -FROM -users AS u +FROM users AS u JOIN profiles AS p ON u.id = p.user_id WHERE - u.active = true AND u.created_at > '2023-01-01' + u.active = true + AND u.created_at > '2023-01-01' GROUP BY u.id, u.name, @@ -77,10 +71,8 @@ HAVING ORDER BY u.created_at DESC, u.name ASC -LIMIT -10 -OFFSET -5;" +LIMIT 10 +OFFSET 5;" `; exports[`Pretty SELECT formatting should maintain single-line format for complex SELECT when pretty disabled 1`] = `"SELECT u.id, u.name, u.email, p.title FROM users AS u JOIN profiles AS p ON u.id = p.user_id WHERE u.active = true AND u.created_at > '2023-01-01' GROUP BY u.id, u.name, u.email, p.title HAVING count(*) > 1 ORDER BY u.created_at DESC, u.name ASC LIMIT 10 OFFSET 5;"`; @@ -92,8 +84,7 @@ exports[`Pretty SELECT formatting should use custom newline and tab characters i id, name, email -FROM -users +FROM users WHERE active = true;" `; diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 3bb7c21d..d7f5f49c 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -323,25 +323,24 @@ export class Deparser implements DeparserVisitor { if (node.fromClause) { const fromList = ListUtils.unwrapList(node.fromClause); - if (this.formatter.isPretty()) { - output.push('FROM'); - const fromItems = fromList - .map(e => this.deparse(e as Node, { ...context, from: true })) - .join(', '); - output.push(fromItems); - } else { - output.push('FROM'); - const fromItems = fromList - .map(e => this.deparse(e as Node, { ...context, from: true })) - .join(', '); - output.push(fromItems); - } + const fromItems = fromList + .map(e => this.deparse(e as Node, { ...context, from: true })) + .join(', '); + output.push('FROM ' + fromItems.trim()); } if (node.whereClause) { if (this.formatter.isPretty()) { output.push('WHERE'); - output.push(this.formatter.indent(this.visit(node.whereClause as Node, context))); + const whereExpr = this.visit(node.whereClause as Node, context); + const lines = whereExpr.split(this.formatter.newline()); + const indentedLines = lines.map((line, index) => { + if (index === 0) { + return this.formatter.indent(line); + } + return line; + }); + output.push(indentedLines.join(this.formatter.newline())); } else { output.push('WHERE'); output.push(this.visit(node.whereClause as Node, context)); @@ -411,13 +410,11 @@ export class Deparser implements DeparserVisitor { } if (node.limitCount) { - output.push('LIMIT'); - output.push(this.visit(node.limitCount as Node, context)); + output.push('LIMIT ' + this.visit(node.limitCount as Node, context)); } if (node.limitOffset) { - output.push('OFFSET'); - output.push(this.visit(node.limitOffset as Node, context)); + output.push('OFFSET ' + this.visit(node.limitOffset as Node, context)); } if (node.lockingClause) { @@ -977,11 +974,10 @@ export class Deparser implements DeparserVisitor { const cteStr = this.visit(cte, context); return this.formatter.newline() + this.formatter.indent(cteStr); }); - return output.join(' ') + cteStrings.join(','); + output.push(cteStrings.join(',')); } else { const cteStrings = ctes.map(cte => this.visit(cte, context)); output.push(cteStrings.join(', ')); - return output.join(' '); } } @@ -1079,11 +1075,21 @@ export class Deparser implements DeparserVisitor { switch (boolop) { case 'AND_EXPR': - const andArgs = args.map(arg => this.visit(arg, boolContext)).join(' AND '); - return formatStr.replace('%s', () => andArgs); + if (this.formatter.isPretty() && args.length > 1) { + const andArgs = args.map(arg => this.visit(arg, boolContext)).join(this.formatter.newline() + ' AND '); + return formatStr.replace('%s', () => andArgs); + } else { + const andArgs = args.map(arg => this.visit(arg, boolContext)).join(' AND '); + return formatStr.replace('%s', () => andArgs); + } case 'OR_EXPR': - const orArgs = args.map(arg => this.visit(arg, boolContext)).join(' OR '); - return formatStr.replace('%s', () => orArgs); + if (this.formatter.isPretty() && args.length > 1) { + const orArgs = args.map(arg => this.visit(arg, boolContext)).join(this.formatter.newline() + ' OR '); + return formatStr.replace('%s', () => orArgs); + } else { + const orArgs = args.map(arg => this.visit(arg, boolContext)).join(' OR '); + return formatStr.replace('%s', () => orArgs); + } case 'NOT_EXPR': return `NOT (${this.visit(args[0], context)})`; default: @@ -3494,12 +3500,6 @@ export class Deparser implements DeparserVisitor { joinStr += 'JOIN'; } - if (this.formatter.isPretty()) { - output.push(this.formatter.newline() + joinStr); - } else { - output.push(joinStr); - } - if (node.rarg) { let rargStr = this.visit(node.rarg, context); @@ -3507,28 +3507,41 @@ export class Deparser implements DeparserVisitor { rargStr = `(${rargStr})`; } - output.push(rargStr); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + joinStr + ' ' + rargStr); + } else { + output.push(joinStr + ' ' + rargStr); + } + } else { + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + joinStr); + } else { + output.push(joinStr); + } } if (node.usingClause && node.usingClause.length > 0) { const usingList = ListUtils.unwrapList(node.usingClause); const columnNames = usingList.map(col => this.visit(col, context)); if (this.formatter.isPretty()) { - output.push(`USING (${columnNames.join(', ')})`); + output.push(` USING (${columnNames.join(', ')})`); } else { - output.push('USING'); - output.push(`(${columnNames.join(', ')})`); + output.push(`USING (${columnNames.join(', ')})`); } } else if (node.quals) { if (this.formatter.isPretty()) { - output.push(`ON ${this.visit(node.quals, context)}`); + output.push(` ON ${this.visit(node.quals, context)}`); } else { - output.push('ON'); - output.push(this.visit(node.quals, context)); + output.push(`ON ${this.visit(node.quals, context)}`); } } - let result = this.formatter.isPretty() ? output.join(' ') : output.join(' '); + let result; + if (this.formatter.isPretty()) { + result = output.join(''); + } else { + result = output.join(' '); + } if (node.join_using_alias && node.join_using_alias.aliasname) { let aliasStr = node.join_using_alias.aliasname; @@ -6433,16 +6446,23 @@ export class Deparser implements DeparserVisitor { } CreatePolicyStmt(node: t.CreatePolicyStmt, context: DeparserContext): string { - const output: string[] = ['CREATE', 'POLICY']; + const output: string[] = []; + const initialParts = ['CREATE', 'POLICY']; if (node.policy_name) { - output.push(`"${node.policy_name}"`); + initialParts.push(`"${node.policy_name}"`); } - output.push('ON'); + output.push(initialParts.join(' ')); + // Add ON clause on new line in pretty mode if (node.table) { - output.push(this.RangeVar(node.table, context)); + if (this.formatter.isPretty()) { + output.push(this.formatter.newline() + this.formatter.indent(`ON ${this.RangeVar(node.table, context)}`)); + } else { + output.push('ON'); + output.push(this.RangeVar(node.table, context)); + } } // Handle AS RESTRICTIVE/PERMISSIVE clause @@ -6480,23 +6500,29 @@ export class Deparser implements DeparserVisitor { if (node.qual) { if (this.formatter.isPretty()) { - output.push(this.formatter.newline() + this.formatter.indent(`USING (${this.visit(node.qual, context)})`)); + const qualExpr = this.visit(node.qual, context); + output.push(this.formatter.newline() + this.formatter.indent('USING (')); + output.push(this.formatter.newline() + this.formatter.indent(this.formatter.indent(qualExpr))); + output.push(this.formatter.newline() + this.formatter.indent(')')); } else { output.push('USING'); output.push(`(${this.visit(node.qual, context)})`); } } - + if (node.with_check) { if (this.formatter.isPretty()) { - output.push(this.formatter.newline() + this.formatter.indent(`WITH CHECK (${this.visit(node.with_check, context)})`)); + const checkExpr = this.visit(node.with_check, context); + output.push(this.formatter.newline() + this.formatter.indent('WITH CHECK (')); + output.push(this.formatter.newline() + this.formatter.indent(this.formatter.indent(checkExpr))); + output.push(this.formatter.newline() + this.formatter.indent(')')); } else { output.push('WITH CHECK'); output.push(`(${this.visit(node.with_check, context)})`); } } - return output.join(' '); + return this.formatter.isPretty() ? output.join('') : output.join(' '); } AlterPolicyStmt(node: t.AlterPolicyStmt, context: DeparserContext): string { From b141280a83a251daff6a7d89a0060f9993f43a34 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 01:51:14 -0700 Subject: [PATCH 11/17] testing harness for pretty print --- packages/deparser/test-utils/index.ts | 69 ++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/deparser/test-utils/index.ts b/packages/deparser/test-utils/index.ts index bb55e761..35f41a81 100644 --- a/packages/deparser/test-utils/index.ts +++ b/packages/deparser/test-utils/index.ts @@ -25,7 +25,10 @@ type ParseErrorType = | 'REPARSE_FAILED' | 'AST_MISMATCH' | 'UNEXPECTED_ERROR' - | 'INVALID_DEPARSED_SQL'; + | 'INVALID_DEPARSED_SQL' + | 'PRETTY_INVALID_DEPARSED_SQL' + | 'PRETTY_REPARSE_FAILED' + | 'PRETTY_AST_MISMATCH'; interface ParseError extends Error { type: ParseErrorType; @@ -71,6 +74,12 @@ function getErrorMessage(type: ParseErrorType): string { return 'Unexpected error during parse/deparse cycle'; case 'INVALID_DEPARSED_SQL': return 'Invalid deparsed SQL'; + case 'PRETTY_INVALID_DEPARSED_SQL': + return 'Invalid deparsed SQL (pretty)'; + case 'PRETTY_REPARSE_FAILED': + return 'Reparse failed - no statements returned (pretty)'; + case 'PRETTY_AST_MISMATCH': + return 'AST mismatch after parse/deparse cycle (pretty)'; } } @@ -112,27 +121,30 @@ export class TestUtils { if (tree.stmts) { for (const stmt of tree.stmts) { if (stmt.stmt) { - const outSql = deparse(stmt.stmt); + const outSql1 = deparse(stmt.stmt, { pretty: false }); + const outSql2 = deparse(stmt.stmt, { pretty: true }); // console.log(`\n๐Ÿ” DEBUGGING SQL COMPARISON for test: ${testName}`); // console.log(`๐Ÿ“ฅ INPUT SQL: ${sql}`); // console.log(`๐Ÿ“ค DEPARSED SQL: ${outSql}`); // console.log(`๐Ÿ”„ SQL MATCH: ${sql.trim() === outSql.trim() ? 'โœ… EXACT MATCH' : 'โŒ DIFFERENT'}`); + // Test non-pretty version first let reparsed; try { - reparsed = await parse(outSql); + reparsed = await parse(outSql1); } catch (parseErr) { throw createParseError( 'INVALID_DEPARSED_SQL', testName, sql, - outSql, + outSql1, cleanTree([stmt]), undefined, parseErr instanceof Error ? parseErr.message : String(parseErr) ); } + const originalClean = cleanTree([stmt]); const reparsedClean = cleanTree(reparsed.stmts || []); @@ -145,13 +157,41 @@ export class TestUtils { } if (!reparsed.stmts) { - throw createParseError('REPARSE_FAILED', testName, sql, outSql, originalClean); + throw createParseError('REPARSE_FAILED', testName, sql, outSql1, originalClean); } try { expect(reparsedClean).toEqual(originalClean); } catch (err) { - throw createParseError('AST_MISMATCH', testName, sql, outSql, originalClean, reparsedClean); + throw createParseError('AST_MISMATCH', testName, sql, outSql1, originalClean, reparsedClean); + } + + // Test pretty version if non-pretty succeeded + let prettyReparsed; + try { + prettyReparsed = await parse(outSql2); + } catch (parseErr) { + throw createParseError( + 'PRETTY_INVALID_DEPARSED_SQL', + testName, + sql, + outSql2, + cleanTree([stmt]), + undefined, + parseErr instanceof Error ? parseErr.message : String(parseErr) + ); + } + + const prettyReparsedClean = cleanTree(prettyReparsed.stmts || []); + + if (!prettyReparsed.stmts) { + throw createParseError('PRETTY_REPARSE_FAILED', testName, sql, outSql2, originalClean); + } + + try { + expect(prettyReparsedClean).toEqual(originalClean); + } catch (err) { + throw createParseError('PRETTY_AST_MISMATCH', testName, sql, outSql2, originalClean, prettyReparsedClean); } } } @@ -185,6 +225,23 @@ export class TestUtils { `\nDIFF (what's missing from actual vs expected):`, diff(parseError.originalAst, parseError.reparsedAst) || 'No diff available' ); + } else if (parseError.type === 'PRETTY_INVALID_DEPARSED_SQL') { + errorMessages.push( + `\nโŒ PRETTY DEPARSER GENERATED INVALID SQL:`, + `ORIGINAL AST:`, + JSON.stringify(parseError.originalAst, null, 2), + `\nPARSE ERROR: ${parseError.parseError}` + ); + } else if (parseError.type === 'PRETTY_AST_MISMATCH') { + errorMessages.push( + `\nโŒ PRETTY AST COMPARISON:`, + `EXPECTED AST:`, + JSON.stringify(parseError.originalAst, null, 2), + `\nACTUAL AST:`, + JSON.stringify(parseError.reparsedAst, null, 2), + `\nDIFF (what's missing from actual vs expected):`, + diff(parseError.originalAst, parseError.reparsedAst) || 'No diff available' + ); } else if (parseError.originalAst) { errorMessages.push(`โŒ AST: ${JSON.stringify(parseError.originalAst, null, 2)}`); } From 9d3839b9139b467f0591f365a8b7e4177e23aac3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 09:05:52 +0000 Subject: [PATCH 12/17] Fix string literal preservation in pretty formatting - Add containsMultilineStringLiteral helper method to detect multi-line string literals - Preserve string literal content in SelectStmt target lists, GROUP BY, HAVING, ORDER BY clauses - Fix WithClause CTE formatting to avoid corrupting string literals during indentation - Resolves all 6 failing kitchen sink tests while maintaining AST equivalence Co-Authored-By: Dan Lynch --- packages/deparser/src/deparser.ts | 40 ++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index d7f5f49c..4fe7f806 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -304,7 +304,13 @@ export class Deparser implements DeparserVisitor { const targetList = ListUtils.unwrapList(node.targetList); if (this.formatter.isPretty()) { const targetStrings = targetList - .map(e => this.formatter.indent(this.visit(e as Node, { ...context, select: true }))); + .map(e => { + const targetStr = this.visit(e as Node, { ...context, select: true }); + if (this.containsMultilineStringLiteral(targetStr)) { + return targetStr; + } + return this.formatter.indent(targetStr); + }); const formattedTargets = targetStrings.join(',' + this.formatter.newline()); output.push('SELECT' + distinctPart); output.push(formattedTargets); @@ -360,7 +366,13 @@ export class Deparser implements DeparserVisitor { const groupList = ListUtils.unwrapList(node.groupClause); if (this.formatter.isPretty()) { const groupItems = groupList - .map(e => this.formatter.indent(this.visit(e as Node, { ...context, group: true }))) + .map(e => { + const groupStr = this.visit(e as Node, { ...context, group: true }); + if (this.containsMultilineStringLiteral(groupStr)) { + return groupStr; + } + return this.formatter.indent(groupStr); + }) .join(',' + this.formatter.newline()); output.push('GROUP BY'); output.push(groupItems); @@ -376,7 +388,12 @@ export class Deparser implements DeparserVisitor { if (node.havingClause) { if (this.formatter.isPretty()) { output.push('HAVING'); - output.push(this.formatter.indent(this.visit(node.havingClause as Node, context))); + const havingStr = this.visit(node.havingClause as Node, context); + if (this.containsMultilineStringLiteral(havingStr)) { + output.push(havingStr); + } else { + output.push(this.formatter.indent(havingStr)); + } } else { output.push('HAVING'); output.push(this.visit(node.havingClause as Node, context)); @@ -396,7 +413,13 @@ export class Deparser implements DeparserVisitor { const sortList = ListUtils.unwrapList(node.sortClause); if (this.formatter.isPretty()) { const sortItems = sortList - .map(e => this.formatter.indent(this.visit(e as Node, { ...context, sort: true }))) + .map(e => { + const sortStr = this.visit(e as Node, { ...context, sort: true }); + if (this.containsMultilineStringLiteral(sortStr)) { + return sortStr; + } + return this.formatter.indent(sortStr); + }) .join(',' + this.formatter.newline()); output.push('ORDER BY'); output.push(sortItems); @@ -972,6 +995,9 @@ export class Deparser implements DeparserVisitor { if (this.formatter.isPretty()) { const cteStrings = ctes.map(cte => { const cteStr = this.visit(cte, context); + if (this.containsMultilineStringLiteral(cteStr)) { + return this.formatter.newline() + cteStr; + } return this.formatter.newline() + this.formatter.indent(cteStr); }); output.push(cteStrings.join(',')); @@ -10900,6 +10926,8 @@ export class Deparser implements DeparserVisitor { return output.join(' '); } - - + private containsMultilineStringLiteral(content: string): boolean { + const stringLiteralRegex = /'[^']*\n[^']*'/g; + return stringLiteralRegex.test(content); + } } From 5aa3cb4a0e9a96dbe7c8b2c6f5442654a12c55d7 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 09:15:00 +0000 Subject: [PATCH 13/17] Add comprehensive pretty print options documentation - Add detailed pretty formatting section to DEPARSER_USAGE.md - Include complete options table with all 5 available options - Provide clear examples for basic usage and custom formatting - Document supported statements and semantic preservation notes - Complete the pretty formatting feature documentation Co-Authored-By: Dan Lynch --- DEPARSER_USAGE.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/DEPARSER_USAGE.md b/DEPARSER_USAGE.md index a6ac6f01..64699c8e 100644 --- a/DEPARSER_USAGE.md +++ b/DEPARSER_USAGE.md @@ -108,6 +108,75 @@ const options = { const sql = deparse(parseResult, options); ``` +### Pretty Formatting Options + +The deparser supports pretty formatting to make SQL output more readable with proper indentation and line breaks: + +```typescript +const options = { + pretty: true, // Enable pretty formatting (default: false) + newline: '\n', // Newline character (default: '\n') + tab: ' ', // Tab/indentation character (default: ' ') + functionDelimiter: '$$', // Function body delimiter (default: '$$') + functionDelimiterFallback: '$EOFCODE$' // Fallback delimiter (default: '$EOFCODE$') +}; + +const sql = deparse(parseResult, options); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `pretty` | `boolean` | `false` | Enable pretty formatting with indentation and line breaks | +| `newline` | `string` | `'\n'` | Character(s) used for line breaks | +| `tab` | `string` | `' '` | Character(s) used for indentation (2 spaces by default) | +| `functionDelimiter` | `string` | `'$$'` | Delimiter used for function bodies | +| `functionDelimiterFallback` | `string` | `'$EOFCODE$'` | Alternative delimiter when default is found in function body | + +#### Pretty Formatting Examples + +**Basic SELECT with pretty formatting:** +```typescript +// Without pretty formatting +const sql1 = deparse(selectAst, { pretty: false }); +// Output: "SELECT id, name, email FROM users WHERE active = true;" + +// With pretty formatting +const sql2 = deparse(selectAst, { pretty: true }); +// Output: +// SELECT +// id, +// name, +// email +// FROM users +// WHERE +// active = true; +``` + +**Custom formatting characters:** +```typescript +const options = { + pretty: true, + newline: '\r\n', // Windows line endings + tab: ' ' // 4-space indentation +}; + +const sql = deparse(parseResult, options); +``` + +**Supported Statements:** +Pretty formatting is supported for: +- `SELECT` statements with proper clause alignment +- `CREATE TABLE` statements with column definitions +- `CREATE POLICY` statements with clause formatting +- Common Table Expressions (CTEs) +- Constraint definitions +- JOIN operations with proper alignment + +**Important Notes:** +- Pretty formatting preserves SQL semantics - the formatted SQL parses to the same AST +- Multi-line string literals are preserved without indentation to maintain their content +- Complex expressions maintain proper parentheses and operator precedence + ## Instance Usage You can also create a deparser instance: @@ -185,4 +254,4 @@ const customSelect = { const sql = deparse(customSelect); // Output: "SELECT * FROM users" -``` \ No newline at end of file +``` From e299321e836c52f12d1a466ac3d15af03875d6e1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 09:17:49 +0000 Subject: [PATCH 14/17] Add pretty print options to deparser README.md - Add concise Options section with table of main options - Include pretty formatting example showing before/after - Reference full documentation in DEPARSER_USAGE.md - Complete user-facing documentation for pretty print feature Co-Authored-By: Dan Lynch --- packages/deparser/README.md | 44 ++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/deparser/README.md b/packages/deparser/README.md index 2d044d1c..0a24f8f2 100644 --- a/packages/deparser/README.md +++ b/packages/deparser/README.md @@ -69,6 +69,48 @@ console.log(deparse(stmt)); // Output: SELECT * FROM another_table ``` +## Options + +The deparser accepts optional configuration for formatting and output control: + +```ts +import { deparseSync as deparse } from 'pgsql-deparser'; + +const options = { + pretty: true, // Enable pretty formatting (default: false) + newline: '\n', // Newline character (default: '\n') + tab: ' ', // Tab/indentation character (default: ' ') + semicolons: true // Add semicolons to statements (default: true) +}; + +const sql = deparse(ast, options); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `pretty` | `boolean` | `false` | Enable pretty formatting with indentation and line breaks | +| `newline` | `string` | `'\n'` | Character(s) used for line breaks | +| `tab` | `string` | `' '` | Character(s) used for indentation | +| `semicolons` | `boolean` | `true` | Add semicolons to SQL statements | + +**Pretty formatting example:** +```ts +// Without pretty formatting +const sql1 = deparse(selectAst, { pretty: false }); +// "SELECT id, name FROM users WHERE active = true;" + +// With pretty formatting +const sql2 = deparse(selectAst, { pretty: true }); +// SELECT +// id, +// name +// FROM users +// WHERE +// active = true; +``` + +For complete documentation and advanced options, see [DEPARSER_USAGE.md](../../DEPARSER_USAGE.md). + ## Why Use `pgsql-deparser`? `pgsql-deparser` is particularly useful in development environments where native dependencies are problematic or in applications where only the deparser functionality is required. Its independence from the full `pgsql-parser` package allows for more focused and lightweight SQL generation tasks. @@ -98,4 +140,4 @@ Built on the excellent work of several contributors: AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND. -No developer or entity involved in creating Software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the Software code or Software CLI, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value. \ No newline at end of file +No developer or entity involved in creating Software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the Software code or Software CLI, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value. From 6029a281de42b43fad77d66c53555de808fa4a85 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 02:36:13 -0700 Subject: [PATCH 15/17] readmes --- DEPARSER_USAGE.md | 8 ++++---- README.md | 11 ++++------- TESTS.md | 11 ----------- packages/parser/README.md | 4 ++-- packages/utils/README.md | 12 ++++++------ 5 files changed, 16 insertions(+), 30 deletions(-) delete mode 100644 TESTS.md diff --git a/DEPARSER_USAGE.md b/DEPARSER_USAGE.md index 64699c8e..70022f0d 100644 --- a/DEPARSER_USAGE.md +++ b/DEPARSER_USAGE.md @@ -218,15 +218,15 @@ These ensure proper handling of different input formats automatically. ### Complete Example ```typescript -import deparse from 'pgsql-deparser'; +import { deparse } from 'pgsql-deparser'; import { parse } from 'pgsql-parser'; // Parse SQL const sql = 'SELECT * FROM users; INSERT INTO logs (action) VALUES ($1);'; -const parseResult = parse(sql); +const parseResult = await parse(sql); // Deparse back to SQL -const regeneratedSql = deparse(parseResult); +const regeneratedSql = await deparse(parseResult); console.log(regeneratedSql); // Output: "SELECT * FROM users;\n\nINSERT INTO logs (action) VALUES ($1);" ``` @@ -252,6 +252,6 @@ const customSelect = { } }; -const sql = deparse(customSelect); +const sql = await deparse(customSelect); // Output: "SELECT * FROM users" ``` diff --git a/README.md b/README.md index fb117eeb..9f5ad700 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ console.log(sql); // SELECT * FROM users WHERE id = 1 #### Build AST Programmatically ```typescript import * as t from '@pgsql/utils'; -import { RangeVar, SelectStmt } from '@pgsql/types'; +import { SelectStmt } from '@pgsql/types'; const stmt: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ targetList: [ @@ -127,9 +127,6 @@ yarn install # Build all packages yarn build - -# Run tests -yarn test ``` ### Building Individual Packages @@ -163,7 +160,7 @@ const ast = await parse('SELECT * FROM users WHERE active = true'); ast[0].RawStmt.stmt.SelectStmt.fromClause[0].RangeVar.relname = 'customers'; // Generate the modified SQL -const newSql = deparse(ast); +const newSql = await deparse(ast); console.log(newSql); // SELECT * FROM customers WHERE active = TRUE ``` @@ -171,7 +168,7 @@ console.log(newSql); // SELECT * FROM customers WHERE active = TRUE ```typescript import ast from '@pgsql/utils'; -import { deparse as deparseSync } from 'pgsql-deparser'; +import { deparse } from 'pgsql-deparser'; const query: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ targetList: [ @@ -207,7 +204,7 @@ const query: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ op: 'SETOP_NONE' }); -console.log(deparse(query)); +console.log(await deparse(query)); // SELECT name, email FROM users WHERE age > 18 ``` diff --git a/TESTS.md b/TESTS.md deleted file mode 100644 index b9cb7869..00000000 --- a/TESTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# Test Results - -## Deparser Tests - -**254/254 tests passing (100%)** - -All deparser tests are now passing successfully, including the PostgreSQL 17 features: -- GENERATED BY DEFAULT AS IDENTITY columns -- UNIQUE NULLS NOT DISTINCT constraints - -The deparser has been updated to properly handle these new PostgreSQL 17 syntax features. diff --git a/packages/parser/README.md b/packages/parser/README.md index 5705f4cc..9c069415 100644 --- a/packages/parser/README.md +++ b/packages/parser/README.md @@ -91,7 +91,7 @@ Here's how you can use the deparser in your TypeScript code, using [`@pgsql/util ```ts import * as t from '@pgsql/utils'; import { RangeVar, SelectStmt } from '@pgsql/types'; -import { deparseSync as deparse } from 'pgsql-deparser'; +import { deparse } from 'pgsql-deparser'; // This could have been obtained from any JSON or AST, not necessarily @pgsql/utils const stmt: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ @@ -117,7 +117,7 @@ const stmt: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ (stmt.SelectStmt.fromClause[0] as {RangeVar: RangeVar}).RangeVar.relname = 'another_table'; // Deparse the modified AST back to a SQL string -console.log(deparse(stmt)); +console.log(await deparse(stmt)); // Output: SELECT * FROM another_table ``` diff --git a/packages/utils/README.md b/packages/utils/README.md index 2f620015..467239fb 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -61,7 +61,7 @@ Explore the PostgreSQL Abstract Syntax Tree (AST) as JSON objects with ease usin ```ts import * as t from '@pgsql/utils'; import { SelectStmt } from '@pgsql/types'; -import { deparseSync as deparse } from 'pgsql-deparser'; +import { deparse } from 'pgsql-deparser'; const selectStmt: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ targetList: [ @@ -83,7 +83,7 @@ const selectStmt: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ }); console.log(selectStmt); // Output: { "SelectStmt": { "targetList": [ { "ResTarget": { "val": { "ColumnRef": { "fields": [ { "A_Star": {} } ] } } } } ], "fromClause": [ { "RangeVar": { "relname": "some_amazing_table", "inh": true, "relpersistence": "p" } } ], "limitOption": "LIMIT_OPTION_DEFAULT", "op": "SETOP_NONE" } } -console.log(deparse(stmt)) +console.log(await deparse(stmt)) // Output: SELECT * FROM some_amazing_table ``` @@ -91,8 +91,8 @@ console.log(deparse(stmt)) ```ts import * as t from '@pgsql/utils'; -import { RangeVar, SelectStmt } from '@pgsql/types'; -import { deparseSync as deparse } from 'pgsql-deparser'; +import { SelectStmt } from '@pgsql/types'; +import { deparse } from 'pgsql-deparser'; const query: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ targetList: [ @@ -128,7 +128,7 @@ const query: { SelectStmt: SelectStmt } = t.nodes.selectStmt({ op: 'SETOP_NONE' }); -deparse(createStmt, {}); +await deparse(createStmt); // SELECT name, email FROM users WHERE age > 18 ``` @@ -167,7 +167,7 @@ const createStmt = t.nodes.createStmt({ }); // `deparse` function converts AST to SQL string -const sql = deparse(createStmt); +const sql = await deparse(createStmt, { pretty: true }); console.log(sql); // OUTPUT: From 4a0e3953cc9677d0539225f18e7c916d8fa61a71 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 02:39:09 -0700 Subject: [PATCH 16/17] pretty in kitchen sink --- __fixtures__/generated/generated.json | 19 +++++++++++++++++++ .../{ => kitchen-sink}/pretty/constraints.sql | 0 .../pretty/create_policy.sql | 0 .../pretty/create_table.sql | 0 .../pretty/select_statements.sql | 0 .../kitchen-sink/pretty-constraints.test.ts | 12 ++++++++++++ .../kitchen-sink/pretty-create_policy.test.ts | 12 ++++++++++++ .../kitchen-sink/pretty-create_table.test.ts | 13 +++++++++++++ .../pretty-select_statements.test.ts | 14 ++++++++++++++ .../pretty/constraints-pretty.test.ts | 2 -- .../pretty/create-policy-pretty.test.ts | 2 -- .../pretty/create-table-pretty.test.ts | 2 -- .../__tests__/pretty/cte-pretty.test.ts | 2 -- .../__tests__/pretty/select-pretty.test.ts | 2 -- 14 files changed, 70 insertions(+), 10 deletions(-) rename __fixtures__/{ => kitchen-sink}/pretty/constraints.sql (100%) rename __fixtures__/{ => kitchen-sink}/pretty/create_policy.sql (100%) rename __fixtures__/{ => kitchen-sink}/pretty/create_table.sql (100%) rename __fixtures__/{ => kitchen-sink}/pretty/select_statements.sql (100%) create mode 100644 packages/deparser/__tests__/kitchen-sink/pretty-constraints.test.ts create mode 100644 packages/deparser/__tests__/kitchen-sink/pretty-create_policy.test.ts create mode 100644 packages/deparser/__tests__/kitchen-sink/pretty-create_table.test.ts create mode 100644 packages/deparser/__tests__/kitchen-sink/pretty-select_statements.test.ts diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index 16bbb3cc..dacf3324 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -1,4 +1,23 @@ { + "pretty/select_statements-1.sql": "SELECT id, name, email FROM users WHERE active = true", + "pretty/select_statements-2.sql": "SELECT \n u.id,\n u.name,\n u.email,\n p.title as profile_title\nFROM users u\nJOIN profiles p ON u.id = p.user_id\nWHERE u.active = true\n AND u.created_at > '2023-01-01'\nGROUP BY u.id, u.name, u.email, p.title\nHAVING COUNT(*) > 1\nORDER BY u.created_at DESC, u.name ASC\nLIMIT 10\nOFFSET 5", + "pretty/select_statements-3.sql": "SELECT id, name FROM users WHERE id IN (\n SELECT user_id FROM orders WHERE total > 100\n)", + "pretty/select_statements-4.sql": "SELECT name FROM customers\nUNION ALL\nSELECT name FROM suppliers\nORDER BY name", + "pretty/select_statements-5.sql": "SELECT name, email FROM users WHERE status = 'active'", + "pretty/select_statements-6.sql": "SELECT u.name, o.total FROM users u, orders o WHERE u.id = o.user_id", + "pretty/create_table-1.sql": "CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL,\n email TEXT UNIQUE\n)", + "pretty/create_table-2.sql": "CREATE TABLE products (\n id SERIAL PRIMARY KEY,\n name VARCHAR(255) NOT NULL,\n price DECIMAL(10,2) CHECK (price > 0),\n category_id INTEGER,\n description TEXT,\n created_at TIMESTAMP DEFAULT now(),\n updated_at TIMESTAMP,\n UNIQUE (name, category_id),\n FOREIGN KEY (category_id) REFERENCES categories(id)\n)", + "pretty/create_table-3.sql": "CREATE TABLE orders (\n id SERIAL PRIMARY KEY,\n subtotal DECIMAL(10,2) NOT NULL,\n tax_rate DECIMAL(5,4) DEFAULT 0.0825,\n tax_amount DECIMAL(10,2) GENERATED ALWAYS AS (subtotal * tax_rate) STORED,\n total DECIMAL(10,2) GENERATED ALWAYS AS (subtotal + tax_amount) STORED\n)", + "pretty/create_table-4.sql": "CREATE TABLE sales (\n id SERIAL,\n sale_date DATE NOT NULL,\n amount DECIMAL(10,2),\n region VARCHAR(50)\n) PARTITION BY RANGE (sale_date)", + "pretty/create_table-5.sql": "CREATE TEMPORARY TABLE temp_calculations (\n id INTEGER,\n value DECIMAL(15,5),\n result TEXT\n)", + "pretty/create_policy-1.sql": "CREATE POLICY user_policy ON users FOR ALL TO authenticated_users USING (user_id = current_user_id())", + "pretty/create_policy-2.sql": "CREATE POLICY admin_policy ON sensitive_data \n AS RESTRICTIVE \n FOR SELECT \n TO admin_role \n USING (department = current_user_department()) \n WITH CHECK (approved = true)", + "pretty/create_policy-3.sql": "CREATE POLICY complex_policy ON documents \n FOR UPDATE \n TO document_editors \n USING (\n owner_id = current_user_id() OR \n (shared = true AND permissions @> '{\"edit\": true}')\n ) \n WITH CHECK (\n status != 'archived' AND \n last_modified > now() - interval '1 day'\n )", + "pretty/create_policy-4.sql": "CREATE POLICY simple_policy ON posts FOR SELECT TO public USING (published = true)", + "pretty/constraints-1.sql": "CREATE TABLE orders (\n id SERIAL PRIMARY KEY,\n user_id INTEGER NOT NULL,\n total DECIMAL(10,2) CHECK (total > 0),\n status VARCHAR(20) DEFAULT 'pending',\n created_at TIMESTAMP DEFAULT now(),\n CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n CONSTRAINT unique_user_date UNIQUE (user_id, created_at),\n CONSTRAINT check_status CHECK (status IN ('pending', 'completed', 'cancelled'))\n)", + "pretty/constraints-2.sql": "ALTER TABLE products ADD CONSTRAINT fk_category \n FOREIGN KEY (category_id) \n REFERENCES categories(id) \n ON UPDATE CASCADE \n ON DELETE SET NULL \n DEFERRABLE INITIALLY DEFERRED", + "pretty/constraints-3.sql": "ALTER TABLE products ADD CONSTRAINT check_price CHECK (price > 0)", + "pretty/constraints-4.sql": "ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email)", "original/simple-1.sql": "SELECT\n *\nFROM\n table_name\nWHERE\n name = 'test' AND num > 7 AND\n last_name LIKE '%''test''%'", "original/simple-2.sql": "SELECT\n *\nFROM\n table_name\nWHERE\n name = 'test' AND num > 7 AND\n last_name NOT LIKE '%''test''%'", "original/simple-3.sql": "SELECT\n *\nFROM\n table_name\nWHERE\n name = 'test' AND num > 7 AND\n last_name ILIKE '%''test''%'", diff --git a/__fixtures__/pretty/constraints.sql b/__fixtures__/kitchen-sink/pretty/constraints.sql similarity index 100% rename from __fixtures__/pretty/constraints.sql rename to __fixtures__/kitchen-sink/pretty/constraints.sql diff --git a/__fixtures__/pretty/create_policy.sql b/__fixtures__/kitchen-sink/pretty/create_policy.sql similarity index 100% rename from __fixtures__/pretty/create_policy.sql rename to __fixtures__/kitchen-sink/pretty/create_policy.sql diff --git a/__fixtures__/pretty/create_table.sql b/__fixtures__/kitchen-sink/pretty/create_table.sql similarity index 100% rename from __fixtures__/pretty/create_table.sql rename to __fixtures__/kitchen-sink/pretty/create_table.sql diff --git a/__fixtures__/pretty/select_statements.sql b/__fixtures__/kitchen-sink/pretty/select_statements.sql similarity index 100% rename from __fixtures__/pretty/select_statements.sql rename to __fixtures__/kitchen-sink/pretty/select_statements.sql diff --git a/packages/deparser/__tests__/kitchen-sink/pretty-constraints.test.ts b/packages/deparser/__tests__/kitchen-sink/pretty-constraints.test.ts new file mode 100644 index 00000000..059a839d --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/pretty-constraints.test.ts @@ -0,0 +1,12 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('pretty-constraints', async () => { + await fixtures.runFixtureTests([ + "pretty/constraints-1.sql", + "pretty/constraints-2.sql", + "pretty/constraints-3.sql", + "pretty/constraints-4.sql" +]); +}); diff --git a/packages/deparser/__tests__/kitchen-sink/pretty-create_policy.test.ts b/packages/deparser/__tests__/kitchen-sink/pretty-create_policy.test.ts new file mode 100644 index 00000000..31999ece --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/pretty-create_policy.test.ts @@ -0,0 +1,12 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('pretty-create_policy', async () => { + await fixtures.runFixtureTests([ + "pretty/create_policy-1.sql", + "pretty/create_policy-2.sql", + "pretty/create_policy-3.sql", + "pretty/create_policy-4.sql" +]); +}); diff --git a/packages/deparser/__tests__/kitchen-sink/pretty-create_table.test.ts b/packages/deparser/__tests__/kitchen-sink/pretty-create_table.test.ts new file mode 100644 index 00000000..50449dd4 --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/pretty-create_table.test.ts @@ -0,0 +1,13 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('pretty-create_table', async () => { + await fixtures.runFixtureTests([ + "pretty/create_table-1.sql", + "pretty/create_table-2.sql", + "pretty/create_table-3.sql", + "pretty/create_table-4.sql", + "pretty/create_table-5.sql" +]); +}); diff --git a/packages/deparser/__tests__/kitchen-sink/pretty-select_statements.test.ts b/packages/deparser/__tests__/kitchen-sink/pretty-select_statements.test.ts new file mode 100644 index 00000000..c8a76114 --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/pretty-select_statements.test.ts @@ -0,0 +1,14 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('pretty-select_statements', async () => { + await fixtures.runFixtureTests([ + "pretty/select_statements-1.sql", + "pretty/select_statements-2.sql", + "pretty/select_statements-3.sql", + "pretty/select_statements-4.sql", + "pretty/select_statements-5.sql", + "pretty/select_statements-6.sql" +]); +}); diff --git a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts index 6600739a..5ce28679 100644 --- a/packages/deparser/__tests__/pretty/constraints-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts @@ -1,5 +1,3 @@ -import { deparseSync } from '../../src'; -import { parse } from 'libpg-query'; import { expectParseDeparse } from '../../test-utils'; describe('Pretty constraint formatting', () => { diff --git a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts index 6ee6e655..4a8b8557 100644 --- a/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -1,5 +1,3 @@ -import { deparseSync } from '../../src'; -import { parse } from 'libpg-query'; import { expectParseDeparse } from '../../test-utils'; describe('Pretty CREATE POLICY formatting', () => { diff --git a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts index aed8ebc2..5d1a810d 100644 --- a/packages/deparser/__tests__/pretty/create-table-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts @@ -1,5 +1,3 @@ -import { deparseSync } from '../../src'; -import { parse } from 'libpg-query'; import { expectParseDeparse } from '../../test-utils'; describe('Pretty CREATE TABLE formatting', () => { diff --git a/packages/deparser/__tests__/pretty/cte-pretty.test.ts b/packages/deparser/__tests__/pretty/cte-pretty.test.ts index 70e0d950..aa411e15 100644 --- a/packages/deparser/__tests__/pretty/cte-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/cte-pretty.test.ts @@ -1,5 +1,3 @@ -import { deparseSync } from '../../src'; -import { parse } from 'libpg-query'; import { expectParseDeparse } from '../../test-utils'; describe('Pretty CTE (Common Table Expressions) formatting', () => { diff --git a/packages/deparser/__tests__/pretty/select-pretty.test.ts b/packages/deparser/__tests__/pretty/select-pretty.test.ts index 81a6fa4f..3508cc95 100644 --- a/packages/deparser/__tests__/pretty/select-pretty.test.ts +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -1,5 +1,3 @@ -import { deparseSync } from '../../src'; -import { parse } from 'libpg-query'; import { expectParseDeparse } from '../../test-utils'; describe('Pretty SELECT formatting', () => { From 26b01e358e13ae4d825107afbc0b1dc56e79b192 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 23 Jun 2025 02:44:39 -0700 Subject: [PATCH 17/17] readme --- README.md | 7 +++---- packages/deparser/README.md | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f5ad700..51e95e0f 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,11 @@

-## PostgreSQL AST Tools - +## PostgreSQL Parsing, Deparsing & AST Tools A comprehensive monorepo for PostgreSQL Abstract Syntax Tree (AST) parsing, manipulation, and code generation. This collection of packages provides everything you need to work with PostgreSQL at the AST level, from parsing SQL queries to generating type-safe TypeScript definitions. -## ๐Ÿ“ฆ Packages Overview +## ๐Ÿ“ฆ Packages | Package | Description | Key Features | |---------|-------------|--------------| @@ -36,7 +35,7 @@ A comprehensive monorepo for PostgreSQL Abstract Syntax Tree (AST) parsing, mani Choose the packages you need: ```bash -# For parsing SQL to AST and back +# For parsing SQL to AST and back (includes deparser) npm install pgsql-parser # For only converting AST to SQL (lighter weight) diff --git a/packages/deparser/README.md b/packages/deparser/README.md index 0a24f8f2..b67e9d64 100644 --- a/packages/deparser/README.md +++ b/packages/deparser/README.md @@ -24,7 +24,7 @@ npm install pgsql-deparser ## Features -* โšก **Pure TypeScript Performance** โ€“ Zero dependencies, no WASM, no compilation - just blazing fast SQL generation +* โšก **Pure TypeScript Performance** โ€“ Zero runtime dependencies, no WASM, no compilation - just blazing fast SQL generation * ๐Ÿชถ **Ultra Lightweight** โ€“ Minimal footprint with laser-focused functionality for AST-to-SQL conversion only * ๐Ÿงช **Battle-Tested Reliability** โ€“ Validated against 23,000+ SQL statements ensuring production-grade stability * ๐ŸŒ **Universal Compatibility** โ€“ Runs anywhere JavaScript does - browsers, Node.js, edge functions, you name it