From 6d8a06895f3cb98bad3a7fb3bcf314e5600a17df Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 29 Aug 2026 16:35:30 +0200 Subject: [PATCH] Preserve single quote literal display --- src/ast/value.rs | 38 ++++++++++++-------------------------- tests/sqlparser_common.rs | 21 +++++++++++++++++++-- tests/sqlparser_mysql.rs | 2 +- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/ast/value.rs b/src/ast/value.rs index a906c4b3f..917dffe58 100644 --- a/src/ast/value.rs +++ b/src/ast/value.rs @@ -533,28 +533,11 @@ impl fmt::Display for NormalizationForm { pub struct EscapeQuotedString<'a> { string: &'a str, quote: char, + always_escape_quote: bool, } impl fmt::Display for EscapeQuotedString<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - // EscapeQuotedString doesn't know which mode of escape was - // chosen by the user. So this code must to correctly display - // strings without knowing if the strings are already escaped - // or not. - // - // If the quote symbol in the string is repeated twice, OR, if - // the quote symbol is after backslash, display all the chars - // without any escape. However, if the quote symbol is used - // just between usual chars, `fmt()` should display it twice." - // - // The following table has examples - // - // | original query | mode | AST Node | serialized | - // | ------------- | --------- | -------------------------------------------------- | ------------ | - // | `"A""B""A"` | no-escape | `DoubleQuotedString(String::from("A\"\"B\"\"A"))` | `"A""B""A"` | - // | `"A""B""A"` | default | `DoubleQuotedString(String::from("A\"B\"A"))` | `"A""B""A"` | - // | `"A\"B\"A"` | no-escape | `DoubleQuotedString(String::from("A\\\"B\\\"A"))` | `"A\"B\"A"` | - // | `"A\"B\"A"` | default | `DoubleQuotedString(String::from("A\"B\"A"))` | `"A""B""A"` | let quote = self.quote; let mut previous_char = char::default(); let mut start_idx = 0; @@ -563,20 +546,15 @@ impl fmt::Display for EscapeQuotedString<'_> { match ch { char if char == quote => { if previous_char == '\\' { - // the quote is already escaped with a backslash, skip peekable_chars.next(); continue; } peekable_chars.next(); match peekable_chars.peek() { - Some((_, c)) if *c == quote => { - // the quote is already escaped with another quote, skip + Some((_, c)) if !self.always_escape_quote && *c == quote => { peekable_chars.next(); } _ => { - // The quote is not escaped. - // Including idx in the range, so the quote at idx will be printed twice: - // in this call to write_str() and in the next one. let end_idx = idx + ch.len_utf8(); f.write_str(&self.string[start_idx..end_idx])?; start_idx = idx; @@ -597,12 +575,20 @@ impl fmt::Display for EscapeQuotedString<'_> { /// Return a helper which formats `string` for inclusion inside a quoted /// literal that uses `quote` as the delimiter. pub fn escape_quoted_string(string: &str, quote: char) -> EscapeQuotedString<'_> { - EscapeQuotedString { string, quote } + EscapeQuotedString { + string, + quote, + always_escape_quote: false, + } } /// Convenience wrapper for escaping strings for single-quoted literals (`'`). pub fn escape_single_quote_string(s: &str) -> EscapeQuotedString<'_> { - escape_quoted_string(s, '\'') + EscapeQuotedString { + string: s, + quote: '\'', + always_escape_quote: true, + } } /// Convenience wrapper for escaping strings for double-quoted literals (`").` diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index c8a2453aa..de642828c 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -1585,13 +1585,21 @@ fn parse_escaped_single_quote_string_predicate_with_no_escape() { let sql = "SELECT id, fname, lname FROM customer \ WHERE salary <> 'Jim''s salary'"; - let ast = TestedDialects::new_with_options( + let statements = TestedDialects::new_with_options( vec![Box::new(MySqlDialect {})], ParserOptions::new() .with_trailing_commas(true) .with_unescape(false), ) - .verified_only_select(sql); + .parse_sql_statements(sql) + .unwrap(); + let Statement::Query(query) = only(statements) else { + unreachable!() + }; + let SetExpr::Select(ast) = *query.body else { + unreachable!() + }; + let ast = *ast; assert_eq!( Some(Expr::BinaryOp { @@ -1605,6 +1613,15 @@ fn parse_escaped_single_quote_string_predicate_with_no_escape() { ); } +#[test] +fn parse_adjacent_single_quotes_round_trip() { + TestedDialects::new(vec![ + Box::new(PostgreSqlDialect {}), + Box::new(MySqlDialect {}), + ]) + .verified_stmt("SELECT * FROM t WHERE ''''''"); +} + #[test] fn parse_number() { let expr = verified_expr("1.0"); diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 0df8393e4..fd56a1afc 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -1679,7 +1679,7 @@ fn check_roundtrip_of_escaped_string() { TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) .verified_stmt(r"SELECT 'I\'m fine'"); TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) - .verified_stmt(r#"SELECT 'I''m fine'"#); + .one_statement_parses_to(r#"SELECT 'I''m fine'"#, ""); TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone()) .verified_stmt(r"SELECT 'I\\\'m fine'"); TestedDialects::new_with_options(vec![Box::new(MySqlDialect {})], options.clone())