diff --git a/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap b/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap new file mode 100644 index 00000000..02c8c3cd --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap @@ -0,0 +1,154 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pretty Misc SQL formatting should format misc-1: Complex CTE with joins and aggregation (non-pretty) 1`] = `"WITH recent_orders AS (SELECT o.id, o.user_id, o.created_at FROM orders AS o WHERE o.created_at > (now() - '30 days'::interval)), high_value_orders AS (SELECT r.user_id, count(*) AS order_count, sum(oi.price * oi.quantity) AS total_spent FROM recent_orders AS r JOIN order_items AS oi ON r.id = oi.order_id GROUP BY r.user_id) SELECT u.id, u.name, h.total_spent FROM users AS u JOIN high_value_orders AS h ON u.id = h.user_id WHERE h.total_spent > 1000 ORDER BY h.total_spent DESC"`; + +exports[`Pretty Misc SQL formatting should format misc-1: Complex CTE with joins and aggregation (pretty) 1`] = ` +"WITH + recent_orders AS (SELECT + o.id, + o.user_id, + o.created_at + FROM orders AS o + WHERE + o.created_at > (now() - '30 days'::interval)), + high_value_orders AS (SELECT + r.user_id, + count(*) AS order_count, + sum(oi.price * oi.quantity) AS total_spent + FROM recent_orders AS r + JOIN order_items AS oi ON r.id = oi.order_id + GROUP BY + r.user_id) +SELECT + u.id, + u.name, + h.total_spent +FROM users AS u +JOIN high_value_orders AS h ON u.id = h.user_id +WHERE + h.total_spent > 1000 +ORDER BY + h.total_spent DESC" +`; + +exports[`Pretty Misc SQL formatting should format misc-2: Window functions with FILTER and GROUPING SETS (non-pretty) 1`] = `"SELECT department, employee_id, count(*) FILTER (WHERE status = 'active') OVER (PARTITION BY department) AS active_count, rank() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank FROM employee_status GROUP BY GROUPING SETS (department, (department, employee_id))"`; + +exports[`Pretty Misc SQL formatting should format misc-2: Window functions with FILTER and GROUPING SETS (pretty) 1`] = ` +"SELECT + department, + employee_id, + count(*) FILTER (WHERE status = 'active') OVER (PARTITION BY department) AS active_count, + rank() OVER ( + PARTITION BY department + ORDER BY salary DESC + ) AS salary_rank +FROM employee_status +GROUP BY + GROUPING SETS (department, (department, employee_id))" +`; + +exports[`Pretty Misc SQL formatting should format misc-3: LATERAL joins with JSON functions (non-pretty) 1`] = `"SELECT u.id, u.name, j.key, j.value FROM users AS u, LATERAL jsonb_each_text(u.preferences) AS j(key, value) WHERE j.key LIKE 'notif_%' AND CAST(j.value AS boolean) = true"`; + +exports[`Pretty Misc SQL formatting should format misc-3: LATERAL joins with JSON functions (pretty) 1`] = ` +"SELECT + u.id, + u.name, + j.key, + j.value +FROM users AS u, LATERAL jsonb_each_text(u.preferences) AS j(key, value) +WHERE + j.key LIKE 'notif_%' + AND CAST(j.value AS boolean) = true" +`; + +exports[`Pretty Misc SQL formatting should format misc-4: EXISTS with nested subqueries and CASE (non-pretty) 1`] = `"SELECT p.id, p.title, CASE WHEN EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id AND r.rating >= 4) THEN 'Popular' ELSE 'Unrated' END AS status FROM products AS p WHERE p.archived = false"`; + +exports[`Pretty Misc SQL formatting should format misc-4: EXISTS with nested subqueries and CASE (pretty) 1`] = ` +"SELECT + p.id, + p.title, +CASE + WHEN EXISTS (SELECT + 1 + FROM reviews AS r + WHERE + r.product_id = p.id + AND r.rating >= 4) THEN 'Popular' + ELSE 'Unrated' +END AS status +FROM products AS p +WHERE + p.archived = false" +`; + +exports[`Pretty Misc SQL formatting should format misc-5: Nested CTEs with type casts and subqueries (non-pretty) 1`] = `"WITH logs AS (SELECT id, CAST(payload AS pg_catalog.json) ->> 'event' AS event, CAST(CAST(payload AS pg_catalog.json) ->> 'ts' AS pg_catalog.timestamp) AS ts FROM event_log WHERE ts > (now() - '7 days'::interval)) SELECT event, count(*) AS freq FROM ( SELECT DISTINCT event, ts::date AS event_day FROM logs ) AS d GROUP BY event ORDER BY freq DESC"`; + +exports[`Pretty Misc SQL formatting should format misc-5: Nested CTEs with type casts and subqueries (pretty) 1`] = ` +"WITH +logs AS (SELECT + id, + CAST(payload AS pg_catalog.json) ->> 'event' AS event, + CAST(CAST(payload AS pg_catalog.json) ->> 'ts' AS pg_catalog.timestamp) AS ts +FROM event_log +WHERE + ts > (now() - '7 days'::interval)) +SELECT + event, + count(*) AS freq +FROM ( SELECT DISTINCT + event, + ts::date AS event_day +FROM logs ) AS d +GROUP BY + event +ORDER BY + freq DESC" +`; + +exports[`Pretty Misc SQL formatting should format misc-6: Complex multi-table joins with nested conditions (non-pretty) 1`] = `"SELECT o.id AS order_id, u.name AS user_name, p.name AS product_name, s.status, sh.shipped_at, r.refund_amount FROM orders AS o JOIN users AS u ON o.user_id = u.id JOIN order_items AS oi ON oi.order_id = o.id JOIN products AS p ON (p.id = oi.product_id AND p.available = true) OR (p.sku = oi.product_sku AND (p.discontinued = false OR p.replacement_id IS NOT NULL)) LEFT JOIN shipping AS sh ON sh.order_id = o.id AND ((sh.carrier = 'UPS' AND sh.tracking_number IS NOT NULL) OR (sh.carrier = 'FedEx' AND sh.shipped_at > (o.created_at + '1 day'::interval))) LEFT JOIN statuses AS s ON s.id = o.status_id AND (s.name <> 'cancelled' OR (s.name = 'cancelled' AND s.updated_at > (now() - '7 days'::interval))) LEFT JOIN refunds AS r ON r.order_id = o.id AND ((r.status = 'approved' AND r.processed_at IS NOT NULL) OR (r.status = 'pending' AND r.requested_at < (now() - '14 days'::interval))) WHERE o.created_at > (now() - '90 days'::interval) AND u.active = true AND (s.status = 'shipped' OR (s.status = 'processing' AND EXISTS (SELECT 1 FROM order_notes AS n WHERE (n.order_id = o.id AND n.note ILIKE '%expedite%')))) ORDER BY o.created_at DESC"`; + +exports[`Pretty Misc SQL formatting should format misc-6: Complex multi-table joins with nested conditions (pretty) 1`] = ` +"SELECT + o.id AS order_id, + u.name AS user_name, + p.name AS product_name, + s.status, + sh.shipped_at, + r.refund_amount +FROM orders AS o +JOIN users AS u ON o.user_id = u.id +JOIN order_items AS oi ON oi.order_id = o.id +JOIN products AS p ON + (p.id = oi.product_id + AND p.available = true) + OR (p.sku = oi.product_sku + AND (p.discontinued = false + OR p.replacement_id IS NOT NULL)) +LEFT JOIN shipping AS sh ON sh.order_id = o.id + AND ((sh.carrier = 'UPS' + AND sh.tracking_number IS NOT NULL) + OR (sh.carrier = 'FedEx' + AND sh.shipped_at > (o.created_at + '1 day'::interval))) +LEFT JOIN statuses AS s ON s.id = o.status_id + AND (s.name <> 'cancelled' + OR (s.name = 'cancelled' + AND s.updated_at > (now() - '7 days'::interval))) +LEFT JOIN refunds AS r ON r.order_id = o.id + AND ((r.status = 'approved' + AND r.processed_at IS NOT NULL) + OR (r.status = 'pending' + AND r.requested_at < (now() - '14 days'::interval))) +WHERE + o.created_at > (now() - '90 days'::interval) + AND u.active = true + AND (s.status = 'shipped' + OR (s.status = 'processing' + AND EXISTS (SELECT + 1 +FROM order_notes AS n +WHERE + (n.order_id = o.id + AND n.note ILIKE '%expedite%')))) +ORDER BY + o.created_at DESC" +`; diff --git a/packages/deparser/__tests__/pretty/misc-pretty.test.ts b/packages/deparser/__tests__/pretty/misc-pretty.test.ts new file mode 100644 index 00000000..734d9e96 --- /dev/null +++ b/packages/deparser/__tests__/pretty/misc-pretty.test.ts @@ -0,0 +1,180 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +import { expectParseDeparse } from '../../test-utils'; + +describe('Pretty Misc SQL formatting', () => { + const misc1Sql = `WITH recent_orders AS ( + SELECT o.id, o.user_id, o.created_at + FROM orders o + WHERE o.created_at > NOW() - INTERVAL '30 days' +), high_value_orders AS ( + SELECT r.user_id, COUNT(*) AS order_count, SUM(oi.price * oi.quantity) AS total_spent + FROM recent_orders r + JOIN order_items oi ON r.id = oi.order_id + GROUP BY r.user_id +) +SELECT u.id, u.name, h.total_spent +FROM users u +JOIN high_value_orders h ON u.id = h.user_id +WHERE h.total_spent > 1000 +ORDER BY h.total_spent DESC`; + + const misc2Sql = `SELECT + department, + employee_id, + COUNT(*) FILTER (WHERE status = 'active') OVER (PARTITION BY department) AS active_count, + RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank +FROM employee_status +GROUP BY GROUPING SETS ((department), (department, employee_id))`; + + const misc3Sql = `SELECT u.id, u.name, j.key, j.value +FROM users u, +LATERAL jsonb_each_text(u.preferences) AS j(key, value) +WHERE j.key LIKE 'notif_%' AND j.value::boolean = true`; + + const misc4Sql = `SELECT p.id, p.title, + CASE + WHEN EXISTS ( + SELECT 1 FROM reviews r + WHERE r.product_id = p.id AND r.rating >= 4 + ) THEN 'Popular' + ELSE 'Unrated' + END AS status +FROM products p +WHERE p.archived = false`; + + const misc5Sql = `WITH logs AS ( + SELECT id, payload::json->>'event' AS event, (payload::json->>'ts')::timestamp AS ts + FROM event_log + WHERE ts > NOW() - INTERVAL '7 days' +) +SELECT event, COUNT(*) AS freq +FROM ( + SELECT DISTINCT event, ts::date AS event_day + FROM logs +) d +GROUP BY event +ORDER BY freq DESC`; + + const misc6Sql = `SELECT + o.id AS order_id, + u.name AS user_name, + p.name AS product_name, + s.status, + sh.shipped_at, + r.refund_amount +FROM orders o +JOIN users u + ON o.user_id = u.id +JOIN order_items oi + ON oi.order_id = o.id +JOIN products p + ON ( + (p.id = oi.product_id AND p.available = true) + OR + (p.sku = oi.product_sku AND (p.discontinued = false OR p.replacement_id IS NOT NULL)) + ) +LEFT JOIN shipping sh + ON ( + sh.order_id = o.id + AND ( + (sh.carrier = 'UPS' AND sh.tracking_number IS NOT NULL) + OR + (sh.carrier = 'FedEx' AND sh.shipped_at > o.created_at + INTERVAL '1 day') + ) + ) +LEFT JOIN statuses s + ON s.id = o.status_id + AND ( + s.name != 'cancelled' + OR (s.name = 'cancelled' AND s.updated_at > NOW() - INTERVAL '7 days') + ) +LEFT JOIN refunds r + ON r.order_id = o.id + AND ( + (r.status = 'approved' AND r.processed_at IS NOT NULL) + OR + (r.status = 'pending' AND r.requested_at < NOW() - INTERVAL '14 days') + ) +WHERE o.created_at > NOW() - INTERVAL '90 days' + AND u.active = true + AND ( + s.status = 'shipped' + OR ( + s.status = 'processing' + AND EXISTS ( + SELECT 1 FROM order_notes n WHERE n.order_id = o.id AND n.note ILIKE '%expedite%' + ) + ) + ) +ORDER BY o.created_at DESC`; + + it('should format misc-1: Complex CTE with joins and aggregation (pretty)', async () => { + const result = await expectParseDeparse(misc1Sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-1: Complex CTE with joins and aggregation (non-pretty)', async () => { + const result = await expectParseDeparse(misc1Sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-2: Window functions with FILTER and GROUPING SETS (pretty)', async () => { + const result = await expectParseDeparse(misc2Sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-2: Window functions with FILTER and GROUPING SETS (non-pretty)', async () => { + const result = await expectParseDeparse(misc2Sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-3: LATERAL joins with JSON functions (pretty)', async () => { + const result = await expectParseDeparse(misc3Sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-3: LATERAL joins with JSON functions (non-pretty)', async () => { + const result = await expectParseDeparse(misc3Sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-4: EXISTS with nested subqueries and CASE (pretty)', async () => { + const result = await expectParseDeparse(misc4Sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-4: EXISTS with nested subqueries and CASE (non-pretty)', async () => { + const result = await expectParseDeparse(misc4Sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-5: Nested CTEs with type casts and subqueries (pretty)', async () => { + const result = await expectParseDeparse(misc5Sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-5: Nested CTEs with type casts and subqueries (non-pretty)', async () => { + const result = await expectParseDeparse(misc5Sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-6: Complex multi-table joins with nested conditions (pretty)', async () => { + const result = await expectParseDeparse(misc6Sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it('should format misc-6: Complex multi-table joins with nested conditions (non-pretty)', async () => { + const result = await expectParseDeparse(misc6Sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + + it('should validate AST equivalence for all misc cases', async () => { + const testCases = [misc1Sql, misc2Sql, misc3Sql, misc4Sql, misc5Sql, misc6Sql]; + + for (const sql of testCases) { + await expectParseDeparse(sql, { pretty: false }); + await expectParseDeparse(sql, { pretty: true }); + } + }); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 4fe7f806..169d74c1 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -993,14 +993,15 @@ export class Deparser implements DeparserVisitor { if (node.ctes && node.ctes.length > 0) { const ctes = ListUtils.unwrapList(node.ctes); if (this.formatter.isPretty()) { - const cteStrings = ctes.map(cte => { + const cteStrings = ctes.map((cte, index) => { const cteStr = this.visit(cte, context); + const prefix = index === 0 ? this.formatter.newline() : ',' + this.formatter.newline(); if (this.containsMultilineStringLiteral(cteStr)) { - return this.formatter.newline() + cteStr; + return prefix + cteStr; } - return this.formatter.newline() + this.formatter.indent(cteStr); + return prefix + this.formatter.indent(cteStr); }); - output.push(cteStrings.join(',')); + output.push(cteStrings.join('')); } else { const cteStrings = ctes.map(cte => this.visit(cte, context)); output.push(cteStrings.join(', ')); @@ -1333,7 +1334,12 @@ export class Deparser implements DeparserVisitor { } if (windowParts.length > 0) { - result += ` OVER (${windowParts.join(' ')})`; + if (this.formatter.isPretty() && windowParts.length > 1) { + const formattedParts = windowParts.map(part => this.formatter.indent(part)); + result += ` OVER (${this.formatter.newline()}${formattedParts.join(this.formatter.newline())}${this.formatter.newline()})`; + } else { + result += ` OVER (${windowParts.join(' ')})`; + } } else { result += ` OVER ()`; } @@ -1977,17 +1983,41 @@ export class Deparser implements DeparserVisitor { } const args = ListUtils.unwrapList(node.args); - for (const arg of args) { - output.push(this.visit(arg, context)); - } + + if (this.formatter.isPretty() && args.length > 0) { + for (const arg of args) { + const whenClause = this.visit(arg, context); + if (this.containsMultilineStringLiteral(whenClause)) { + output.push(this.formatter.newline() + whenClause); + } else { + output.push(this.formatter.newline() + this.formatter.indent(whenClause)); + } + } - if (node.defresult) { - output.push('ELSE'); - output.push(this.visit(node.defresult, context)); - } + if (node.defresult) { + const elseResult = this.visit(node.defresult, context); + if (this.containsMultilineStringLiteral(elseResult)) { + output.push(this.formatter.newline() + 'ELSE ' + elseResult); + } else { + output.push(this.formatter.newline() + this.formatter.indent('ELSE ' + elseResult)); + } + } - output.push('END'); - return output.join(' '); + output.push(this.formatter.newline() + 'END'); + return output.join(' '); + } else { + for (const arg of args) { + output.push(this.visit(arg, context)); + } + + if (node.defresult) { + output.push('ELSE'); + output.push(this.visit(node.defresult, context)); + } + + output.push('END'); + return output.join(' '); + } } CoalesceExpr(node: t.CoalesceExpr, context: DeparserContext): string { @@ -3555,10 +3585,20 @@ export class Deparser implements DeparserVisitor { output.push(`USING (${columnNames.join(', ')})`); } } else if (node.quals) { + const qualsStr = this.visit(node.quals, context); if (this.formatter.isPretty()) { - output.push(` ON ${this.visit(node.quals, context)}`); + // For complex JOIN conditions, format with proper indentation + if (qualsStr.includes('AND') || qualsStr.includes('OR') || qualsStr.length > 50) { + if (this.containsMultilineStringLiteral(qualsStr)) { + output.push(` ON ${qualsStr}`); + } else { + output.push(` ON${this.formatter.newline()}${this.formatter.indent(qualsStr)}`); + } + } else { + output.push(` ON ${qualsStr}`); + } } else { - output.push(`ON ${this.visit(node.quals, context)}`); + output.push(`ON ${qualsStr}`); } }