Skip to main content

khive_query/
language.rs

1//! Query language detection and dispatch.
2
3use crate::ast::GqlQuery;
4use crate::error::QueryError;
5use crate::parsers;
6use crate::parsers::sparql::leading_keyword;
7
8/// Which query language the input is written in.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum QueryLanguage {
11    Gql,
12    Sparql,
13}
14
15/// Parse a query string in the given language into a [`GqlQuery`] AST.
16pub fn parse(language: QueryLanguage, input: &str) -> Result<GqlQuery, QueryError> {
17    match language {
18        QueryLanguage::Gql => parsers::gql::parse(input),
19        QueryLanguage::Sparql => parsers::sparql::parse(input),
20    }
21}
22
23/// Auto-detect language and parse (`SELECT` → SPARQL, `MATCH` → GQL, fallback → GQL).
24///
25/// Write-shaped input is rejected before dialect dispatch with a clear,
26/// actionable error naming the mutation verbs to use instead.  This guard
27/// runs first so that forms such as `WITH <g> DELETE …` and
28/// `PREFIX ex: <…> INSERT DATA { … }` — which don't start with `SELECT` and
29/// therefore fall through to the GQL path — are caught on the public entry
30/// point rather than producing a generic parse error.
31///
32/// The per-parser guards in `parsers::gql` and `parsers::sparql` remain in
33/// place for defense-in-depth for direct callers of those functions.
34pub fn parse_auto(input: &str) -> Result<GqlQuery, QueryError> {
35    let trimmed = input.trim();
36    reject_write(trimmed)?;
37    if trimmed
38        .as_bytes()
39        .get(..6)
40        .is_some_and(|p| p.eq_ignore_ascii_case(b"SELECT"))
41    {
42        parsers::sparql::parse(trimmed)
43    } else if trimmed
44        .as_bytes()
45        .get(..5)
46        .is_some_and(|p| p.eq_ignore_ascii_case(b"MATCH"))
47    {
48        parsers::gql::parse(trimmed)
49    } else {
50        // Fall back to GQL to preserve existing behavior for unknown prefixes.
51        parsers::gql::parse(trimmed)
52    }
53}
54
55/// Unified write-shape guard: rejects GQL/Cypher mutations and SPARQL Update
56/// ops before dialect dispatch.  Uses `leading_keyword` so that SPARQL
57/// prologues (PREFIX / BASE) and line comments are skipped before the keyword
58/// is inspected.
59fn reject_write(input: &str) -> Result<(), QueryError> {
60    match leading_keyword(input).as_str() {
61        // GQL / Cypher write forms
62        "CREATE" | "DELETE" | "DETACH" | "SET" | "REMOVE" | "MERGE" | "INSERT" | "UPDATE"
63        // SPARQL Update forms
64        | "WITH" | "LOAD" | "CLEAR" | "DROP" | "COPY" | "MOVE" | "ADD" => {
65            Err(QueryError::Unsupported(
66                "the query verb is read-only; \
67                 to mutate the graph use: create, update, link, merge, delete"
68                    .into(),
69            ))
70        }
71        _ => Ok(()),
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::error::QueryError;
79
80    // --- Read-only public-path regression tests (#16) ---
81
82    #[test]
83    fn parse_auto_with_delete_rejected() {
84        let err = parse_auto("WITH <http://g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }").unwrap_err();
85        assert!(
86            matches!(err, QueryError::Unsupported(_)),
87            "WITH … DELETE must return Unsupported on the public path; got {err:?}"
88        );
89        let msg = err.to_string();
90        assert!(msg.contains("read-only"), "got: {msg}");
91        assert!(
92            msg.contains("create") && msg.contains("update") && msg.contains("delete"),
93            "error must name the mutation verbs; got: {msg}"
94        );
95    }
96
97    #[test]
98    fn parse_auto_prefixed_insert_data_rejected() {
99        let err = parse_auto("PREFIX ex: <http://e/> INSERT DATA { ex:a ex:b ex:c }").unwrap_err();
100        assert!(
101            matches!(err, QueryError::Unsupported(_)),
102            "prefixed INSERT DATA must return Unsupported on the public path; got {err:?}"
103        );
104        let msg = err.to_string();
105        assert!(msg.contains("read-only"), "got: {msg}");
106    }
107
108    #[test]
109    fn parse_auto_prefixed_with_delete_rejected() {
110        // Proves both prologue-skip (PREFIX) AND WITH keyword on the public path.
111        let err = parse_auto(
112            "PREFIX ex: <http://e/> WITH <http://g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }",
113        )
114        .unwrap_err();
115        assert!(
116            matches!(err, QueryError::Unsupported(_)),
117            "PREFIX + WITH … DELETE must return Unsupported on the public path; got {err:?}"
118        );
119        let msg = err.to_string();
120        assert!(msg.contains("read-only"), "got: {msg}");
121    }
122
123    #[test]
124    fn parse_auto_detach_delete_rejected() {
125        let err = parse_auto("DETACH DELETE (n)").unwrap_err();
126        assert!(
127            matches!(err, QueryError::Unsupported(_)),
128            "DETACH DELETE must return Unsupported on the public path; got {err:?}"
129        );
130        let msg = err.to_string();
131        assert!(msg.contains("read-only"), "got: {msg}");
132    }
133
134    #[test]
135    fn parse_auto_gql_match_not_rejected() {
136        let q = parse_auto("MATCH (a:concept) RETURN a").unwrap();
137        assert!(!q.pattern.elements.is_empty(), "valid GQL MATCH must parse");
138    }
139
140    #[test]
141    fn parse_auto_sparql_select_not_rejected() {
142        let q = parse_auto("SELECT ?a WHERE { ?a :extends ?b . }").unwrap();
143        assert!(
144            !q.pattern.elements.is_empty(),
145            "valid SPARQL SELECT must parse"
146        );
147    }
148
149    #[test]
150    fn parse_auto_load_rejected() {
151        // LOAD is in reject_write's union set but NOT in reject_gql_write, so
152        // if reject_write is reverted this routes to GQL and returns a Parse
153        // error, not Unsupported. This test is therefore sensitive to the
154        // parse_auto guard specifically.
155        let err = parse_auto("LOAD <http://e/data>").unwrap_err();
156        assert!(
157            matches!(err, QueryError::Unsupported(_)),
158            "LOAD must return Unsupported on the public path; got {err:?}"
159        );
160        let msg = err.to_string();
161        assert!(msg.contains("read-only"), "got: {msg}");
162    }
163}