Skip to main content

oxisql_parse/
parameterize.rs

1//! SQL literal parameterization: replace all literal values with `?`.
2//!
3//! The `parameterize` function is a lexical (not AST-level) transformation
4//! that replaces numeric literals, string literals, `NULL`, `TRUE`, and `FALSE`
5//! with positional `?` placeholders.  Quoted identifiers (`"name"`) are
6//! deliberately left untouched.
7//!
8//! The resulting `ParameterizedSql::template` can be used as a stable cache key
9//! that is independent of the concrete literal values.
10
11// ── Output type ───────────────────────────────────────────────────────────────
12
13/// The result of `parameterize(sql)`.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ParameterizedSql {
16    /// The SQL text with every literal replaced by `?`.
17    pub template: String,
18    /// The original literal values in order of replacement.
19    pub literals: Vec<String>,
20}
21
22// ── Public API ────────────────────────────────────────────────────────────────
23
24/// Replace all SQL literals in `sql` with `?` placeholders.
25///
26/// Handles:
27/// * Integer, decimal, and scientific-notation numeric literals (`42`, `3.14`, `1e10`)
28/// * Negative numeric literals when preceded by an operator (`= -5`)
29/// * Single-quoted string literals, including `''`-escaped quotes (`'it''s'`)
30/// * Double-quoted identifiers are **not** parameterized (kept as-is)
31/// * `NULL`, `TRUE`, `FALSE` keywords (case-insensitive, word-boundary aware)
32/// * Line comments (`-- …`) and block comments (`/* … */`) are passed through
33///   unchanged (their content is never parameterized)
34///
35/// # Examples
36///
37/// ```rust
38/// use oxisql_parse::parameterize;
39///
40/// let p = parameterize("SELECT * FROM t WHERE id = 42 AND name = 'Alice'");
41/// assert_eq!(p.template, "SELECT * FROM t WHERE id = ? AND name = ?");
42/// assert_eq!(p.literals, vec!["42", "'Alice'"]);
43/// ```
44pub fn parameterize(sql: &str) -> ParameterizedSql {
45    let bytes = sql.as_bytes();
46    let n = bytes.len();
47    let mut template = String::with_capacity(n);
48    let mut literals: Vec<String> = Vec::new();
49    let mut i = 0usize;
50
51    while i < n {
52        // ── Line comment: -- until newline ────────────────────────────────
53        if i + 1 < n && bytes[i] == b'-' && bytes[i + 1] == b'-' {
54            while i < n && bytes[i] != b'\n' {
55                template.push(bytes[i] as char);
56                i += 1;
57            }
58            continue;
59        }
60
61        // ── Block comment: /* … */ ────────────────────────────────────────
62        if i + 1 < n && bytes[i] == b'/' && bytes[i + 1] == b'*' {
63            template.push('/');
64            template.push('*');
65            i += 2;
66            while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
67                template.push(bytes[i] as char);
68                i += 1;
69            }
70            if i + 1 < n {
71                template.push('*');
72                template.push('/');
73                i += 2;
74            }
75            continue;
76        }
77
78        // ── Single-quoted string literal ──────────────────────────────────
79        if bytes[i] == b'\'' {
80            let start = i;
81            i += 1;
82            while i < n {
83                if bytes[i] == b'\'' {
84                    i += 1;
85                    if i < n && bytes[i] == b'\'' {
86                        // Escaped '' — continue inside the literal.
87                        i += 1;
88                    } else {
89                        break; // Closing quote.
90                    }
91                } else {
92                    i += 1;
93                }
94            }
95            literals.push(sql[start..i].to_string());
96            template.push('?');
97            continue;
98        }
99
100        // ── Double-quoted identifier (keep as-is) ─────────────────────────
101        if bytes[i] == b'"' {
102            template.push('"');
103            i += 1;
104            while i < n && bytes[i] != b'"' {
105                template.push(bytes[i] as char);
106                i += 1;
107            }
108            if i < n {
109                template.push('"');
110                i += 1;
111            }
112            continue;
113        }
114
115        // ── NULL keyword ──────────────────────────────────────────────────
116        if keyword_at(bytes, i, b"NULL") {
117            literals.push("NULL".to_string());
118            template.push('?');
119            i += 4;
120            continue;
121        }
122
123        // ── TRUE keyword ──────────────────────────────────────────────────
124        if keyword_at(bytes, i, b"TRUE") {
125            literals.push("TRUE".to_string());
126            template.push('?');
127            i += 4;
128            continue;
129        }
130
131        // ── FALSE keyword ─────────────────────────────────────────────────
132        if keyword_at(bytes, i, b"FALSE") {
133            literals.push("FALSE".to_string());
134            template.push('?');
135            i += 5;
136            continue;
137        }
138
139        // ── Positive numeric literal ──────────────────────────────────────
140        if bytes[i].is_ascii_digit() {
141            let start = i;
142            i = consume_number(bytes, i);
143            literals.push(sql[start..i].to_string());
144            template.push('?');
145            continue;
146        }
147
148        // ── Negative numeric literal: -<digit> in a value context ─────────
149        if bytes[i] == b'-'
150            && i + 1 < n
151            && bytes[i + 1].is_ascii_digit()
152            && in_value_context(&template)
153        {
154            let start = i;
155            i += 1; // skip '-'
156            i = consume_number(bytes, i);
157            literals.push(sql[start..i].to_string());
158            template.push('?');
159            continue;
160        }
161
162        // ── Pass through everything else ──────────────────────────────────
163        template.push(bytes[i] as char);
164        i += 1;
165    }
166
167    ParameterizedSql { template, literals }
168}
169
170// ── Helpers ───────────────────────────────────────────────────────────────────
171
172/// Consume a number starting at `i` (digits, optional `.digits`, optional `e[+-]digits`).
173fn consume_number(bytes: &[u8], mut i: usize) -> usize {
174    let n = bytes.len();
175    while i < n && bytes[i].is_ascii_digit() {
176        i += 1;
177    }
178    if i < n && bytes[i] == b'.' {
179        i += 1;
180        while i < n && bytes[i].is_ascii_digit() {
181            i += 1;
182        }
183    }
184    if i < n && (bytes[i] == b'e' || bytes[i] == b'E') {
185        i += 1;
186        if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
187            i += 1;
188        }
189        while i < n && bytes[i].is_ascii_digit() {
190            i += 1;
191        }
192    }
193    i
194}
195
196/// Check whether `keyword` (uppercase) appears at position `i` in `bytes`,
197/// case-insensitively and at a word boundary.
198fn keyword_at(bytes: &[u8], i: usize, keyword: &[u8]) -> bool {
199    let n = keyword.len();
200    if i + n > bytes.len() {
201        return false;
202    }
203    // Case-insensitive match.
204    for (j, &k) in keyword.iter().enumerate() {
205        if bytes[i + j].to_ascii_uppercase() != k {
206            return false;
207        }
208    }
209    // Must not be a continuation of a longer identifier.
210    let after = i + n;
211    if after < bytes.len() && (bytes[after].is_ascii_alphanumeric() || bytes[after] == b'_') {
212        return false;
213    }
214    // Must not follow an identifier character (avoid matching mid-word).
215    if i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') {
216        return false;
217    }
218    true
219}
220
221/// Return `true` when the last non-whitespace character in `template` is an
222/// operator or opening delimiter, indicating that a literal (possibly negative)
223/// is expected next.
224fn in_value_context(template: &str) -> bool {
225    let last = template
226        .bytes()
227        .rev()
228        .find(|&b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r'));
229    matches!(
230        last,
231        None | Some(b'=')
232            | Some(b'<')
233            | Some(b'>')
234            | Some(b'(')
235            | Some(b',')
236            | Some(b'[')
237            | Some(b'+')
238            | Some(b'*')
239            | Some(b'/')
240            | Some(b'!')
241    )
242}
243
244// ── Tests ─────────────────────────────────────────────────────────────────────
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_integer_literal() {
252        let p = parameterize("SELECT * FROM t WHERE id = 42");
253        assert_eq!(p.template, "SELECT * FROM t WHERE id = ?");
254        assert_eq!(p.literals, vec!["42"]);
255    }
256
257    #[test]
258    fn test_string_literal() {
259        let p = parameterize("SELECT * FROM t WHERE name = 'Alice'");
260        assert_eq!(p.template, "SELECT * FROM t WHERE name = ?");
261        assert_eq!(p.literals, vec!["'Alice'"]);
262    }
263
264    #[test]
265    fn test_float_literal() {
266        let p = parameterize("WHERE x = 3.14");
267        assert_eq!(p.template, "WHERE x = ?");
268        assert_eq!(p.literals, vec!["3.14"]);
269    }
270
271    #[test]
272    fn test_scientific_notation() {
273        let p = parameterize("WHERE x = 1.5e10");
274        assert_eq!(p.template, "WHERE x = ?");
275        assert_eq!(p.literals, vec!["1.5e10"]);
276    }
277
278    #[test]
279    fn test_null_keyword() {
280        let p = parameterize("WHERE x IS NULL");
281        assert_eq!(p.template, "WHERE x IS ?");
282        assert_eq!(p.literals, vec!["NULL"]);
283    }
284
285    #[test]
286    fn test_true_false_keywords() {
287        let p = parameterize("WHERE active = TRUE AND deleted = FALSE");
288        assert_eq!(p.template, "WHERE active = ? AND deleted = ?");
289        assert_eq!(p.literals, vec!["TRUE", "FALSE"]);
290    }
291
292    #[test]
293    fn test_negative_integer() {
294        let p = parameterize("WHERE x = -5");
295        assert_eq!(p.template, "WHERE x = ?");
296        assert_eq!(p.literals, vec!["-5"]);
297    }
298
299    #[test]
300    fn test_escaped_quote_in_string() {
301        // 'it''s' is a single string literal with an escaped apostrophe.
302        let p = parameterize("WHERE name = 'it''s'");
303        assert_eq!(p.template, "WHERE name = ?");
304        assert_eq!(p.literals, vec!["'it''s'"]);
305    }
306
307    #[test]
308    fn test_double_quoted_identifier_not_parameterized() {
309        let p = parameterize(r#"SELECT "id" FROM t WHERE "id" = 1"#);
310        assert!(
311            p.template.contains(r#""id""#),
312            "double-quoted identifier should be preserved"
313        );
314        assert_eq!(p.literals, vec!["1"]);
315    }
316
317    #[test]
318    fn test_multiple_literals() {
319        let p = parameterize("SELECT * FROM t WHERE x = 1 AND y = 'foo' AND z = NULL");
320        assert_eq!(
321            p.template,
322            "SELECT * FROM t WHERE x = ? AND y = ? AND z = ?"
323        );
324        assert_eq!(p.literals.len(), 3);
325    }
326
327    #[test]
328    fn test_parameterize_is_fixpoint() {
329        // Parameterizing the template again should produce the same template.
330        let sql = "SELECT id FROM t WHERE x = 42 AND name = 'Bob' AND active = TRUE";
331        let first = parameterize(sql);
332        let second = parameterize(&first.template);
333        assert_eq!(
334            first.template, second.template,
335            "parameterize should be a fixpoint"
336        );
337    }
338
339    #[test]
340    fn test_line_comment_not_parameterized() {
341        let p = parameterize("SELECT 1 -- this is 42\nFROM t");
342        // The 1 in SELECT is parameterized; the 42 in the comment is not.
343        assert_eq!(
344            p.literals.len(),
345            1,
346            "only the numeric literal 1 should be replaced"
347        );
348    }
349
350    #[test]
351    fn test_subtraction_not_negative_number() {
352        // "x - 5": the - follows the identifier x (not a value context).
353        let p = parameterize("WHERE x - 5 > 0");
354        // The 5 after - should still be parameterized (positive literal), and 0 too.
355        // The - itself stays as operator.
356        assert!(
357            p.template.contains('-'),
358            "subtraction operator should not be consumed as negative literal"
359        );
360    }
361}