diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index dacf3324..016fa6e0 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -5,6 +5,19 @@ "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/misc-1.sql": "WITH recent_orders AS (\n SELECT o.id, o.user_id, o.created_at\n FROM orders o\n WHERE o.created_at > NOW() - INTERVAL '30 days'\n), high_value_orders AS (\n SELECT r.user_id, COUNT(*) AS order_count, SUM(oi.price * oi.quantity) AS total_spent\n FROM recent_orders r\n JOIN order_items oi ON r.id = oi.order_id\n GROUP BY r.user_id\n)\nSELECT u.id, u.name, h.total_spent\nFROM users u\nJOIN high_value_orders h ON u.id = h.user_id\nWHERE h.total_spent > 1000\nORDER BY h.total_spent DESC", + "pretty/misc-2.sql": "SELECT\n department,\n employee_id,\n COUNT(*) FILTER (WHERE status = 'active') OVER (PARTITION BY department) AS active_count,\n RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS salary_rank\nFROM employee_status\nGROUP BY GROUPING SETS ((department), (department, employee_id))", + "pretty/misc-3.sql": "SELECT u.id, u.name, j.key, j.value\nFROM users u,\nLATERAL jsonb_each_text(u.preferences) AS j(key, value)\nWHERE j.key LIKE 'notif_%' AND j.value::boolean = true", + "pretty/misc-4.sql": "SELECT p.id, p.title,\n CASE\n WHEN EXISTS (\n SELECT 1 FROM reviews r\n WHERE r.product_id = p.id AND r.rating >= 4\n ) THEN 'Popular'\n ELSE 'Unrated'\n END AS status\nFROM products p\nWHERE p.archived = false", + "pretty/misc-5.sql": "WITH logs AS (\n SELECT id, payload::json->>'event' AS event, (payload::json->>'ts')::timestamp AS ts\n FROM event_log\n WHERE ts > NOW() - INTERVAL '7 days'\n)\nSELECT event, COUNT(*) AS freq\nFROM (\n SELECT DISTINCT event, ts::date AS event_day\n FROM logs\n) d\nGROUP BY event\nORDER BY freq DESC", + "pretty/misc-6.sql": "SELECT \n o.id AS order_id,\n u.name AS user_name,\n p.name AS product_name,\n s.status,\n sh.shipped_at,\n r.refund_amount\nFROM orders o\nJOIN users u \n ON o.user_id = u.id\nJOIN order_items oi \n ON oi.order_id = o.id\nJOIN products p \n ON (\n (p.id = oi.product_id AND p.available = true)\n OR \n (p.sku = oi.product_sku AND (p.discontinued = false OR p.replacement_id IS NOT NULL))\n )\nLEFT JOIN shipping sh \n ON (\n sh.order_id = o.id \n AND (\n (sh.carrier = 'UPS' AND sh.tracking_number IS NOT NULL)\n OR \n (sh.carrier = 'FedEx' AND sh.shipped_at > o.created_at + INTERVAL '1 day')\n )\n )\nLEFT JOIN statuses s \n ON s.id = o.status_id \n AND (\n s.name != 'cancelled'\n OR (s.name = 'cancelled' AND s.updated_at > NOW() - INTERVAL '7 days')\n )\nLEFT JOIN refunds r \n ON r.order_id = o.id \n AND (\n (r.status = 'approved' AND r.processed_at IS NOT NULL)\n OR \n (r.status = 'pending' AND r.requested_at < NOW() - INTERVAL '14 days')\n )\nWHERE o.created_at > NOW() - INTERVAL '90 days'\n AND u.active = true\n AND (\n s.status = 'shipped' \n OR (\n s.status = 'processing' \n AND EXISTS (\n SELECT 1 FROM order_notes n WHERE n.order_id = o.id AND n.note ILIKE '%expedite%'\n )\n )\n )\nORDER BY o.created_at DESC", + "pretty/misc-7.sql": "select (CASE \nWHEN ( n = 2 ) THEN ARRAY[ 'month' ]\nWHEN ( n = 4 ) THEN ARRAY[ 'year' ]\nWHEN ( n = 6 ) THEN ARRAY[ 'year', 'month' ]\nWHEN ( n = 8 ) THEN ARRAY[ 'day' ]\nWHEN ( n = 1024 ) THEN ARRAY[ 'hour' ]\nWHEN ( n = 1032 ) THEN ARRAY[ 'day', 'hour' ]\nWHEN ( n = 2048 ) THEN ARRAY[ 'minute' ]\nWHEN ( n = 3072 ) THEN ARRAY[ 'hour', 'minute' ]\nWHEN ( n = 3080 ) THEN ARRAY[ 'day', 'minute' ]\nWHEN ( n = 4096 ) THEN ARRAY[ 'second' ]\nWHEN ( n = 6144 ) THEN ARRAY[ 'minute', 'second' ]\nWHEN ( n = 7168 ) THEN ARRAY[ 'hour', 'second' ]\nWHEN ( n = 7176 ) THEN ARRAY[ 'day', 'second' ]\nWHEN ( n = 32767 ) THEN ARRAY[]::text[]\nEND)", + "pretty/misc-8.sql": "SELECT (\n CASE \n WHEN n = 2 OR n = 3 THEN ARRAY['month', COALESCE(extra_label, 'unknown')]\n WHEN n IN (4, 5) THEN \n CASE \n WHEN is_leap_year THEN ARRAY['year', 'leap']\n ELSE ARRAY['year']\n END\n WHEN n = 6 THEN ARRAY['year', 'month', 'quarter']\n WHEN n = 8 THEN ARRAY['day', 'week', compute_label(n)]\n WHEN n = 1024 THEN ARRAY['hour', format('%s-hour', extra_label)]\n WHEN n = 1032 AND flag = true THEN ARRAY['day', 'hour', 'flagged']\n WHEN n BETWEEN 2048 AND 2049 THEN ARRAY['minute', 'tick']\n WHEN n = 3072 THEN ARRAY['hour', 'minute', current_setting('timezone')]\n WHEN n = 3080 THEN ARRAY['day', 'minute', to_char(now(), 'HH24:MI')]\n WHEN n IN (4096, 4097, 4098) THEN ARRAY['second', 'millisecond']\n WHEN n = 6144 THEN ARRAY['minute', 'second', CASE WHEN use_micro = true THEN 'microsecond' ELSE 'none' END]\n WHEN n = 7168 OR (n > 7170 AND n < 7180) THEN ARRAY['hour', 'second', 'buffered']\n WHEN n = 7176 THEN ARRAY['day', 'second', extra_info::text]\n WHEN n = 32767 THEN ARRAY[]::text[]\n ELSE ARRAY['undefined', 'unknown', 'fallback']\n END\n)", + "pretty/misc-9.sql": "SELECT \n user_id,\n (CASE \n WHEN EXISTS (SELECT 1 FROM logins WHERE logins.user_id = users.user_id AND success = false) \n THEN 'risky'\n ELSE 'safe'\n END) AS risk_status\nFROM users", + "pretty/misc-10.sql": "SELECT * \nFROM orders\nWHERE \n status = (CASE \n WHEN shipped_at IS NOT NULL THEN 'shipped'\n WHEN canceled_at IS NOT NULL THEN 'canceled'\n ELSE 'processing'\n END)", + "pretty/misc-11.sql": "SELECT *\nFROM users u,\nLATERAL (\n SELECT \n (CASE \n WHEN u.is_admin THEN 'admin_dashboard'\n ELSE 'user_dashboard'\n END) AS dashboard_view\n) AS derived", + "pretty/misc-12.sql": "SELECT \n id,\n (SELECT \n CASE \n WHEN COUNT(*) > 5 THEN 'frequent'\n ELSE 'occasional'\n END\n FROM purchases p WHERE p.user_id = u.id) AS purchase_freq\nFROM users u", + "pretty/misc-13.sql": "SELECT \n id,\n CASE \n WHEN rank() OVER (ORDER BY score DESC) = 1 THEN 'top'\n ELSE 'normal'\n END AS tier\nFROM players", "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)", diff --git a/__fixtures__/kitchen-sink/pretty/misc.sql b/__fixtures__/kitchen-sink/pretty/misc.sql new file mode 100644 index 00000000..876fd989 --- /dev/null +++ b/__fixtures__/kitchen-sink/pretty/misc.sql @@ -0,0 +1,214 @@ +-- 1. Deeply nested CTEs with joins and aggregation +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; + +-- 2. Complex SELECT with window functions, FILTER, and GROUPING SETS +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)); + +-- 3. Query with JSON accessors, lateral joins, and functions +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; + +-- 4. Use of EXISTS with nested correlated subqueries and CASE logic +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; + +-- 5. Inlined functions, CTEs, multi-level nesting with type casts +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; + +-- 6. A massive one for ya + +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; + +-- 7. A case + +select (CASE +WHEN ( n = 2 ) THEN ARRAY[ 'month' ] +WHEN ( n = 4 ) THEN ARRAY[ 'year' ] +WHEN ( n = 6 ) THEN ARRAY[ 'year', 'month' ] +WHEN ( n = 8 ) THEN ARRAY[ 'day' ] +WHEN ( n = 1024 ) THEN ARRAY[ 'hour' ] +WHEN ( n = 1032 ) THEN ARRAY[ 'day', 'hour' ] +WHEN ( n = 2048 ) THEN ARRAY[ 'minute' ] +WHEN ( n = 3072 ) THEN ARRAY[ 'hour', 'minute' ] +WHEN ( n = 3080 ) THEN ARRAY[ 'day', 'minute' ] +WHEN ( n = 4096 ) THEN ARRAY[ 'second' ] +WHEN ( n = 6144 ) THEN ARRAY[ 'minute', 'second' ] +WHEN ( n = 7168 ) THEN ARRAY[ 'hour', 'second' ] +WHEN ( n = 7176 ) THEN ARRAY[ 'day', 'second' ] +WHEN ( n = 32767 ) THEN ARRAY[]::text[] +END); + +-- 8. A case + +SELECT ( + CASE + WHEN n = 2 OR n = 3 THEN ARRAY['month', COALESCE(extra_label, 'unknown')] + WHEN n IN (4, 5) THEN + CASE + WHEN is_leap_year THEN ARRAY['year', 'leap'] + ELSE ARRAY['year'] + END + WHEN n = 6 THEN ARRAY['year', 'month', 'quarter'] + WHEN n = 8 THEN ARRAY['day', 'week', compute_label(n)] + WHEN n = 1024 THEN ARRAY['hour', format('%s-hour', extra_label)] + WHEN n = 1032 AND flag = true THEN ARRAY['day', 'hour', 'flagged'] + WHEN n BETWEEN 2048 AND 2049 THEN ARRAY['minute', 'tick'] + WHEN n = 3072 THEN ARRAY['hour', 'minute', current_setting('timezone')] + WHEN n = 3080 THEN ARRAY['day', 'minute', to_char(now(), 'HH24:MI')] + WHEN n IN (4096, 4097, 4098) THEN ARRAY['second', 'millisecond'] + WHEN n = 6144 THEN ARRAY['minute', 'second', CASE WHEN use_micro = true THEN 'microsecond' ELSE 'none' END] + WHEN n = 7168 OR (n > 7170 AND n < 7180) THEN ARRAY['hour', 'second', 'buffered'] + WHEN n = 7176 THEN ARRAY['day', 'second', extra_info::text] + WHEN n = 32767 THEN ARRAY[]::text[] + ELSE ARRAY['undefined', 'unknown', 'fallback'] + END +); + + +-- 9. A case with select + +SELECT + user_id, + (CASE + WHEN EXISTS (SELECT 1 FROM logins WHERE logins.user_id = users.user_id AND success = false) + THEN 'risky' + ELSE 'safe' + END) AS risk_status +FROM users; + +-- 10. A case in where clause + +SELECT * +FROM orders +WHERE + status = (CASE + WHEN shipped_at IS NOT NULL THEN 'shipped' + WHEN canceled_at IS NOT NULL THEN 'canceled' + ELSE 'processing' + END); + +-- 11. A case in lateral join + +SELECT * +FROM users u, +LATERAL ( + SELECT + (CASE + WHEN u.is_admin THEN 'admin_dashboard' + ELSE 'user_dashboard' + END) AS dashboard_view +) AS derived; + +-- 12. A CASE used inside a scalar subquery in SELECT + +SELECT + id, + (SELECT + CASE + WHEN COUNT(*) > 5 THEN 'frequent' + ELSE 'occasional' + END + FROM purchases p WHERE p.user_id = u.id) AS purchase_freq +FROM users u; + +-- 13. A case in window function + +SELECT + id, + CASE + WHEN rank() OVER (ORDER BY score DESC) = 1 THEN 'top' + ELSE 'normal' + END AS tier +FROM players; \ No newline at end of file diff --git a/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts b/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts new file mode 100644 index 00000000..8c93e54f --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/pretty-misc.test.ts @@ -0,0 +1,21 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('pretty-misc', async () => { + await fixtures.runFixtureTests([ + "pretty/misc-1.sql", + "pretty/misc-2.sql", + "pretty/misc-3.sql", + "pretty/misc-4.sql", + "pretty/misc-5.sql", + "pretty/misc-6.sql", + "pretty/misc-7.sql", + "pretty/misc-8.sql", + "pretty/misc-9.sql", + "pretty/misc-10.sql", + "pretty/misc-11.sql", + "pretty/misc-12.sql", + "pretty/misc-13.sql" +]); +}); 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..2e1359cd --- /dev/null +++ b/packages/deparser/__tests__/pretty/__snapshots__/misc-pretty.test.ts.snap @@ -0,0 +1,280 @@ +// 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" +`; + +exports[`Pretty Misc SQL formatting should format misc-7: Large Case Stmt (non-pretty) 1`] = `"SELECT CASE WHEN n = 2 THEN ARRAY['month'] WHEN n = 4 THEN ARRAY['year'] WHEN n = 6 THEN ARRAY['year', 'month'] WHEN n = 8 THEN ARRAY['day'] WHEN n = 1024 THEN ARRAY['hour'] WHEN n = 1032 THEN ARRAY['day', 'hour'] WHEN n = 2048 THEN ARRAY['minute'] WHEN n = 3072 THEN ARRAY['hour', 'minute'] WHEN n = 3080 THEN ARRAY['day', 'minute'] WHEN n = 4096 THEN ARRAY['second'] WHEN n = 6144 THEN ARRAY['minute', 'second'] WHEN n = 7168 THEN ARRAY['hour', 'second'] WHEN n = 7176 THEN ARRAY['day', 'second'] WHEN n = 32767 THEN CAST(ARRAY[] AS text[]) END"`; + +exports[`Pretty Misc SQL formatting should format misc-7: Large Case Stmt (pretty) 1`] = ` +"SELECT +CASE + WHEN n = 2 THEN ARRAY['month'] + WHEN n = 4 THEN ARRAY['year'] + WHEN n = 6 THEN ARRAY['year', 'month'] + WHEN n = 8 THEN ARRAY['day'] + WHEN n = 1024 THEN ARRAY['hour'] + WHEN n = 1032 THEN ARRAY['day', 'hour'] + WHEN n = 2048 THEN ARRAY['minute'] + WHEN n = 3072 THEN ARRAY['hour', 'minute'] + WHEN n = 3080 THEN ARRAY['day', 'minute'] + WHEN n = 4096 THEN ARRAY['second'] + WHEN n = 6144 THEN ARRAY['minute', 'second'] + WHEN n = 7168 THEN ARRAY['hour', 'second'] + WHEN n = 7176 THEN ARRAY['day', 'second'] + WHEN n = 32767 THEN CAST(ARRAY[] AS text[]) +END" +`; + +exports[`Pretty Misc SQL formatting should format misc-8: Large Case Stmt (non-pretty) 1`] = `"SELECT CASE WHEN n = 2 OR n = 3 THEN ARRAY['month', COALESCE(extra_label, 'unknown')] WHEN n IN (4, 5) THEN CASE WHEN is_leap_year THEN ARRAY['year', 'leap'] ELSE ARRAY['year'] END WHEN n = 6 THEN ARRAY['year', 'month', 'quarter'] WHEN n = 8 THEN ARRAY['day', 'week', compute_label(n)] WHEN n = 1024 THEN ARRAY['hour', format('%s-hour', extra_label)] WHEN n = 1032 AND flag = true THEN ARRAY['day', 'hour', 'flagged'] WHEN n BETWEEN 2048 AND 2049 THEN ARRAY['minute', 'tick'] WHEN n = 3072 THEN ARRAY['hour', 'minute', current_setting('timezone')] WHEN n = 3080 THEN ARRAY['day', 'minute', to_char(now(), 'HH24:MI')] WHEN n IN (4096, 4097, 4098) THEN ARRAY['second', 'millisecond'] WHEN n = 6144 THEN ARRAY['minute', 'second', CASE WHEN use_micro = true THEN 'microsecond' ELSE 'none' END] WHEN n = 7168 OR (n > 7170 AND n < 7180) THEN ARRAY['hour', 'second', 'buffered'] WHEN n = 7176 THEN ARRAY['day', 'second', CAST(extra_info AS text)] WHEN n = 32767 THEN CAST(ARRAY[] AS text[]) ELSE ARRAY['undefined', 'unknown', 'fallback'] END"`; + +exports[`Pretty Misc SQL formatting should format misc-8: Large Case Stmt (pretty) 1`] = ` +"SELECT +CASE + WHEN n = 2 + OR n = 3 THEN ARRAY['month', COALESCE(extra_label, 'unknown')] +WHEN n IN (4, 5) THEN CASE + WHEN is_leap_year THEN ARRAY['year', 'leap'] + ELSE ARRAY['year'] +END + WHEN n = 6 THEN ARRAY['year', 'month', 'quarter'] + WHEN n = 8 THEN ARRAY['day', 'week', compute_label(n)] + WHEN n = 1024 THEN ARRAY['hour', format('%s-hour', extra_label)] + WHEN n = 1032 + AND flag = true THEN ARRAY['day', 'hour', 'flagged'] + WHEN n BETWEEN 2048 AND 2049 THEN ARRAY['minute', 'tick'] + WHEN n = 3072 THEN ARRAY['hour', 'minute', current_setting('timezone')] + WHEN n = 3080 THEN ARRAY['day', 'minute', to_char(now(), 'HH24:MI')] + WHEN n IN (4096, 4097, 4098) THEN ARRAY['second', 'millisecond'] +WHEN n = 6144 THEN ARRAY['minute', 'second', CASE + WHEN use_micro = true THEN 'microsecond' + ELSE 'none' +END] + WHEN n = 7168 + OR (n > 7170 + AND n < 7180) THEN ARRAY['hour', 'second', 'buffered'] + WHEN n = 7176 THEN ARRAY['day', 'second', CAST(extra_info AS text)] + WHEN n = 32767 THEN CAST(ARRAY[] AS text[]) + ELSE ARRAY['undefined', 'unknown', 'fallback'] +END" +`; + +exports[`Pretty Misc SQL formatting should format misc-9: Large Case Stmt (non-pretty) 1`] = `"SELECT user_id, CASE WHEN EXISTS (SELECT 1 FROM logins WHERE logins.user_id = users.user_id AND success = false) THEN 'risky' ELSE 'safe' END AS risk_status FROM users"`; + +exports[`Pretty Misc SQL formatting should format misc-9: Large Case Stmt (pretty) 1`] = ` +"SELECT + user_id, +CASE + WHEN EXISTS (SELECT + 1 + FROM logins + WHERE + logins.user_id = users.user_id + AND success = false) THEN 'risky' + ELSE 'safe' +END AS risk_status +FROM users" +`; + +exports[`Pretty Misc SQL formatting should format misc-10: Where Clause (non-pretty) 1`] = `"SELECT * FROM orders WHERE status = (CASE WHEN shipped_at IS NOT NULL THEN 'shipped' WHEN canceled_at IS NOT NULL THEN 'canceled' ELSE 'processing' END)"`; + +exports[`Pretty Misc SQL formatting should format misc-10: Where Clause (pretty) 1`] = ` +"SELECT + * +FROM orders +WHERE + status = (CASE + WHEN shipped_at IS NOT NULL THEN 'shipped' + WHEN canceled_at IS NOT NULL THEN 'canceled' + ELSE 'processing' +END)" +`; + +exports[`Pretty Misc SQL formatting should format misc-11: Lateral Join (non-pretty) 1`] = `"SELECT * FROM users AS u, LATERAL ( SELECT CASE WHEN u.is_admin THEN 'admin_dashboard' ELSE 'user_dashboard' END AS dashboard_view ) AS derived"`; + +exports[`Pretty Misc SQL formatting should format misc-11: Lateral Join (pretty) 1`] = ` +"SELECT + * +FROM users AS u, LATERAL ( SELECT +CASE + WHEN u.is_admin THEN 'admin_dashboard' + ELSE 'user_dashboard' +END AS dashboard_view ) AS derived" +`; + +exports[`Pretty Misc SQL formatting should format misc-12: Scalar Subquery (non-pretty) 1`] = `"SELECT id, (SELECT CASE WHEN count(*) > 5 THEN 'frequent' ELSE 'occasional' END FROM purchases AS p WHERE p.user_id = u.id) AS purchase_freq FROM users AS u"`; + +exports[`Pretty Misc SQL formatting should format misc-12: Scalar Subquery (pretty) 1`] = ` +"SELECT + id, +(SELECT +CASE + WHEN count(*) > 5 THEN 'frequent' + ELSE 'occasional' +END +FROM purchases AS p +WHERE + p.user_id = u.id) AS purchase_freq +FROM users AS u" +`; + +exports[`Pretty Misc SQL formatting should format misc-13: Window Function (non-pretty) 1`] = `"SELECT id, CASE WHEN rank() OVER (ORDER BY score DESC) = 1 THEN 'top' ELSE 'normal' END AS tier FROM players"`; + +exports[`Pretty Misc SQL formatting should format misc-13: Window Function (pretty) 1`] = ` +"SELECT + id, +CASE + WHEN rank() OVER (ORDER BY score DESC) = 1 THEN 'top' + ELSE 'normal' +END AS tier +FROM players" +`; 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..fa366902 --- /dev/null +++ b/packages/deparser/__tests__/pretty/misc-pretty.test.ts @@ -0,0 +1,87 @@ +import { deparseSync } from '../../src'; +import { parse } from 'libpg-query'; +import { expectParseDeparse } from '../../test-utils'; + +const generateCoded = require('../../../../__fixtures__/generated/generated.json'); + +describe('Pretty Misc SQL formatting', () => { + const testCases = [ + { + key: 'pretty/misc-1.sql', + description: 'Complex CTE with joins and aggregation' + }, + { + key: 'pretty/misc-2.sql', + description: 'Window functions with FILTER and GROUPING SETS' + }, + { + key: 'pretty/misc-3.sql', + description: 'LATERAL joins with JSON functions' + }, + { + key: 'pretty/misc-4.sql', + description: 'EXISTS with nested subqueries and CASE' + }, + { + key: 'pretty/misc-5.sql', + description: 'Nested CTEs with type casts and subqueries' + }, + { + key: 'pretty/misc-6.sql', + description: 'Complex multi-table joins with nested conditions' + }, + { + key: 'pretty/misc-7.sql', + description: 'Large Case Stmt' + }, + { + key: 'pretty/misc-8.sql', + description: 'Large Case Stmt' + }, + { + key: 'pretty/misc-9.sql', + description: 'Large Case Stmt' + }, + { + key: 'pretty/misc-10.sql', + description: 'Where Clause' + }, + { + key: 'pretty/misc-11.sql', + description: 'Lateral Join' + }, + { + key: 'pretty/misc-12.sql', + description: 'Scalar Subquery' + }, + { + key: 'pretty/misc-13.sql', + description: 'Window Function' + } + ]; + + // Generate individual tests for each case and format type + testCases.forEach(({ key, description }, index) => { + const sql = generateCoded[key]; + const testName = `misc-${index + 1}`; + + it(`should format ${testName}: ${description} (pretty)`, async () => { + const result = await expectParseDeparse(sql, { pretty: true }); + expect(result).toMatchSnapshot(); + }); + + it(`should format ${testName}: ${description} (non-pretty)`, async () => { + const result = await expectParseDeparse(sql, { pretty: false }); + expect(result).toMatchSnapshot(); + }); + }); + + it('should validate AST equivalence for all misc cases', async () => { + const allSql = testCases.map(({ key }) => generateCoded[key]); + + for (const sql of allSql) { + 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}`); } }