From 296a1520d97f302016c6d7faca6d7ac083fd47d9 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:44:46 +0000 Subject: [PATCH 1/2] feat(format): give parenthesised groups that span lines their own lines A group such as a nested CAST that spans lines no longer opens or closes on a line it shares with another group: CAST(CAST((... now puts each opener's inside on its own line, closed on its own line. A long line breaks at its loosest operators first (|| before + and - before *, /, // and %), and an operator that continues the one operand of a group keeps the group's indentation instead of one level more. Converted SQL gets the same layout; only whitespace changes, and every token keeps its case. 15 of the 1,066 format corpus entries change layout. --- .../profile/tests/test_format_corpus.rs | 4 +- .../duckdb/profile/tests/test_format_sql.rs | 11 +- .../src/format/_helpers/pipeline.rs | 5 + .../src/format/_helpers/printed_layout.rs | 104 +++++++++++++++++- .../format/tests/fixtures/format_corpus.json | 47 +++++--- .../src/analysis/tests/test_layout.rs | 22 ++-- website/public/index.html | 22 ++-- website/src/content/docs/docs/policy.mdx | 2 +- 8 files changed, 179 insertions(+), 38 deletions(-) diff --git a/crates/sqlquack-format/src/bigquery/profile/tests/test_format_corpus.rs b/crates/sqlquack-format/src/bigquery/profile/tests/test_format_corpus.rs index 68b506ba7..6e6600ca3 100644 --- a/crates/sqlquack-format/src/bigquery/profile/tests/test_format_corpus.rs +++ b/crates/sqlquack-format/src/bigquery/profile/tests/test_format_corpus.rs @@ -69,7 +69,9 @@ fn given_dialect_corpus_when_formatting_then_output_layout_and_refusals_are_stab }, Departure { sql: "SELECT STRUCT((SELECT 'foo')).*", - formatted: Some("SELECT\n STRUCT ((\n SELECT 'foo'\n )).*"), + formatted: Some( + "SELECT\n STRUCT (\n (\n SELECT 'foo'\n )\n ).*", + ), reason: "SQLClean's Polyglot printer put a space before `.*`; SQLQuack keeps the field access touching its operand", receipt: None, }, diff --git a/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs b/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs index b4fa6b7d7..683a22f4d 100644 --- a/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs +++ b/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs @@ -119,7 +119,7 @@ fn given_constructs_sqlquack_converts_to_when_formatting_then_they_are_laid_out( line_width: None, dialect: FormatDialect::DuckDB, expected_outcome: Ok( - "SELECT\n x IN ((\n (\n SELECT 1\n ) UNION ALL (\n SELECT 2\n )\n ) UNION ALL (\n SELECT 3\n ))\nFROM t", + "SELECT\n x IN (\n (\n (\n SELECT 1\n ) UNION ALL (\n SELECT 2\n )\n ) UNION ALL (\n SELECT 3\n )\n )\nFROM t", ), }, test_types::FormatSqlTestCase { @@ -131,6 +131,15 @@ fn given_constructs_sqlquack_converts_to_when_formatting_then_they_are_laid_out( "SELECT\n SUM(amount) OVER (\n PARTITION BY customer_id, region\n ORDER BY ordered_at\n ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW\n ) AS running,\n SUM(x) OVER (ORDER BY y) AS s\nFROM orders", ), }, + test_types::FormatSqlTestCase { + description: "nested casts that span lines each open and close on their own lines, and a long operand breaks at `+` before `*`", + sql: "select cast(cast((total_amount * 100000000 + adjustment * 100000000) // 6 as decimal(38,0)) * 0.00000001 as decimal(18,8)) as third from t", + line_width: Some(50), + dialect: FormatDialect::DuckDB, + expected_outcome: Ok( + "SELECT\n CAST(\n CAST(\n (\n total_amount * 100000000\n + adjustment * 100000000\n )\n // 6 AS decimal(38, 0)\n )\n * 0.00000001 AS decimal(18, 8)\n ) AS third\nFROM t", + ), + }, test_types::FormatSqlTestCase { description: "a single-item GROUP BY or ORDER BY and a join's condition without AND or OR stay on their header line", sql: "select a from t left join u on (t.id = u.id) join v on v.id = t.id and v.k = 1 group by a order by a desc", diff --git a/crates/sqlquack-format/src/format/_helpers/pipeline.rs b/crates/sqlquack-format/src/format/_helpers/pipeline.rs index e950fb643..86ca13971 100644 --- a/crates/sqlquack-format/src/format/_helpers/pipeline.rs +++ b/crates/sqlquack-format/src/format/_helpers/pipeline.rs @@ -115,6 +115,11 @@ fn print_layout( if let Some(line_width) = options.line_width { wrap_lines(&mut sql, line_width); } + while sql.separate_multiline_groups() { + if let Some(line_width) = options.line_width { + wrap_lines(&mut sql, line_width); + } + } sql.lead_operators_of_multiline_operands(); Ok(AuthoredPrint { sql: keep_verbatim(authored, sql.render(), &oracle.verbatim, profile)?, diff --git a/crates/sqlquack-format/src/format/_helpers/printed_layout.rs b/crates/sqlquack-format/src/format/_helpers/printed_layout.rs index 50eda7e3f..602f9542f 100644 --- a/crates/sqlquack-format/src/format/_helpers/printed_layout.rs +++ b/crates/sqlquack-format/src/format/_helpers/printed_layout.rs @@ -16,6 +16,8 @@ const BETWEEN_SEPARATOR: &str = "AND"; const VALUES_KEYWORD: &str = "VALUES"; const DECODE_FUNCTION: &str = "DECODE"; const CASE_KEYWORD: &str = "CASE"; +const END_KEYWORD: &str = "END"; +const WINDOW_KEYWORD: &str = "OVER"; const JOIN_KEYWORD: &str = "JOIN"; const SINGLE_ITEM_CLAUSES: [&str; 4] = ["SELECT", "WHERE", "QUALIFY", "HAVING"]; /// Words whose clause header is ` BY`, such as GROUP BY. @@ -41,7 +43,8 @@ const CHAIN_KEYWORDS: [&str; 2] = ["AND", "OR"]; const BETWEEN_BOUNDARIES: [&str; 12] = [ "AND", "OR", "WHEN", "THEN", "ELSE", "WHERE", "ON", "HAVING", "QUALIFY", "SELECT", ",", "NOT", ]; -const ARITHMETIC_OPERATORS: [&str; 5] = ["+", "-", "||", "/", "%"]; +/// Binary operators a long line breaks before, loosest-binding first: a line breaks at the first tier it holds. +const OPERATOR_TIERS: [&[&str]; 3] = [&["||"], &["+", "-"], &["*", "/", "//", "%"]]; const WINDOW_CLAUSE_KEYWORDS: [&str; 4] = ["ORDER", "ROWS", "RANGE", "GROUPS"]; const CASE_BRANCH_KEYWORDS: [&str; 3] = ["WHEN", "THEN", "ELSE"]; const TYPE_PARAMETER_WORDS: [&str; 1] = ["MAX"]; @@ -452,6 +455,61 @@ impl PrintedSql<'_> { } } + /// Start the inside of each parenthesised group that spans lines on its own line and close it on its own line, so a group never opens or closes on the line of another; true when a gap changed. + pub(crate) fn separate_multiline_groups(&mut self) -> bool { + let rows: Vec = (0..self.len()) + .filter(|&index| self.is(index, VALUES_KEYWORD)) + .flat_map(|values| self.values_rows(values)) + .map(|(opener, _)| opener) + .collect(); + let mut changed = false; + for opener in 0..self.len() { + if self.tokens[opener] != OPEN_PARENTHESIS || rows.contains(&opener) { + continue; + } + let Some(closer) = self.partners[opener] else { + continue; + }; + if closer <= opener + 1 + || self.locked[opener + 1] + || !self.spans_lines(opener, closer) + || !self.gaps[opener + 1].trim().is_empty() + || !self.gaps[closer].trim().is_empty() + { + continue; + } + let opens = !self.gaps[opener + 1].contains('\n'); + let closes = !self.gaps[closer].contains('\n'); + if !opens && !closes { + continue; + } + let (first, _) = self.line_of(opener); + let indentation = self.indentation(first); + if opens { + self.indent_lines(opener + 2, closer - 1, self.indent); + let inner = newline(indentation + self.indent); + self.gaps[opener + 1] = inner.clone(); + let separates_arguments = opener > 0 + && !self.is(opener - 1, DECODE_FUNCTION) + && !self.is(opener - 1, WINDOW_KEYWORD); + for index in opener + 2..closer { + if separates_arguments + && self.depths[index] == self.depths[opener + 1] + && self.tokens[index - 1] == COMMA + && !self.gaps[index].contains('\n') + { + self.set_break(index, &inner); + } + } + } + if closes { + self.gaps[closer] = newline(indentation); + } + changed = true; + } + changed + } + /// Start the next line with any binary operator whose operand spans several lines. pub(crate) fn lead_operators_of_multiline_operands(&mut self) { for index in 1..self.len().saturating_sub(1) { @@ -894,14 +952,44 @@ impl PrintedSql<'_> { /// Apply the outermost applicable break: chains, VALUES rows, bracket groups, then commas. fn break_line(&mut self, line: &Line) -> bool { let values_row = self.tokens[line.first] == OPEN_PARENTHESIS && self.locked[line.first + 1]; + let operator_indent = if self.sole_operand_of_group(line) { + 0 + } else { + self.indent + }; !values_row && (self.break_before(line, &CHAIN_KEYWORDS, 0) - || self.break_before(line, &ARITHMETIC_OPERATORS, self.indent) + || OPERATOR_TIERS + .iter() + .any(|operators| self.break_before(line, operators, operator_indent)) || self.break_values_rows(line) || self.explode_group(line) || self.break_after_commas(line)) } + /// Whether `line` starts or continues with an operator the one operand of a parenthesised group whose inside starts on its own line, so an operator broken onto a new line cannot read as the start of another item. + fn sole_operand_of_group(&self, line: &Line) -> bool { + let Some(opener) = (0..line.first).rev().find(|&index| { + self.tokens[index] == OPEN_PARENTHESIS + && self.partners[index].is_some_and(|closer| closer > line.last) + }) else { + return false; + }; + let Some(closer) = self.partners[opener] else { + return false; + }; + let inner = self.depths[opener + 1]; + let continues_operand = line.first == opener + 1 + || OPERATOR_TIERS + .iter() + .any(|operators| operators.contains(&self.upper(line.first).as_str())); + continues_operand + && self.gaps[opener + 1].contains('\n') + && self.line_depth(line) == inner + && (opener + 1..closer) + .all(|index| self.depths[index] != inner || self.tokens[index] != COMMA) + } + /// Put each row of a VALUES list that starts this line on its own line. fn break_values_rows(&mut self, line: &Line) -> bool { let depth = self.line_depth(line); @@ -1074,12 +1162,24 @@ impl PrintedSql<'_> { fn break_before(&mut self, line: &Line, operators: &[&str], extra_indent: usize) -> bool { let depth = self.line_depth(line); let mut between = false; + let mut open_cases = 0_usize; let mut breaks: Vec = Vec::new(); for index in line.first..=line.last { if self.depths[index] != depth || self.locked[index] { continue; } let upper = self.upper(index); + if upper == CASE_KEYWORD { + open_cases += 1; + continue; + } + if upper == END_KEYWORD { + open_cases = open_cases.saturating_sub(1); + continue; + } + if open_cases > 0 { + continue; + } if upper == BETWEEN_KEYWORD { between = true; continue; diff --git a/crates/sqlquack-format/src/format/tests/fixtures/format_corpus.json b/crates/sqlquack-format/src/format/tests/fixtures/format_corpus.json index e1d48c234..b5459a763 100644 --- a/crates/sqlquack-format/src/format/tests/fixtures/format_corpus.json +++ b/crates/sqlquack-format/src/format/tests/fixtures/format_corpus.json @@ -289,12 +289,14 @@ { "dialect": "bigquery", "sql": "SELECT ARRAY((SELECT AS STRUCT 1 AS a, 2 AS b))", - "formatted": "SELECT\n ARRAY((\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ))" + "formatted": "SELECT\n ARRAY(\n (\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n )\n )", + "previously_formatted": "SELECT\n ARRAY((\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ))" }, { "dialect": "bigquery", "sql": "SELECT ARRAY((SELECT AS STRUCT 1 AS a, 2 AS b) LIMIT 10)", - "formatted": "SELECT\n ARRAY((\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ) LIMIT 10)" + "formatted": "SELECT\n ARRAY(\n (\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ) LIMIT 10\n )", + "previously_formatted": "SELECT\n ARRAY((\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ) LIMIT 10)" }, { "dialect": "bigquery", @@ -535,7 +537,8 @@ { "dialect": "bigquery", "sql": "SELECT * FROM ML.PREDICT(MODEL mydataset.mymodel, (SELECT label, column1, column2 FROM mydataset.mytable))", - "formatted": "SELECT *\nFROM ML.PREDICT(MODEL mydataset.mymodel, (\n SELECT\n label,\n column1,\n column2\n FROM mydataset.mytable\n))" + "formatted": "SELECT *\nFROM ML.PREDICT(\n MODEL mydataset.mymodel,\n (\n SELECT\n label,\n column1,\n column2\n FROM mydataset.mytable\n )\n)", + "previously_formatted": "SELECT *\nFROM ML.PREDICT(MODEL mydataset.mymodel, (\n SELECT\n label,\n column1,\n column2\n FROM mydataset.mytable\n))" }, { "dialect": "bigquery", @@ -555,7 +558,8 @@ { "dialect": "bigquery", "sql": "SELECT * FROM ML.PREDICT(MODEL `my_project`.my_dataset.my_model, (SELECT * FROM input_data))", - "formatted": "SELECT *\nFROM ML.PREDICT(MODEL `my_project`.my_dataset.my_model, (\n SELECT *\n FROM input_data\n))" + "formatted": "SELECT *\nFROM ML.PREDICT(\n MODEL `my_project`.my_dataset.my_model,\n (\n SELECT *\n FROM input_data\n )\n)", + "previously_formatted": "SELECT *\nFROM ML.PREDICT(MODEL `my_project`.my_dataset.my_model, (\n SELECT *\n FROM input_data\n))" }, { "dialect": "bigquery", @@ -570,7 +574,8 @@ { "dialect": "bigquery", "sql": "SELECT * FROM ML.FEATURES_AT_TIME((SELECT 1), num_rows => 1)", - "formatted": "SELECT *\nFROM ML.FEATURES_AT_TIME((\n SELECT 1\n), num_rows => 1)" + "formatted": "SELECT *\nFROM ML.FEATURES_AT_TIME(\n (\n SELECT 1\n ),\n num_rows => 1\n)", + "previously_formatted": "SELECT *\nFROM ML.FEATURES_AT_TIME((\n SELECT 1\n), num_rows => 1)" }, { "dialect": "bigquery", @@ -615,7 +620,8 @@ { "dialect": "bigquery", "sql": "SELECT * FROM ML.FORECAST(MODEL `mydataset.mymodel`, (SELECT * FROM mydataset.query_table), STRUCT())", - "formatted": "SELECT *\nFROM ML.FORECAST(MODEL `mydataset.mymodel`, (\n SELECT *\n FROM mydataset.query_table\n), STRUCT())" + "formatted": "SELECT *\nFROM ML.FORECAST(\n MODEL `mydataset.mymodel`,\n (\n SELECT *\n FROM mydataset.query_table\n ),\n STRUCT()\n)", + "previously_formatted": "SELECT *\nFROM ML.FORECAST(MODEL `mydataset.mymodel`, (\n SELECT *\n FROM mydataset.query_table\n), STRUCT())" }, { "dialect": "bigquery", @@ -695,7 +701,8 @@ { "dialect": "bigquery", "sql": "SELECT JSON_OBJECT(['a', 'b'], [STRUCT(10 AS id, 'Red' AS color), STRUCT(20 AS id, 'Blue' AS color)]) AS json_data", - "formatted": "SELECT\n JSON_OBJECT(['a', 'b'], [\n STRUCT(10 AS id, 'Red' AS color),\n STRUCT(20 AS id, 'Blue' AS color)\n ]) AS json_data" + "formatted": "SELECT\n JSON_OBJECT(\n ['a', 'b'],\n [\n STRUCT(10 AS id, 'Red' AS color),\n STRUCT(20 AS id, 'Blue' AS color)\n ]\n ) AS json_data", + "previously_formatted": "SELECT\n JSON_OBJECT(['a', 'b'], [\n STRUCT(10 AS id, 'Red' AS color),\n STRUCT(20 AS id, 'Blue' AS color)\n ]) AS json_data" }, { "dialect": "bigquery", @@ -705,7 +712,8 @@ { "dialect": "bigquery", "sql": "SELECT MOD((SELECT 1), 2)", - "formatted": "SELECT\n MOD((\n SELECT 1\n ), 2)" + "formatted": "SELECT\n MOD(\n (\n SELECT 1\n ),\n 2\n )", + "previously_formatted": "SELECT\n MOD((\n SELECT 1\n ), 2)" }, { "dialect": "bigquery", @@ -2042,7 +2050,8 @@ { "dialect": "duckdb", "sql": "\n SELECT\n l_returnflag,\n l_linestatus,\n sum_qty: sum(l_quantity),\n sum_base_price: sum(l_extendedprice),\n sum_disc_price: sum(l_extendedprice * (1-l_discount)),\n sum_charge: sum(l_extendedprice * (1-l_discount) * (1+l_tax)),\n avg_qty: avg(l_quantity),\n avg_price: avg(l_extendedprice),\n avg_disc: avg(l_discount),\n count_order: count(*)\n ", - "formatted": "SELECT\n l_returnflag,\n l_linestatus,\n sum_qty:sum(l_quantity),\n sum_base_price:sum(l_extendedprice),\n sum_disc_price:sum(l_extendedprice\n * (\n 1 - l_discount\n )),\n sum_charge:sum(l_extendedprice\n * (\n 1 - l_discount\n )\n * (\n 1 + l_tax\n )),\n avg_qty:avg(l_quantity),\n avg_price:avg(l_extendedprice),\n avg_disc:avg(l_discount),\n count_order:count(*)" + "formatted": "SELECT\n l_returnflag,\n l_linestatus,\n sum_qty:sum(l_quantity),\n sum_base_price:sum(l_extendedprice),\n sum_disc_price:sum(\n l_extendedprice\n * (\n 1 - l_discount\n )\n ),\n sum_charge:sum(\n l_extendedprice\n * (\n 1 - l_discount\n )\n * (\n 1 + l_tax\n )\n ),\n avg_qty:avg(l_quantity),\n avg_price:avg(l_extendedprice),\n avg_disc:avg(l_discount),\n count_order:count(*)", + "previously_formatted": "SELECT\n l_returnflag,\n l_linestatus,\n sum_qty:sum(l_quantity),\n sum_base_price:sum(l_extendedprice),\n sum_disc_price:sum(l_extendedprice\n * (\n 1 - l_discount\n )),\n sum_charge:sum(l_extendedprice\n * (\n 1 - l_discount\n )\n * (\n 1 + l_tax\n )),\n avg_qty:avg(l_quantity),\n avg_price:avg(l_extendedprice),\n avg_disc:avg(l_discount),\n count_order:count(*)" }, { "dialect": "duckdb", @@ -2586,8 +2595,8 @@ { "dialect": "postgres", "sql": "SELECT 1 FROM ((VALUES (1)) AS vals(id) LEFT OUTER JOIN tbl ON vals.id = tbl.id)", - "formatted": "SELECT 1\nFROM ((VALUES (1)) AS vals(id)\nLEFT OUTER JOIN tbl ON vals.id = tbl.id)", - "previously_formatted": "SELECT 1\nFROM ((VALUES (1)) AS vals(id)\nLEFT OUTER JOIN tbl\n ON vals.id = tbl.id)" + "formatted": "SELECT 1\nFROM (\n (VALUES (1)) AS vals(id)\n LEFT OUTER JOIN tbl ON vals.id = tbl.id\n)", + "previously_formatted": "SELECT 1\nFROM ((VALUES (1)) AS vals(id)\nLEFT OUTER JOIN tbl ON vals.id = tbl.id)" }, { "dialect": "postgres", @@ -2688,7 +2697,8 @@ { "dialect": "postgres", "sql": "SELECT TRIM(ARRAY_TO_STRING(ARRAY(SELECT val FROM UNNEST(ARRAY['a', 'b']) WITH ORDINALITY AS u(val, rn)), ' '))", - "formatted": "SELECT\n TRIM(ARRAY_TO_STRING(\n ARRAY(\n SELECT val\n FROM UNNEST(ARRAY['a', 'b']) WITH ORDINALITY AS u(val, rn)\n ),\n ' '\n ))" + "formatted": "SELECT\n TRIM(\n ARRAY_TO_STRING(\n ARRAY(\n SELECT val\n FROM UNNEST(ARRAY['a', 'b']) WITH ORDINALITY AS u(val, rn)\n ),\n ' '\n )\n )", + "previously_formatted": "SELECT\n TRIM(ARRAY_TO_STRING(\n ARRAY(\n SELECT val\n FROM UNNEST(ARRAY['a', 'b']) WITH ORDINALITY AS u(val, rn)\n ),\n ' '\n ))" }, { "dialect": "postgres", @@ -3986,8 +3996,8 @@ { "dialect": "snowflake", "sql": "SELECT * FROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (SELECT DISTINCT quarter FROM ad_campaign_types_by_quarter WHERE television = TRUE ORDER BY quarter)) ORDER BY empid", - "formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (SELECT DISTINCT\n quarter\nFROM ad_campaign_types_by_quarter\nWHERE television = TRUE\nORDER BY quarter))\nORDER BY empid", - "previously_formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (SELECT DISTINCT\n quarter\nFROM ad_campaign_types_by_quarter\nWHERE television = TRUE\nORDER BY\n quarter))\nORDER BY\n empid" + "formatted": "SELECT *\nFROM quarterly_sales PIVOT(\n SUM(amount) FOR quarter IN (\n SELECT DISTINCT\n quarter\n FROM ad_campaign_types_by_quarter\n WHERE television = TRUE\n ORDER BY quarter\n )\n)\nORDER BY empid", + "previously_formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (SELECT DISTINCT\n quarter\nFROM ad_campaign_types_by_quarter\nWHERE television = TRUE\nORDER BY quarter))\nORDER BY empid" }, { "dialect": "snowflake", @@ -4719,7 +4729,8 @@ { "dialect": "snowflake", "sql": "SELECT * FROM (TABLE('t1') CROSS JOIN TABLE('t2'))", - "formatted": "SELECT *\nFROM (TABLE('t1')\nCROSS JOIN TABLE('t2'))" + "formatted": "SELECT *\nFROM (\n TABLE('t1')\n CROSS JOIN TABLE('t2')\n)", + "previously_formatted": "SELECT *\nFROM (TABLE('t1')\nCROSS JOIN TABLE('t2'))" }, { "dialect": "snowflake", @@ -4749,7 +4760,8 @@ { "dialect": "snowflake", "sql": "SELECT value FROM TABLE(FLATTEN(input => SELECT PARSE_JSON('[1, 2]')))", - "formatted": "SELECT value\nFROM TABLE(FLATTEN(input => SELECT\n PARSE_JSON('[1, 2]')))" + "formatted": "SELECT value\nFROM TABLE(\n FLATTEN(\n input => SELECT\n PARSE_JSON('[1, 2]')\n )\n)", + "previously_formatted": "SELECT value\nFROM TABLE(FLATTEN(input => SELECT\n PARSE_JSON('[1, 2]')))" }, { "dialect": "snowflake", @@ -4914,7 +4926,8 @@ { "dialect": "snowflake", "sql": "SELECT * FROM TABLE(model_trained_with_labeled_data!DETECT_ANOMALIES(INPUT_DATA => TABLE(view_with_data_to_analyze), TIMESTAMP_COLNAME => 'date', TARGET_COLNAME => 'sales', CONFIG_OBJECT => OBJECT_CONSTRUCT('prediction_interval', 0.99)))", - "formatted": "SELECT *\nFROM TABLE(model_trained_with_labeled_data!DETECT_ANOMALIES(\n INPUT_DATA => TABLE(view_with_data_to_analyze),\n TIMESTAMP_COLNAME => 'date',\n TARGET_COLNAME => 'sales',\n CONFIG_OBJECT => OBJECT_CONSTRUCT('prediction_interval', 0.99)\n))" + "formatted": "SELECT *\nFROM TABLE(\n model_trained_with_labeled_data!DETECT_ANOMALIES(\n INPUT_DATA => TABLE(view_with_data_to_analyze),\n TIMESTAMP_COLNAME => 'date',\n TARGET_COLNAME => 'sales',\n CONFIG_OBJECT => OBJECT_CONSTRUCT('prediction_interval', 0.99)\n )\n)", + "previously_formatted": "SELECT *\nFROM TABLE(model_trained_with_labeled_data!DETECT_ANOMALIES(\n INPUT_DATA => TABLE(view_with_data_to_analyze),\n TIMESTAMP_COLNAME => 'date',\n TARGET_COLNAME => 'sales',\n CONFIG_OBJECT => OBJECT_CONSTRUCT('prediction_interval', 0.99)\n))" }, { "dialect": "snowflake", diff --git a/crates/sqlquack/src/analysis/tests/test_layout.rs b/crates/sqlquack/src/analysis/tests/test_layout.rs index fefb8fc4a..f2f775917 100644 --- a/crates/sqlquack/src/analysis/tests/test_layout.rs +++ b/crates/sqlquack/src/analysis/tests/test_layout.rs @@ -21,16 +21,22 @@ fn given_converted_sql_when_laid_out_then_long_lines_break_and_tokens_keep_their sql: r#"SELECT "ORDERS"."ORDER_ID" AS "ORDER_ID", CAST(CAST((sign(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38,2)) * 100000000 AS HUGEINT)) * ((2 * abs(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38,2)) * 100000000 AS HUGEINT)) + 3) // 6)) AS DECIMAL(38,0)) * 0.00000001 AS DECIMAL(18,8)) AS "THIRD" FROM "ORDERS" AS "ORDERS""#, expected_layout: r#"SELECT "ORDERS"."ORDER_ID" AS "ORDER_ID", - CAST(CAST(( - sign(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) - * ( + CAST( + CAST( ( - 2 * abs(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) + 3 - ) - // 6 + sign(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) + * ( + ( + 2 + * abs(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) + + 3 + ) + // 6 + ) + ) AS DECIMAL(38, 0) ) - ) AS DECIMAL(38, 0)) - * 0.00000001 AS DECIMAL(18, 8)) AS "THIRD" + * 0.00000001 AS DECIMAL(18, 8) + ) AS "THIRD" FROM "ORDERS" AS "ORDERS""#, }, LayoutTestCase { diff --git a/website/public/index.html b/website/public/index.html index e6dafb945..956fb46e6 100644 --- a/website/public/index.html +++ b/website/public/index.html @@ -159,16 +159,22 @@

DuckDB SQL that keeps Snowflake's meaning

sqlquack convert div.sql --no-config SELECT "ORDERS"."ORDER_ID" AS "ORDER_ID", - CAST(CAST(( - sign(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) - * ( + CAST( + CAST( ( - 2 * abs(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) + 3 - ) - // 6 + sign(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) + * ( + ( + 2 + * abs(CAST(CAST("ORDERS"."TOTAL" AS DECIMAL(38, 2)) * 100000000 AS HUGEINT)) + + 3 + ) + // 6 + ) + ) AS DECIMAL(38, 0) ) - ) AS DECIMAL(38, 0)) - * 0.00000001 AS DECIMAL(18, 8)) AS "THIRD" + * 0.00000001 AS DECIMAL(18, 8) + ) AS "THIRD" FROM "ORDERS" AS "ORDERS" sqlquack convert: done, converted diff --git a/website/src/content/docs/docs/policy.mdx b/website/src/content/docs/docs/policy.mdx index d6bb08aff..258404f05 100644 --- a/website/src/content/docs/docs/policy.mdx +++ b/website/src/content/docs/docs/policy.mdx @@ -106,7 +106,7 @@ SELECT "ORDERS"."ORDER_ID" AS "ORDER_ID", CAST( CAST(hash(CAST("ORDERS"."CUSTOMER_ID" AS HUGEINT)) AS HUGEINT) - - 9223372036854775808 AS DECIMAL(19, 0) + - 9223372036854775808 AS DECIMAL(19, 0) ) AS "BUCKET" FROM "ORDERS" AS "ORDERS" sqlquack convert: done, converted (1 known difference allowed: hash — sqlquack explain hash) From d3df6d55da49ec02803366416426bfef3f57809d Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:38:13 +0000 Subject: [PATCH 2/2] fix(format): keep the extra indent of a tighter operator under a looser one A line inside a group led by an operator of another tier, such as + c, kept the group's indentation when it broke again, so * d lined up under + c and read as (... + c) * d. Such a line now keeps the usual extra indent. A subquery's clauses and a window's PARTITION BY are no longer taken for a group's only operand. VALUES row openers are looked up in a mask instead of a list, so a long VALUES list no longer slows the layout down quadratically. The first commit also stops a long line from breaking before AND, OR or an arithmetic operator that sits between a CASE and its END on that line. --- .../duckdb/profile/tests/test_format_sql.rs | 9 ++++ .../src/format/_helpers/printed_layout.rs | 41 +++++++++++-------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs b/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs index 683a22f4d..77c1c9db0 100644 --- a/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs +++ b/crates/sqlquack-format/src/duckdb/profile/tests/test_format_sql.rs @@ -140,6 +140,15 @@ fn given_constructs_sqlquack_converts_to_when_formatting_then_they_are_laid_out( "SELECT\n CAST(\n CAST(\n (\n total_amount * 100000000\n + adjustment * 100000000\n )\n // 6 AS decimal(38, 0)\n )\n * 0.00000001 AS decimal(18, 8)\n ) AS third\nFROM t", ), }, + test_types::FormatSqlTestCase { + description: "inside a group, a tighter operator broken under a looser one keeps the extra indent that shows it binds first", + sql: "select (first_long_operand_name + second_long_operand_name + third_long_operand_name * fourth_long_operand_name) as v from t", + line_width: Some(40), + dialect: FormatDialect::DuckDB, + expected_outcome: Ok( + "SELECT\n (\n first_long_operand_name\n + second_long_operand_name\n + third_long_operand_name\n * fourth_long_operand_name\n ) AS v\nFROM t", + ), + }, test_types::FormatSqlTestCase { description: "a single-item GROUP BY or ORDER BY and a join's condition without AND or OR stay on their header line", sql: "select a from t left join u on (t.id = u.id) join v on v.id = t.id and v.k = 1 group by a order by a desc", diff --git a/crates/sqlquack-format/src/format/_helpers/printed_layout.rs b/crates/sqlquack-format/src/format/_helpers/printed_layout.rs index 602f9542f..718c79ec8 100644 --- a/crates/sqlquack-format/src/format/_helpers/printed_layout.rs +++ b/crates/sqlquack-format/src/format/_helpers/printed_layout.rs @@ -457,14 +457,15 @@ impl PrintedSql<'_> { /// Start the inside of each parenthesised group that spans lines on its own line and close it on its own line, so a group never opens or closes on the line of another; true when a gap changed. pub(crate) fn separate_multiline_groups(&mut self) -> bool { - let rows: Vec = (0..self.len()) - .filter(|&index| self.is(index, VALUES_KEYWORD)) - .flat_map(|values| self.values_rows(values)) - .map(|(opener, _)| opener) - .collect(); + let mut row_opener = vec![false; self.len()]; + for values in (0..self.len()).filter(|&index| self.is(index, VALUES_KEYWORD)) { + for (opener, _) in self.values_rows(values) { + row_opener[opener] = true; + } + } let mut changed = false; - for opener in 0..self.len() { - if self.tokens[opener] != OPEN_PARENTHESIS || rows.contains(&opener) { + for (opener, &row) in row_opener.iter().enumerate() { + if self.tokens[opener] != OPEN_PARENTHESIS || row { continue; } let Some(closer) = self.partners[opener] else { @@ -949,25 +950,26 @@ impl PrintedSql<'_> { parameters.then_some(closer) } - /// Apply the outermost applicable break: chains, VALUES rows, bracket groups, then commas. + /// Apply the outermost applicable break: chains, VALUES rows, bracket groups, then commas; an operator in a group's only operand takes the group's indentation unless the line starts with another tier's operator, so `+ c` then `* d` cannot read as `(… + c) * d`. fn break_line(&mut self, line: &Line) -> bool { let values_row = self.tokens[line.first] == OPEN_PARENTHESIS && self.locked[line.first + 1]; - let operator_indent = if self.sole_operand_of_group(line) { - 0 - } else { - self.indent - }; + let sole_operand = self.sole_operand_of_group(line); + let leading_tier = OPERATOR_TIERS + .iter() + .position(|operators| operators.contains(&self.upper(line.first).as_str())); + let indent = self.indent; !values_row && (self.break_before(line, &CHAIN_KEYWORDS, 0) - || OPERATOR_TIERS - .iter() - .any(|operators| self.break_before(line, operators, operator_indent)) + || OPERATOR_TIERS.iter().enumerate().any(|(tier, operators)| { + let at_group = sole_operand && leading_tier.is_none_or(|lead| lead == tier); + self.break_before(line, operators, if at_group { 0 } else { indent }) + }) || self.break_values_rows(line) || self.explode_group(line) || self.break_after_commas(line)) } - /// Whether `line` starts or continues with an operator the one operand of a parenthesised group whose inside starts on its own line, so an operator broken onto a new line cannot read as the start of another item. + /// Whether `line` starts or continues with an operator the one operand of a parenthesised group whose inside starts on its own line, so an operator broken onto a new line cannot read as the start of another item. A subquery's clauses and a window's PARTITION BY are never one operand. fn sole_operand_of_group(&self, line: &Line) -> bool { let Some(opener) = (0..line.first).rev().find(|&index| { self.tokens[index] == OPEN_PARENTHESIS @@ -978,6 +980,11 @@ impl PrintedSql<'_> { let Some(closer) = self.partners[opener] else { return false; }; + if self.kinds[opener + 1] == TokenType::Select + || (opener > 0 && self.is(opener - 1, WINDOW_KEYWORD)) + { + return false; + } let inner = self.depths[opener + 1]; let continues_operand = line.first == opener + 1 || OPERATOR_TIERS