Skip to main content

vespertide_core/
sql_escape.rs

1//! SQL string-literal escaping helpers, shared across crates.
2//!
3//! Mirrors how production SQL emitters handle single-quote escape in
4//! string literals. Centralizing the helper means future hardening (NUL
5//! byte filtering, backslash escapes for MySQL `ANSI_QUOTES`, …) lands in
6//! one place instead of being re-applied across 5+ callsites in
7//! `vespertide-cli`, `vespertide-core`, and `vespertide-query`.
8
9use std::borrow::Cow;
10
11/// Escape SQL single quotes by doubling them — the canonical, portable
12/// way to embed a string inside a `'...'` literal across Postgres /
13/// `MySQL` / `SQLite`. Returns a borrowed slice when no escape is required
14/// (zero allocation), otherwise an owned `String`.
15///
16/// ```rust
17/// use std::borrow::Cow;
18/// use vespertide_core::escape_sql_string_literal;
19///
20/// assert!(matches!(escape_sql_string_literal("hello"), Cow::Borrowed(_)));
21/// assert_eq!(escape_sql_string_literal("O'Brien"), "O''Brien");
22/// assert_eq!(escape_sql_string_literal("'leading"), "''leading");
23/// assert_eq!(escape_sql_string_literal(""), "");
24/// ```
25#[must_use]
26pub fn escape_sql_string_literal(s: &str) -> Cow<'_, str> {
27    if s.contains('\'') {
28        Cow::Owned(s.replace('\'', "''"))
29    } else {
30        Cow::Borrowed(s)
31    }
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37
38    #[test]
39    fn empty_string_borrows() {
40        let out = escape_sql_string_literal("");
41        assert!(matches!(out, Cow::Borrowed(_)));
42        assert_eq!(out, "");
43    }
44
45    #[test]
46    fn clean_string_borrows() {
47        let out = escape_sql_string_literal("hello world");
48        assert!(matches!(out, Cow::Borrowed(_)));
49        assert_eq!(out, "hello world");
50    }
51
52    #[test]
53    fn single_quote_in_middle_escapes() {
54        let out = escape_sql_string_literal("O'Brien");
55        assert!(matches!(out, Cow::Owned(_)));
56        assert_eq!(out, "O''Brien");
57    }
58
59    #[test]
60    fn leading_quote_escapes() {
61        let out = escape_sql_string_literal("'leading");
62        assert!(matches!(out, Cow::Owned(_)));
63        assert_eq!(out, "''leading");
64    }
65
66    #[test]
67    fn trailing_quote_escapes() {
68        let out = escape_sql_string_literal("trailing'");
69        assert!(matches!(out, Cow::Owned(_)));
70        assert_eq!(out, "trailing''");
71    }
72
73    #[test]
74    fn only_quote_escapes() {
75        let out = escape_sql_string_literal("'");
76        assert!(matches!(out, Cow::Owned(_)));
77        assert_eq!(out, "''");
78    }
79
80    #[test]
81    fn multiple_quotes_escape_each() {
82        let out = escape_sql_string_literal("a'b'c'");
83        assert!(matches!(out, Cow::Owned(_)));
84        assert_eq!(out, "a''b''c''");
85    }
86
87    #[test]
88    fn consecutive_quotes_escape_each() {
89        let out = escape_sql_string_literal("''");
90        assert!(matches!(out, Cow::Owned(_)));
91        assert_eq!(out, "''''");
92    }
93
94    #[test]
95    fn unicode_preserved_when_borrowed() {
96        let out = escape_sql_string_literal("héllo 世界");
97        assert!(matches!(out, Cow::Borrowed(_)));
98        assert_eq!(out, "héllo 世界");
99    }
100
101    #[test]
102    fn unicode_preserved_when_owned() {
103        let out = escape_sql_string_literal("héllo'世界");
104        assert!(matches!(out, Cow::Owned(_)));
105        assert_eq!(out, "héllo''世界");
106    }
107
108    #[test]
109    fn matches_naive_replace() {
110        // Equivalence oracle: helper output must equal raw .replace()
111        for s in [
112            "",
113            "x",
114            "'",
115            "''",
116            "a'",
117            "'a",
118            "a'b",
119            "O'Brien's",
120            "no quotes here",
121            "héllo 世界",
122        ] {
123            assert_eq!(
124                escape_sql_string_literal(s).as_ref(),
125                s.replace('\'', "''"),
126                "mismatch for input {s:?}"
127            );
128        }
129    }
130}