Skip to main content

scythe_codegen/
sql_literal.rs

1//! Escape SQL text for safe splicing into a target-language string literal.
2//!
3//! Every backend embeds the query's SQL text directly into the generated source as a
4//! string literal (`conn.prepare("SELECT ...")`, `sqlx::query!("SELECT ...")`, and so on).
5//! Before this module existed, only the TypeScript backends escaped that text
6//! ([`escape_ts_template_literal`](crate::backends::typescript_common::escape_ts_template_literal) and
7//! [`escape_ts_double_quoted_literal`](crate::backends::typescript_common::escape_ts_double_quoted_literal)); every
8//! other backend spliced the cleaned SQL straight into a host literal with no escaping at
9//! all. Two distinct failure classes follow from that:
10//!
11//! - **Injection.** Kotlin's non-raw `"..."` string, Ruby's `"..."`, and Elixir's `"..."`
12//!   are all *interpolating* literals: `$name`, `#{name}`, and `#{name}` respectively splice
13//!   a same-scope variable's runtime value into the string. When SQL text contains one of
14//!   those sequences and it happens to name an in-scope parameter, the generated code
15//!   compiles cleanly and substitutes caller-controlled data directly into the SQL text --
16//!   bypassing the `?`/`$N` parameter binding entirely. This is the root cause of the
17//!   Kotlin injection tracked as issue #176.
18//! - **Silent corruption or a hard compile break.** The remaining host forms (Go, Java, C#,
19//!   PHP, Python, Rust, and the raw-string variants) do not interpolate, but an unescaped
20//!   backslash or embedded quote either changes what the database receives (a `LIKE
21//!   'a\_b%'` backslash silently becomes a real escape) or breaks the literal outright (a
22//!   quoted SQL identifier like `SELECT "type"` terminates the host string early).
23//!
24//! One function per **host literal form** lives here, not one per backend, because the
25//! literal form -- not the driver -- determines what needs escaping. Every function takes
26//! the already-cleaned SQL text (post `clean_sql`/`clean_sql_oneline`/placeholder rewriting)
27//! and returns text safe to splice verbatim between that host form's delimiters. None of
28//! them touch delimiters themselves except [`rust_raw_string_literal`], which must choose
29//! its own delimiter width.
30//!
31//! Only literal forms actually emitted by a backend in this crate are implemented here --
32//! see each function's doc comment for which backends call it.
33
34/// Escape SQL text for splicing into a Kotlin non-raw `"..."` string literal.
35///
36/// Kotlin non-raw strings interpolate on `$identifier` and `${expression}`; left
37/// unescaped, SQL text containing `$name` where `name` matches an in-scope parameter
38/// compiles cleanly and substitutes the parameter's runtime value directly into the SQL
39/// text, bypassing the `?` binding entirely (issue #176). `\$` is Kotlin's own escape for a
40/// literal dollar sign, so escaping it here is not a workaround -- it is how Kotlin spells
41/// "a dollar sign that is not a template".
42///
43/// Also escapes backslash, the closing quote, and the three control characters
44/// (newline, carriage return, tab) a non-raw Kotlin string cannot contain unescaped.
45/// Used by `kotlin-jdbc`, `kotlin-r2dbc`, and `kotlin-exposed`.
46pub fn escape_kotlin_string(sql: &str) -> String {
47    escape_char_by_char(sql, |ch| match ch {
48        '\\' => Some("\\\\"),
49        '"' => Some("\\\""),
50        '$' => Some("\\$"),
51        '\n' => Some("\\n"),
52        '\r' => Some("\\r"),
53        '\t' => Some("\\t"),
54        _ => None,
55    })
56}
57
58/// Escape SQL text for splicing into a Ruby double-quoted `"..."` string literal.
59///
60/// Ruby double-quoted strings interpolate on `#{expression}`; SQL text containing that
61/// sequence would otherwise splice an in-scope variable's value into the query the same
62/// way Kotlin's `$name` does. Ruby double-quoted strings freely contain literal newlines
63/// and tabs, so only backslash, the closing quote, and a literal `#{` need escaping.
64///
65/// Used by `ruby-pg`, `ruby-sqlite3`, `ruby-mysql2`, `ruby-oci8`, `ruby-trilogy`, and
66/// `ruby-tiny-tds` (on the raw SQL text, before that backend's own `#{client.escape(...)}`
67/// parameter interpolations are spliced in -- see that backend for why order matters there).
68pub fn escape_ruby_double_quoted(sql: &str) -> String {
69    escape_interpolating_double_quoted(sql, '#')
70}
71
72/// Escape SQL text for splicing into an Elixir double-quoted `"..."` string literal.
73///
74/// Elixir double-quoted strings interpolate on `#{expression}`, identically to Ruby.
75/// Used by `elixir-postgrex`, `elixir-ecto`, `elixir-myxql`, `elixir-exqlite`, `elixir-tds`,
76/// and `elixir-jamdb`.
77pub fn escape_elixir_double_quoted(sql: &str) -> String {
78    escape_interpolating_double_quoted(sql, '#')
79}
80
81/// Shared implementation for host literals that interpolate on `<marker>{expression}`
82/// (Ruby and Elixir both use `#{...}`). Escapes backslash, the closing double quote, and
83/// the two-character interpolation opener; leaves literal newlines and tabs untouched,
84/// since both hosts permit them unescaped inside a double-quoted string.
85fn escape_interpolating_double_quoted(sql: &str, marker: char) -> String {
86    let mut out = String::with_capacity(sql.len());
87    let mut chars = sql.chars().peekable();
88    while let Some(ch) = chars.next() {
89        match ch {
90            '\\' => out.push_str("\\\\"),
91            '"' => out.push_str("\\\""),
92            c if c == marker && chars.peek() == Some(&'{') => {
93                out.push('\\');
94                out.push(marker);
95            }
96            _ => out.push(ch),
97        }
98    }
99    out
100}
101
102/// Escape SQL text for splicing into a PHP single-quoted `'...'` string literal.
103///
104/// PHP backends (`php-pdo`, `php-amphp`) previously spliced SQL into a *double*-quoted
105/// PHP string, which interpolates on `$name` and `{$expr}` the same way Kotlin's non-raw
106/// strings do -- SQL text containing `$conn` or a same-named in-scope parameter would
107/// substitute that variable's runtime value into the query text. PHP single-quoted strings
108/// do not interpolate at all (the only two escapes they recognize are `\\` and `\'`), so
109/// switching the emitted literal to single-quoted and escaping only those two characters
110/// closes the injection class without needing to hunt for every interpolation trigger.
111/// Literal newlines and tabs pass through unescaped, since PHP single-quoted strings permit
112/// them.
113pub fn escape_php_single_quoted(sql: &str) -> String {
114    escape_char_by_char(sql, |ch| match ch {
115        '\\' => Some("\\\\"),
116        '\'' => Some("\\'"),
117        _ => None,
118    })
119}
120
121/// Escape SQL text for splicing into a Java plain `"..."` string literal.
122///
123/// Java string literals do not interpolate, but a raw newline, backslash, or double quote
124/// each either fails to compile or changes what the database receives. Used by `java-jdbc`
125/// and `java-r2dbc`, both of which pre-flatten SQL to one line via `clean_sql_oneline`, but
126/// the newline/tab/carriage-return escapes here make the function correct independent of
127/// that upstream choice.
128pub fn escape_java_string(sql: &str) -> String {
129    escape_char_by_char(sql, |ch| match ch {
130        '\\' => Some("\\\\"),
131        '"' => Some("\\\""),
132        '\n' => Some("\\n"),
133        '\r' => Some("\\r"),
134        '\t' => Some("\\t"),
135        _ => None,
136    })
137}
138
139/// Escape SQL text for splicing into a Go interpreted `"..."` string literal.
140///
141/// Identical escape set to [`escape_java_string`] -- Go interpreted strings share the same
142/// restrictions (no raw newline, backslash and quote must be escaped). Used by `go-pgx`,
143/// `go-database-sql`, `go-godror`, and `go-gosnowflake`.
144pub fn escape_go_interpreted_string(sql: &str) -> String {
145    escape_char_by_char(sql, |ch| match ch {
146        '\\' => Some("\\\\"),
147        '"' => Some("\\\""),
148        '\n' => Some("\\n"),
149        '\r' => Some("\\r"),
150        '\t' => Some("\\t"),
151        _ => None,
152    })
153}
154
155/// Escape SQL text for splicing into a C# verbatim `@"..."` string literal.
156///
157/// The C# backends previously used a *regular* `"..."` string with no escaping at all,
158/// which breaks on both an embedded quote and a backslash (`LIKE 'a\_b%'` fails to compile
159/// outright, since `\_` is not a recognized regular-string escape). Verbatim strings sidestep
160/// backslash entirely -- it is literal there, exactly matching what SQL text needs -- and
161/// permit raw newlines, so the only escape required is doubling an embedded `"` to `""`.
162/// Used by all six C# backends (`csharp-npgsql`, `csharp-sqlclient`, `csharp-mysqlconnector`,
163/// `csharp-microsoft-sqlite`, `csharp-oracle`, `csharp-snowflake`).
164pub fn escape_csharp_verbatim_string(sql: &str) -> String {
165    sql.replace('"', "\"\"")
166}
167
168/// Escape SQL text for splicing into a Python triple-double-quoted `"""..."""` string
169/// literal.
170///
171/// Triple-quoted strings permit raw newlines and tabs, so only backslash and `"` need
172/// escaping; escaping every `"` (not just runs of three) also means a lone or doubled
173/// quote can never accidentally combine with the literal's own delimiter. Used by
174/// `python-asyncpg`, `python-psycopg3`, `python-aiosqlite`, `python-aiomysql`,
175/// `python-duckdb`, and (via `python_common::write_execute_call`) `python-oracledb`,
176/// `python-snowflake`, and `python-pyodbc`.
177pub fn escape_python_triple_double(sql: &str) -> String {
178    escape_char_by_char(sql, |ch| match ch {
179        '\\' => Some("\\\\"),
180        '"' => Some("\\\""),
181        _ => None,
182    })
183}
184
185/// Double every literal `%` in SQL text destined for a Python `%`-paramstyle DB-API driver
186/// (psycopg3's `%(name)s`, aiomysql's/PyMySQL's `%s`).
187///
188/// Both drivers apply Python `%`-style string formatting to the *entire* SQL string at
189/// execute time, not just to the placeholder tokens the generator emits. A literal `%`
190/// anywhere in the query -- a `LIKE '100%'` pattern, a `to_char(..., 'HH24%')` format
191/// string -- is therefore read by the driver as the start of a conversion specifier: `%'`
192/// is not a valid one, so psycopg raises `ValueError: unsupported format character` at
193/// execute time, and PyMySQL either raises the same way or silently mis-binds (issue
194/// #201). Doubling `%` to `%%` is the driver's own escape for a literal percent, identical
195/// to Python's own `%` operator.
196///
197/// Callers MUST run this over the *raw* SQL text -- after `clean_sql`/
198/// `clean_sql_with_optional`, before [`crate::backends::rewrite_pg_placeholders`] rewrites
199/// placeholders to their `%`-paramstyle form. Doing it in the other order would double the
200/// `%` in every placeholder this function's caller is about to emit (`%(id)s` becomes
201/// `%%(id)s`, which the driver then reads as a literal percent followed by literal text
202/// `(id)s`, not a binding) and corrupt every parameter. Because the source SQL at this
203/// point still uses `$N` or bare `?` placeholders -- never `%` -- every `%` found here is
204/// unambiguously literal SQL text, so a single unconditional `replace` is correct with no
205/// span-awareness needed, unlike the interpolation escaping elsewhere in this module.
206///
207/// Callers MUST ALSO apply this **only to a query that actually binds parameters**. Both
208/// drivers do the `%`-formatting pass inside `execute(query, params)` and skip it entirely
209/// for `execute(query)` -- psycopg3 documents `%` as not special when no parameters are
210/// passed, and PyMySQL simply never reaches `query % escaped_args` with `args=None`. So
211/// doubling a parameterless query's `%` is not a no-op: the doubled text reaches the server
212/// verbatim, and `LIKE '100%%'` matches a literal percent sign instead of acting as a
213/// wildcard. That is the same silent wrong answer #201 is about, merely inverted, which is
214/// why the backends gate the call on a non-empty parameter list rather than applying it
215/// unconditionally.
216pub fn double_percent_for_percent_paramstyle(sql: &str) -> String {
217    sql.replace('%', "%%")
218}
219
220/// Escape SQL text for splicing into a Rust plain `"..."` string literal.
221///
222/// Rust string literals permit raw newlines, so only backslash and `"` need escaping.
223/// Used by `rust-sibyl` and `rust-sqlx` (the latter's `sqlx::query!`/`query_as!` macros
224/// receive the *decoded* string value at compile time, identical to the unescaped source
225/// text, so escaping here has no effect on the macro's compile-time query verification).
226pub fn escape_rust_string(sql: &str) -> String {
227    escape_char_by_char(sql, |ch| match ch {
228        '\\' => Some("\\\\"),
229        '"' => Some("\\\""),
230        _ => None,
231    })
232}
233
234/// Build a complete Rust raw string literal (`r#"..."#`, widened as needed) containing
235/// `sql` verbatim.
236///
237/// Raw strings cannot escape anything -- that is the point of using one -- so a SQL body
238/// containing the delimiter sequence `"#` breaks a fixed `r#"..."#` literal outright
239/// (issue #179's `rust-tokio-postgres`/`rust-tiberius` case: `"#` anywhere in the query,
240/// including inside an ordinary comment or string, ends the literal early and leaves the
241/// rest as malformed Rust). This widens the hash run only as far as the content requires:
242/// plain SQL gets the same `r#"..."#` these backends always emitted, and only a query that
243/// actually contains `"#`, `"##`, ... pays for a wider delimiter.
244///
245/// Returns the full literal, delimiters included, ready to splice directly in place of a
246/// hand-written `r#"{}"#`.
247pub fn rust_raw_string_literal(sql: &str) -> String {
248    let mut hashes = 1usize;
249    loop {
250        let marker = format!("\"{}", "#".repeat(hashes));
251        if !sql.contains(&marker) {
252            break;
253        }
254        hashes += 1;
255    }
256    let delim = "#".repeat(hashes);
257    format!("r{delim}\"{sql}\"{delim}")
258}
259
260/// Shared implementation for host literals whose escape set is a fixed per-character
261/// substitution table with no multi-character lookahead (i.e. everything except the
262/// `#{`/`${`-style interpolation escapes, which need [`escape_interpolating_double_quoted`]
263/// or their own bespoke pass).
264///
265/// `substitute` returns `Some(replacement)` when it handled `ch` itself, `None` to fall
266/// through to pushing `ch` unchanged. Iterating character-by-character rather than chaining
267/// `str::replace` calls sidesteps the classic bug where an earlier pass's *output*
268/// accidentally matches a later pass's pattern (e.g. escaping `"` before `\` would turn `"`
269/// into `\"`, and the backslash pass would then re-escape the very backslash the quote pass
270/// just inserted).
271fn escape_char_by_char(sql: &str, substitute: impl Fn(char) -> Option<&'static str>) -> String {
272    let mut out = String::with_capacity(sql.len());
273    for ch in sql.chars() {
274        match substitute(ch) {
275            Some(replacement) => out.push_str(replacement),
276            None => out.push(ch),
277        }
278    }
279    out
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// Adversarial corpus shared by every round-trip test below: interpolation markers for
287    /// every interpolating host, the raw-string-breaking sequence, quote/backslash pairs a
288    /// naive single-pass replace gets wrong, whitespace control characters, non-ASCII text,
289    /// and a full SQL fragment with a doubled-apostrophe string literal.
290    fn adversarial_corpus() -> Vec<&'static str> {
291        vec![
292            "",
293            "$var",
294            "${x}",
295            "#{x}",
296            "`backtick`",
297            "single'quote",
298            "double\"quote",
299            "back\\slash",
300            "double\\\\backslash",
301            "new\nline",
302            "tab\tchar",
303            "carriage\rreturn",
304            "quote\"then\\backslash",
305            "backslash\\then\"quote",
306            "\"#",
307            "\"##",
308            "café",
309            "日本語",
310            "SELECT * FROM t WHERE name = 'O''Brien'",
311            "SELECT \"type\", \"class\" FROM items WHERE note = 'a\\_b%' AND tag = '$name' AND memo = '#{x}'",
312        ]
313    }
314
315    // --- Kotlin ---------------------------------------------------------
316
317    /// Reference unescaper matching Kotlin's own string-literal grammar: `\\`, `\"`, `\$`,
318    /// `\n`, `\r`, `\t` are the only escapes [`escape_kotlin_string`] ever emits.
319    fn unescape_kotlin(s: &str) -> String {
320        let mut out = String::with_capacity(s.len());
321        let mut chars = s.chars();
322        while let Some(ch) = chars.next() {
323            if ch == '\\' {
324                match chars.next() {
325                    Some('\\') => out.push('\\'),
326                    Some('"') => out.push('"'),
327                    Some('$') => out.push('$'),
328                    Some('n') => out.push('\n'),
329                    Some('r') => out.push('\r'),
330                    Some('t') => out.push('\t'),
331                    Some(other) => panic!("unexpected Kotlin escape \\{other}"),
332                    None => panic!("dangling backslash"),
333                }
334            } else {
335                out.push(ch);
336            }
337        }
338        out
339    }
340
341    #[test]
342    fn kotlin_escape_round_trips_over_adversarial_corpus() {
343        for input in adversarial_corpus() {
344            let escaped = escape_kotlin_string(input);
345            assert_eq!(unescape_kotlin(&escaped), input, "input: {input:?} -> {escaped:?}");
346        }
347    }
348
349    /// Direct regression test for issue #176: SQL containing `$name`, where `name` is also
350    /// the emitted parameter's identifier, must not produce a Kotlin string that
351    /// interpolates it. This is the exact shape of the reported injection: a literal `$`
352    /// followed by an identifier equal to an in-scope variable name.
353    #[test]
354    fn kotlin_escape_neutralizes_the_176_injection_shape() {
355        let sql = "SELECT id, name FROM users WHERE name = $1 AND data::text = 'literal-$name-marker'";
356        let escaped = escape_kotlin_string(sql);
357        assert!(
358            !escaped.contains("$name") || escaped.contains("\\$name"),
359            "a bare, unescaped $name must never reach the Kotlin literal: {escaped:?}"
360        );
361        assert!(
362            escaped.contains("\\$name"),
363            "expected \\$name (Kotlin's escape for a literal dollar sign), got: {escaped:?}"
364        );
365        // Round-trips back to the exact original SQL, byte for byte.
366        assert_eq!(unescape_kotlin(&escaped), sql);
367    }
368
369    #[test]
370    fn kotlin_dollar_digit_is_not_kotlin_template_syntax_but_is_still_escaped() {
371        // $1 is never a valid Kotlin identifier start (digits cannot begin one), so Kotlin
372        // itself treats it literally either way -- but escaping it is still correct and
373        // keeps the function's behavior uniform regardless of what follows `$`.
374        let escaped = escape_kotlin_string("WHERE id = $1");
375        assert_eq!(escaped, "WHERE id = \\$1");
376    }
377
378    // --- Ruby / Elixir (#{...} interpolation) ---------------------------
379
380    fn unescape_hash_brace_interpolated(s: &str) -> String {
381        let mut out = String::with_capacity(s.len());
382        let mut chars = s.chars().peekable();
383        while let Some(ch) = chars.next() {
384            if ch == '\\' {
385                match chars.next() {
386                    Some('\\') => out.push('\\'),
387                    Some('"') => out.push('"'),
388                    Some('#') => out.push('#'),
389                    Some(other) => panic!("unexpected escape \\{other}"),
390                    None => panic!("dangling backslash"),
391                }
392            } else {
393                out.push(ch);
394            }
395        }
396        out
397    }
398
399    #[test]
400    fn ruby_escape_round_trips_over_adversarial_corpus() {
401        for input in adversarial_corpus() {
402            let escaped = escape_ruby_double_quoted(input);
403            assert_eq!(
404                unescape_hash_brace_interpolated(&escaped),
405                input,
406                "input: {input:?} -> {escaped:?}"
407            );
408        }
409    }
410
411    #[test]
412    fn ruby_escape_neutralizes_hash_brace_interpolation() {
413        let escaped = escape_ruby_double_quoted("literal-#{name}-marker");
414        assert!(
415            escaped.contains("\\#{"),
416            "expected an escaped #{{ opener, got: {escaped:?}"
417        );
418        assert!(!escaped.contains("marker-#{") && escaped.starts_with("literal-\\#{"));
419    }
420
421    /// A `#` not immediately followed by `{` is not an interpolation opener and must pass
422    /// through unescaped, e.g. an ordinary `#` in a comment-like SQL fragment.
423    #[test]
424    fn ruby_escape_leaves_bare_hash_alone() {
425        assert_eq!(escape_ruby_double_quoted("a#b"), "a#b");
426    }
427
428    #[test]
429    fn elixir_escape_round_trips_over_adversarial_corpus() {
430        for input in adversarial_corpus() {
431            let escaped = escape_elixir_double_quoted(input);
432            assert_eq!(
433                unescape_hash_brace_interpolated(&escaped),
434                input,
435                "input: {input:?} -> {escaped:?}"
436            );
437        }
438    }
439
440    // --- PHP --------------------------------------------------------------
441
442    fn unescape_php_single_quoted(s: &str) -> String {
443        let mut out = String::with_capacity(s.len());
444        let mut chars = s.chars();
445        while let Some(ch) = chars.next() {
446            if ch == '\\' {
447                match chars.next() {
448                    Some('\\') => out.push('\\'),
449                    Some('\'') => out.push('\''),
450                    // PHP single-quoted strings pass any other backslash through literally
451                    // (e.g. `\n` stays the two characters backslash-n).
452                    Some(other) => {
453                        out.push('\\');
454                        out.push(other);
455                    }
456                    None => out.push('\\'),
457                }
458            } else {
459                out.push(ch);
460            }
461        }
462        out
463    }
464
465    #[test]
466    fn php_escape_round_trips_over_adversarial_corpus() {
467        for input in adversarial_corpus() {
468            let escaped = escape_php_single_quoted(input);
469            assert_eq!(
470                unescape_php_single_quoted(&escaped),
471                input,
472                "input: {input:?} -> {escaped:?}"
473            );
474        }
475    }
476
477    #[test]
478    fn php_single_quoted_never_interpolates_a_dollar_variable() {
479        // The point of switching to single-quoted: `$conn`/`$name` must reach the literal
480        // as inert text, not a variable reference. Single-quoted PHP performs no
481        // interpolation at all, so no escaping of `$` is needed for that guarantee to hold
482        // -- this test documents that the chosen literal form, not a `$` escape, is what
483        // closes the PHP interpolation class.
484        let escaped = escape_php_single_quoted("literal-$conn-marker");
485        assert_eq!(escaped, "literal-$conn-marker");
486    }
487
488    // --- Java / Go (shared escape set) -------------------------------------
489
490    fn unescape_backslash_quote_control(s: &str) -> String {
491        let mut out = String::with_capacity(s.len());
492        let mut chars = s.chars();
493        while let Some(ch) = chars.next() {
494            if ch == '\\' {
495                match chars.next() {
496                    Some('\\') => out.push('\\'),
497                    Some('"') => out.push('"'),
498                    Some('n') => out.push('\n'),
499                    Some('r') => out.push('\r'),
500                    Some('t') => out.push('\t'),
501                    Some(other) => panic!("unexpected escape \\{other}"),
502                    None => panic!("dangling backslash"),
503                }
504            } else {
505                out.push(ch);
506            }
507        }
508        out
509    }
510
511    #[test]
512    fn java_escape_round_trips_over_adversarial_corpus() {
513        for input in adversarial_corpus() {
514            let escaped = escape_java_string(input);
515            assert_eq!(
516                unescape_backslash_quote_control(&escaped),
517                input,
518                "input: {input:?} -> {escaped:?}"
519            );
520        }
521    }
522
523    #[test]
524    fn go_escape_round_trips_over_adversarial_corpus() {
525        for input in adversarial_corpus() {
526            let escaped = escape_go_interpreted_string(input);
527            assert_eq!(
528                unescape_backslash_quote_control(&escaped),
529                input,
530                "input: {input:?} -> {escaped:?}"
531            );
532        }
533    }
534
535    /// Direct regression test for the "loud case" in issue #150/#179: a quoted SQL
536    /// identifier must not terminate the host literal early.
537    #[test]
538    fn java_escape_handles_quoted_identifier() {
539        let escaped = escape_java_string("SELECT \"type\", \"class\" FROM items");
540        assert!(escaped.contains("\\\"type\\\""));
541        assert!(
542            !escaped.contains("\"type\","),
543            "the raw quote must not survive unescaped"
544        );
545    }
546
547    #[test]
548    fn go_escape_handles_backslash_in_like_pattern() {
549        let escaped = escape_go_interpreted_string("LIKE 'a\\_b%'");
550        assert_eq!(escaped, "LIKE 'a\\\\_b%'");
551    }
552
553    // --- C# -----------------------------------------------------------------
554
555    fn unescape_csharp_verbatim(s: &str) -> String {
556        s.replace("\"\"", "\"")
557    }
558
559    #[test]
560    fn csharp_verbatim_round_trips_over_adversarial_corpus() {
561        for input in adversarial_corpus() {
562            let escaped = escape_csharp_verbatim_string(input);
563            assert_eq!(
564                unescape_csharp_verbatim(&escaped),
565                input,
566                "input: {input:?} -> {escaped:?}"
567            );
568        }
569    }
570
571    #[test]
572    fn csharp_verbatim_leaves_backslash_literal() {
573        // Verbatim strings treat `\` as an ordinary character, exactly matching what a SQL
574        // `LIKE 'a\_b%'` pattern needs -- no escaping required, and the round-trip test
575        // above with `back\slash` confirms the value survives unchanged.
576        assert_eq!(escape_csharp_verbatim_string("a\\_b"), "a\\_b");
577    }
578
579    // --- Python ---------------------------------------------------------
580
581    #[test]
582    fn python_triple_double_round_trips_over_adversarial_corpus() {
583        for input in adversarial_corpus() {
584            let escaped = escape_python_triple_double(input);
585            assert_eq!(
586                unescape_backslash_quote_control_no_nrt(&escaped),
587                input,
588                "input: {input:?} -> {escaped:?}"
589            );
590        }
591    }
592
593    /// Like [`unescape_backslash_quote_control`], but only `\\` and `\"` are escapes --
594    /// [`escape_python_triple_double`] leaves real newlines/tabs untouched, so a decoder
595    /// must too.
596    fn unescape_backslash_quote_control_no_nrt(s: &str) -> String {
597        let mut out = String::with_capacity(s.len());
598        let mut chars = s.chars();
599        while let Some(ch) = chars.next() {
600            if ch == '\\' {
601                match chars.next() {
602                    Some('\\') => out.push('\\'),
603                    Some('"') => out.push('"'),
604                    Some(other) => panic!("unexpected escape \\{other}"),
605                    None => panic!("dangling backslash"),
606                }
607            } else {
608                out.push(ch);
609            }
610        }
611        out
612    }
613
614    #[test]
615    fn python_triple_double_preserves_raw_newline() {
616        let escaped = escape_python_triple_double("line1\nline2");
617        assert_eq!(
618            escaped, "line1\nline2",
619            "python triple-quoted strings permit raw newlines"
620        );
621    }
622
623    // --- Python %-paramstyle doubling (#201) -------------------------------
624
625    #[test]
626    fn double_percent_doubles_a_lone_percent() {
627        assert_eq!(double_percent_for_percent_paramstyle("LIKE '100%'"), "LIKE '100%%'");
628    }
629
630    #[test]
631    fn double_percent_doubles_every_occurrence() {
632        assert_eq!(
633            double_percent_for_percent_paramstyle("a%b%c"),
634            "a%%b%%c",
635            "every literal % must be doubled, not just the first"
636        );
637    }
638
639    #[test]
640    fn double_percent_is_a_no_op_when_no_percent_present() {
641        let sql = "SELECT id FROM users WHERE id = $1";
642        assert_eq!(double_percent_for_percent_paramstyle(sql), sql);
643    }
644
645    #[test]
646    fn double_percent_leaves_dollar_placeholders_untouched() {
647        // Regression guard for #201: doubling must run before placeholder rewriting, so at
648        // this stage the only placeholder syntax present is `$N` -- never `%`. This pins
649        // that a `$N` placeholder survives the pass unchanged.
650        let sql = "SELECT * FROM t WHERE name LIKE $1 AND note LIKE '100%'";
651        assert_eq!(
652            double_percent_for_percent_paramstyle(sql),
653            "SELECT * FROM t WHERE name LIKE $1 AND note LIKE '100%%'"
654        );
655    }
656
657    // --- Rust -------------------------------------------------------------
658
659    #[test]
660    fn rust_plain_round_trips_over_adversarial_corpus() {
661        for input in adversarial_corpus() {
662            let escaped = escape_rust_string(input);
663            assert_eq!(
664                unescape_backslash_quote_control_no_nrt(&escaped),
665                input,
666                "input: {input:?} -> {escaped:?}"
667            );
668        }
669    }
670
671    /// Extracts the SQL body back out of a full `rN#"..."N#` literal built by
672    /// [`rust_raw_string_literal`], for round-trip assertions.
673    fn unwrap_rust_raw_string_literal(literal: &str) -> &str {
674        let rest = literal.strip_prefix('r').expect("must start with r");
675        let hashes = rest.chars().take_while(|&c| c == '#').count();
676        let delim = &rest[..hashes];
677        let opened = &rest[hashes..];
678        let opened = opened.strip_prefix('"').expect("must open with a quote");
679        let closer = format!("\"{delim}");
680        opened
681            .strip_suffix(&closer)
682            .expect("must close with matching delimiter")
683    }
684
685    #[test]
686    fn rust_raw_string_round_trips_over_adversarial_corpus() {
687        for input in adversarial_corpus() {
688            let literal = rust_raw_string_literal(input);
689            assert_eq!(
690                unwrap_rust_raw_string_literal(&literal),
691                input,
692                "input: {input:?} -> {literal:?}"
693            );
694        }
695    }
696
697    /// Direct regression test for the raw-string-breaking case in issue #179: `"#` anywhere
698    /// in the SQL must not terminate a fixed `r#"..."#` literal early. The widened
699    /// delimiter must not appear inside the SQL body itself, or the same problem recurs one
700    /// level up.
701    #[test]
702    fn rust_raw_string_widens_delimiter_when_sql_contains_hash_quote() {
703        let sql = "a\"#b";
704        let literal = rust_raw_string_literal(sql);
705        assert_eq!(literal, "r##\"a\"#b\"##");
706        assert_eq!(unwrap_rust_raw_string_literal(&literal), sql);
707    }
708
709    #[test]
710    fn rust_raw_string_plain_sql_uses_single_hash() {
711        let literal = rust_raw_string_literal("SELECT 1");
712        assert_eq!(literal, "r#\"SELECT 1\"#");
713    }
714
715    #[test]
716    fn rust_raw_string_widens_again_for_double_hash() {
717        let sql = "a\"##b";
718        let literal = rust_raw_string_literal(sql);
719        assert_eq!(literal, "r###\"a\"##b\"###");
720        assert_eq!(unwrap_rust_raw_string_literal(&literal), sql);
721    }
722}