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    parse_auto_with_language(input).map(|(_, query)| query)
38}
39
40/// Auto-detects and parses `input`, retaining the selected query language.
41///
42/// # Errors
43///
44/// Returns [`QueryError`] when syntax is invalid, write-shaped, or unsupported.
45pub fn parse_auto_with_language(input: &str) -> Result<(QueryLanguage, GqlQuery), QueryError> {
46    let trimmed = input.trim();
47    reject_write(trimmed)?;
48    let language = if trimmed
49        .as_bytes()
50        .get(..6)
51        .is_some_and(|p| p.eq_ignore_ascii_case(b"SELECT"))
52    {
53        QueryLanguage::Sparql
54    } else {
55        QueryLanguage::Gql
56    };
57    parse(language, trimmed).map(|query| (language, query))
58}
59
60/// Rejects GQL/Cypher mutations and SPARQL Update before dialect dispatch.
61fn reject_write(input: &str) -> Result<(), QueryError> {
62    match leading_keyword(input).as_str() {
63        "CREATE" | "DELETE" | "DETACH" | "SET" | "REMOVE" | "MERGE" | "INSERT" | "UPDATE"
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    #[test]
81    fn parse_auto_with_delete_rejected() {
82        let err = parse_auto("WITH <http://g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }").unwrap_err();
83        assert!(
84            matches!(err, QueryError::Unsupported(_)),
85            "WITH … DELETE must return Unsupported on the public path; got {err:?}"
86        );
87        let msg = err.to_string();
88        assert!(msg.contains("read-only"), "got: {msg}");
89        assert!(
90            msg.contains("create") && msg.contains("update") && msg.contains("delete"),
91            "error must name the mutation verbs; got: {msg}"
92        );
93    }
94
95    #[test]
96    fn parse_auto_prefixed_insert_data_rejected() {
97        let err = parse_auto("PREFIX ex: <http://e/> INSERT DATA { ex:a ex:b ex:c }").unwrap_err();
98        assert!(
99            matches!(err, QueryError::Unsupported(_)),
100            "prefixed INSERT DATA must return Unsupported on the public path; got {err:?}"
101        );
102        let msg = err.to_string();
103        assert!(msg.contains("read-only"), "got: {msg}");
104    }
105
106    #[test]
107    fn parse_auto_prefixed_with_delete_rejected() {
108        let err = parse_auto(
109            "PREFIX ex: <http://e/> WITH <http://g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }",
110        )
111        .unwrap_err();
112        assert!(
113            matches!(err, QueryError::Unsupported(_)),
114            "PREFIX + WITH … DELETE must return Unsupported on the public path; got {err:?}"
115        );
116        let msg = err.to_string();
117        assert!(msg.contains("read-only"), "got: {msg}");
118    }
119
120    #[test]
121    fn parse_auto_detach_delete_rejected() {
122        let err = parse_auto("DETACH DELETE (n)").unwrap_err();
123        assert!(
124            matches!(err, QueryError::Unsupported(_)),
125            "DETACH DELETE must return Unsupported on the public path; got {err:?}"
126        );
127        let msg = err.to_string();
128        assert!(msg.contains("read-only"), "got: {msg}");
129    }
130
131    #[test]
132    fn parse_auto_gql_match_not_rejected() {
133        let q = parse_auto("MATCH (a:concept) RETURN a").unwrap();
134        assert!(!q.pattern.elements.is_empty(), "valid GQL MATCH must parse");
135    }
136
137    #[test]
138    fn parse_auto_sparql_select_not_rejected() {
139        let q = parse_auto("SELECT ?a WHERE { ?a :extends ?b . }").unwrap();
140        assert!(
141            !q.pattern.elements.is_empty(),
142            "valid SPARQL SELECT must parse"
143        );
144    }
145
146    #[test]
147    fn parse_auto_with_language_reports_selected_parser() {
148        let (gql_language, _) = parse_auto_with_language("MATCH (a:concept) RETURN a").unwrap();
149        assert_eq!(gql_language, QueryLanguage::Gql);
150
151        let (sparql_language, _) =
152            parse_auto_with_language("SELECT ?a WHERE { ?a :extends ?b . }").unwrap();
153        assert_eq!(sparql_language, QueryLanguage::Sparql);
154    }
155
156    #[test]
157    fn parse_auto_load_rejected() {
158        let err = parse_auto("LOAD <http://e/data>").unwrap_err();
159        assert!(
160            matches!(err, QueryError::Unsupported(_)),
161            "LOAD must return Unsupported on the public path; got {err:?}"
162        );
163        let msg = err.to_string();
164        assert!(msg.contains("read-only"), "got: {msg}");
165    }
166}