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