reddb_server/storage/query/planner/
cache_key.rs1use crate::storage::query::lexer::{Lexer, Token};
54use crate::storage::schema::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 += 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 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 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 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 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 out.push(b as char);
184 preserve_numeric_literal = false;
185 last_was_space = false;
186 i += 1;
187 }
188
189 if out.ends_with(' ') {
192 out.pop();
193 }
194
195 out
196}
197
198pub fn same_cache_key(a: &str, b: &str) -> bool {
202 normalize_cache_key(a) == normalize_cache_key(b)
203}
204
205pub 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 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 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 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 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}