Skip to main content

rete_core/
service.rs

1//! SPARQL 1.1 federated query (`SERVICE`) — the client seam and result parsing.
2//!
3//! The engine itself performs no I/O (the same rule as [`RangeReader`]): a
4//! `SERVICE <endpoint> { … }` block is lowered to a plan node that, at
5//! evaluation time, hands the serialized sub-query to a host-injected
6//! [`ServiceClient`] and joins the returned solutions back into the pipeline.
7//! The CLI backs the seam with an HTTP client, the wasm bindings with XHR; a
8//! `Rete` with no client attached errors on (non-SILENT) `SERVICE`.
9//!
10//! Solutions cross the seam as [`Binding`]s of variable → **N-Triples term
11//! token** (`<iri>`, `"lit"@lang`, `"lit"^^<dt>`, `_:b0`) — the same canonical
12//! tokens the engine uses everywhere — so [`parse_sparql_json_results`] is the
13//! one place the SPARQL protocol's JSON shape is understood.
14//!
15//! [`RangeReader`]: crate::reader::RangeReader
16
17use crate::bgp::Binding;
18
19/// Executes one SPARQL query against a remote endpoint (the SPARQL Protocol)
20/// and returns its solutions. Implementations own transport, auth, and
21/// timeouts; they typically `POST` the query with
22/// `Accept: application/sparql-results+json` and feed the body through
23/// [`parse_sparql_json_results`]. Errors are strings, surfaced verbatim as the
24/// query error (or, under `SERVICE SILENT`, swallowed per the spec) — name the
25/// endpoint in the message, the engine adds no prefix.
26pub trait ServiceClient: Send + Sync {
27    fn query(&self, endpoint: &str, query: &str) -> Result<Vec<Binding>, String>;
28}
29
30/// Parse a SPARQL 1.1 Query Results JSON document (`application/sparql-results+json`)
31/// into bindings of variable → N-Triples term token. An ASK document (no
32/// `results`) yields no bindings. Unknown per-binding fields are ignored;
33/// `xsd:string` datatypes are dropped (a simple literal — matching how plain
34/// literals are tokenized everywhere else in the engine).
35pub fn parse_sparql_json_results(body: &str) -> Result<Vec<Binding>, String> {
36    let doc: serde_json::Value =
37        serde_json::from_str(body).map_err(|e| format!("results are not JSON: {e}"))?;
38    let Some(results) = doc.get("results") else {
39        return Ok(Vec::new()); // an ASK response — no solutions to join
40    };
41    let bindings = results
42        .get("bindings")
43        .and_then(|b| b.as_array())
44        .ok_or("malformed results: no bindings array")?;
45    let mut out = Vec::with_capacity(bindings.len());
46    for sol in bindings {
47        let obj = sol.as_object().ok_or("malformed solution")?;
48        let mut b = Binding::new();
49        for (var, term) in obj {
50            if let Some(token) = json_term_token(term) {
51                b.insert(var.clone(), token);
52            }
53        }
54        out.push(b);
55    }
56    Ok(out)
57}
58
59/// One JSON result term → its N-Triples token, `None` for a shape this reader
60/// doesn't recognize (the binding is then treated as unbound — never a panic).
61fn json_term_token(term: &serde_json::Value) -> Option<String> {
62    let ty = term.get("type")?.as_str()?;
63    let value = term.get("value")?.as_str()?;
64    match ty {
65        "uri" => Some(format!("<{value}>")),
66        "bnode" => Some(format!("_:{value}")),
67        // Virtuoso emits the legacy "typed-literal"; treat it as "literal".
68        "literal" | "typed-literal" => {
69            let mut tok = String::with_capacity(value.len() + 8);
70            tok.push('"');
71            escape_literal_into(value, &mut tok);
72            tok.push('"');
73            if let Some(lang) = term.get("xml:lang").and_then(|l| l.as_str()) {
74                tok.push('@');
75                tok.push_str(lang);
76            } else if let Some(dt) = term.get("datatype").and_then(|d| d.as_str()) {
77                // xsd:string is the simple-literal datatype — keep the plain
78                // token so it joins with locally-ingested plain literals.
79                if dt != "http://www.w3.org/2001/XMLSchema#string" {
80                    tok.push_str("^^<");
81                    tok.push_str(dt);
82                    tok.push('>');
83                }
84            }
85            Some(tok)
86        }
87        _ => None,
88    }
89}
90
91/// Escape a literal's lexical form for an N-Triples token (the JSON carries it
92/// unescaped).
93fn escape_literal_into(s: &str, out: &mut String) {
94    for c in s.chars() {
95        match c {
96            '\\' => out.push_str("\\\\"),
97            '"' => out.push_str("\\\""),
98            '\n' => out.push_str("\\n"),
99            '\r' => out.push_str("\\r"),
100            '\t' => out.push_str("\\t"),
101            other => out.push(other),
102        }
103    }
104}
105
106// --- the serializer (the inverse of the parser above) --------------------------
107//
108// `rete serve` speaks the SPARQL Protocol, so it must EMIT
109// `application/sparql-results+json` too. Serializing here, next to the parser,
110// keeps the whole JSON↔token mapping in one file (and round-trip-testable).
111
112/// Serialize a SELECT result as a SPARQL 1.1 Query Results JSON document.
113/// `vars` fixes the `head` order (pass the projection); solutions' bindings are
114/// term tokens exactly as the engine returns them.
115pub fn sparql_json_results(vars: &[String], solutions: &[Binding]) -> String {
116    use serde_json::{json, Map, Value};
117    let bindings: Vec<Value> = solutions
118        .iter()
119        .map(|sol| {
120            let mut obj = Map::new();
121            for (var, token) in sol {
122                if let Some(term) = token_json_term(token) {
123                    obj.insert(var.clone(), term);
124                }
125            }
126            Value::Object(obj)
127        })
128        .collect();
129    json!({
130        "head": { "vars": vars },
131        "results": { "bindings": bindings },
132    })
133    .to_string()
134}
135
136/// Serialize an ASK result as a SPARQL 1.1 Query Results JSON document.
137pub fn sparql_json_ask(boolean: bool) -> String {
138    serde_json::json!({ "head": {}, "boolean": boolean }).to_string()
139}
140
141/// One N-Triples term token → its JSON result term. `None` for a malformed
142/// token (the binding is then omitted, never a panic).
143fn token_json_term(token: &str) -> Option<serde_json::Value> {
144    use crate::terms;
145    use serde_json::json;
146    if let Some(iri) = terms::iri_content(token) {
147        return Some(json!({ "type": "uri", "value": iri }));
148    }
149    if terms::is_blank(token) {
150        return Some(json!({ "type": "bnode", "value": token.strip_prefix("_:")? }));
151    }
152    if terms::is_literal(token) {
153        let value = terms::literal_lexical(token)?;
154        let lang = terms::lang_tag(token)?;
155        if !lang.is_empty() {
156            return Some(json!({ "type": "literal", "value": value, "xml:lang": lang }));
157        }
158        let dt = terms::literal_datatype(token)?;
159        if dt == "http://www.w3.org/2001/XMLSchema#string" {
160            return Some(json!({ "type": "literal", "value": value }));
161        }
162        return Some(json!({ "type": "literal", "value": value, "datatype": dt }));
163    }
164    None
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn parses_every_term_shape() {
173        let body = r#"{
174            "head": {"vars": ["s", "l", "n", "b", "u"]},
175            "results": {"bindings": [
176                {
177                    "s": {"type": "uri", "value": "http://ex/a"},
178                    "l": {"type": "literal", "value": "hi", "xml:lang": "en"},
179                    "n": {"type": "literal", "value": "42",
180                          "datatype": "http://www.w3.org/2001/XMLSchema#integer"},
181                    "b": {"type": "bnode", "value": "b0"}
182                },
183                {
184                    "s": {"type": "uri", "value": "http://ex/b"},
185                    "l": {"type": "typed-literal", "value": "plain",
186                          "datatype": "http://www.w3.org/2001/XMLSchema#string"}
187                }
188            ]}
189        }"#;
190        let sols = parse_sparql_json_results(body).unwrap();
191        assert_eq!(sols.len(), 2);
192        assert_eq!(sols[0]["s"], "<http://ex/a>");
193        assert_eq!(sols[0]["l"], "\"hi\"@en");
194        assert_eq!(
195            sols[0]["n"],
196            "\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>"
197        );
198        assert_eq!(sols[0]["b"], "_:b0");
199        // `u` is unbound in both rows; xsd:string normalizes to a plain literal.
200        assert!(!sols[0].contains_key("u"));
201        assert_eq!(sols[1]["l"], "\"plain\"");
202    }
203
204    #[test]
205    fn escapes_literal_lexical_forms() {
206        let body = r#"{"results": {"bindings": [
207            {"l": {"type": "literal", "value": "a \"q\"\nb\\c"}}
208        ]}}"#;
209        let sols = parse_sparql_json_results(body).unwrap();
210        assert_eq!(sols[0]["l"], "\"a \\\"q\\\"\\nb\\\\c\"");
211    }
212
213    #[test]
214    fn ask_and_garbage_documents() {
215        // An ASK response has no results member — zero solutions, not an error.
216        assert!(parse_sparql_json_results(r#"{"boolean": true}"#)
217            .unwrap()
218            .is_empty());
219        assert!(parse_sparql_json_results("not json").is_err());
220        assert!(parse_sparql_json_results(r#"{"results": {}}"#).is_err());
221    }
222
223    /// The serializer is the parser's inverse: every term shape (IRI, plain /
224    /// lang / typed / escaped literal, bnode, unbound) survives
225    /// serialize→parse unchanged — the property `rete serve` relies on when a
226    /// rete SERVICE client queries a rete endpoint.
227    #[test]
228    fn serialize_parse_round_trips_every_term_shape() {
229        let vars: Vec<String> = ["s", "l", "n", "b", "e", "u"]
230            .iter()
231            .map(|v| v.to_string())
232            .collect();
233        let sols = vec![
234            binding_of(&[
235                ("s", "<http://ex/a>"),
236                ("l", "\"hi\"@en"),
237                ("n", "\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>"),
238                ("b", "_:b0"),
239                ("e", "\"a \\\"q\\\"\\nb\\\\c\""),
240                // `u` unbound
241            ]),
242            binding_of(&[("s", "<http://ex/b>"), ("l", "\"plain\"")]),
243        ];
244        let doc = sparql_json_results(&vars, &sols);
245        let back = parse_sparql_json_results(&doc).unwrap();
246        assert_eq!(back, sols, "serialize→parse must be the identity");
247        // ASK round-trips to zero solutions (the parser's ASK contract).
248        assert!(parse_sparql_json_results(&sparql_json_ask(true))
249            .unwrap()
250            .is_empty());
251    }
252
253    fn binding_of(pairs: &[(&str, &str)]) -> Binding {
254        pairs
255            .iter()
256            .map(|(k, v)| (k.to_string(), v.to_string()))
257            .collect()
258    }
259}