Skip to main content

qualia_core_db/sparql_library/sparql_grammar/
update.rs

1//! SPARQL 1.1 Update parser.
2//!
3//! Parses `INSERT DATA`, `DELETE DATA`, `DELETE … INSERT … WHERE`, `CLEAR`,
4//! `CREATE`, `DROP`, and `LOAD` into the `UpdateOperation` the existing
5//! `UpdateExecutor` runs. Parsing is non-destructive; the caller decides whether
6//! and how to apply the operation (the mutation path is governed — signed WAL,
7//! scope-respecting — see the daemon's update handler).
8//!
9//! `INSERT DATA`/`DELETE DATA` carry concrete ground triples (no variables),
10//! packed into the operation's fixed `[NQuin; 64]` array. `DELETE/INSERT WHERE`
11//! carry `PatternId`s built via the group-pattern parser.
12
13use std::collections::HashMap;
14
15use crate::sparql_ast::SparqlQueryContext;
16use crate::sparql_library::sparql_grammar::pattern::parse_where_group;
17use crate::sparql_library::sparql_grammar::tokenizer::{tokenize, Token};
18use crate::sparql_library::sparql_update::UpdateOperation;
19use crate::NQuin;
20
21/// Detect whether a query string is a SPARQL Update request (vs a read query).
22pub fn is_update(query: &str) -> bool {
23    let up = query.trim_start().to_ascii_uppercase();
24    up.starts_with("INSERT")
25        || up.starts_with("DELETE")
26        || up.starts_with("CLEAR")
27        || up.starts_with("CREATE")
28        || up.starts_with("DROP")
29        || up.starts_with("LOAD")
30}
31
32/// Parse a single SPARQL Update operation. (Multi-operation requests separated
33/// by `;` are not handled here — the caller may split on top-level `;`.)
34pub fn parse_update(
35    input: &str,
36    ctx: &mut SparqlQueryContext,
37    prefixes: &HashMap<String, String>,
38) -> Result<UpdateOperation, String> {
39    let trimmed = input.trim();
40    let up = trimmed.to_ascii_uppercase();
41
42    if up.starts_with("INSERT DATA") {
43        let body = brace_body(trimmed, "INSERT DATA")?;
44        let (quins, quin_count) = parse_ground_triples(&body, prefixes)?;
45        Ok(UpdateOperation::InsertData { quins, quin_count })
46    } else if up.starts_with("DELETE DATA") {
47        let body = brace_body(trimmed, "DELETE DATA")?;
48        let (quins, quin_count) = parse_ground_triples(&body, prefixes)?;
49        Ok(UpdateOperation::DeleteData { quins, quin_count })
50    } else if up.starts_with("DELETE") && up.contains("WHERE") {
51        parse_delete_insert(trimmed, ctx, prefixes)
52    } else if up.starts_with("INSERT") && up.contains("WHERE") {
53        parse_delete_insert(trimmed, ctx, prefixes)
54    } else if up.starts_with("CLEAR") {
55        Ok(UpdateOperation::Clear {
56            graph: parse_graph_ref(trimmed, "CLEAR", prefixes),
57        })
58    } else if up.starts_with("CREATE") {
59        Ok(UpdateOperation::Create {
60            graph: parse_graph_ref(trimmed, "CREATE", prefixes),
61        })
62    } else if up.starts_with("DROP") {
63        Ok(UpdateOperation::Drop {
64            graph: parse_graph_ref(trimmed, "DROP", prefixes),
65        })
66    } else if up.starts_with("LOAD") {
67        parse_load(trimmed, prefixes)
68    } else {
69        Err("unrecognised SPARQL Update operation".to_string())
70    }
71}
72
73/// Extract the `{ … }` body after a keyword prefix (matching balanced braces).
74fn brace_body(input: &str, keyword: &str) -> Result<String, String> {
75    let after = input[keyword.len()..].trim_start();
76    let open = after
77        .find('{')
78        .ok_or_else(|| format!("expected '{{' after {keyword}"))?;
79    let bytes = after.as_bytes();
80    let mut depth = 0i32;
81    let mut i = open;
82    let mut end = None;
83    while i < bytes.len() {
84        match bytes[i] {
85            b'{' => depth += 1,
86            b'}' => {
87                depth -= 1;
88                if depth == 0 {
89                    end = Some(i);
90                    break;
91                }
92            }
93            _ => {}
94        }
95        i += 1;
96    }
97    let end = end.ok_or_else(|| format!("unbalanced braces after {keyword}"))?;
98    Ok(after[open + 1..end].to_string())
99}
100
101/// Parse concrete ground triples (`s p o .` …) into a fixed `[NQuin; 64]`.
102/// Variables are rejected — DATA blocks must be ground.
103fn parse_ground_triples(
104    body: &str,
105    prefixes: &HashMap<String, String>,
106) -> Result<([NQuin; 64], u8), String> {
107    let tokens = tokenize(body)?;
108    let mut quins = [NQuin::default(); 64];
109    let mut count = 0usize;
110    let mut i = 0usize;
111
112    // Read terms three at a time, skipping `.` separators.
113    let mut terms: Vec<u64> = Vec::new();
114    while i < tokens.len() {
115        match &tokens[i] {
116            Token::Punct('.') => {
117                i += 1;
118            }
119            Token::Var(_) => {
120                return Err("INSERT/DELETE DATA must be ground (no variables)".to_string());
121            }
122            tok => {
123                terms.push(term_hash(tok, prefixes)?);
124                i += 1;
125                if terms.len() == 3 {
126                    if count >= 64 {
127                        return Err("too many triples in a DATA block (max 64)".to_string());
128                    }
129                    quins[count] = make_quin(terms[0], terms[1], terms[2]);
130                    count += 1;
131                    terms.clear();
132                }
133            }
134        }
135    }
136    if !terms.is_empty() {
137        return Err("trailing incomplete triple in DATA block".to_string());
138    }
139    if count == 0 {
140        return Err("DATA block contains no triples".to_string());
141    }
142    Ok((quins, count as u8))
143}
144
145fn parse_delete_insert(
146    input: &str,
147    ctx: &mut SparqlQueryContext,
148    prefixes: &HashMap<String, String>,
149) -> Result<UpdateOperation, String> {
150    // Extract optional DELETE { … }, optional INSERT { … }, and WHERE { … }.
151    let delete_pattern = if let Some(pos) = ci_find(input, "DELETE") {
152        // Only the DELETE template (before INSERT/WHERE).
153        let body = brace_body(&input[pos..], "DELETE")?;
154        Some(parse_group_text(&body, ctx, prefixes)?)
155    } else {
156        None
157    };
158    let insert_pattern = if let Some(pos) = ci_find(input, "INSERT") {
159        let body = brace_body(&input[pos..], "INSERT")?;
160        Some(parse_group_text(&body, ctx, prefixes)?)
161    } else {
162        None
163    };
164    let where_pos = ci_find(input, "WHERE").ok_or("DELETE/INSERT requires a WHERE clause")?;
165    let where_body = brace_body(&input[where_pos..], "WHERE")?;
166    let where_pattern = parse_group_text(&where_body, ctx, prefixes)?;
167
168    Ok(UpdateOperation::DeleteInsert {
169        delete_pattern: delete_pattern.unwrap_or(where_pattern),
170        insert_pattern: insert_pattern.unwrap_or(where_pattern),
171        where_pattern,
172    })
173}
174
175/// Parse a `{ … }`-less group body by wrapping it back in braces for the group
176/// parser.
177fn parse_group_text(
178    body: &str,
179    ctx: &mut SparqlQueryContext,
180    prefixes: &HashMap<String, String>,
181) -> Result<crate::sparql_ast::PatternId, String> {
182    let wrapped = format!("{{ {body} }}");
183    parse_where_group(&wrapped, ctx, prefixes)
184}
185
186fn parse_graph_ref(input: &str, keyword: &str, prefixes: &HashMap<String, String>) -> u64 {
187    // `CLEAR [SILENT] [GRAPH] <iri>` / `CLEAR DEFAULT|ALL`. Default graph → 0.
188    let rest = input[keyword.len()..].trim();
189    let rest_up = rest.to_ascii_uppercase();
190    if rest_up.starts_with("DEFAULT") || rest_up.starts_with("ALL") || rest.is_empty() {
191        return 0;
192    }
193    if let Ok(tokens) = tokenize(rest) {
194        for t in &tokens {
195            if let Token::Iri(_) | Token::Prefixed(_, _) = t {
196                if let Ok(h) = term_hash(t, prefixes) {
197                    return h;
198                }
199            }
200        }
201    }
202    0
203}
204
205fn parse_load(input: &str, prefixes: &HashMap<String, String>) -> Result<UpdateOperation, String> {
206    let tokens = tokenize(&input[4..])?;
207    let mut uri = 0u64;
208    let mut graph = 0u64;
209    let mut seen_into = false;
210    for t in &tokens {
211        match t {
212            Token::Word(w) if w.eq_ignore_ascii_case("INTO") => seen_into = true,
213            Token::Iri(_) | Token::Prefixed(_, _) => {
214                let h = term_hash(t, prefixes)?;
215                if seen_into {
216                    graph = h;
217                } else {
218                    uri = h;
219                }
220            }
221            _ => {}
222        }
223    }
224    Ok(UpdateOperation::Load { uri, graph })
225}
226
227/// Case-insensitive substring search returning the byte index of the keyword.
228fn ci_find(haystack: &str, needle: &str) -> Option<usize> {
229    let h = haystack.to_ascii_uppercase();
230    h.find(&needle.to_ascii_uppercase())
231}
232
233fn term_hash(tok: &Token, prefixes: &HashMap<String, String>) -> Result<u64, String> {
234    match tok {
235        Token::Iri(iri) => Ok(crate::lexicon::generate_60bit_token(iri.as_bytes())),
236        Token::Prefixed(prefix, local) => {
237            let expanded = match prefixes.get(prefix) {
238                Some(base) => format!("{base}{local}"),
239                None => format!("{prefix}:{local}"),
240            };
241            Ok(crate::lexicon::generate_60bit_token(expanded.as_bytes()))
242        }
243        Token::Str { value, .. } => Ok(crate::lexicon::generate_60bit_token(value.as_bytes())),
244        Token::Num(text) => Ok(text
245            .parse::<u64>()
246            .unwrap_or_else(|_| crate::lexicon::generate_60bit_token(text.as_bytes()))),
247        Token::Bool(b) => Ok(crate::lexicon::generate_60bit_token(if *b {
248            b"true"
249        } else {
250            b"false"
251        })),
252        Token::Word(w) if w == "a" => Ok(crate::lexicon::generate_60bit_token(
253            b"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
254        )),
255        Token::Word(w) => Ok(crate::lexicon::generate_60bit_token(w.as_bytes())),
256        other => Err(format!("invalid term in update: {other:?}")),
257    }
258}
259
260fn make_quin(subject: u64, predicate: u64, object: u64) -> NQuin {
261    let mut q = NQuin {
262        subject,
263        predicate,
264        object,
265        context: 0,
266        metadata: 0,
267        parity: 0,
268    };
269    q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
270    q
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn ctx() -> SparqlQueryContext {
278        SparqlQueryContext::new()
279    }
280
281    #[test]
282    fn detects_update_vs_query() {
283        assert!(is_update("INSERT DATA { <a> <b> <c> }"));
284        assert!(is_update("DELETE WHERE { ?s ?p ?o }"));
285        assert!(!is_update("SELECT ?s WHERE { ?s ?p ?o }"));
286    }
287
288    #[test]
289    fn parses_insert_data() {
290        let mut c = ctx();
291        let op = parse_update(
292            "INSERT DATA { <http://a> <http://b> <http://c> . <http://d> <http://e> <http://f> }",
293            &mut c,
294            &HashMap::new(),
295        )
296        .unwrap();
297        match op {
298            UpdateOperation::InsertData { quin_count, quins } => {
299                assert_eq!(quin_count, 2);
300                assert_ne!(quins[0].subject, 0);
301                assert_eq!(
302                    quins[0].parity,
303                    quins[0].subject ^ quins[0].predicate ^ quins[0].object
304                );
305            }
306            other => panic!("expected InsertData, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn parses_delete_data() {
312        let mut c = ctx();
313        let op = parse_update(
314            "DELETE DATA { <http://a> <http://b> <http://c> }",
315            &mut c,
316            &HashMap::new(),
317        )
318        .unwrap();
319        assert!(matches!(
320            op,
321            UpdateOperation::DeleteData { quin_count: 1, .. }
322        ));
323    }
324
325    #[test]
326    fn insert_data_rejects_variables() {
327        let mut c = ctx();
328        let err = parse_update("INSERT DATA { ?s <b> <c> }", &mut c, &HashMap::new()).unwrap_err();
329        assert!(err.contains("ground"), "got {err}");
330    }
331
332    #[test]
333    fn parses_delete_insert_where() {
334        let mut c = ctx();
335        let op = parse_update(
336            "DELETE { ?s <http://old> ?o } INSERT { ?s <http://new> ?o } WHERE { ?s <http://old> ?o }",
337            &mut c,
338            &HashMap::new(),
339        )
340        .unwrap();
341        assert!(matches!(op, UpdateOperation::DeleteInsert { .. }));
342    }
343
344    #[test]
345    fn parses_clear_and_drop() {
346        let mut c = ctx();
347        assert!(matches!(
348            parse_update("CLEAR GRAPH <http://g>", &mut c, &HashMap::new()).unwrap(),
349            UpdateOperation::Clear { .. }
350        ));
351        assert!(matches!(
352            parse_update("DROP GRAPH <http://g>", &mut c, &HashMap::new()).unwrap(),
353            UpdateOperation::Drop { .. }
354        ));
355        assert!(matches!(
356            parse_update("CLEAR DEFAULT", &mut c, &HashMap::new()).unwrap(),
357            UpdateOperation::Clear { graph: 0 }
358        ));
359    }
360}