Skip to main content

reddb_rql/modes/
detect.rs

1//! Query Mode Detection
2//!
3//! Automatically detects the query language based on syntax patterns.
4
5/// Supported query modes
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum QueryMode {
8    /// SQL-style: SELECT ... FROM ... WHERE
9    Sql,
10    /// Gremlin traversal: g.V().out().has(...)
11    Gremlin,
12    /// Cypher pattern matching: MATCH (a)-[r]->(b) RETURN
13    Cypher,
14    /// SPARQL RDF queries: SELECT ?var WHERE { ... }
15    Sparql,
16    /// Path queries: PATH FROM ... TO ... VIA
17    Path,
18    /// Natural language queries
19    Natural,
20    /// Unknown mode
21    Unknown,
22}
23
24/// Detect the query mode from input string
25pub fn detect_mode(input: &str) -> QueryMode {
26    let trimmed = input.trim();
27    let lower = trimmed.to_lowercase();
28
29    // Check for quoted natural language (starts with quote)
30    if trimmed.starts_with('"') || trimmed.starts_with('\'') {
31        return QueryMode::Natural;
32    }
33
34    // Gremlin: starts with g. or __.
35    if lower.starts_with("g.") || lower.starts_with("__.") {
36        return QueryMode::Gremlin;
37    }
38
39    // Path: PATH or PATHS keyword at start
40    if lower.starts_with("path ") || lower.starts_with("paths ") {
41        return QueryMode::Path;
42    }
43
44    // SPARQL: has ?variable pattern or PREFIX keyword
45    if lower.starts_with("prefix ") || has_sparql_pattern(&lower) {
46        return QueryMode::Sparql;
47    }
48
49    // Cypher: MATCH keyword at start
50    if lower.starts_with("match ") || lower.starts_with("match(") {
51        return QueryMode::Cypher;
52    }
53
54    // SQL: SELECT, FROM, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, GRAPH, SEARCH at start
55    // Plus transaction / admin one-word commands that have no trailing
56    // clause (BEGIN/COMMIT/ROLLBACK/SAVEPOINT/RELEASE/VACUUM/ANALYZE/
57    // RESET/TENANT/etc.) — matched by equality on the trimmed token.
58    let first_token = lower.split_whitespace().next().unwrap_or("");
59    if matches!(
60        first_token,
61        "begin"
62            | "start"
63            | "commit"
64            | "rollback"
65            | "savepoint"
66            | "release"
67            | "end"
68            | "vacuum"
69            | "analyze"
70            | "reset"
71            | "checkpoint"
72            | "checkout"
73            | "merge"
74            | "cherry"
75            | "revert"
76            | "resolve"
77            | "copy"
78            | "refresh"
79            | "explain"
80            | "grant"
81            | "revoke"
82            | "attach"
83            | "detach"
84            | "simulate"
85            | "lint"
86            | "migrate"
87            | "apply"
88            | "events"
89            | "describe"
90            | "desc"
91    ) {
92        return QueryMode::Sql;
93    }
94    if lower.starts_with("select ")
95        || lower.starts_with("from ")
96        || lower.starts_with("insert ")
97        || lower.starts_with("update ")
98        || lower.starts_with("delete ")
99        || lower.starts_with("truncate ")
100        || lower.starts_with("create ")
101        || lower.starts_with("drop ")
102        || lower.starts_with("alter ")
103        || lower.starts_with("vector ")
104        || lower.starts_with("hybrid ")
105        || lower.starts_with("graph ")
106        || lower.starts_with("queue ")
107        || lower.starts_with("events ")
108        || lower.starts_with("tree ")
109        || lower.starts_with("hll ")
110        || lower.starts_with("sketch ")
111        || lower.starts_with("filter ")
112        || lower.starts_with("vault ")
113        || lower.starts_with("unseal vault ")
114        || lower.starts_with("rotate vault ")
115        || lower.starts_with("history vault ")
116        || lower.starts_with("list vault ")
117        || lower.starts_with("list kv ")
118        || lower.starts_with("watch vault ")
119        || lower.starts_with("delete vault ")
120        || lower.starts_with("purge vault ")
121        || lower.starts_with("search ")
122        || lower.starts_with("ask ")
123        || lower.starts_with("put config ")
124        || lower.starts_with("get config ")
125        || lower.starts_with("resolve config ")
126        || lower.starts_with("rotate config ")
127        || lower.starts_with("delete config ")
128        || lower.starts_with("history config ")
129        || lower.starts_with("list config ")
130        || lower.starts_with("watch config ")
131        || lower.starts_with("incr config ")
132        || lower.starts_with("decr config ")
133        || lower.starts_with("add config ")
134        || lower.starts_with("invalidate config ")
135        || lower.starts_with("invalidate tags ")
136        || lower.starts_with("set config ")
137        || lower.starts_with("set secret ")
138        || lower.starts_with("set kv ")
139        || lower.starts_with("set tenant")
140        || lower.starts_with("show create ")
141        || lower.starts_with("show config")
142        || lower.starts_with("show collections")
143        || lower.starts_with("show tables")
144        || lower.starts_with("show queues")
145        || lower.starts_with("show vectors")
146        || lower.starts_with("show documents")
147        || lower.starts_with("show timeseries")
148        || lower.starts_with("show graphs")
149        || lower.starts_with("kv ")
150        || lower.starts_with("show kv")
151        || lower.starts_with("show configs")
152        || lower.starts_with("show vaults")
153        || lower.starts_with("show schema")
154        || lower.starts_with("show indices")
155        || lower.starts_with("show indexes")
156        || lower.starts_with("show sample ")
157        || lower.starts_with("show secret")
158        || lower.starts_with("show stats")
159        || lower.starts_with("show tenant")
160        || lower.starts_with("show policies")
161        || lower.starts_with("show effective ")
162        || lower.starts_with("rank of ")
163        || lower.starts_with("rank range ")
164        || lower.starts_with("approx rank of ")
165        || lower.starts_with("approximate rank of ")
166        || lower.starts_with("zrank ")
167        || lower.starts_with("zrange ")
168        || lower.starts_with("describe ")
169        || lower.starts_with("desc ")
170    {
171        // But check if it's SPARQL-style SELECT with ?variable.
172        // Bare SQL placeholders (`?`) and numbered placeholders (`?1`)
173        // must stay in SQL mode for parameter binding.
174        if lower.starts_with("select ") && has_sparql_variable(&lower) {
175            return QueryMode::Sparql;
176        }
177        return QueryMode::Sql;
178    }
179
180    // Natural language detection: common question words and patterns
181    if is_natural_language(&lower) {
182        return QueryMode::Natural;
183    }
184
185    QueryMode::Unknown
186}
187
188/// Check for SPARQL-specific patterns
189fn has_sparql_pattern(lower: &str) -> bool {
190    // SPARQL variables start with ? or $
191    // SPARQL has WHERE { } with triple patterns
192
193    // Check for ?variable pattern. Bare SQL placeholders (`?`) and
194    // numbered placeholders (`?1`) are not SPARQL variables.
195    let has_var = has_sparql_variable(lower);
196
197    // Check for typical SPARQL structure
198    let has_triple_pattern = lower.contains(" where {") || lower.contains(" where{");
199
200    // Check for RDF predicates (prefixed URIs like :predicate or prefix:pred)
201    let has_prefix_pattern = lower.contains(":")
202        && (lower.contains(":<")
203            || lower.contains("> :")
204            || lower.contains(" :") && lower.contains("?"));
205
206    has_var || has_triple_pattern || has_prefix_pattern
207}
208
209fn has_sparql_variable(input: &str) -> bool {
210    let bytes = input.as_bytes();
211    bytes
212        .windows(2)
213        .any(|pair| pair[0] == b'?' && is_sparql_variable_start(pair[1]))
214}
215
216fn is_sparql_variable_start(byte: u8) -> bool {
217    byte.is_ascii_alphabetic() || byte == b'_'
218}
219
220/// Detect natural language patterns
221fn is_natural_language(lower: &str) -> bool {
222    // Question words
223    let question_starters = [
224        "find ", "show ", "list ", "what ", "which ", "where ", "how ", "who ", "get ", "give ",
225        "tell ", "display ", "search ", "look ",
226    ];
227
228    // Common natural language verbs/phrases
229    let nl_patterns = [
230        " with ",
231        " for ",
232        " that ",
233        " have ",
234        " has ",
235        " can ",
236        " are ",
237        " is ",
238        " all ",
239        " me ",
240        " the ",
241        " from ",
242        " to ",
243        " on ",
244        " in ",
245        "vulnerable",
246        "credential",
247        "password",
248        "user",
249        "host",
250        "service",
251        "connected",
252        "reachable",
253        "exposed",
254        "critical",
255    ];
256
257    // Check starters
258    for starter in question_starters.iter() {
259        if lower.starts_with(starter) {
260            return true;
261        }
262    }
263
264    // Check for multiple natural language patterns (at least 2)
265    let pattern_count = nl_patterns.iter().filter(|p| lower.contains(*p)).count();
266
267    pattern_count >= 2
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_sql_detection() {
276        assert_eq!(
277            detect_mode("SELECT * FROM users WHERE id = 1"),
278            QueryMode::Sql
279        );
280        assert_eq!(detect_mode("select name, age from hosts"), QueryMode::Sql);
281        assert_eq!(
282            detect_mode("FROM hosts h WHERE h.os = 'Linux'"),
283            QueryMode::Sql
284        );
285        assert_eq!(
286            detect_mode("INSERT INTO users VALUES (1, 'alice')"),
287            QueryMode::Sql
288        );
289        assert_eq!(
290            detect_mode("UPDATE hosts SET status = 'active'"),
291            QueryMode::Sql
292        );
293        assert_eq!(
294            detect_mode("DELETE FROM logs WHERE age > 30"),
295            QueryMode::Sql
296        );
297        assert_eq!(
298            detect_mode("QUEUE GROUP CREATE tasks workers"),
299            QueryMode::Sql
300        );
301        assert_eq!(
302            detect_mode("EVENTS BACKFILL users TO audit"),
303            QueryMode::Sql
304        );
305        assert_eq!(detect_mode("TREE VALIDATE forest.org"), QueryMode::Sql);
306        assert_eq!(
307            detect_mode("VECTOR SEARCH embeddings SIMILAR TO [1.0, 0.0] LIMIT 5"),
308            QueryMode::Sql
309        );
310        assert_eq!(
311            detect_mode("HYBRID FROM hosts VECTOR SEARCH embeddings SIMILAR TO [1.0, 0.0] LIMIT 5"),
312            QueryMode::Sql
313        );
314        assert_eq!(
315            detect_mode("ASK 'what happened on host 10.0.0.1?' USING groq"),
316            QueryMode::Sql
317        );
318        assert_eq!(
319            detect_mode("SELECT name FROM t WHERE id = ?"),
320            QueryMode::Sql
321        );
322        assert_eq!(
323            detect_mode("SELECT name FROM t WHERE id = ?1"),
324            QueryMode::Sql
325        );
326        assert_eq!(
327            detect_mode("INSERT INTO t (id, name) VALUES (?, ?)"),
328            QueryMode::Sql
329        );
330        assert_eq!(
331            detect_mode("SET SECRET red.secret.api = 'x'"),
332            QueryMode::Sql
333        );
334        assert_eq!(detect_mode("SHOW SECRET red.secret"), QueryMode::Sql);
335        assert_eq!(detect_mode("SHOW SECRETS"), QueryMode::Sql);
336        assert_eq!(detect_mode("VAULT PUT secrets.api = 'x'"), QueryMode::Sql);
337        assert_eq!(
338            detect_mode("LIST KV settings PREFIX feature LIMIT 10"),
339            QueryMode::Sql
340        );
341        assert_eq!(detect_mode("SHOW SAMPLE users"), QueryMode::Sql);
342        assert_eq!(detect_mode("SHOW TABLES"), QueryMode::Sql);
343        assert_eq!(detect_mode("SHOW QUEUES"), QueryMode::Sql);
344        assert_eq!(detect_mode("SHOW VECTORS"), QueryMode::Sql);
345        assert_eq!(detect_mode("SHOW DOCUMENTS"), QueryMode::Sql);
346        assert_eq!(detect_mode("SHOW TIMESERIES"), QueryMode::Sql);
347        assert_eq!(detect_mode("SHOW GRAPHS"), QueryMode::Sql);
348        assert_eq!(detect_mode("SHOW KV"), QueryMode::Sql);
349        assert_eq!(detect_mode("SHOW KVS"), QueryMode::Sql);
350        assert_eq!(detect_mode("SHOW CONFIGS"), QueryMode::Sql);
351        assert_eq!(detect_mode("SHOW VAULTS"), QueryMode::Sql);
352        assert_eq!(detect_mode("SHOW SCHEMA users"), QueryMode::Sql);
353        assert_eq!(detect_mode("SHOW CREATE TABLE users"), QueryMode::Sql);
354        assert_eq!(detect_mode("DESCRIBE users"), QueryMode::Sql);
355        assert_eq!(detect_mode("DESC users"), QueryMode::Sql);
356        assert_eq!(detect_mode("SHOW INDICES"), QueryMode::Sql);
357        assert_eq!(detect_mode("SHOW INDEXES"), QueryMode::Sql);
358        assert_eq!(detect_mode("SHOW STATS users"), QueryMode::Sql);
359        assert_eq!(detect_mode("EXPLAIN MIGRATION *"), QueryMode::Sql);
360    }
361
362    #[test]
363    fn test_gremlin_detection() {
364        assert_eq!(detect_mode("g.V()"), QueryMode::Gremlin);
365        assert_eq!(detect_mode("g.V().hasLabel('host')"), QueryMode::Gremlin);
366        assert_eq!(
367            detect_mode("g.V().out('connects').in('has_service')"),
368            QueryMode::Gremlin
369        );
370        assert_eq!(
371            detect_mode("g.E().hasLabel('auth_access')"),
372            QueryMode::Gremlin
373        );
374        assert_eq!(
375            detect_mode("__.out('knows').has('name', 'bob')"),
376            QueryMode::Gremlin
377        );
378        assert_eq!(
379            detect_mode("g.V('host:10.0.0.1').repeat(out()).times(3)"),
380            QueryMode::Gremlin
381        );
382    }
383
384    #[test]
385    fn test_cypher_detection() {
386        assert_eq!(
387            detect_mode("MATCH (a)-[r]->(b) RETURN a, b"),
388            QueryMode::Cypher
389        );
390        assert_eq!(
391            detect_mode("MATCH (h:Host)-[:HAS_SERVICE]->(s:Service)"),
392            QueryMode::Cypher
393        );
394        assert_eq!(
395            detect_mode("match (n) where n.ip = '10.0.0.1' return n"),
396            QueryMode::Cypher
397        );
398        assert_eq!(
399            detect_mode("MATCH(a:User) RETURN a.name"),
400            QueryMode::Cypher
401        );
402    }
403
404    #[test]
405    fn test_sparql_detection() {
406        assert_eq!(
407            detect_mode("SELECT ?name WHERE { ?s :name ?name }"),
408            QueryMode::Sparql
409        );
410        assert_eq!(
411            detect_mode("PREFIX ex: <http://example.org/> SELECT ?x WHERE { ?x ex:type ?t }"),
412            QueryMode::Sparql
413        );
414        assert_eq!(
415            detect_mode("SELECT ?host ?ip WHERE { ?host :hasIP ?ip }"),
416            QueryMode::Sparql
417        );
418        assert_eq!(
419            detect_mode("SELECT ?x WHERE { ?x rdf:type :Foo }"),
420            QueryMode::Sparql
421        );
422    }
423
424    #[test]
425    fn test_path_detection() {
426        assert_eq!(
427            detect_mode("PATH FROM host('10.0.0.1') TO host('10.0.0.2')"),
428            QueryMode::Path
429        );
430        assert_eq!(
431            detect_mode("PATHS ALL FROM credential('admin') TO host('db')"),
432            QueryMode::Path
433        );
434        assert_eq!(
435            detect_mode("path from user('root') to service('ssh') via auth_access"),
436            QueryMode::Path
437        );
438    }
439
440    #[test]
441    fn test_natural_detection() {
442        assert_eq!(
443            detect_mode("find all hosts with ssh open"),
444            QueryMode::Natural
445        );
446        assert_eq!(
447            detect_mode("show me vulnerable services"),
448            QueryMode::Natural
449        );
450        assert_eq!(
451            detect_mode("what credentials can reach the database?"),
452            QueryMode::Natural
453        );
454        assert_eq!(
455            detect_mode("list users with weak passwords"),
456            QueryMode::Natural
457        );
458        assert_eq!(
459            detect_mode("\"find hosts connected to 10.0.0.1\""),
460            QueryMode::Natural
461        );
462        assert_eq!(
463            detect_mode("which hosts have critical vulnerabilities?"),
464            QueryMode::Natural
465        );
466    }
467
468    #[test]
469    fn test_edge_cases() {
470        // Empty input
471        assert_eq!(detect_mode(""), QueryMode::Unknown);
472
473        // Just whitespace
474        assert_eq!(detect_mode("   "), QueryMode::Unknown);
475
476        // Case insensitivity
477        assert_eq!(detect_mode("SELECT"), QueryMode::Unknown); // No space after
478        assert_eq!(detect_mode("G.V()"), QueryMode::Gremlin);
479        assert_eq!(detect_mode("Match (a) RETURN a"), QueryMode::Cypher);
480    }
481}