krishiv_sql/
statement_completion.rs1#![forbid(unsafe_code)]
2use datafusion::prelude::SessionContext;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct UseTarget {
25 pub catalog: Option<String>,
27 pub schema: Option<String>,
29}
30
31fn is_quoted(ident: &str) -> bool {
33 let t = ident.trim();
34 ['`', '"']
35 .iter()
36 .any(|q| t.len() >= 2 && t.starts_with(*q) && t.ends_with(*q))
37}
38
39fn unquote(ident: &str) -> String {
40 let t = ident.trim();
41 for q in ['`', '"'] {
42 if let Some(inner) = t.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
43 return inner.to_string();
44 }
45 }
46 t.to_string()
47}
48
49pub fn parse_use(query: &str) -> Option<UseTarget> {
57 let q = query.trim().trim_end_matches(';').trim();
58 let (head, remainder) = q.split_once(char::is_whitespace)?;
60 if !head.eq_ignore_ascii_case("USE") {
61 return None;
62 }
63 let remainder = remainder.trim();
64 if remainder.is_empty() {
65 return None;
66 }
67 let (is_catalog, name_part) = match remainder.split_once(char::is_whitespace) {
71 Some((w, r)) if w.eq_ignore_ascii_case("CATALOG") => (true, r.trim()),
72 Some((w, r))
73 if w.eq_ignore_ascii_case("SCHEMA")
74 || w.eq_ignore_ascii_case("DATABASE")
75 || w.eq_ignore_ascii_case("NAMESPACE") =>
76 {
77 (false, r.trim())
78 }
79 _ => (false, remainder),
80 };
81 let was_quoted = is_quoted(name_part);
85 let name = unquote(name_part);
86 if name.is_empty() {
87 return None;
88 }
89 if is_catalog {
90 return Some(UseTarget {
91 catalog: Some(name),
92 schema: None,
93 });
94 }
95 if let Some((cat, sch)) = name.split_once('.').filter(|_| !was_quoted) {
97 return Some(UseTarget {
98 catalog: Some(unquote(cat)),
99 schema: Some(unquote(sch)),
100 });
101 }
102 Some(UseTarget {
103 catalog: None,
104 schema: Some(name),
105 })
106}
107
108pub fn apply_use(ctx: &SessionContext, query: &str) -> Option<Result<(), String>> {
112 let target = parse_use(query)?;
113 let state_ref = ctx.state_ref();
114 let mut state = state_ref.write();
115 let opts = state.config_mut().options_mut();
116 if let Some(catalog) = target.catalog {
117 opts.catalog.default_catalog = catalog;
118 }
119 if let Some(schema) = target.schema {
120 opts.catalog.default_schema = schema;
121 }
122 Some(Ok(()))
123}
124
125pub fn rewrite_show_databases(query: &str) -> Option<String> {
129 let q = query.trim().trim_end_matches(';').trim();
130 let upper = q.to_ascii_uppercase();
131 let is_show = upper.starts_with("SHOW DATABASES") || upper.starts_with("SHOW SCHEMAS");
132 if !is_show {
133 return None;
134 }
135
136 let (before_like, like_pattern) = match upper.find(" LIKE ") {
144 Some(idx) => (
145 q.get(..idx).unwrap_or_default(),
146 q.get(idx + " LIKE ".len()..).map(str::trim),
147 ),
148 None => (q, None),
149 };
150
151 let mut predicates: Vec<String> = Vec::new();
152 if let Some(catalog) = catalog_qualifier(before_like) {
153 predicates.push(format!(
156 "catalog_name = '{}'",
157 catalog.replace('\'', "''")
158 ));
159 }
160 if let Some(pattern) = like_pattern.filter(|p| !p.is_empty()) {
161 predicates.push(format!("schema_name LIKE {pattern}"));
162 }
163
164 let where_clause = if predicates.is_empty() {
165 String::new()
166 } else {
167 format!(" WHERE {}", predicates.join(" AND "))
168 };
169 Some(format!(
170 "SELECT schema_name AS namespace FROM information_schema.schemata{where_clause} \
171 ORDER BY namespace"
172 ))
173}
174
175fn catalog_qualifier(head: &str) -> Option<String> {
179 let mut tokens = head.split_whitespace().collect::<Vec<_>>();
180 let catalog = tokens.pop()?;
181 let keyword = tokens.pop()?;
182 if keyword.eq_ignore_ascii_case("FROM") || keyword.eq_ignore_ascii_case("IN") {
183 let name = unquote(catalog);
184 (!name.is_empty()).then_some(name)
185 } else {
186 None
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn parse_use_forms() {
196 assert_eq!(
197 parse_use("USE analytics"),
198 Some(UseTarget {
199 catalog: None,
200 schema: Some("analytics".into())
201 })
202 );
203 assert_eq!(
204 parse_use("USE SCHEMA sales"),
205 Some(UseTarget {
206 catalog: None,
207 schema: Some("sales".into())
208 })
209 );
210 assert_eq!(
211 parse_use("USE DATABASE sales;"),
212 Some(UseTarget {
213 catalog: None,
214 schema: Some("sales".into())
215 })
216 );
217 assert_eq!(
218 parse_use("USE CATALOG lakehouse"),
219 Some(UseTarget {
220 catalog: Some("lakehouse".into()),
221 schema: None
222 })
223 );
224 assert_eq!(
225 parse_use("USE lake.sales"),
226 Some(UseTarget {
227 catalog: Some("lake".into()),
228 schema: Some("sales".into())
229 })
230 );
231 assert_eq!(
232 parse_use("USE `my schema`").unwrap().schema.as_deref(),
233 Some("my schema")
234 );
235 assert_eq!(parse_use("SELECT 1"), None);
236 }
237
238 #[test]
239 fn show_databases_rewrite() {
240 assert!(
241 rewrite_show_databases("SHOW DATABASES")
242 .unwrap()
243 .contains("information_schema.schemata")
244 );
245 assert!(
246 rewrite_show_databases("SHOW SCHEMAS")
247 .unwrap()
248 .contains("AS namespace")
249 );
250 let with_like = rewrite_show_databases("SHOW DATABASES LIKE 'sal%'").unwrap();
251 assert!(with_like.contains("LIKE 'sal%'"));
252 assert_eq!(rewrite_show_databases("SHOW TABLES"), None);
253 }
254
255 #[test]
261 fn show_databases_honours_the_catalog_qualifier() {
262 for sql in ["SHOW SCHEMAS IN prod", "SHOW DATABASES FROM prod"] {
263 let rewritten = rewrite_show_databases(sql).expect("recognised");
264 assert!(
265 rewritten.contains("catalog_name = 'prod'"),
266 "{sql} must filter by catalog: {rewritten}"
267 );
268 }
269 }
270
271 #[test]
273 fn catalog_qualifier_and_like_compose() {
274 let rewritten =
275 rewrite_show_databases("SHOW SCHEMAS IN prod LIKE 'sal%'").expect("recognised");
276 assert!(rewritten.contains("catalog_name = 'prod'"), "{rewritten}");
277 assert!(rewritten.contains("schema_name LIKE 'sal%'"), "{rewritten}");
278 assert!(rewritten.contains(" AND "), "{rewritten}");
279 }
280
281 #[test]
283 fn bare_show_databases_is_unfiltered() {
284 let rewritten = rewrite_show_databases("SHOW DATABASES").expect("recognised");
285 assert!(!rewritten.contains("WHERE"), "{rewritten}");
286 }
287
288 #[test]
290 fn a_quoted_use_target_is_not_split_on_its_dot() {
291 assert_eq!(
292 parse_use("USE `my.schema`"),
293 Some(UseTarget {
294 catalog: None,
295 schema: Some("my.schema".into())
296 }),
297 "a back-quoted name is a single identifier, not catalog.schema"
298 );
299 assert_eq!(
301 parse_use("USE lake.sales"),
302 Some(UseTarget {
303 catalog: Some("lake".into()),
304 schema: Some("sales".into())
305 })
306 );
307 }
308
309 #[tokio::test]
310 async fn use_changes_default_schema_end_to_end() {
311 let engine = crate::SqlEngine::new();
312 engine
315 .sql("USE information_schema")
316 .await
317 .expect("USE runs");
318 let batches = engine
319 .sql("SELECT count(*) AS c FROM tables")
320 .await
321 .expect("unqualified `tables` resolves via the new default schema")
322 .collect()
323 .await
324 .expect("collect");
325 let total: i64 = {
326 use arrow::array::Int64Array;
327 batches[0]
328 .column(0)
329 .as_any()
330 .downcast_ref::<Int64Array>()
331 .unwrap()
332 .value(0)
333 };
334 assert!(total > 0, "information_schema.tables should be non-empty");
335 }
336
337 #[tokio::test]
338 async fn show_databases_lists_schemas() {
339 let engine = crate::SqlEngine::new();
340 let batches = engine
341 .sql("SHOW DATABASES")
342 .await
343 .expect("SHOW DATABASES runs")
344 .collect()
345 .await
346 .expect("collect");
347 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
349 assert!(total >= 1);
350 assert_eq!(batches[0].schema().field(0).name(), "namespace");
351 }
352}