diff --git a/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs b/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs new file mode 100644 index 000000000..179072238 --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs @@ -0,0 +1,246 @@ +//! Where lines break: before a token, and after one that asks for a break. + +use crate::layout::_helpers::plan::Plan; +use crate::layout::_helpers::tokens::{Token, TokenKind}; +use crate::layout::constants::{ + BY_CLAUSE_WORDS, CLAUSE_WORDS, INDENT, INLINE_WIDTH, JOIN_MODIFIERS, NON_CALL_WORDS, + SELECT_MODIFIERS, SET_OPERATION_MODIFIERS, SET_OPERATIONS, +}; +use crate::layout::models::{Case, Clause, Frame, Walk}; + +/// The walk over a statement, which breaks its lines when `broken`. +pub(crate) fn start(broken: bool) -> Walk { + Walk { + frames: vec![Frame { + query: true, + broken, + inline: !broken, + call: false, + base: 0, + close: 0, + clause: Clause::Other, + item_pending: false, + }], + cases: Vec::new(), + line_indent: 0, + forced: None, + laid_out: String::new(), + } +} + +impl Walk { + /// Appends `tokens[index]`, on a new line at `level` or after its own spacing. + pub(crate) fn push_token(&mut self, tokens: &[Token<'_>], index: usize, level: Option) { + let token = &tokens[index]; + match (index, level) { + (0, _) => {} + (_, Some(level)) => { + self.laid_out.push('\n'); + self.laid_out.push_str(&INDENT.repeat(level)); + self.line_indent = level; + } + (_, None) if token.spaced => self.laid_out.push(' '), + (_, None) => {} + } + self.laid_out.push_str(token.text); + } + + /// The indentation level of the new line `tokens[index]` starts, or `None` to keep its line. + pub(crate) fn line_break(&mut self, tokens: &[Token<'_>], index: usize) -> Option { + if let Some((_, level)) = self.forced.filter(|(at, _)| *at == index) { + return Some(level); + } + let token = &tokens[index]; + let depth = self.frames.len(); + let case = self.cases.last().filter(|case| case.depth == depth); + let frame = self.frames.last_mut()?; + if token.is_symbol(")") { + return (depth > 1 && frame.query && frame.broken).then_some(frame.close); + } + if frame.inline { + return None; + } + if let Some(level) = case.and_then(|case| case_break(token, case)) { + return Some(level); + } + if !frame.query || !frame.broken { + return None; + } + if frame.item_pending { + return frame.first_item_break(tokens, index); + } + (starts_clause(tokens, index) || is_set_operation(tokens, index)).then_some(frame.base) + } + + /// Opens or closes what `tokens[index]` opens or closes, and the break it asks for after it. + pub(crate) fn after_token( + &mut self, + tokens: &[Token<'_>], + index: usize, + plan: &Plan, + ) -> Option<(usize, usize)> { + let token = &tokens[index]; + match token { + token if token.is_symbol("(") => self.open_frame(tokens, index, plan), + token if token.is_symbol(")") => { + if self.frames.len() > 1 { + self.frames.pop(); + } + None + } + token if token.is("CASE") => { + let frame = self.frames.last()?; + let broken = !frame.inline + && !frame.call + && plan.width(index, plan.case_end(index)) > INLINE_WIDTH; + self.cases.push(Case { + broken, + indent: self.line_indent, + depth: self.frames.len(), + }); + None + } + token if token.is("END") => { + self.cases.pop(); + None + } + token if token.kind == TokenKind::LineComment => Some((index + 1, self.line_indent)), + _ => self.frames.last_mut()?.clause_break(tokens, index), + } + } + + /// Opens the frame of the parenthesis at `tokens[index]`, breaking inside a long query. + fn open_frame( + &mut self, + tokens: &[Token<'_>], + index: usize, + plan: &Plan, + ) -> Option<(usize, usize)> { + let frame = self.frames.last()?; + let query = plan.opens_query(tokens, index); + let broken = query && !frame.inline && plan.width(index, plan.close(index)) > INLINE_WIDTH; + let base = self.line_indent + 1; + let opened = Frame { + query, + broken, + inline: frame.inline || (query && !broken), + call: !query && (frame.call || calls(tokens, index)), + base, + close: self.line_indent, + clause: Clause::Other, + item_pending: false, + }; + self.frames.push(opened); + broken.then_some((index + 1, base)) + } +} + +impl Frame { + /// The line the select list's first item starts, after any `DISTINCT` or `DISTINCT ON (…)`. + fn first_item_break(&mut self, tokens: &[Token<'_>], index: usize) -> Option { + let token = &tokens[index]; + let modifier = SELECT_MODIFIERS.iter().any(|word| token.is(word)) + || (token.is_symbol("(") && tokens[index - 1].is("ON")); + if modifier { + return None; + } + self.item_pending = false; + Some(self.base + 1) + } + + /// The clause `tokens[index]` moves a broken query into, and the break its commas ask for. + fn clause_break(&mut self, tokens: &[Token<'_>], index: usize) -> Option<(usize, usize)> { + let token = &tokens[index]; + if !self.query || !self.broken { + return None; + } + match token { + token if token.is("SELECT") => { + (self.clause, self.item_pending) = (Clause::Select, true); + None + } + token if token.is("WITH") && self.clause == Clause::Other => { + self.clause = Clause::With; + None + } + token if token.is_symbol(",") => match self.clause { + Clause::Select => Some((index + 1, self.base + 1)), + Clause::With => Some((index + 1, self.base)), + Clause::Other => None, + }, + _ if is_set_operation(tokens, index) => { + self.clause = Clause::Other; + Some((after_modifiers(tokens, index), self.base)) + } + _ if starts_clause(tokens, index) => { + self.clause = Clause::Other; + None + } + _ => None, + } + } +} + +/// The level of the line `token` starts within `case`, when the `CASE` breaks. +fn case_break(token: &Token<'_>, case: &Case) -> Option { + match case.broken { + true if token.is("WHEN") || token.is("ELSE") => Some(case.indent + 1), + true if token.is("END") => Some(case.indent), + _ => None, + } +} + +/// The index of the first token after the set operation at `index` and its modifiers. +fn after_modifiers(tokens: &[Token<'_>], index: usize) -> usize { + (index + 1..tokens.len()) + .find(|at| !is_set_operation_modifier(&tokens[*at])) + .unwrap_or(tokens.len()) +} + +/// Whether `token` is `ALL`, `DISTINCT`, `BY` or `NAME` after a set operation. +fn is_set_operation_modifier(token: &Token<'_>) -> bool { + SET_OPERATION_MODIFIERS.iter().any(|word| token.is(word)) +} + +/// Whether `token` can come before `JOIN` in a join. +fn is_join_modifier(token: &Token<'_>) -> bool { + JOIN_MODIFIERS.iter().any(|word| token.is(word)) +} + +/// Whether `tokens[index]` starts a clause: a clause word, `GROUP BY`, `ORDER BY` or a join. +fn starts_clause(tokens: &[Token<'_>], index: usize) -> bool { + let token = &tokens[index]; + let previous = index.checked_sub(1).map(|at| &tokens[at]); + let next = tokens.get(index + 1); + if token.is("FROM") && previous.is_some_and(|previous| previous.is("DISTINCT")) { + return false; + } + let joins = (token.is("JOIN") || is_join_modifier(token)) + && !previous.is_some_and(is_join_modifier) + && tokens[index..] + .iter() + .find(|token| !is_join_modifier(token)) + .is_some_and(|token| token.is("JOIN")); + CLAUSE_WORDS.iter().any(|word| token.is(word)) + || (BY_CLAUSE_WORDS.iter().any(|word| token.is(word)) + && next.is_some_and(|next| next.is("BY"))) + || joins +} + +/// Whether `tokens[index]` is a set operation, not the `EXCEPT` of a `* EXCEPT (…)` column list. +fn is_set_operation(tokens: &[Token<'_>], index: usize) -> bool { + SET_OPERATIONS.iter().any(|word| tokens[index].is(word)) + && !(tokens[index].is("EXCEPT") && index > 0 && tokens[index - 1].is_symbol("*")) +} + +/// Whether the parenthesis at `tokens[index]` calls a function rather than grouping. +fn calls(tokens: &[Token<'_>], index: usize) -> bool { + let Some(previous) = index.checked_sub(1).map(|at| &tokens[at]) else { + return false; + }; + match previous.kind { + TokenKind::Word => !NON_CALL_WORDS.iter().any(|word| previous.is(word)), + TokenKind::Quoted => true, + _ => previous.is_symbol("]") || previous.is_symbol(")"), + } +} diff --git a/crates/sqlquack-duckdb/src/layout/_helpers/mod.rs b/crates/sqlquack-duckdb/src/layout/_helpers/mod.rs new file mode 100644 index 000000000..ad51c813c --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/_helpers/mod.rs @@ -0,0 +1,5 @@ +//! Tokens, the facts about them, and where lines break. + +pub(crate) mod breaks; +pub(crate) mod plan; +pub(crate) mod tokens; diff --git a/crates/sqlquack-duckdb/src/layout/_helpers/plan.rs b/crates/sqlquack-duckdb/src/layout/_helpers/plan.rs new file mode 100644 index 000000000..4ae876b27 --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/_helpers/plan.rs @@ -0,0 +1,83 @@ +//! What the layout knows before it starts: matching parentheses and `END`s, and widths. + +use crate::layout::_helpers::tokens::{Token, TokenKind}; +use crate::layout::constants::{QUERY_OPENERS, SET_OPERATIONS}; + +/// Facts about the tokens, each found in one pass over them. +pub(crate) struct Plan { + /// For each opening parenthesis, the index of its closing one. + closes: Vec, + /// For each `CASE`, the index of its `END`. + ends: Vec, + /// For each opening parenthesis, whether a set operation stands directly inside it. + set_operations: Vec, + /// Characters other than whitespace before each token, and in all of them at the end. + widths: Vec, +} + +impl Plan { + /// The facts about `tokens`; an unmatched parenthesis or `CASE` reaches the last token. + pub(crate) fn new(tokens: &[Token<'_>]) -> Self { + let last = tokens.len().saturating_sub(1); + let (mut closes, mut ends) = (vec![last; tokens.len()], vec![last; tokens.len()]); + let mut set_operations = vec![false; tokens.len()]; + let (mut parentheses, mut cases): (Vec, Vec) = (Vec::new(), Vec::new()); + let mut widths = Vec::with_capacity(tokens.len() + 1); + widths.push(0); + for (index, token) in tokens.iter().enumerate() { + widths.push(widths[index] + token.text.chars().count()); + match token { + token if token.is_symbol("(") => parentheses.push(index), + token if token.is_symbol(")") => { + if let Some(open) = parentheses.pop() { + closes[open] = index; + } + } + token if token.is("CASE") => cases.push(index), + token if token.is("END") => { + if let Some(open) = cases.pop() { + ends[open] = index; + } + } + token if SET_OPERATIONS.iter().any(|word| token.is(word)) => { + if let Some(open) = parentheses.last() { + set_operations[*open] = true; + } + } + _ => {} + } + } + Self { + closes, + ends, + set_operations, + widths, + } + } + + /// The index of the parenthesis closing the one at `open`. + pub(crate) fn close(&self, open: usize) -> usize { + self.closes[open] + } + + /// The index of the `END` closing the `CASE` at `open`. + pub(crate) fn case_end(&self, open: usize) -> usize { + self.ends[open] + } + + /// Characters other than whitespace from token `first` through token `last`. + pub(crate) fn width(&self, first: usize, last: usize) -> usize { + self.widths + .get(last + 1) + .map_or(0, |end| end.saturating_sub(self.widths[first])) + } + + /// Whether the parenthesis at `open` holds a query or a set operation of queries. + pub(crate) fn opens_query(&self, tokens: &[Token<'_>], open: usize) -> bool { + let first = tokens[open + 1..] + .iter() + .find(|token| !matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment)); + self.set_operations[open] + || first.is_some_and(|token| QUERY_OPENERS.iter().any(|word| token.is(word))) + } +} diff --git a/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs b/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs new file mode 100644 index 000000000..7e5235891 --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs @@ -0,0 +1,118 @@ +//! The tokens of DuckDB SQL, for a layout that changes only the whitespace between them. + +/// What a token is, as far as the layout needs to know. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TokenKind { + /// A bare word: a keyword, a function name or an unquoted name or number. + Word, + /// A string literal or a quoted name, kept whole. + Quoted, + /// A `--` comment, which ends its line. + LineComment, + /// A `/* */` comment. + BlockComment, + /// One character of punctuation or an operator. + Symbol, +} + +/// One token of the SQL and whether whitespace came before it. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Token<'sql> { + /// The token's text, exactly as written. + pub(crate) text: &'sql str, + /// What kind of token it is. + pub(crate) kind: TokenKind, + /// Whether whitespace separated it from the token before. + pub(crate) spaced: bool, +} + +impl Token<'_> { + /// Whether the token is the bare word `word`, in any case. + pub(crate) fn is(&self, word: &str) -> bool { + self.kind == TokenKind::Word && self.text.eq_ignore_ascii_case(word) + } + + /// Whether the token is the symbol `symbol`. + pub(crate) fn is_symbol(&self, symbol: &str) -> bool { + self.kind == TokenKind::Symbol && self.text == symbol + } +} + +/// The tokens of `sql`; strings, quoted names and comments stay single tokens. +pub(crate) fn tokens(sql: &str) -> Vec> { + let bytes = sql.as_bytes(); + let mut tokens: Vec> = Vec::new(); + let (mut index, mut spaced) = (0, false); + while let Some(&byte) = bytes.get(index) { + if byte.is_ascii_whitespace() { + spaced = true; + index += 1; + continue; + } + let (end, kind) = match (byte, bytes.get(index + 1).copied()) { + (b'\'' | b'"', _) => (quoted_end(bytes, index), TokenKind::Quoted), + (b'-', Some(b'-')) => (line_end(bytes, index), TokenKind::LineComment), + (b'/', Some(b'*')) => (comment_end(bytes, index), TokenKind::BlockComment), + (byte, _) if is_word_byte(byte) => (word_end(bytes, index), TokenKind::Word), + _ => (symbol_end(sql, index), TokenKind::Symbol), + }; + tokens.push(Token { + text: &sql[index..end], + kind, + spaced, + }); + (index, spaced) = (end, false); + } + tokens +} + +/// Whether `byte` continues a bare word; bytes of non-ASCII characters count as word bytes. +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || !byte.is_ascii() +} + +/// The index past the quoted text opening at `start`, where a doubled quote is part of the text. +fn quoted_end(bytes: &[u8], start: usize) -> usize { + let quote = bytes[start]; + let mut index = start + 1; + while let Some(&byte) = bytes.get(index) { + match (byte == quote, bytes.get(index + 1) == Some("e)) { + (true, true) => index += 2, + (true, false) => return index + 1, + (false, _) => index += 1, + } + } + index +} + +/// The index of the line break ending the `--` comment at `start`, or the end of the SQL. +fn line_end(bytes: &[u8], start: usize) -> usize { + bytes[start..] + .iter() + .position(|byte| *byte == b'\n') + .map_or(bytes.len(), |offset| start + offset) +} + +/// The index past the `*/` closing the comment at `start`, or the end of the SQL. +fn comment_end(bytes: &[u8], start: usize) -> usize { + bytes[start + 2..] + .windows(2) + .position(|pair| pair == b"*/") + .map_or(bytes.len(), |offset| start + 2 + offset + 2) +} + +/// The index past the bare word at `start`. +fn word_end(bytes: &[u8], start: usize) -> usize { + bytes[start..] + .iter() + .position(|byte| !is_word_byte(*byte)) + .map_or(bytes.len(), |offset| start + offset) +} + +/// The index past the one character at `start`. +fn symbol_end(sql: &str, start: usize) -> usize { + sql[start..] + .chars() + .next() + .map_or(sql.len(), |character| start + character.len_utf8()) +} diff --git a/crates/sqlquack-duckdb/src/layout/constants.rs b/crates/sqlquack-duckdb/src/layout/constants.rs new file mode 100644 index 000000000..110d453a2 --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/constants.rs @@ -0,0 +1,48 @@ +//! The layout's indentation, width and clause words. + +/// One level of indentation. +pub const INDENT: &str = " "; + +/// The longest query or `CASE` kept on one line, counted in characters other than whitespace. +pub const INLINE_WIDTH: usize = 60; + +/// Words that start a clause of a query on a new line. +pub const CLAUSE_WORDS: [&str; 8] = [ + "SELECT", "FROM", "WHERE", "HAVING", "QUALIFY", "WINDOW", "LIMIT", "OFFSET", +]; + +/// Words that open a query inside parentheses, besides a set operation at its level. +pub const QUERY_OPENERS: [&str; 3] = ["SELECT", "WITH", "VALUES"]; + +/// Words that start a clause when `BY` follows them. +pub const BY_CLAUSE_WORDS: [&str; 2] = ["GROUP", "ORDER"]; + +/// Set operations, which stand on a line of their own. +pub const SET_OPERATIONS: [&str; 3] = ["UNION", "INTERSECT", "EXCEPT"]; + +/// Words that can come between a set operation and the query after it. +pub const SET_OPERATION_MODIFIERS: [&str; 4] = ["ALL", "DISTINCT", "BY", "NAME"]; + +/// Words that can come before `JOIN` in a join. +pub const JOIN_MODIFIERS: [&str; 11] = [ + "LEFT", + "RIGHT", + "FULL", + "INNER", + "OUTER", + "CROSS", + "NATURAL", + "ASOF", + "POSITIONAL", + "SEMI", + "ANTI", +]; + +/// Words after which a parenthesis groups an expression rather than calling a function. +pub const NON_CALL_WORDS: [&str; 15] = [ + "SELECT", "WHERE", "AND", "OR", "NOT", "WHEN", "THEN", "ELSE", "IN", "ON", "AS", "HAVING", + "QUALIFY", "BY", "IS", +]; + +/// Words that can come between `SELECT` and its first item. +pub const SELECT_MODIFIERS: [&str; 3] = ["DISTINCT", "ALL", "ON"]; diff --git a/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs b/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs new file mode 100644 index 000000000..74892ef0c --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs @@ -0,0 +1,26 @@ +//! `checked_layout`: the layout of DuckDB SQL, checked to change only whitespace. + +use crate::layout::_helpers::tokens::tokens; +use crate::layout::main::layout_sql::layout_sql; + +/// `sql` laid out by [`layout_sql`], or why that changed more than whitespace or is not idempotent. +pub fn checked_layout(sql: &str) -> Result { + let laid_out = layout_sql(sql); + let texts = |sql: &str| -> Vec { + tokens(sql) + .iter() + .map(|token| token.text.to_owned()) + .collect() + }; + if texts(sql) != texts(&laid_out) { + return Err("its tokens differ from the single-line SQL's".to_owned()); + } + let stripped = |sql: &str| -> String { sql.chars().filter(|c| !c.is_whitespace()).collect() }; + if stripped(sql) != stripped(&laid_out) { + return Err("its characters other than whitespace differ".to_owned()); + } + if layout_sql(&laid_out) != laid_out { + return Err("laying it out again changes it".to_owned()); + } + Ok(laid_out) +} diff --git a/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs b/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs new file mode 100644 index 000000000..81a8b5a7d --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs @@ -0,0 +1,19 @@ +//! `layout_sql`: DuckDB SQL laid out over lines, with only the whitespace between tokens changed. + +use crate::layout::_helpers::breaks::start; +use crate::layout::_helpers::plan::Plan; +use crate::layout::_helpers::tokens::{Token, tokens}; +use crate::layout::constants::INLINE_WIDTH; + +/// `sql` one clause and select item per line, nested queries and long `CASE`s indented by depth. +pub fn layout_sql(sql: &str) -> String { + let tokens: Vec> = tokens(sql); + let plan = Plan::new(&tokens); + let mut walk = start(plan.width(0, tokens.len().saturating_sub(1)) > INLINE_WIDTH); + for index in 0..tokens.len() { + let level = walk.line_break(&tokens, index); + walk.push_token(&tokens, index, level); + walk.forced = walk.after_token(&tokens, index, &plan); + } + walk.laid_out +} diff --git a/crates/sqlquack-duckdb/src/layout/main/mod.rs b/crates/sqlquack-duckdb/src/layout/main/mod.rs new file mode 100644 index 000000000..78c00d85f --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/main/mod.rs @@ -0,0 +1,4 @@ +//! One building block per module. + +pub mod checked_layout; +pub mod layout_sql; diff --git a/crates/sqlquack-duckdb/src/layout/mod.rs b/crates/sqlquack-duckdb/src/layout/mod.rs new file mode 100644 index 000000000..062f5bfcc --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/mod.rs @@ -0,0 +1,9 @@ +//! A simple layout of DuckDB SQL over lines, changing only the whitespace between tokens. + +pub(crate) mod _helpers; +pub mod constants; +pub mod main; +pub(crate) mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlquack-duckdb/src/layout/models.rs b/crates/sqlquack-duckdb/src/layout/models.rs new file mode 100644 index 000000000..69e1e00b9 --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/models.rs @@ -0,0 +1,59 @@ +//! The state the layout keeps while it walks the tokens. + +/// The clause a query is in, which decides where its commas break. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Clause { + /// The select list: one item per line. + Select, + /// The common table expressions: one per line. + With, + /// Any other clause: commas stay inline. + Other, +} + +/// One pair of parentheses being laid out, or the statement itself. +#[derive(Debug)] +pub(crate) struct Frame { + /// Whether it holds a query rather than an expression. + pub(crate) query: bool, + /// Whether its lines break; a short query stays on one line. + pub(crate) broken: bool, + /// Whether nothing inside it may break, because an enclosing query stays on one line. + pub(crate) inline: bool, + /// Whether a function call encloses it within its query, so a `CASE` in it stays inline. + pub(crate) call: bool, + /// The indentation level of its clauses. + pub(crate) base: usize, + /// The indentation level of its closing parenthesis. + pub(crate) close: usize, + /// The clause it is in. + pub(crate) clause: Clause, + /// Whether the select list's first item is still to come. + pub(crate) item_pending: bool, +} + +/// One `CASE` being laid out. +#[derive(Debug)] +pub(crate) struct Case { + /// Whether it breaks at `WHEN`, `ELSE` and `END`. + pub(crate) broken: bool, + /// The indentation level of the line it starts on. + pub(crate) indent: usize, + /// How many frames were open where it starts. + pub(crate) depth: usize, +} + +/// Where the layout is: open frames and `CASE`s, the line's indentation and a pending break. +#[derive(Debug)] +pub(crate) struct Walk { + /// The open parentheses, outermost first, the statement's own frame at the bottom. + pub(crate) frames: Vec, + /// The open `CASE`s, outermost first. + pub(crate) cases: Vec, + /// The indentation level of the current line. + pub(crate) line_indent: usize, + /// A line break the previous token asked for: the token it comes before, and its level. + pub(crate) forced: Option<(usize, usize)>, + /// The SQL laid out so far. + pub(crate) laid_out: String, +} diff --git a/crates/sqlquack-duckdb/src/layout/tests.rs b/crates/sqlquack-duckdb/src/layout/tests.rs new file mode 100644 index 000000000..e08edccf2 --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_layout_sql.rs"] +mod test_layout_sql; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs b/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs new file mode 100644 index 000000000..41499469b --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs @@ -0,0 +1,74 @@ +use crate::layout::main::checked_layout::checked_layout; +use crate::layout::tests::test_types::LayoutTestCase; + +#[test] +fn given_converted_sql_when_laid_out_then_clauses_items_and_long_cases_break_by_depth() { + let test_cases = [ + LayoutTestCase { + description: "a short query stays on one line", + sql: r#"SELECT CAST(1 AS DECIMAL(1,0)) AS "1""#, + expected_layout: r#"SELECT CAST(1 AS DECIMAL(1,0)) AS "1""#, + }, + LayoutTestCase { + description: "a common table expression, a join, a long CASE and a short subquery", + sql: r#"WITH "X" AS (SELECT "O"."CUST" AS "CUST", sum("O"."AMT") AS "T" FROM "ORDERS" AS "O" GROUP BY "O"."CUST" HAVING (sum("O"."AMT") > 10)) SELECT "C"."NAME" AS "NAME", (CASE WHEN ("X"."T" > 100) THEN 'big' WHEN ("X"."T" > 50) THEN 'mid' ELSE 'small' END) AS "SZ" FROM "X" AS "X" LEFT JOIN "CUSTOMERS" AS "C" ON ("C"."NAME" = "X"."CUST") WHERE ("X"."T" IN (SELECT "O"."AMT" FROM "ORDERS" AS "O")) ORDER BY "X"."T" DESC NULLS FIRST LIMIT 5"#, + expected_layout: r#"WITH "X" AS ( + SELECT + "O"."CUST" AS "CUST", + sum("O"."AMT") AS "T" + FROM "ORDERS" AS "O" + GROUP BY "O"."CUST" + HAVING (sum("O"."AMT") > 10) +) +SELECT + "C"."NAME" AS "NAME", + (CASE + WHEN ("X"."T" > 100) THEN 'big' + WHEN ("X"."T" > 50) THEN 'mid' + ELSE 'small' + END) AS "SZ" +FROM "X" AS "X" +LEFT JOIN "CUSTOMERS" AS "C" ON ("C"."NAME" = "X"."CUST") +WHERE ("X"."T" IN (SELECT "O"."AMT" FROM "ORDERS" AS "O")) +ORDER BY "X"."T" DESC NULLS FIRST +LIMIT 5"#, + }, + LayoutTestCase { + description: "a set operation stands on its own line, and a short branch stays inline", + sql: r#"(SELECT "T"."ID" AS "ID", "T"."NAME" AS "NAME" FROM "T" AS "T" WHERE ("T"."ID" > 1)) UNION ALL (SELECT "U"."ID" AS "ID", "U"."NAME" AS "NAME" FROM "U" AS "U")"#, + expected_layout: r#"( + SELECT + "T"."ID" AS "ID", + "T"."NAME" AS "NAME" + FROM "T" AS "T" + WHERE ("T"."ID" > 1) +) +UNION ALL (SELECT "U"."ID" AS "ID", "U"."NAME" AS "NAME" FROM "U" AS "U")"#, + }, + LayoutTestCase { + description: "a long CASE inside a function call stays inline", + sql: r#"SELECT list_transform([1], lambda x: CASE WHEN abs(x) > 9007199254740992 THEN CAST('a long raise message' AS INTEGER) ELSE x END)[1] AS "E" FROM "T" AS "T""#, + expected_layout: r#"SELECT + list_transform([1], lambda x: CASE WHEN abs(x) > 9007199254740992 THEN CAST('a long raise message' AS INTEGER) ELSE x END)[1] AS "E" +FROM "T" AS "T""#, + }, + LayoutTestCase { + description: "keywords in strings, quoted names, IS DISTINCT FROM and * EXCEPT do not break", + sql: r#"SELECT DISTINCT * EXCEPT ("B"), 'x FROM y, z' AS "WHERE, FROM" FROM "T" AS "T" WHERE ("T"."A" IS DISTINCT FROM "T"."B")"#, + expected_layout: r#"SELECT DISTINCT + * EXCEPT ("B"), + 'x FROM y, z' AS "WHERE, FROM" +FROM "T" AS "T" +WHERE ("T"."A" IS DISTINCT FROM "T"."B")"#, + }, + ]; + for test_case in test_cases { + let laid_out = checked_layout(test_case.sql); + assert_eq!( + laid_out.as_deref(), + Ok(test_case.expected_layout), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-duckdb/src/layout/tests/test_types.rs b/crates/sqlquack-duckdb/src/layout/tests/test_types.rs new file mode 100644 index 000000000..820149f5d --- /dev/null +++ b/crates/sqlquack-duckdb/src/layout/tests/test_types.rs @@ -0,0 +1,6 @@ +/// Single-line SQL and its expected layout. +pub(crate) struct LayoutTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_layout: &'static str, +} diff --git a/crates/sqlquack-duckdb/src/lib.rs b/crates/sqlquack-duckdb/src/lib.rs index bbd8debec..e8dcfc7e3 100644 --- a/crates/sqlquack-duckdb/src/lib.rs +++ b/crates/sqlquack-duckdb/src/lib.rs @@ -9,6 +9,7 @@ pub mod checking; pub mod floats; pub mod hashing; pub mod held; +pub mod layout; pub mod raises; pub mod semi_structured; pub mod temporal; diff --git a/crates/sqlquack-evidence/src/verify/_helpers/judge.rs b/crates/sqlquack-evidence/src/verify/_helpers/judge.rs index 71ab602b4..59ff087a9 100644 --- a/crates/sqlquack-evidence/src/verify/_helpers/judge.rs +++ b/crates/sqlquack-evidence/src/verify/_helpers/judge.rs @@ -18,6 +18,7 @@ use crate::results::main::load_result::load_result; use crate::results::models::{RecordedOutcome, SessionDefaults, StoredResult}; use crate::verify::_helpers::compile_errors::undeclared_compile_error; use crate::verify::_helpers::error_check::error_check; +use crate::verify::_helpers::layout_check::{layout_failure, reads_clock}; use crate::verify::_helpers::microsecond_evidence::differs_below_microseconds; use crate::verify::_helpers::row_checks::{RowCheck, row_check}; use crate::verify::_helpers::session::{SessionCheck, session_check}; @@ -124,6 +125,12 @@ fn judge_stored( null_order: policy.comparator.null_order.duckdb_setting().to_owned(), }; let replayed = replay_on_duckdb((&setup, &duckdb_sql), python.as_deref(), &settings); + let layout = layout_failure( + &converter.laid_out(&duckdb_sql), + (&setup, &duckdb_sql), + (&replayed, reads_clock(provenance.divergences.keys())), + (python.as_deref(), &settings), + ); let duckdb_error = replayed.as_ref().err().cloned(); let verdict = match &case.expected_divergence { Some(divergence) => divergence_verdict( @@ -157,6 +164,10 @@ fn judge_stored( }, (verdict, _) => verdict, }; + let verdict = match layout { + Some(reason) => Verdict::Failed { reason }, + None => verdict, + }; let error_check = match (&verdict, &stored.metadata.error, duckdb_error) { (Verdict::Passed, Some(recorded), Some(error)) => Some(error_check( (recorded, &error), diff --git a/crates/sqlquack-evidence/src/verify/_helpers/layout_check.rs b/crates/sqlquack-evidence/src/verify/_helpers/layout_check.rs new file mode 100644 index 000000000..79ca8ee08 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/_helpers/layout_check.rs @@ -0,0 +1,53 @@ +//! The layout check: the laid-out SQL returns exactly what the single-line SQL returns. + +use sqlquack_compare::values::models::ResultSet; + +use crate::replay::main::replay_on_duckdb::replay_on_duckdb; +use crate::replay::models::{PythonReplay, ReplaySettings}; + +/// The divergence whose values read the clock, so two replays may differ by the time between them. +const CLOCK_DIVERGENCE: &str = "current_time"; + +/// Why the laid-out SQL returns another result or error than the single-line `sql`, if it does. +pub(crate) fn layout_failure( + laid_out: &Result, + (setup, sql): (&[String], &str), + (single_line, reads_clock): (&Result, bool), + (python, settings): (Option<&PythonReplay>, &ReplaySettings), +) -> Option { + let laid_out = match laid_out { + Ok(laid_out) if laid_out == sql => return None, + Ok(laid_out) => laid_out, + Err(reason) => return Some(format!("the layout changed more than whitespace: {reason}")), + }; + let replayed = replay_on_duckdb((setup, laid_out), python, settings); + let same = match (single_line, &replayed) { + (Ok(single_line), Ok(replayed)) => same_result(single_line, replayed, reads_clock), + (Err(single_line), Err(replayed)) => first_line(single_line) == first_line(replayed), + _ => false, + }; + (!same).then(|| { + format!( + "the laid-out SQL returned another result than the single-line SQL: {}", + match &replayed { + Ok(_) => "different rows or columns".to_owned(), + Err(error) => first_line(error).to_owned(), + } + ) + }) +} + +/// Whether the provenance's divergences include one whose values read the clock. +pub(crate) fn reads_clock<'name>(mut divergences: impl Iterator) -> bool { + divergences.any(|divergence| divergence == CLOCK_DIVERGENCE) +} + +/// Whether two results have the same schema and, unless they read the clock, the same rows. +fn same_result(left: &ResultSet, right: &ResultSet, reads_clock: bool) -> bool { + left.schema == right.schema && (reads_clock || left.batches == right.batches) +} + +/// An error's first line; later lines quote the SQL with its line numbers. +fn first_line(error: &str) -> &str { + error.lines().next().unwrap_or_default() +} diff --git a/crates/sqlquack-evidence/src/verify/_helpers/mod.rs b/crates/sqlquack-evidence/src/verify/_helpers/mod.rs index 99dc475bf..3913899e9 100644 --- a/crates/sqlquack-evidence/src/verify/_helpers/mod.rs +++ b/crates/sqlquack-evidence/src/verify/_helpers/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod claims; pub(crate) mod compile_errors; pub(crate) mod error_check; pub(crate) mod judge; +pub(crate) mod layout_check; pub(crate) mod messages; pub(crate) mod microsecond_evidence; pub(crate) mod row_checks; diff --git a/crates/sqlquack-evidence/src/verify/types.rs b/crates/sqlquack-evidence/src/verify/types.rs index 32ebb1dd1..2c90fd54f 100644 --- a/crates/sqlquack-evidence/src/verify/types.rs +++ b/crates/sqlquack-evidence/src/verify/types.rs @@ -11,6 +11,11 @@ use crate::verify::models::Conversion; pub trait CaseConverter: std::fmt::Debug { /// The conversion of `case`. fn convert(&self, case: &LoadedCase) -> Conversion; + + /// The converted `sql` as users see it, or why laying it out changed more than whitespace. + fn laid_out(&self, sql: &str) -> Result { + Ok(sql.to_owned()) + } } /// For each rule id, the failure modes it declares. diff --git a/crates/sqlquack-python/src/bindings/_helpers/convert_json.rs b/crates/sqlquack-python/src/bindings/_helpers/convert_json.rs index 62089b9d3..a885750a6 100644 --- a/crates/sqlquack-python/src/bindings/_helpers/convert_json.rs +++ b/crates/sqlquack-python/src/bindings/_helpers/convert_json.rs @@ -20,6 +20,7 @@ pub(crate) fn convert_json(sql: &str, request: &str, path: &str) -> PyResult, /// `--session NAME=VALUE`, by name. pub session: BTreeMap, + /// `--compact`. + pub compact: bool, } diff --git a/crates/sqlquack/src/analysis/models.rs b/crates/sqlquack/src/analysis/models.rs index b6a882ee4..f8d8e63cd 100644 --- a/crates/sqlquack/src/analysis/models.rs +++ b/crates/sqlquack/src/analysis/models.rs @@ -10,6 +10,7 @@ use sqlquack_core::parsing::models::SourceDialect; use sqlquack_core::provenance::main::merge_provenance::merge_provenance; use sqlquack_core::provenance::models::Provenance; use sqlquack_core::refusals::models::Refusal; +use sqlquack_duckdb::layout::main::checked_layout::checked_layout; use sqlquack_evidence::cases::models::LoadedCase; use sqlquack_evidence::constants::{INPUT_RELATION, ORDINAL_COLUMN}; use sqlquack_evidence::replay::models::{PythonFunction, PythonReplay}; @@ -164,6 +165,10 @@ impl CaseConverter for PipelineConverter { }, } } + + fn laid_out(&self, sql: &str) -> Result { + checked_layout(sql) + } } /// A Python function as SQLQuack's runtime registers it in DuckDB. diff --git a/crates/sqlquack/src/cli/_helpers/convert_command.rs b/crates/sqlquack/src/cli/_helpers/convert_command.rs index 853e50caf..e45c23f31 100644 --- a/crates/sqlquack/src/cli/_helpers/convert_command.rs +++ b/crates/sqlquack/src/cli/_helpers/convert_command.rs @@ -12,6 +12,7 @@ use sqlquack_core::diagnostics::main::unplaced_diagnostic::unplaced_diagnostic; use sqlquack_core::diagnostics::models::Diagnostic; use sqlquack_core::provenance::main::merge_provenance::merge_provenance; use sqlquack_core::refusals::models::Refusal; +use sqlquack_duckdb::layout::main::layout_sql::layout_sql; use sqlquack_policy::policy::constants::{SESSION_TIME_ZONE, TIMEZONE_PARAMETER}; use sqlquack_policy::policy::models::{DivergenceFailure, Policy}; use sqlquack_rules::conversion::models::ConversionOptions; @@ -253,10 +254,10 @@ pub(crate) fn chosen_options( ) } -/// The report of a finished analysis of `sql`. +/// The report of a finished analysis of `sql`, its SQL one clause per line unless `compact`. pub(crate) fn analysis_report( analysis: &Analysis, - sql: &str, + (sql, compact): (&str, bool), (schema, options, policy): (&Schema, &ConversionOptions, Option<&Policy>), (arguments, parameters, profile): (&PolicyArguments, &BTreeMap, String), ) -> Result { @@ -292,7 +293,10 @@ pub(crate) fn analysis_report( true => analysis .conversions .iter() - .map(|done| done.sql.clone()) + .map(|done| match compact { + true => done.sql.clone(), + false => layout_sql(&done.sql), + }) .collect::>() .join(";\n"), false => String::new(), diff --git a/crates/sqlquack/src/cli/main/convert_report.rs b/crates/sqlquack/src/cli/main/convert_report.rs index cb8a39389..50a1e4c00 100644 --- a/crates/sqlquack/src/cli/main/convert_report.rs +++ b/crates/sqlquack/src/cli/main/convert_report.rs @@ -46,7 +46,7 @@ pub fn convert_report(arguments: &ConvertArguments, sql: &str) -> Result, + /// Print each converted statement on one line instead of one clause per line, in `--json` too. + #[arg(long)] + pub compact: bool, /// How to print the report on standard output. #[command(flatten)] pub output: OutputArguments, @@ -369,7 +372,7 @@ pub struct ConvertReport { pub converted: bool, /// The session profile conversion ran under. pub profile: String, - /// The DuckDB SQL, one statement after another; empty when refused. + /// The DuckDB SQL, one clause per line unless `--compact`; empty when refused. pub sql: String, /// Each refusal. pub refusals: Vec, diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 700bc216c..e48288373 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -7,9 +7,9 @@ use crate::helpers::{ use crate::test_types::{ ColumnNameTestCase, ConvertDiagnosticTestCase, ConvertOutcome, ConvertTestCase, ConvertedRunTestCase, CountedCallsTestCase, CrossingTestCase, DedicatedOverrideTestCase, - DeepChainTestCase, DivergenceHelpTestCase, FailureKindTestCase, OutputFormatTestCase, - OverrideFailureTestCase, OverrideTestCase, PolicyFreeTestCase, PythonRefusalTestCase, - PythonRunTestCase, RunErrorTestCase, SchemaErrorTestCase, ValueTestCase, + DeepChainTestCase, DivergenceHelpTestCase, FailureKindTestCase, LayoutFlagsTestCase, + OutputFormatTestCase, OverrideFailureTestCase, OverrideTestCase, PolicyFreeTestCase, + PythonRefusalTestCase, PythonRunTestCase, RunErrorTestCase, SchemaErrorTestCase, ValueTestCase, }; use sha2::{Digest, Sha256}; @@ -1663,6 +1663,39 @@ fn given_refused_sql_when_converting_then_text_and_json_carry_the_same_coded_dia } } +#[test] +fn given_layout_flags_when_converting_then_clauses_break_unless_compact_in_text_and_json() { + let test_cases = [ + LayoutFlagsTestCase { + description: "each clause and select item on its own line, in text and in --json", + flags: &[], + expected_sql: "SELECT\n CAST(1 AS DECIMAL(1,0)) AS \"A\",\n CAST(2 AS DECIMAL(1,0)) AS \"B\",\n \ + 'some longer text here' AS \"C\"\nWHERE (CAST(1 AS DECIMAL(1,0)) = CAST(1 AS DECIMAL(1,0)))", + }, + LayoutFlagsTestCase { + description: "--compact prints the statement on one line, in text and in --json", + flags: &["--compact"], + expected_sql: "SELECT CAST(1 AS DECIMAL(1,0)) AS \"A\", CAST(2 AS DECIMAL(1,0)) AS \"B\", \ + 'some longer text here' AS \"C\" WHERE (CAST(1 AS DECIMAL(1,0)) = CAST(1 AS DECIMAL(1,0)))", + }, + ]; + let sql = "SELECT 1 AS a, 2 AS b, 'some longer text here' AS c WHERE 1 = 1"; + for test_case in test_cases { + let (text, _) = convert_streams(sql, &[&["--builtin-policy"], test_case.flags].concat()); + let (json, _) = convert_streams( + sql, + &[&["--builtin-policy", "--json"], test_case.flags].concat(), + ); + let report: serde_json::Value = serde_json::from_str(&json).expect("a JSON report"); + assert_eq!( + (text.trim_end(), report["sql"].as_str()), + (test_case.expected_sql, Some(test_case.expected_sql)), + "{}", + test_case.description + ); + } +} + #[test] fn given_output_flags_when_running_then_format_json_and_json_agree_and_help_lists_exit_status() { let test_cases = [ diff --git a/crates/sqlquack/tests/cli/test_types.rs b/crates/sqlquack/tests/cli/test_types.rs index da310088e..9f24907df 100644 --- a/crates/sqlquack/tests/cli/test_types.rs +++ b/crates/sqlquack/tests/cli/test_types.rs @@ -294,3 +294,10 @@ pub(crate) struct SchemaErrorTestCase { pub(crate) schema: &'static str, pub(crate) expected_error: &'static str, } + +/// Convert flags and the SQL convert prints under them, from standard output or `--json`'s `sql`. +pub(crate) struct LayoutFlagsTestCase { + pub(crate) description: &'static str, + pub(crate) flags: &'static [&'static str], + pub(crate) expected_sql: &'static str, +} diff --git a/python/src/sqlquack/api/main/convert.py b/python/src/sqlquack/api/main/convert.py index ee400b4e7..c01287c7d 100644 --- a/python/src/sqlquack/api/main/convert.py +++ b/python/src/sqlquack/api/main/convert.py @@ -28,6 +28,7 @@ def convert( "loud_divergences_only": chosen.loud_divergences_only, "overrides": path_argument(chosen.overrides), "verified_overrides": dict(chosen.verified_overrides), + "compact": chosen.compact, } result: dict[str, Any] = loaded(lambda: convert_json(sql, json.dumps(request), path)) report: dict[str, Any] = result["report"] diff --git a/python/src/sqlquack/api/models.py b/python/src/sqlquack/api/models.py index ef9d09ee8..e22727029 100644 --- a/python/src/sqlquack/api/models.py +++ b/python/src/sqlquack/api/models.py @@ -76,3 +76,4 @@ class ConvertOptions: loud_divergences_only: bool = False overrides: str | os.PathLike[str] | None = None verified_overrides: Mapping[str, str] = field(default_factory=dict) + compact: bool = False diff --git a/tests/unit/python/src/sqlquack/api/main/test_convert.py b/tests/unit/python/src/sqlquack/api/main/test_convert.py index 85dec139f..bd9ea93c5 100644 --- a/tests/unit/python/src/sqlquack/api/main/test_convert.py +++ b/tests/unit/python/src/sqlquack/api/main/test_convert.py @@ -31,6 +31,13 @@ options=ConvertOptions(builtin_policy=True, schema="schema.json"), expected_converted=True, ), + CommandLineTestCase( + description="a converted query on one line", + sql="SELECT order_id, price * 2 AS doubled FROM orders", + arguments=("--builtin-policy", "--schema", "schema.json", "--compact"), + options=ConvertOptions(builtin_policy=True, schema="schema.json", compact=True), + expected_converted=True, + ), CommandLineTestCase( description="no policy flag and no sqlquack.toml: the built-in policy", sql="SELECT order_id, price * 2 AS doubled FROM orders", diff --git a/website/public/playground/playground.js b/website/public/playground/playground.js index dc23822a1..c328cb7f8 100644 --- a/website/public/playground/playground.js +++ b/website/public/playground/playground.js @@ -191,7 +191,8 @@ async function run() { spans.push({ kind: refused ? 'warn' : 'error', span: diagnostic.span, text: tone(rendered(diagnostic, chars)) }); let body = ''; if (translate.status !== 'skipped') { - body = (translate.statements ?? []).map((s) => terminal(escape(s.sql))).join(''); + // Translated SQL keeps its line layout and scrolls sideways; diagnostics still wrap. + body = (translate.statements ?? []).map((s) => `
${escape(s.sql)}
`).join(''); body += (translate.diagnostics ?? []).map((d) => terminal(tone(rendered(d, chars)))).join(''); } const policy = translate.profile ? `policy: ${translate.profile}` : ''; diff --git a/website/public/styles.css b/website/public/styles.css index 0729c64ca..2bd7b4d25 100644 --- a/website/public/styles.css +++ b/website/public/styles.css @@ -1264,6 +1264,8 @@ footer a { padding: 0; border: 1px solid var(--inner-line); background: var(--panel); + /* A grid item: lets the translated SQL scroll inside the stage instead of widening the page. */ + min-width: 0; } .pg-stage header { @@ -1373,6 +1375,12 @@ footer a { overflow-wrap: anywhere; } +.pg-body .terminal.sql { + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; +} + .pg-body .terminal + .terminal { border-top-style: dashed; } diff --git a/website/src/content/docs/docs/cli/convert.mdx b/website/src/content/docs/docs/cli/convert.mdx index 6df8086bc..65d97b473 100644 --- a/website/src/content/docs/docs/cli/convert.mdx +++ b/website/src/content/docs/docs/cli/convert.mdx @@ -20,12 +20,18 @@ echo "SELECT 1 AS total" | sqlquack convert --builtin-policy --json ``` `PATH` is a SQL file. Without one, or with `-`, `convert` reads standard input. The DuckDB SQL goes -to standard output. Progress, the known divergences and assumptions it relies on, and refusals go -to standard error. +to standard output, laid out with each clause on its own line, select items one per line, and +nested queries and long `CASE` expressions indented; short statements stay on one line. Progress, +the known divergences and assumptions it relies on, and refusals go to standard error. ```console $ sqlquack convert revenue.sql --schema schema.json --builtin-policy -SELECT "ORDERS"."CUSTOMER_ID" AS "CUSTOMER_ID", CAST(sum("ORDERS"."TOTAL") AS DECIMAL(24,2)) AS "REVENUE" FROM "ORDERS" AS "ORDERS" WHERE ("ORDERS"."STATUS" = 'shipped') GROUP BY "ORDERS"."CUSTOMER_ID" +SELECT + "ORDERS"."CUSTOMER_ID" AS "CUSTOMER_ID", + CAST(sum("ORDERS"."TOTAL") AS DECIMAL(24,2)) AS "REVENUE" +FROM "ORDERS" AS "ORDERS" +WHERE ("ORDERS"."STATUS" = 'shipped') +GROUP BY "ORDERS"."CUSTOMER_ID" sqlquack convert: done, converted under the built-in default ``` @@ -56,6 +62,7 @@ SQL that does not parse, or that Snowflake rejects, is refused with the same cod | `--session NAME=VALUE` | A Snowflake session parameter of your session. Repeatable. See [Sessions](/docs/policy/#sessions). | | `--overrides ` | User overrides: a `FUNCTION()` file, or a directory of them, each with `replaces` and `dialect duckdb`. | | `--verified-override NAME=SHA256` | An override verified for the body with that hash, as `convert --json` reports. | +| `--compact` | Print each statement on one line instead, in the JSON report too. | | `--format text\|json`, `--json` | Print the report as text or JSON. | `convert` also takes the policy flags: `--policy`, `--builtin-policy`, `--strict`, @@ -82,7 +89,8 @@ With `--json`, `convert` prints one object: } ``` -A refusal in `refusals` carries the same [diagnostic fields](/docs/diagnostics/overview/#json-output). +`sql` is laid out as the text output prints it, or on one line with `--compact`. A refusal in +`refusals` carries the same [diagnostic fields](/docs/diagnostics/overview/#json-output). ## Exit status diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index 419a16653..f43d8ab34 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -26,7 +26,12 @@ Once the schema declares `ORDERS`, the same file converts: ```console $ sqlquack convert revenue.sql --builtin-policy --schema schema.json -SELECT "ORDERS"."CUSTOMER_ID" AS "CUSTOMER_ID", CAST(sum("ORDERS"."TOTAL") AS DECIMAL(24,2)) AS "REVENUE" FROM "ORDERS" AS "ORDERS" WHERE ("ORDERS"."STATUS" = 'shipped') GROUP BY "ORDERS"."CUSTOMER_ID" +SELECT + "ORDERS"."CUSTOMER_ID" AS "CUSTOMER_ID", + CAST(sum("ORDERS"."TOTAL") AS DECIMAL(24,2)) AS "REVENUE" +FROM "ORDERS" AS "ORDERS" +WHERE ("ORDERS"."STATUS" = 'shipped') +GROUP BY "ORDERS"."CUSTOMER_ID" sqlquack convert: done, converted under the built-in default ``` diff --git a/website/src/content/docs/docs/python.mdx b/website/src/content/docs/docs/python.mdx index 3af634699..636391ec4 100644 --- a/website/src/content/docs/docs/python.mdx +++ b/website/src/content/docs/docs/python.mdx @@ -75,8 +75,8 @@ SQL in rendered diagnostics. It returns a `CheckResult`: `ConvertOptions` has one field per [`convert`](/docs/cli/convert/) flag, with the same defaults: `schema`, `policy`, `builtin_policy`, `session`, `strict`, `utc_session`, -`confirm_declared_offsets`, `python_runtime`, `loud_divergences_only`, `overrides` and -`verified_overrides`. `schema`, `policy` and `overrides` take a path (`str` or `os.PathLike`), not +`confirm_declared_offsets`, `python_runtime`, `loud_divergences_only`, `overrides`, +`verified_overrides` and `compact`. `schema`, `policy` and `overrides` take a path (`str` or `os.PathLike`), not the parsed contents. `session` and `verified_overrides` take a mapping, such as `session={"TIMEZONE": "UTC"}`, not a list of `NAME=VALUE` strings. With `policy` and `builtin_policy` both unset, `convert` reads `sqlquack.toml` from the current directory and raises diff --git a/website/src/content/docs/docs/quickstart.mdx b/website/src/content/docs/docs/quickstart.mdx index c48235429..1865c19f6 100644 --- a/website/src/content/docs/docs/quickstart.mdx +++ b/website/src/content/docs/docs/quickstart.mdx @@ -86,7 +86,12 @@ statement reads. Declare them in a [schema file](/docs/schema/): ```console $ sqlquack convert revenue.sql --schema schema.json --builtin-policy -SELECT "ORDERS"."CUSTOMER_ID" AS "CUSTOMER_ID", CAST(sum("ORDERS"."TOTAL") AS DECIMAL(24,2)) AS "REVENUE" FROM "ORDERS" AS "ORDERS" WHERE ("ORDERS"."STATUS" = 'shipped') GROUP BY "ORDERS"."CUSTOMER_ID" +SELECT + "ORDERS"."CUSTOMER_ID" AS "CUSTOMER_ID", + CAST(sum("ORDERS"."TOTAL") AS DECIMAL(24,2)) AS "REVENUE" +FROM "ORDERS" AS "ORDERS" +WHERE ("ORDERS"."STATUS" = 'shipped') +GROUP BY "ORDERS"."CUSTOMER_ID" sqlquack convert: done, converted under the built-in default ``` diff --git a/website/tests/playground.test.mjs b/website/tests/playground.test.mjs index b115283f6..e3886e8d8 100644 --- a/website/tests/playground.test.mjs +++ b/website/tests/playground.test.mjs @@ -103,3 +103,17 @@ test('every example stops at the stage it is meant to, and the default translate assert.deepEqual(refused.diagnostics.map((d) => d.code), ['SQ0008']); assert.ok(CAPTIONS.refused); }); + +test('translated SQL is laid out one clause per line unless the compact setting asks for one line', () => { + const sql = "SELECT order_id, note, 'a longer literal' AS label FROM orders WHERE order_id > 1"; + const translated = (settings) => JSON.parse(analyze(sql, SCHEMA, JSON.stringify(settings))).translate.statements[0].sql; + const laidOut = translated({}); + assert.equal( + laidOut, + 'SELECT\n "ORDERS"."ORDER_ID" AS "ORDER_ID",\n "ORDERS"."NOTE" AS "NOTE",\n ' + + '\'a longer literal\' AS "LABEL"\nFROM "ORDERS" AS "ORDERS"\nWHERE ("ORDERS"."ORDER_ID" > CAST(1 AS DECIMAL(1,0)))', + ); + const compact = translated({ compact: true }); + assert.equal(compact, laidOut.replace(/\n */g, ' ')); + assert.ok(!compact.includes('\n')); +}); diff --git a/website/wasm/src/lib.rs b/website/wasm/src/lib.rs index ddc57a502..f6ee9c655 100644 --- a/website/wasm/src/lib.rs +++ b/website/wasm/src/lib.rs @@ -23,6 +23,7 @@ use sqlquack_core::refusals::models::{Refusal, RefusalCategory}; use sqlquack_duckdb::blocks::main::column_type::column_type; use sqlquack_duckdb::blocks::main::quoted_identifier::quoted_identifier; use sqlquack_duckdb::checking::constants::SEMI_STRUCTURED_HOLDING_TYPE; +use sqlquack_duckdb::layout::main::layout_sql::layout_sql; use sqlquack_policy::policy::models::Policy; use sqlquack_rules::catalogue::main::builtin_catalogue::builtin_catalogue; use sqlquack_rules::conversion::main::convert_statement::convert_statement; @@ -41,7 +42,8 @@ use sqlquack_syntax::{Dialect, Expression, ParseOptions}; use wasm_bindgen::prelude::wasm_bindgen; /// Every stage's result for `sql` over the relations `schema` declares, as JSON. `settings` is -/// JSON `{"policy": TOML, "session": "NAME=VALUE" lines, "strict": bool, "utc_session": bool}`; +/// JSON `{"policy": TOML, "session": "NAME=VALUE" lines, "strict": bool, "utc_session": bool, +/// "compact": bool}`, `compact` printing each statement on one line as `convert --compact` does; /// an empty policy is the built-in default, as `sqlquack convert --builtin-policy`. #[wasm_bindgen] pub fn analyze(sql: &str, schema: &str, settings: Option) -> String { @@ -127,15 +129,14 @@ struct Settings { session: String, strict: bool, utc_session: bool, + compact: bool, } /// Conversion options for `settings` as `sqlquack convert` chooses them, and the matched profile; /// a session no recorded profile covers is a refusal, as on the command line. -fn chosen_options(settings: &str) -> Result, String> { - let settings: Settings = match settings.trim() { - "" => Settings::default(), - text => serde_json::from_str(text).map_err(|error| error.to_string())?, - }; +fn chosen_options( + settings: &Settings, +) -> Result, String> { let policy = match settings.policy.trim() { "" => None, text => Some( @@ -208,7 +209,14 @@ fn report(sql: &str, schema: &str, settings: &str) -> Value { Ok(catalog) => catalog, Err(error) => return json!({ "schema_error": error }), }; - let session = match chosen_options(settings) { + let settings: Settings = match settings.trim() { + "" => Settings::default(), + text => match serde_json::from_str(text) { + Ok(settings) => settings, + Err(error) => return json!({ "settings_error": error.to_string() }), + }, + }; + let session = match chosen_options(&settings) { Ok(session) => session, Err(error) => return json!({ "settings_error": error }), }; @@ -306,7 +314,10 @@ fn report(sql: &str, schema: &str, settings: &str) -> Value { let elapsed = Duration::from_secs_f64((now_ms() - started).max(0.0) / 1000.0); match done.and_then(|done| within_budget(done, (sql, elapsed), &options)) { Ok(done) => converted.push(json!({ - "sql": done.sql, + "sql": match settings.compact { + true => done.sql.clone(), + false => layout_sql(&done.sql), + }, "columns": done.columns.iter().map(|column| json!({ "name": column.name, "type": column.data_type.to_string(),