reddb_server/storage/query/planner/
cache_key.rs1use reddb_rql::lexer::{Lexer, Token};
54use reddb_types::Value;
55
56pub 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; let mut preserve_numeric_literal = false;
68 while i < bytes.len() {
69 let b = bytes[i];
70
71 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 if b == b'\'' {
83 i += 1;
84 while i < bytes.len() {
85 if bytes[i] == b'\'' {
86 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 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 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 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 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 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 out.push(b as char);
178 preserve_numeric_literal = false;
179 last_was_space = false;
180 i += 1;
181 }
182
183 if out.ends_with(' ') {
186 out.pop();
187 }
188
189 out
190}
191
192pub fn same_cache_key(a: &str, b: &str) -> bool {
196 normalize_cache_key(a) == normalize_cache_key(b)
197}
198
199pub 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 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 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 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 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
599fn 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}