Skip to main content

reddb_server/storage/query/planner/
cache_key.rs

1//! Plan cache key normalisation — Fase 4 P1 building block.
2//!
3//! Normalises a raw SQL query string into a canonical cache key
4//! by replacing literal tokens (integers, floats, strings,
5//! booleans, null) with a single `?` placeholder. Two queries
6//! that differ only in their literal values collapse to the
7//! same key.
8//!
9//! ## Why here
10//!
11//! The full parameter-binding story — `Expr::Parameter(n)` in
12//! the AST, a bind phase that substitutes concrete values
13//! before execution, cache-hit reuse of the parsed expression
14//! — requires invasive changes to every path that holds a
15//! `QueryExpr`. That's Fase 4 W3+ scope.
16//!
17//! This module is the smallest immediately-shippable piece:
18//! the normalised cache key. Today's `impl_core::execute_query`
19//! keys the plan cache by raw SQL text, so `WHERE id = 1` and
20//! `WHERE id = 2` produce different entries. Normalising the
21//! key first means both queries hit a shared entry.
22//!
23//! BUT the cached entry still contains the *old* literal
24//! values baked into its `QueryExpr`, so cache hits must
25//! re-parse the new query and discard the cached plan's
26//! AST if the literals matter for execution. The follow-up
27//! commit does exactly that — `execute_query` will compare the
28//! normalised form on lookup and re-parse when the cached
29//! plan's literals don't match the fresh query.
30//!
31//! Until that follow-up, this module is the fast-path
32//! building block: cheap tokenisation + literal stripping,
33//! producing a stable `String` the cache can use.
34//!
35//! ## Algorithm
36//!
37//! Single-pass tokenizer-lite that walks the query character
38//! by character and emits a canonical form:
39//!
40//! - Integers / floats: emit `?`
41//! - Single-quoted strings: emit `?`; double-quoted identifiers stay verbatim.
42//! - `TRUE` / `FALSE` / `NULL` keywords (case-insensitive,
43//!   word-bounded): emit `?`
44//! - Everything else: copy verbatim.
45//! - Whitespace runs collapse to a single space so `SELECT  a`
46//!   and `SELECT a` produce the same key.
47//! - Keywords are uppercased so `select` and `SELECT` match.
48//!
49//! The output is a best-effort canonical form. It's not a
50//! formal parse — we only care about stable equivalence
51//! classes, not strict correctness.
52
53use reddb_rql::lexer::{Lexer, Token};
54use reddb_types::Value;
55
56/// Normalise a raw SQL query into a cache-friendly canonical
57/// form. Stable across whitespace, case, and literal values;
58/// identical AST shapes collapse to the same output.
59///
60/// Worst case O(n) where n = input length, O(1) state. No
61/// allocation beyond the output string.
62pub fn normalize_cache_key(sql: &str) -> String {
63    let mut out = String::with_capacity(sql.len());
64    let bytes = sql.as_bytes();
65    let mut i = 0;
66    let mut last_was_space = true; // suppress leading space
67    let mut preserve_numeric_literal = false;
68    while i < bytes.len() {
69        let b = bytes[i];
70
71        // Whitespace collapse.
72        if b.is_ascii_whitespace() {
73            if !last_was_space {
74                out.push(' ');
75                last_was_space = true;
76            }
77            i += 1;
78            continue;
79        }
80
81        // Single-quoted string: scan to matching quote, emit `?`.
82        if b == b'\'' {
83            i += 1;
84            while i < bytes.len() {
85                if bytes[i] == b'\'' {
86                    // SQL escape: two consecutive quotes is a
87                    // literal quote inside the string. Skip both
88                    // and continue scanning.
89                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
90                        i += 2;
91                        continue;
92                    }
93                    i += 1;
94                    break;
95                }
96                i += 1;
97            }
98            out.push('?');
99            last_was_space = false;
100            continue;
101        }
102
103        // Double-quoted string (identifier in SQL-92; still
104        // handled as opaque here — quoted identifiers are
105        // case-sensitive so we emit them verbatim).
106        if b == b'"' {
107            let start = i;
108            i = quoted_identifier_end(bytes, i);
109            out.push_str(&sql[start..i]);
110            last_was_space = false;
111            continue;
112        }
113
114        // Numeric literal: integer, float, or scientific.
115        // Optional leading sign is NOT consumed here because it
116        // could be a binary operator; we only canonicalise
117        // digit-led runs.
118        if b.is_ascii_digit() {
119            let start = i;
120            while i < bytes.len()
121                && (bytes[i].is_ascii_digit()
122                    || bytes[i] == b'.'
123                    || bytes[i] == b'e'
124                    || bytes[i] == b'E'
125                    || bytes[i] == b'+'
126                    || bytes[i] == b'-')
127            {
128                // Only consume + / - when immediately following
129                // e / E (scientific notation exponent sign).
130                if bytes[i] == b'+' || bytes[i] == b'-' {
131                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
132                    if prev != b'e' && prev != b'E' {
133                        break;
134                    }
135                }
136                i += 1;
137            }
138            if preserve_numeric_literal {
139                out.push_str(&sql[start..i]);
140                preserve_numeric_literal = false;
141            } else {
142                out.push('?');
143            }
144            last_was_space = false;
145            continue;
146        }
147
148        // Identifier / keyword run.
149        if b.is_ascii_alphabetic() || b == b'_' {
150            let start = i;
151            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
152                i += 1;
153            }
154            let word = &sql[start..i];
155            // Case-insensitive keyword canonicalisation for the
156            // three literal keywords TRUE / FALSE / NULL.
157            if word.eq_ignore_ascii_case("true")
158                || word.eq_ignore_ascii_case("false")
159                || word.eq_ignore_ascii_case("null")
160            {
161                out.push('?');
162                preserve_numeric_literal = false;
163            } else if is_cache_key_keyword(word) {
164                push_uppercase(&mut out, word);
165                preserve_numeric_literal =
166                    word.eq_ignore_ascii_case("limit") || word.eq_ignore_ascii_case("offset");
167            } else {
168                out.push_str(word);
169                preserve_numeric_literal = false;
170            }
171            last_was_space = false;
172            continue;
173        }
174
175        // Everything else (punctuation, operators, parens).
176        // Emit verbatim.
177        out.push(b as char);
178        preserve_numeric_literal = false;
179        last_was_space = false;
180        i += 1;
181    }
182
183    // Trim a single trailing space so `SELECT 1 ` and
184    // `SELECT 1` collapse.
185    if out.ends_with(' ') {
186        out.pop();
187    }
188
189    out
190}
191
192/// Returns true when two raw SQL strings would hit the same
193/// plan cache slot. Used by diagnostic tools to verify the
194/// normalisation is doing its job.
195pub fn same_cache_key(a: &str, b: &str) -> bool {
196    normalize_cache_key(a) == normalize_cache_key(b)
197}
198
199/// Fused single-pass of `normalize_cache_key` + `extract_literal_bindings`.
200///
201/// The normalize pass already identifies every literal token
202/// (byte-scan state machine — single quotes, numeric runs,
203/// TRUE/FALSE/NULL keywords). Extracting the bound `Value`
204/// alongside is strictly cheaper than running a separate `Lexer`
205/// pass, which is what `extract_literal_bindings` does today.
206///
207/// On the plan-cache HIT path (every UPDATE / repeat SELECT in a
208/// hot loop) this saves one full lex of the query text per hit.
209pub fn normalize_and_extract(sql: &str) -> (String, Vec<Value>) {
210    let mut out = String::with_capacity(sql.len());
211    let mut binds: Vec<Value> = Vec::new();
212    let bytes = sql.as_bytes();
213    let mut i = 0;
214    let mut last_was_space = true;
215    let mut preserve_numeric_literal = false;
216    while i < bytes.len() {
217        let b = bytes[i];
218
219        if b.is_ascii_whitespace() {
220            if !last_was_space {
221                out.push(' ');
222                last_was_space = true;
223            }
224            i += 1;
225            continue;
226        }
227
228        if b == b'\'' {
229            // Walk the string body, honouring the SQL '' escape.
230            // Handle the escape-free fast path as a single span copy.
231            i += 1;
232            let body_start = i;
233            let mut literal: Option<String> = None;
234            while i < bytes.len() {
235                if bytes[i] == b'\'' {
236                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
237                        // Escaped single quote — switch to the
238                        // owned-accumulator mode if we haven't
239                        // already, copying what we've seen so far.
240                        let acc = literal.get_or_insert_with(|| sql[body_start..i].to_string());
241                        acc.push('\'');
242                        i += 2;
243                        continue;
244                    }
245                    break;
246                }
247                if let Some(ref mut acc) = literal {
248                    acc.push(bytes[i] as char);
249                }
250                i += 1;
251            }
252            let value = match literal {
253                Some(s) => s,
254                None => sql[body_start..i].to_string(),
255            };
256            if i < bytes.len() && bytes[i] == b'\'' {
257                i += 1;
258            }
259            binds.push(Value::text(value));
260            out.push('?');
261            last_was_space = false;
262            continue;
263        }
264
265        if b == b'"' {
266            let start = i;
267            i = quoted_identifier_end(bytes, i);
268            out.push_str(&sql[start..i]);
269            last_was_space = false;
270            continue;
271        }
272
273        if b.is_ascii_digit() {
274            let start = i;
275            while i < bytes.len()
276                && (bytes[i].is_ascii_digit()
277                    || bytes[i] == b'.'
278                    || bytes[i] == b'e'
279                    || bytes[i] == b'E'
280                    || bytes[i] == b'+'
281                    || bytes[i] == b'-')
282            {
283                if bytes[i] == b'+' || bytes[i] == b'-' {
284                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
285                    if prev != b'e' && prev != b'E' {
286                        break;
287                    }
288                }
289                i += 1;
290            }
291            let lit = &sql[start..i];
292            if preserve_numeric_literal {
293                out.push_str(lit);
294                preserve_numeric_literal = false;
295            } else {
296                out.push('?');
297                if lit.contains('.') || lit.contains('e') || lit.contains('E') {
298                    if let Ok(v) = lit.parse::<f64>() {
299                        binds.push(Value::Float(v));
300                    }
301                } else if let Ok(v) = lit.parse::<i64>() {
302                    binds.push(Value::Integer(v));
303                } else if let Ok(v) = lit.parse::<u64>() {
304                    binds.push(Value::UnsignedInteger(v));
305                }
306            }
307            last_was_space = false;
308            continue;
309        }
310
311        if b.is_ascii_alphabetic() || b == b'_' {
312            let start = i;
313            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
314                i += 1;
315            }
316            let word = &sql[start..i];
317            if word.eq_ignore_ascii_case("true") {
318                out.push('?');
319                binds.push(Value::Boolean(true));
320                preserve_numeric_literal = false;
321            } else if word.eq_ignore_ascii_case("false") {
322                out.push('?');
323                binds.push(Value::Boolean(false));
324                preserve_numeric_literal = false;
325            } else if word.eq_ignore_ascii_case("null") {
326                out.push('?');
327                binds.push(Value::Null);
328                preserve_numeric_literal = false;
329            } else if is_cache_key_keyword(word) {
330                push_uppercase(&mut out, word);
331                preserve_numeric_literal =
332                    word.eq_ignore_ascii_case("limit") || word.eq_ignore_ascii_case("offset");
333            } else {
334                out.push_str(word);
335                preserve_numeric_literal = false;
336            }
337            last_was_space = false;
338            continue;
339        }
340
341        out.push(b as char);
342        preserve_numeric_literal = false;
343        last_was_space = false;
344        i += 1;
345    }
346
347    if out.ends_with(' ') {
348        out.pop();
349    }
350
351    (out, binds)
352}
353
354fn is_cache_key_keyword(word: &str) -> bool {
355    matches!(
356        word.to_ascii_uppercase().as_str(),
357        "SELECT"
358            | "FROM"
359            | "WHERE"
360            | "AND"
361            | "OR"
362            | "NOT"
363            | "AS"
364            | "IS"
365            | "BETWEEN"
366            | "LIKE"
367            | "IN"
368            | "ORDER"
369            | "BY"
370            | "ASC"
371            | "DESC"
372            | "NULLS"
373            | "FIRST"
374            | "LAST"
375            | "LIMIT"
376            | "OFFSET"
377            | "GROUP"
378            | "HAVING"
379            | "COUNT"
380            | "SUM"
381            | "AVG"
382            | "MIN"
383            | "MAX"
384            | "DISTINCT"
385            | "INSERT"
386            | "INTO"
387            | "VALUES"
388            | "UPDATE"
389            | "SET"
390            | "DELETE"
391            | "CREATE"
392            | "TABLE"
393            | "DOCUMENT"
394            | "KV"
395            | "DROP"
396            | "ALTER"
397            | "INDEX"
398            | "JOIN"
399            | "INNER"
400            | "LEFT"
401            | "RIGHT"
402            | "FULL"
403            | "OUTER"
404            | "ON"
405            | "RETURNING"
406            | "CONTAINS"
407            | "STARTS"
408            | "ENDS"
409            | "WITH"
410            | "EXPLAIN"
411    )
412}
413
414fn push_uppercase(out: &mut String, word: &str) {
415    for c in word.chars() {
416        out.push(c.to_ascii_uppercase());
417    }
418}
419
420pub fn extract_literal_bindings(sql: &str) -> Result<Vec<Value>, String> {
421    let mut lexer = Lexer::new(sql);
422    let mut binds = Vec::new();
423    let mut skip_next_numeric = false;
424
425    loop {
426        let spanned = lexer.next_token().map_err(|err| err.to_string())?;
427        match spanned.token {
428            Token::Eof => break,
429            Token::Limit | Token::Offset => {
430                skip_next_numeric = true;
431            }
432            Token::Integer(n) => {
433                if !skip_next_numeric {
434                    binds.push(Value::Integer(n));
435                }
436                skip_next_numeric = false;
437            }
438            Token::Float(n) => {
439                if !skip_next_numeric {
440                    binds.push(Value::Float(n));
441                }
442                skip_next_numeric = false;
443            }
444            Token::String(s) => {
445                binds.push(Value::text(s));
446                skip_next_numeric = false;
447            }
448            Token::True => {
449                binds.push(Value::Boolean(true));
450                skip_next_numeric = false;
451            }
452            Token::False => {
453                binds.push(Value::Boolean(false));
454                skip_next_numeric = false;
455            }
456            Token::Null => {
457                binds.push(Value::Null);
458                skip_next_numeric = false;
459            }
460            _ => {
461                skip_next_numeric = false;
462            }
463        }
464    }
465
466    Ok(binds)
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn integer_literals_collapse() {
475        assert_eq!(
476            normalize_cache_key("SELECT * FROM t WHERE id = 1"),
477            normalize_cache_key("SELECT * FROM t WHERE id = 2"),
478        );
479    }
480
481    #[test]
482    fn string_literals_collapse() {
483        assert_eq!(
484            normalize_cache_key("SELECT * FROM t WHERE name = 'alice'"),
485            normalize_cache_key("SELECT * FROM t WHERE name = 'bob'"),
486        );
487    }
488
489    #[test]
490    fn case_insensitive_keywords() {
491        assert_eq!(
492            normalize_cache_key("select * from t"),
493            normalize_cache_key("SELECT * FROM t"),
494        );
495    }
496
497    #[test]
498    fn whitespace_collapses() {
499        assert_eq!(
500            normalize_cache_key("SELECT   *  FROM  t"),
501            normalize_cache_key("SELECT * FROM t"),
502        );
503    }
504
505    #[test]
506    fn different_shape_different_key() {
507        assert_ne!(
508            normalize_cache_key("SELECT * FROM a WHERE x = 1"),
509            normalize_cache_key("SELECT * FROM b WHERE x = 1"),
510        );
511    }
512
513    #[test]
514    fn float_and_scientific_collapse() {
515        assert_eq!(
516            normalize_cache_key("SELECT 1.5e10"),
517            normalize_cache_key("SELECT 3.14"),
518        );
519    }
520
521    #[test]
522    fn null_and_boolean_are_literals() {
523        assert_eq!(
524            normalize_cache_key("WHERE x IS NULL"),
525            normalize_cache_key("WHERE x IS TRUE"),
526        );
527    }
528
529    #[test]
530    fn quoted_identifiers_preserved() {
531        // Double-quoted identifiers stay verbatim so
532        // "col" and "other" don't collapse.
533        assert_ne!(
534            normalize_cache_key(r#"SELECT "col" FROM t"#),
535            normalize_cache_key(r#"SELECT "other" FROM t"#),
536        );
537    }
538
539    #[test]
540    fn unquoted_identifier_case_is_preserved() {
541        assert_ne!(
542            normalize_cache_key("SELECT name FROM docs WHERE UserId = 1"),
543            normalize_cache_key("SELECT name FROM docs WHERE userid = 1"),
544        );
545        assert_ne!(
546            normalize_and_extract("SELECT name FROM docs WHERE UserId = 1").0,
547            normalize_and_extract("SELECT name FROM docs WHERE userid = 1").0,
548        );
549    }
550
551    #[test]
552    fn limit_and_offset_literals_remain_in_shape() {
553        assert_ne!(
554            normalize_cache_key("SELECT * FROM t WHERE id = 1 LIMIT 10"),
555            normalize_cache_key("SELECT * FROM t WHERE id = 2 LIMIT 20"),
556        );
557        assert_ne!(
558            normalize_cache_key("SELECT * FROM t WHERE id = 1 OFFSET 10"),
559            normalize_cache_key("SELECT * FROM t WHERE id = 2 OFFSET 20"),
560        );
561    }
562
563    #[test]
564    fn normalize_and_extract_agrees_with_separate_paths() {
565        let queries = [
566            "SELECT * FROM users WHERE id = 42",
567            "UPDATE users SET score = 99.5 WHERE city = 'NYC' AND age > 30",
568            "DELETE FROM t WHERE name = 'al''ice' AND active = TRUE",
569            "SELECT 1, 'x', 2.5, NULL, FALSE FROM t",
570            "SELECT * FROM t LIMIT 10 OFFSET 5",
571        ];
572        for q in queries {
573            let (fk, fb) = normalize_and_extract(q);
574            assert_eq!(fk, normalize_cache_key(q), "cache_key mismatch for: {q}");
575            let sep = extract_literal_bindings(q).unwrap();
576            assert_eq!(
577                fb.len(),
578                sep.len(),
579                "bind count mismatch for {q}: fused={:?} sep={:?}",
580                fb,
581                sep
582            );
583            // Compare by string repr (Value doesn't derive PartialEq uniformly).
584            for (a, b) in fb.iter().zip(sep.iter()) {
585                assert_eq!(format!("{a:?}"), format!("{b:?}"), "bind mismatch for {q}");
586            }
587        }
588    }
589
590    #[test]
591    fn extract_literal_bindings_skips_limit_and_offset() {
592        let binds =
593            extract_literal_bindings("SELECT * FROM t WHERE age = 18 AND active = true LIMIT 10")
594                .unwrap();
595        assert_eq!(binds, vec![Value::Integer(18), Value::Boolean(true)]);
596    }
597}
598
599// Match lexer escapes without normalizing anything inside the identifier.
600fn quoted_identifier_end(bytes: &[u8], start: usize) -> usize {
601    let mut i = start + 1;
602    while i < bytes.len() {
603        if bytes[i] == b'\\' {
604            i = (i + 2).min(bytes.len());
605        } else if bytes[i] == b'"' {
606            if bytes.get(i + 1) == Some(&b'"') {
607                i += 2;
608            } else {
609                return i + 1;
610            }
611        } else {
612            i += 1;
613        }
614    }
615    i
616}
617
618#[cfg(test)]
619mod quoted_identifier_tests {
620    use super::*;
621
622    #[test]
623    fn escaped_identifier_digits_are_not_literal_parameters() {
624        for name in [r#""a\"1""#, r#""a""1""#, r#""back\\slash 12""#] {
625            let query = format!("SELECT {name} FROM t WHERE v = 7");
626            let (key, binds) = normalize_and_extract(&query);
627            assert_eq!(key, format!("SELECT {name} FROM t WHERE v = ?"));
628            assert_eq!(key, normalize_cache_key(&query));
629            assert_eq!(binds, vec![Value::Integer(7)]);
630        }
631        assert_ne!(
632            normalize_and_extract(r#"SELECT "a\"1" FROM t WHERE v=7"#).0,
633            normalize_and_extract(r#"SELECT "a\"2" FROM t WHERE v=7"#).0,
634        );
635    }
636}