Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ fn given_dialect_corpus_when_formatting_then_output_layout_and_refusals_are_stab
},
Departure {
sql: "SELECT STRUCT<STRING>((SELECT 'foo')).*",
formatted: Some("SELECT\n STRUCT<STRING> ((\n SELECT 'foo'\n )).*"),
formatted: Some(
"SELECT\n STRUCT<STRING> (\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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -131,6 +131,24 @@ 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: "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",
Expand Down
5 changes: 5 additions & 0 deletions crates/sqlquack-format/src/format/_helpers/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
Expand Down
113 changes: 110 additions & 3 deletions crates/sqlquack-format/src/format/_helpers/printed_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<word> BY`, such as GROUP BY.
Expand All @@ -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"];
Expand Down Expand Up @@ -452,6 +455,62 @@ 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 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, &row) in row_opener.iter().enumerate() {
if self.tokens[opener] != OPEN_PARENTHESIS || row {
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) {
Expand Down Expand Up @@ -891,17 +950,53 @@ 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 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)
|| self.break_before(line, &ARITHMETIC_OPERATORS, self.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. 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
&& self.partners[index].is_some_and(|closer| closer > line.last)
}) else {
return false;
};
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
.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);
Expand Down Expand Up @@ -1074,12 +1169,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<usize> = 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;
Expand Down
Loading
Loading