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