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 +``` 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/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/__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/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. 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..e2e29089 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/create-policy-pretty.test.ts.snap @@ -0,0 +1,73 @@ +// 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() + );" +`; + +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 + );" +`; + +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 + );" +`; + +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());"`; + +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() + );" +`; 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/__snapshots__/cte-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap new file mode 100644 index 00000000..132770d7 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/cte-pretty.test.ts.snap @@ -0,0 +1,96 @@ +// 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 new file mode 100644 index 00000000..7f3e6932 --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/select-pretty.test.ts.snap @@ -0,0 +1,90 @@ +// 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 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, + 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..6600739a --- /dev/null +++ b/packages/deparser/__tests__/pretty/constraints-pretty.test.ts @@ -0,0 +1,51 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +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;`; + + 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 result = await expectParseDeparse(foreignKeyConstraintSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const result = await expectParseDeparse(foreignKeyConstraintSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format check constraint with pretty option enabled', async () => { + const result = await expectParseDeparse(checkConstraintSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex table with constraints with pretty option enabled', async () => { + const result = await expectParseDeparse(complexTableSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex table when pretty disabled', async () => { + const result = await expectParseDeparse(complexTableSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const result = await expectParseDeparse(foreignKeyConstraintSql, { + 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..6ee6e655 --- /dev/null +++ b/packages/deparser/__tests__/pretty/create-policy-pretty.test.ts @@ -0,0 +1,52 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +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());`; + + 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 () => { + const result = await expectParseDeparse(basicPolicySql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const result = await expectParseDeparse(basicPolicySql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex CREATE POLICY with pretty option enabled', async () => { + const result = await expectParseDeparse(complexPolicySql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex policy when pretty disabled', async () => { + const result = await expectParseDeparse(complexPolicySql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format simple CREATE POLICY with pretty option enabled', async () => { + const result = await expectParseDeparse(simplePolicySql, { pretty: true }); + 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, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); 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..aed8ebc2 --- /dev/null +++ b/packages/deparser/__tests__/pretty/create-table-pretty.test.ts @@ -0,0 +1,45 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +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);`; + + 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 result = await expectParseDeparse(basicTableSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const result = await expectParseDeparse(basicTableSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex CREATE TABLE with pretty option enabled', async () => { + const result = await expectParseDeparse(complexTableSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex table when pretty disabled', async () => { + const result = await expectParseDeparse(complexTableSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should use custom newline and tab characters in pretty mode', async () => { + const result = await expectParseDeparse(basicTableSql, { + pretty: true, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); 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 new file mode 100644 index 00000000..81a6fa4f --- /dev/null +++ b/packages/deparser/__tests__/pretty/select-pretty.test.ts @@ -0,0 +1,59 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +import { expectParseDeparse } from '../../test-utils'; + +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 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;`; + + it('should format basic SELECT with pretty option enabled', async () => { + const result = await expectParseDeparse(basicSelectSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format when pretty option disabled', async () => { + const result = await expectParseDeparse(basicSelectSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format complex SELECT with pretty option enabled', async () => { + const result = await expectParseDeparse(complexSelectSql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should maintain single-line format for complex SELECT when pretty disabled', async () => { + const result = await expectParseDeparse(complexSelectSql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format SELECT with subquery with pretty option enabled', async () => { + const result = await expectParseDeparse(selectWithSubquerySql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format SELECT with UNION with pretty option enabled', async () => { + const result = await expectParseDeparse(selectUnionSql, { pretty: true }); + 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, + newline: '\r\n', + tab: ' ' + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index fa7807c3..4fe7f806 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 = { @@ -218,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); @@ -271,25 +274,52 @@ 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); - const targets = targetList - .map(e => this.visit(e as Node, { ...context, select: true })) - .join(', '); - output.push(targets); + if (this.formatter.isPretty()) { + const targetStrings = targetList + .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); + } else { + const targets = targetList + .map(e => this.visit(e as Node, { ...context, select: true })) + .join(', '); + output.push(targets); + } } if (node.intoClause) { @@ -298,17 +328,29 @@ 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); + output.push('FROM ' + fromItems.trim()); } if (node.whereClause) { - output.push('WHERE'); - output.push(this.visit(node.whereClause as Node, context)); + if (this.formatter.isPretty()) { + output.push('WHERE'); + 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)); + } } if (node.valuesLists) { @@ -321,17 +363,41 @@ 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()) { + const groupItems = groupList + .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); + } else { + output.push('GROUP BY'); + 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('HAVING'); + 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)); + } } if (node.windowClause) { @@ -344,22 +410,34 @@ 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()) { + const sortItems = sortList + .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); + } else { + output.push('ORDER BY'); + const sortItems = sortList + .map(e => this.visit(e as Node, { ...context, sort: true })) + .join(', '); + output.push(sortItems); + } } 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) { @@ -370,6 +448,10 @@ export class Deparser implements DeparserVisitor { output.push(lockingClauses); } + if (this.formatter.isPretty()) { + const filteredOutput = output.filter(item => item.trim() !== ''); + return filteredOutput.join(this.formatter.newline()); + } return output.join(' '); } @@ -908,9 +990,22 @@ 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); + if (this.containsMultilineStringLiteral(cteStr)) { + return this.formatter.newline() + cteStr; + } + return this.formatter.newline() + this.formatter.indent(cteStr); + }); + output.push(cteStrings.join(',')); + } else { + const cteStrings = ctes.map(cte => this.visit(cte, context)); + output.push(cteStrings.join(', ')); + } + } return output.join(' '); } @@ -1006,11 +1101,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: @@ -2139,7 +2244,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('')); } @@ -2432,38 +2545,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) { @@ -2520,17 +2645,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(' '); } @@ -3352,11 +3504,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 { @@ -3376,8 +3526,6 @@ export class Deparser implements DeparserVisitor { joinStr += 'JOIN'; } - output.push(joinStr); - if (node.rarg) { let rargStr = this.visit(node.rarg, context); @@ -3385,22 +3533,42 @@ 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) { - 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 (${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 ${this.visit(node.quals, context)}`); + } } - let result = output.join(' '); + let result; + if (this.formatter.isPretty()) { + result = output.join(''); + } else { + result = 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) { @@ -3411,7 +3579,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) { @@ -6305,46 +6472,83 @@ 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 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()) { + 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) { - output.push('WITH CHECK'); - output.push(`(${this.visit(node.with_check, context)})`); + if (this.formatter.isPretty()) { + 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 { @@ -10722,6 +10926,8 @@ export class Deparser implements DeparserVisitor { return output.join(' '); } - - + private containsMultilineStringLiteral(content: string): boolean { + const stringLiteralRegex = /'[^']*\n[^']*'/g; + return stringLiteralRegex.test(content); + } } 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; + } } diff --git a/packages/deparser/test-utils/index.ts b/packages/deparser/test-utils/index.ts index 6efb4b4e..35f41a81 100644 --- a/packages/deparser/test-utils/index.ts +++ b/packages/deparser/test-utils/index.ts @@ -1,18 +1,34 @@ 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: DeparserOptions = { 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); + + return sql2; +} + type ParseErrorType = | 'PARSE_FAILED' | 'INVALID_STATEMENT' | '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; @@ -58,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)'; } } @@ -99,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 || []); @@ -132,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); } } } @@ -172,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)}`); }