wp_knowledge/
sql_route.rs1use std::collections::HashMap;
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10use std::sync::{OnceLock, RwLock};
11
12use crate::runtime::runtime;
13
14const ROUTE_MEMO_CAP: usize = 1024;
16
17struct RouteMemo {
18 epoch: u64,
19 map: HashMap<u64, Option<(String, String)>>,
20}
21
22fn route_memo() -> &'static RwLock<RouteMemo> {
23 static MEMO: OnceLock<RwLock<RouteMemo>> = OnceLock::new();
24 MEMO.get_or_init(|| {
25 RwLock::new(RouteMemo {
26 epoch: 0,
27 map: HashMap::new(),
28 })
29 })
30}
31
32fn sql_route_key(sql: &str) -> u64 {
33 let mut hasher = DefaultHasher::new();
34 sql.hash(&mut hasher);
35 hasher.finish()
36}
37
38pub fn route_provider_sql(sql: &str) -> Option<(String, String)> {
44 let epoch = runtime().named_provider_epoch();
45 let key = sql_route_key(sql);
46 {
47 let memo = route_memo()
48 .read()
49 .expect("sql route memo read lock poisoned");
50 if memo.epoch == epoch
51 && let Some(routed) = memo.map.get(&key)
52 {
53 return routed.clone();
54 }
55 }
56 let routed = compute_route(sql);
57 let mut memo = route_memo()
58 .write()
59 .expect("sql route memo write lock poisoned");
60 if memo.epoch != epoch {
61 memo.epoch = epoch;
62 memo.map.clear();
63 }
64 if memo.map.len() >= ROUTE_MEMO_CAP {
65 memo.map.clear();
66 }
67 memo.map.insert(key, routed.clone());
68 routed
69}
70
71fn compute_route(sql: &str) -> Option<(String, String)> {
72 let table = first_table_name(sql)?;
73 let name = table.split('.').next()?;
74 if name.is_empty() || !runtime().provider_exists(name) {
75 return None;
76 }
77 Some((name.to_string(), strip_provider_prefix(sql, name)))
78}
79
80fn is_ident_byte(byte: u8) -> bool {
81 byte == b'_' || byte.is_ascii_alphanumeric()
82}
83
84fn find_keyword(sql: &str, keyword: &[u8]) -> Option<usize> {
86 let bytes = sql.as_bytes();
87 let mut idx = 0usize;
88
89 while idx < bytes.len() {
90 match bytes[idx] {
91 b'\'' | b'"' => {
92 let quote = bytes[idx];
93 idx += 1;
94 while idx < bytes.len() {
95 if bytes[idx] == quote {
96 idx += 1;
97 if idx < bytes.len() && bytes[idx] == quote {
98 idx += 1;
99 continue;
100 }
101 break;
102 }
103 idx += 1;
104 }
105 }
106 b'`' => {
107 idx += 1;
108 while idx < bytes.len() && bytes[idx] != b'`' {
109 idx += 1;
110 }
111 idx += usize::from(idx < bytes.len());
112 }
113 b'[' => {
114 idx += 1;
115 while idx < bytes.len() && bytes[idx] != b']' {
116 idx += 1;
117 }
118 idx += usize::from(idx < bytes.len());
119 }
120 b'-' if bytes.get(idx + 1) == Some(&b'-') => {
121 idx += 2;
123 while idx < bytes.len() && bytes[idx] != b'\n' {
124 idx += 1;
125 }
126 }
127 b'/' if bytes.get(idx + 1) == Some(&b'*') => {
128 idx += 2;
130 while idx + 1 < bytes.len() && !(bytes[idx] == b'*' && bytes[idx + 1] == b'/') {
131 idx += 1;
132 }
133 if idx + 1 < bytes.len() {
134 idx += 2;
135 } else {
136 idx = bytes.len();
137 }
138 }
139 _ => {
140 let end = idx + keyword.len();
141 if end <= bytes.len()
142 && bytes[idx..end].eq_ignore_ascii_case(keyword)
143 && idx
144 .checked_sub(1)
145 .is_none_or(|prev| !is_ident_byte(bytes[prev]))
146 && bytes.get(end).is_none_or(|next| !is_ident_byte(*next))
147 {
148 return Some(idx);
149 }
150 idx += 1;
151 }
152 }
153 }
154
155 None
156}
157
158fn matching_paren(sql: &str, open_pos: usize) -> Option<usize> {
160 let bytes = sql.as_bytes();
161 let mut depth = 0usize;
162 let mut idx = open_pos;
163
164 while idx < bytes.len() {
165 match bytes[idx] {
166 b'\'' | b'"' => {
167 let quote = bytes[idx];
168 idx += 1;
169 while idx < bytes.len() {
170 if bytes[idx] == quote {
171 idx += 1;
172 if idx < bytes.len() && bytes[idx] == quote {
173 idx += 1;
174 continue;
175 }
176 break;
177 }
178 idx += 1;
179 }
180 }
181 b'`' => {
182 idx += 1;
183 while idx < bytes.len() && bytes[idx] != b'`' {
184 idx += 1;
185 }
186 idx += usize::from(idx < bytes.len());
187 }
188 b'[' => {
189 idx += 1;
190 while idx < bytes.len() && bytes[idx] != b']' {
191 idx += 1;
192 }
193 idx += usize::from(idx < bytes.len());
194 }
195 b'-' if bytes.get(idx + 1) == Some(&b'-') => {
196 idx += 2;
197 while idx < bytes.len() && bytes[idx] != b'\n' {
198 idx += 1;
199 }
200 }
201 b'/' if bytes.get(idx + 1) == Some(&b'*') => {
202 idx += 2;
203 while idx + 1 < bytes.len() && !(bytes[idx] == b'*' && bytes[idx + 1] == b'/') {
204 idx += 1;
205 }
206 if idx + 1 < bytes.len() {
207 idx += 2;
208 } else {
209 idx = bytes.len();
210 }
211 }
212 b'(' => {
213 depth += 1;
214 idx += 1;
215 }
216 b')' => {
217 depth = depth.checked_sub(1)?;
218 if depth == 0 {
219 return Some(idx);
220 }
221 idx += 1;
222 }
223 _ => idx += 1,
224 }
225 }
226
227 None
228}
229
230pub fn first_table_name(sql: &str) -> Option<&str> {
234 let from_pos = find_keyword(sql, b"from")?;
235 let mut table_start = from_pos + b"from".len();
236 let bytes = sql.as_bytes();
237 while table_start < bytes.len() && bytes[table_start].is_ascii_whitespace() {
238 table_start += 1;
239 }
240
241 if bytes.get(table_start) == Some(&b'(') {
242 let close_pos = matching_paren(sql, table_start)?;
243 return first_table_name(&sql[table_start + 1..close_pos]);
244 }
245
246 let table_end = sql[table_start..]
247 .find(|c: char| c.is_ascii_whitespace() || matches!(c, ';' | ',' | ')'))
248 .map_or(sql.len(), |end| table_start + end);
249 let table = sql[table_start..table_end].trim();
250 if table.is_empty() { None } else { Some(table) }
251}
252
253pub fn strip_provider_prefix(sql: &str, name: &str) -> String {
259 if name.is_empty() {
260 return sql.to_string();
261 }
262 let bytes = sql.as_bytes();
263 let name_bytes = name.as_bytes();
264 let mut out = String::with_capacity(sql.len());
265 let mut i = 0usize;
266 while i < bytes.len() {
267 let b = bytes[i];
268 match b {
270 b'\'' | b'"' | b'`' => {
271 let quote = b;
272 let start = i;
273 i += 1;
274 while i < bytes.len() {
275 if bytes[i] == quote {
276 i += 1;
277 if i < bytes.len() && bytes[i] == quote {
278 i += 1;
279 continue;
280 }
281 break;
282 }
283 i += 1;
284 }
285 out.push_str(&sql[start..i]);
286 continue;
287 }
288 b'[' => {
289 let start = i;
290 i += 1;
291 while i < bytes.len() && bytes[i] != b']' {
292 i += 1;
293 }
294 i += usize::from(i < bytes.len());
295 out.push_str(&sql[start..i]);
296 continue;
297 }
298 b'-' if bytes.get(i + 1) == Some(&b'-') => {
299 let start = i;
300 while i < bytes.len() && bytes[i] != b'\n' {
301 i += 1;
302 }
303 out.push_str(&sql[start..i]);
304 continue;
305 }
306 b'/' if bytes.get(i + 1) == Some(&b'*') => {
307 let start = i;
308 i += 2;
309 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
310 i += 1;
311 }
312 if i + 1 < bytes.len() {
313 i += 2;
314 } else {
315 i = bytes.len();
316 }
317 out.push_str(&sql[start..i]);
318 continue;
319 }
320 _ => {}
321 }
322 let prev = i.checked_sub(1).map(|p| bytes[p]);
324 let is_boundary = prev.is_none_or(|p| !is_ident_byte(p) && p != b'.');
325 if is_boundary
326 && i + name_bytes.len() < bytes.len()
327 && &bytes[i..i + name_bytes.len()] == name_bytes
328 && bytes[i + name_bytes.len()] == b'.'
329 && bytes
330 .get(i + name_bytes.len() + 1)
331 .is_some_and(|next| is_ident_byte(*next) || matches!(*next, b'"' | b'`' | b'['))
332 {
333 i += name_bytes.len() + 1;
334 continue;
335 }
336 out.push(b as char);
337 i += 1;
338 }
339 out
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::loader::ProviderKind;
346 use crate::runtime::{DatasourceId, runtime, runtime_test_guard};
347
348 fn install_test_provider(name: &str) {
349 use crate::error::KnowledgeResult;
350 use crate::mem::RowData;
351 use crate::runtime::ProviderExecutor;
352 use async_trait::async_trait;
353 use std::sync::Arc;
354 use wp_model_core::model::{DataField, DataType, Value};
355
356 struct TestProvider;
357
358 #[async_trait]
359 impl ProviderExecutor for TestProvider {
360 fn query(&self, _sql: &str) -> KnowledgeResult<Vec<RowData>> {
361 Ok(vec![vec![DataField::new(
362 DataType::default(),
363 "v",
364 Value::Null,
365 )]])
366 }
367 fn query_fields(
368 &self,
369 _sql: &str,
370 _params: &[DataField],
371 ) -> KnowledgeResult<Vec<RowData>> {
372 self.query("")
373 }
374 fn query_row(&self, _sql: &str) -> KnowledgeResult<RowData> {
375 Ok(vec![DataField::new(DataType::default(), "v", Value::Null)])
376 }
377 fn query_named_fields(
378 &self,
379 _sql: &str,
380 _params: &[DataField],
381 ) -> KnowledgeResult<RowData> {
382 self.query_row("")
383 }
384 }
385
386 runtime()
387 .install_provider_named(
388 name,
389 ProviderKind::Postgres,
390 DatasourceId::from_seed(ProviderKind::Postgres, name),
391 |_generation| Ok(Arc::new(TestProvider)),
392 false,
393 )
394 .expect("install named provider");
395 }
396
397 #[test]
398 fn route_provider_sql_matches_installed_provider() {
399 let _guard = runtime_test_guard().lock().expect("guard");
400 install_test_provider("geo");
401
402 let (name, stripped) = route_provider_sql(
403 "select country_name from geo.public.ip_geo_city where ip_num = :ip",
404 )
405 .expect("route to geo");
406 assert_eq!(name, "geo");
407 assert_eq!(
408 stripped,
409 "select country_name from public.ip_geo_city where ip_num = :ip"
410 );
411 }
412
413 #[test]
414 fn route_provider_sql_falls_back_for_unknown_or_unqualified() {
415 let _guard = runtime_test_guard().lock().expect("guard");
416 install_test_provider("geo");
417
418 assert!(route_provider_sql("select a from nope.public.t").is_none());
420 assert!(route_provider_sql("select a from ip_geo_city").is_none());
422 }
423
424 #[test]
425 fn strip_keeps_string_literals_and_mid_dotted_idents() {
426 let sql = "select geo.country_name from geo.public.t where name = 'geo.x'";
427 let out = strip_provider_prefix(sql, "geo");
428 assert_eq!(
429 out,
430 "select country_name from public.t where name = 'geo.x'"
431 );
432
433 let sql2 = "select a.geo.country from geo.public.t";
435 let out2 = strip_provider_prefix(sql2, "geo");
436 assert_eq!(out2, "select a.geo.country from public.t");
437
438 let sql3 = "select geography.x from geo.public.t";
440 let out3 = strip_provider_prefix(sql3, "geo");
441 assert_eq!(out3, "select geography.x from public.t");
442 }
443
444 #[test]
445 fn strip_handles_distinct_qualifiers() {
446 let sql = "select group_concat(distinct geo.asset_type) from geo.asset_enrichment";
447 let out = strip_provider_prefix(sql, "geo");
448 assert_eq!(
449 out,
450 "select group_concat(distinct asset_type) from asset_enrichment"
451 );
452 }
453
454 #[test]
455 fn route_provider_sql_handles_subquery() {
456 let _guard = runtime_test_guard().lock().expect("guard");
457 install_test_provider("geo");
458
459 let sql = "select a from (select a from geo.public.t where x = 1) sub";
460 let (name, stripped) = route_provider_sql(sql).expect("route subquery to geo");
461 assert_eq!(name, "geo");
462 assert_eq!(
463 stripped,
464 "select a from (select a from public.t where x = 1) sub"
465 );
466 }
467
468 #[test]
469 fn route_provider_sql_prefix_requires_installed_provider() {
470 let _guard = runtime_test_guard().lock().expect("guard");
471 assert!(route_provider_sql("select a from ghost.public.t").is_none());
473 }
474
475 #[test]
476 fn route_provider_sql_ignores_from_inside_comment() {
477 let _guard = runtime_test_guard().lock().expect("guard");
478 install_test_provider("geo");
479
480 let sql = "select 1 /* from ghost.x */ from geo.public.t";
482 let (name, stripped) = route_provider_sql(sql).expect("route to geo");
483 assert_eq!(name, "geo");
484 assert_eq!(stripped, "select 1 /* from ghost.x */ from public.t");
485
486 let sql2 = "select 1 -- from ghost.x\nfrom geo.public.t";
488 let (name, stripped2) = route_provider_sql(sql2).expect("route to geo");
489 assert_eq!(name, "geo");
490 assert_eq!(stripped2, "select 1 -- from ghost.x\nfrom public.t");
491 }
492
493 #[test]
494 fn strip_skips_comments() {
495 let sql = "select geo.a -- geo.b\nfrom geo.public.t /* geo.c */";
496 let out = strip_provider_prefix(sql, "geo");
497 assert_eq!(out, "select a -- geo.b\nfrom public.t /* geo.c */");
498 }
499
500 #[test]
501 fn strip_keeps_backtick_quoted_identifiers() {
502 let sql = "select `geo.col` from geo.public.t";
503 let out = strip_provider_prefix(sql, "geo");
504 assert_eq!(out, "select `geo.col` from public.t");
505 }
506
507 #[test]
508 fn strip_handles_provider_name_with_underscore_and_digits() {
509 let sql = "select x from geo_db_v1.public.t where geo_db_v1.k = 1";
510 let out = strip_provider_prefix(sql, "geo_db_v1");
511 assert_eq!(out, "select x from public.t where k = 1");
512 }
513
514 #[test]
515 fn strip_does_not_touch_name_inside_longer_identifier() {
516 let sql = "select geox.a, xgeo.b from geo.public.t";
518 let out = strip_provider_prefix(sql, "geo");
519 assert_eq!(out, "select geox.a, xgeo.b from public.t");
520 }
521
522 #[test]
523 fn strip_supports_quoted_table_names() {
524 let out = strip_provider_prefix("select x from geo.\"public\".\"t\"", "geo");
526 assert_eq!(out, "select x from \"public\".\"t\"");
527
528 let out = strip_provider_prefix("select x from geo.`public`.`t`", "geo");
529 assert_eq!(out, "select x from `public`.`t`");
530
531 let out = strip_provider_prefix("select x from geo.[public].[t]", "geo");
532 assert_eq!(out, "select x from [public].[t]");
533 }
534}