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/// Parses `input` as the selected language into a [`GqlQuery`].
16///
17/// # Errors
18///
19/// Returns [`QueryError`] when syntax is invalid, write-shaped, or unsupported.
20/// See `crates/khive-query/docs/api/parsing.md` for accepted dialect subsets.
21pub fn parse(language: QueryLanguage, input: &str) -> Result<GqlQuery, QueryError> {
22    match language {
23        QueryLanguage::Gql => parsers::gql::parse(input),
24        QueryLanguage::Sparql => parsers::sparql::parse(input),
25    }
26}
27
28/// Auto-detects SPARQL for `SELECT`, GQL for `MATCH`, and otherwise falls back to GQL.
29///
30/// Write-shaped input is rejected before dispatch, including SPARQL prologues.
31///
32/// # Errors
33///
34/// Returns [`QueryError`] when syntax is invalid, write-shaped, or unsupported.
35/// See `crates/khive-query/docs/api/parsing.md` for detection and guard behavior.
36pub fn parse_auto(input: &str) -> Result<GqlQuery, QueryError> {
37    let trimmed = input.trim();
38    reject_write(trimmed)?;
39    if trimmed
40        .as_bytes()
41        .get(..6)
42        .is_some_and(|p| p.eq_ignore_ascii_case(b"SELECT"))
43    {
44        parsers::sparql::parse(trimmed)
45    } else if trimmed
46        .as_bytes()
47        .get(..5)
48        .is_some_and(|p| p.eq_ignore_ascii_case(b"MATCH"))
49    {
50        parsers::gql::parse(trimmed)
51    } else {
52        // Preserve compatibility for inputs without a recognized leading keyword.
53        parsers::gql::parse(trimmed)
54    }
55}
56
57/// Rejects GQL/Cypher mutations and SPARQL Update before dialect dispatch.
58fn reject_write(input: &str) -> Result<(), QueryError> {
59    match leading_keyword(input).as_str() {
60        "CREATE" | "DELETE" | "DETACH" | "SET" | "REMOVE" | "MERGE" | "INSERT" | "UPDATE"
61        | "WITH" | "LOAD" | "CLEAR" | "DROP" | "COPY" | "MOVE" | "ADD" => {
62            Err(QueryError::Unsupported(
63                "the query verb is read-only; \
64                 to mutate the graph use: create, update, link, merge, delete"
65                    .into(),
66            ))
67        }
68        _ => Ok(()),
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::error::QueryError;
76
77    #[test]
78    fn parse_auto_with_delete_rejected() {
79        let err = parse_auto("WITH <http://g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }").unwrap_err();
80        assert!(
81            matches!(err, QueryError::Unsupported(_)),
82            "WITH … DELETE must return Unsupported on the public path; got {err:?}"
83        );
84        let msg = err.to_string();
85        assert!(msg.contains("read-only"), "got: {msg}");
86        assert!(
87            msg.contains("create") && msg.contains("update") && msg.contains("delete"),
88            "error must name the mutation verbs; got: {msg}"
89        );
90    }
91
92    #[test]
93    fn parse_auto_prefixed_insert_data_rejected() {
94        let err = parse_auto("PREFIX ex: <http://e/> INSERT DATA { ex:a ex:b ex:c }").unwrap_err();
95        assert!(
96            matches!(err, QueryError::Unsupported(_)),
97            "prefixed INSERT DATA must return Unsupported on the public path; got {err:?}"
98        );
99        let msg = err.to_string();
100        assert!(msg.contains("read-only"), "got: {msg}");
101    }
102
103    #[test]
104    fn parse_auto_prefixed_with_delete_rejected() {
105        let err = parse_auto(
106            "PREFIX ex: <http://e/> WITH <http://g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }",
107        )
108        .unwrap_err();
109        assert!(
110            matches!(err, QueryError::Unsupported(_)),
111            "PREFIX + WITH … DELETE must return Unsupported on the public path; got {err:?}"
112        );
113        let msg = err.to_string();
114        assert!(msg.contains("read-only"), "got: {msg}");
115    }
116
117    #[test]
118    fn parse_auto_detach_delete_rejected() {
119        let err = parse_auto("DETACH DELETE (n)").unwrap_err();
120        assert!(
121            matches!(err, QueryError::Unsupported(_)),
122            "DETACH DELETE must return Unsupported on the public path; got {err:?}"
123        );
124        let msg = err.to_string();
125        assert!(msg.contains("read-only"), "got: {msg}");
126    }
127
128    #[test]
129    fn parse_auto_gql_match_not_rejected() {
130        let q = parse_auto("MATCH (a:concept) RETURN a").unwrap();
131        assert!(!q.pattern.elements.is_empty(), "valid GQL MATCH must parse");
132    }
133
134    #[test]
135    fn parse_auto_sparql_select_not_rejected() {
136        let q = parse_auto("SELECT ?a WHERE { ?a :extends ?b . }").unwrap();
137        assert!(
138            !q.pattern.elements.is_empty(),
139            "valid SPARQL SELECT must parse"
140        );
141    }
142
143    #[test]
144    fn parse_auto_load_rejected() {
145        let err = parse_auto("LOAD <http://e/data>").unwrap_err();
146        assert!(
147            matches!(err, QueryError::Unsupported(_)),
148            "LOAD must return Unsupported on the public path; got {err:?}"
149        );
150        let msg = err.to_string();
151        assert!(msg.contains("read-only"), "got: {msg}");
152    }
153}