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