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//! - Quoted strings (single + double): emit `?`
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 crate::storage::query::lexer::{Lexer, Token};
54use crate::storage::schema::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 += 1;
109            while i < bytes.len() && bytes[i] != b'"' {
110                i += 1;
111            }
112            if i < bytes.len() {
113                i += 1;
114            }
115            out.push_str(&sql[start..i]);
116            last_was_space = false;
117            continue;
118        }
119
120        // Numeric literal: integer, float, or scientific.
121        // Optional leading sign is NOT consumed here because it
122        // could be a binary operator; we only canonicalise
123        // digit-led runs.
124        if b.is_ascii_digit() {
125            let start = i;
126            while i < bytes.len()
127                && (bytes[i].is_ascii_digit()
128                    || bytes[i] == b'.'
129                    || bytes[i] == b'e'
130                    || bytes[i] == b'E'
131                    || bytes[i] == b'+'
132                    || bytes[i] == b'-')
133            {
134                // Only consume + / - when immediately following
135                // e / E (scientific notation exponent sign).
136                if bytes[i] == b'+' || bytes[i] == b'-' {
137                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
138                    if prev != b'e' && prev != b'E' {
139                        break;
140                    }
141                }
142                i += 1;
143            }
144            if preserve_numeric_literal {
145                out.push_str(&sql[start..i]);
146                preserve_numeric_literal = false;
147            } else {
148                out.push('?');
149            }
150            last_was_space = false;
151            continue;
152        }
153
154        // Identifier / keyword run.
155        if b.is_ascii_alphabetic() || b == b'_' {
156            let start = i;
157            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
158                i += 1;
159            }
160            let word = &sql[start..i];
161            // Case-insensitive keyword canonicalisation for the
162            // three literal keywords TRUE / FALSE / NULL.
163            if word.eq_ignore_ascii_case("true")
164                || word.eq_ignore_ascii_case("false")
165                || word.eq_ignore_ascii_case("null")
166            {
167                out.push('?');
168                preserve_numeric_literal = false;
169            } else if is_cache_key_keyword(word) {
170                push_uppercase(&mut out, word);
171                preserve_numeric_literal =
172                    word.eq_ignore_ascii_case("limit") || word.eq_ignore_ascii_case("offset");
173            } else {
174                out.push_str(word);
175                preserve_numeric_literal = false;
176            }
177            last_was_space = false;
178            continue;
179        }
180
181        // Everything else (punctuation, operators, parens).
182        // Emit verbatim.
183        out.push(b as char);
184        preserve_numeric_literal = false;
185        last_was_space = false;
186        i += 1;
187    }
188
189    // Trim a single trailing space so `SELECT 1 ` and
190    // `SELECT 1` collapse.
191    if out.ends_with(' ') {
192        out.pop();
193    }
194
195    out
196}
197
198/// Returns true when two raw SQL strings would hit the same
199/// plan cache slot. Used by diagnostic tools to verify the
200/// normalisation is doing its job.
201pub fn same_cache_key(a: &str, b: &str) -> bool {
202    normalize_cache_key(a) == normalize_cache_key(b)
203}
204
205/// Fused single-pass of `normalize_cache_key` + `extract_literal_bindings`.
206///
207/// The normalize pass already identifies every literal token
208/// (byte-scan state machine — single quotes, numeric runs,
209/// TRUE/FALSE/NULL keywords). Extracting the bound `Value`
210/// alongside is strictly cheaper than running a separate `Lexer`
211/// pass, which is what `extract_literal_bindings` does today.
212///
213/// On the plan-cache HIT path (every UPDATE / repeat SELECT in a
214/// hot loop) this saves one full lex of the query text per hit.
215pub fn normalize_and_extract(sql: &str) -> (String, Vec<Value>) {
216    let mut out = String::with_capacity(sql.len());
217    let mut binds: Vec<Value> = Vec::new();
218    let bytes = sql.as_bytes();
219    let mut i = 0;
220    let mut last_was_space = true;
221    let mut preserve_numeric_literal = false;
222    while i < bytes.len() {
223        let b = bytes[i];
224
225        if b.is_ascii_whitespace() {
226            if !last_was_space {
227                out.push(' ');
228                last_was_space = true;
229            }
230            i += 1;
231            continue;
232        }
233
234        if b == b'\'' {
235            // Walk the string body, honouring the SQL '' escape.
236            // Handle the escape-free fast path as a single span copy.
237            i += 1;
238            let body_start = i;
239            let mut literal: Option<String> = None;
240            while i < bytes.len() {
241                if bytes[i] == b'\'' {
242                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
243                        // Escaped single quote — switch to the
244                        // owned-accumulator mode if we haven't
245                        // already, copying what we've seen so far.
246                        let acc = literal.get_or_insert_with(|| sql[body_start..i].to_string());
247                        acc.push('\'');
248                        i += 2;
249                        continue;
250                    }
251                    break;
252                }
253                if let Some(ref mut acc) = literal {
254                    acc.push(bytes[i] as char);
255                }
256                i += 1;
257            }
258            let value = match literal {
259                Some(s) => s,
260                None => sql[body_start..i].to_string(),
261            };
262            if i < bytes.len() && bytes[i] == b'\'' {
263                i += 1;
264            }
265            binds.push(Value::text(value));
266            out.push('?');
267            last_was_space = false;
268            continue;
269        }
270
271        if b == b'"' {
272            let start = i;
273            i += 1;
274            while i < bytes.len() && bytes[i] != b'"' {
275                i += 1;
276            }
277            if i < bytes.len() {
278                i += 1;
279            }
280            out.push_str(&sql[start..i]);
281            last_was_space = false;
282            continue;
283        }
284
285        if b.is_ascii_digit() {
286            let start = i;
287            while i < bytes.len()
288                && (bytes[i].is_ascii_digit()
289                    || bytes[i] == b'.'
290                    || bytes[i] == b'e'
291                    || bytes[i] == b'E'
292                    || bytes[i] == b'+'
293                    || bytes[i] == b'-')
294            {
295                if bytes[i] == b'+' || bytes[i] == b'-' {
296                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
297                    if prev != b'e' && prev != b'E' {
298                        break;
299                    }
300                }
301                i += 1;
302            }
303            let lit = &sql[start..i];
304            if preserve_numeric_literal {
305                out.push_str(lit);
306                preserve_numeric_literal = false;
307            } else {
308                out.push('?');
309                if lit.contains('.') || lit.contains('e') || lit.contains('E') {
310                    if let Ok(v) = lit.parse::<f64>() {
311                        binds.push(Value::Float(v));
312                    }
313                } else if let Ok(v) = lit.parse::<i64>() {
314                    binds.push(Value::Integer(v));
315                } else if let Ok(v) = lit.parse::<u64>() {
316                    binds.push(Value::UnsignedInteger(v));
317                }
318            }
319            last_was_space = false;
320            continue;
321        }
322
323        if b.is_ascii_alphabetic() || b == b'_' {
324            let start = i;
325            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
326                i += 1;
327            }
328            let word = &sql[start..i];
329            if word.eq_ignore_ascii_case("true") {
330                out.push('?');
331                binds.push(Value::Boolean(true));
332                preserve_numeric_literal = false;
333            } else if word.eq_ignore_ascii_case("false") {
334                out.push('?');
335                binds.push(Value::Boolean(false));
336                preserve_numeric_literal = false;
337            } else if word.eq_ignore_ascii_case("null") {
338                out.push('?');
339                binds.push(Value::Null);
340                preserve_numeric_literal = false;
341            } else if is_cache_key_keyword(word) {
342                push_uppercase(&mut out, word);
343                preserve_numeric_literal =
344                    word.eq_ignore_ascii_case("limit") || word.eq_ignore_ascii_case("offset");
345            } else {
346                out.push_str(word);
347                preserve_numeric_literal = false;
348            }
349            last_was_space = false;
350            continue;
351        }
352
353        out.push(b as char);
354        preserve_numeric_literal = false;
355        last_was_space = false;
356        i += 1;
357    }
358
359    if out.ends_with(' ') {
360        out.pop();
361    }
362
363    (out, binds)
364}
365
366fn is_cache_key_keyword(word: &str) -> bool {
367    matches!(
368        word.to_ascii_uppercase().as_str(),
369        "SELECT"
370            | "FROM"
371            | "WHERE"
372            | "AND"
373            | "OR"
374            | "NOT"
375            | "AS"
376            | "IS"
377            | "BETWEEN"
378            | "LIKE"
379            | "IN"
380            | "ORDER"
381            | "BY"
382            | "ASC"
383            | "DESC"
384            | "NULLS"
385            | "FIRST"
386            | "LAST"
387            | "LIMIT"
388            | "OFFSET"
389            | "GROUP"
390            | "HAVING"
391            | "COUNT"
392            | "SUM"
393            | "AVG"
394            | "MIN"
395            | "MAX"
396            | "DISTINCT"
397            | "INSERT"
398            | "INTO"
399            | "VALUES"
400            | "UPDATE"
401            | "SET"
402            | "DELETE"
403            | "CREATE"
404            | "TABLE"
405            | "DOCUMENT"
406            | "KV"
407            | "DROP"
408            | "ALTER"
409            | "INDEX"
410            | "JOIN"
411            | "INNER"
412            | "LEFT"
413            | "RIGHT"
414            | "FULL"
415            | "OUTER"
416            | "ON"
417            | "RETURNING"
418            | "CONTAINS"
419            | "STARTS"
420            | "ENDS"
421            | "WITH"
422            | "EXPLAIN"
423    )
424}
425
426fn push_uppercase(out: &mut String, word: &str) {
427    for c in word.chars() {
428        out.push(c.to_ascii_uppercase());
429    }
430}
431
432pub fn extract_literal_bindings(sql: &str) -> Result<Vec<Value>, String> {
433    let mut lexer = Lexer::new(sql);
434    let mut binds = Vec::new();
435    let mut skip_next_numeric = false;
436
437    loop {
438        let spanned = lexer.next_token().map_err(|err| err.to_string())?;
439        match spanned.token {
440            Token::Eof => break,
441            Token::Limit | Token::Offset => {
442                skip_next_numeric = true;
443            }
444            Token::Integer(n) => {
445                if !skip_next_numeric {
446                    binds.push(Value::Integer(n));
447                }
448                skip_next_numeric = false;
449            }
450            Token::Float(n) => {
451                if !skip_next_numeric {
452                    binds.push(Value::Float(n));
453                }
454                skip_next_numeric = false;
455            }
456            Token::String(s) => {
457                binds.push(Value::text(s));
458                skip_next_numeric = false;
459            }
460            Token::True => {
461                binds.push(Value::Boolean(true));
462                skip_next_numeric = false;
463            }
464            Token::False => {
465                binds.push(Value::Boolean(false));
466                skip_next_numeric = false;
467            }
468            Token::Null => {
469                binds.push(Value::Null);
470                skip_next_numeric = false;
471            }
472            _ => {
473                skip_next_numeric = false;
474            }
475        }
476    }
477
478    Ok(binds)
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn integer_literals_collapse() {
487        assert_eq!(
488            normalize_cache_key("SELECT * FROM t WHERE id = 1"),
489            normalize_cache_key("SELECT * FROM t WHERE id = 2"),
490        );
491    }
492
493    #[test]
494    fn string_literals_collapse() {
495        assert_eq!(
496            normalize_cache_key("SELECT * FROM t WHERE name = 'alice'"),
497            normalize_cache_key("SELECT * FROM t WHERE name = 'bob'"),
498        );
499    }
500
501    #[test]
502    fn case_insensitive_keywords() {
503        assert_eq!(
504            normalize_cache_key("select * from t"),
505            normalize_cache_key("SELECT * FROM t"),
506        );
507    }
508
509    #[test]
510    fn whitespace_collapses() {
511        assert_eq!(
512            normalize_cache_key("SELECT   *  FROM  t"),
513            normalize_cache_key("SELECT * FROM t"),
514        );
515    }
516
517    #[test]
518    fn different_shape_different_key() {
519        assert_ne!(
520            normalize_cache_key("SELECT * FROM a WHERE x = 1"),
521            normalize_cache_key("SELECT * FROM b WHERE x = 1"),
522        );
523    }
524
525    #[test]
526    fn float_and_scientific_collapse() {
527        assert_eq!(
528            normalize_cache_key("SELECT 1.5e10"),
529            normalize_cache_key("SELECT 3.14"),
530        );
531    }
532
533    #[test]
534    fn null_and_boolean_are_literals() {
535        assert_eq!(
536            normalize_cache_key("WHERE x IS NULL"),
537            normalize_cache_key("WHERE x IS TRUE"),
538        );
539    }
540
541    #[test]
542    fn quoted_identifiers_preserved() {
543        // Double-quoted identifiers stay verbatim so
544        // "col" and "other" don't collapse.
545        assert_ne!(
546            normalize_cache_key(r#"SELECT "col" FROM t"#),
547            normalize_cache_key(r#"SELECT "other" FROM t"#),
548        );
549    }
550
551    #[test]
552    fn unquoted_identifier_case_is_preserved() {
553        assert_ne!(
554            normalize_cache_key("SELECT name FROM docs WHERE UserId = 1"),
555            normalize_cache_key("SELECT name FROM docs WHERE userid = 1"),
556        );
557        assert_ne!(
558            normalize_and_extract("SELECT name FROM docs WHERE UserId = 1").0,
559            normalize_and_extract("SELECT name FROM docs WHERE userid = 1").0,
560        );
561    }
562
563    #[test]
564    fn limit_and_offset_literals_remain_in_shape() {
565        assert_ne!(
566            normalize_cache_key("SELECT * FROM t WHERE id = 1 LIMIT 10"),
567            normalize_cache_key("SELECT * FROM t WHERE id = 2 LIMIT 20"),
568        );
569        assert_ne!(
570            normalize_cache_key("SELECT * FROM t WHERE id = 1 OFFSET 10"),
571            normalize_cache_key("SELECT * FROM t WHERE id = 2 OFFSET 20"),
572        );
573    }
574
575    #[test]
576    fn normalize_and_extract_agrees_with_separate_paths() {
577        let queries = [
578            "SELECT * FROM users WHERE id = 42",
579            "UPDATE users SET score = 99.5 WHERE city = 'NYC' AND age > 30",
580            "DELETE FROM t WHERE name = 'al''ice' AND active = TRUE",
581            "SELECT 1, 'x', 2.5, NULL, FALSE FROM t",
582            "SELECT * FROM t LIMIT 10 OFFSET 5",
583        ];
584        for q in queries {
585            let (fk, fb) = normalize_and_extract(q);
586            assert_eq!(fk, normalize_cache_key(q), "cache_key mismatch for: {q}");
587            let sep = extract_literal_bindings(q).unwrap();
588            assert_eq!(
589                fb.len(),
590                sep.len(),
591                "bind count mismatch for {q}: fused={:?} sep={:?}",
592                fb,
593                sep
594            );
595            // Compare by string repr (Value doesn't derive PartialEq uniformly).
596            for (a, b) in fb.iter().zip(sep.iter()) {
597                assert_eq!(format!("{a:?}"), format!("{b:?}"), "bind mismatch for {q}");
598            }
599        }
600    }
601
602    #[test]
603    fn extract_literal_bindings_skips_limit_and_offset() {
604        let binds =
605            extract_literal_bindings("SELECT * FROM t WHERE age = 18 AND active = true LIMIT 10")
606                .unwrap();
607        assert_eq!(binds, vec![Value::Integer(18), Value::Boolean(true)]);
608    }
609}