Skip to main content

qualia_core_db/sparql_library/serialisers/
sparql_results.rs

1//! SPARQL Result Formatters
2//!
3//! Formats SPARQL query results as XML, JSON, TSV, and CSV.
4
5use crate::sparql_ast::*;
6use std::io::Write;
7
8/// Result formatter
9pub struct ResultFormatter;
10
11impl ResultFormatter {
12    fn format_value_xml<W: Write>(
13        writer: &mut W,
14        value: u64,
15        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
16    ) -> std::io::Result<()> {
17        // 1. SPARQL-Star embedded triple (only when the lexicon resolves it —
18        //    the tag collides with xsd:integer).
19        if (value & crate::resolver::MSB_FLAG) == 0
20            && (value & crate::resolver::INLINE_TAG_MASK) == crate::resolver::TAG_EMBEDDED
21        {
22            if let Some(lex) = lexicon {
23                if let Some([s, p, o]) = lex.lookup_embedded_triple(value) {
24                    writeln!(writer, r#"        <triple>"#)?;
25                    writeln!(writer, r#"          <subject>"#)?;
26                    Self::format_value_xml(writer, s, lexicon)?;
27                    writeln!(writer, r#"          </subject>"#)?;
28                    writeln!(writer, r#"          <predicate>"#)?;
29                    Self::format_value_xml(writer, p, lexicon)?;
30                    writeln!(writer, r#"          </predicate>"#)?;
31                    writeln!(writer, r#"          <object>"#)?;
32                    Self::format_value_xml(writer, o, lexicon)?;
33                    writeln!(writer, r#"          </object>"#)?;
34                    return writeln!(writer, r#"        </triple>"#);
35                }
36            }
37        }
38
39        // 2. Inline-typed literal → <literal datatype="...">…</literal>
40        //    (previously always emitted as <uri>).
41        if let Some(lit) = crate::resolver::classify_inline_literal(value) {
42            return writeln!(
43                writer,
44                r#"        <literal datatype="{}">{}</literal>"#,
45                lit.datatype_iri(),
46                lit
47            );
48        }
49
50        // 3. IRI: lexicon-resolved, else did:q42 pointer, else hash fallback.
51        let uri = if let Some(bytes) = crate::resolver::resolve_hash(value) {
52            String::from_utf8_lossy(bytes).into_owned()
53        } else if (value & crate::resolver::MSB_FLAG) != 0 {
54            format!("did:q42:ptr/{:016x}", value & !crate::resolver::MSB_FLAG)
55        } else {
56            format!("urn:hash:{:016x}", value)
57        };
58        writeln!(writer, r#"        <uri>{}</uri>"#, uri)
59    }
60
61    fn format_value_json<W: Write>(
62        writer: &mut W,
63        value: u64,
64        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
65    ) -> std::io::Result<()> {
66        // 1. SPARQL-Star embedded triple. Its tag shares the bit pattern of
67        //    xsd:integer, so only take this branch when the lexicon actually
68        //    resolves the value to a triple (else it is an inline integer).
69        if (value & crate::resolver::MSB_FLAG) == 0
70            && (value & crate::resolver::INLINE_TAG_MASK) == crate::resolver::TAG_EMBEDDED
71        {
72            if let Some(lex) = lexicon {
73                if let Some([s, p, o]) = lex.lookup_embedded_triple(value) {
74                    writeln!(writer, r#"      {{"#)?;
75                    writeln!(writer, r#"        "type": "triple","#)?;
76                    writeln!(writer, r#"        "value": {{"#)?;
77                    write!(writer, r#"          "subject": "#)?;
78                    Self::format_value_json(writer, s, lexicon)?;
79                    writeln!(writer, r#","#)?;
80                    write!(writer, r#"          "predicate": "#)?;
81                    Self::format_value_json(writer, p, lexicon)?;
82                    writeln!(writer, r#","#)?;
83                    write!(writer, r#"          "object": "#)?;
84                    Self::format_value_json(writer, o, lexicon)?;
85                    writeln!(writer, r#""#)?;
86                    writeln!(writer, r#"        }}"#)?;
87                    return write!(writer, r#"      }}"#);
88                }
89            }
90        }
91
92        // 2. Inline-typed literal (xsd:integer/decimal/boolean/float). Previously
93        //    every non-embedded value was emitted as "type":"uri", so literals
94        //    were mistyped in the SPARQL 1.1 JSON results.
95        if let Some(lit) = crate::resolver::classify_inline_literal(value) {
96            writeln!(writer, r#"      {{"#)?;
97            writeln!(writer, r#"        "type": "literal","#)?;
98            writeln!(writer, r#"        "value": "{}","#, lit)?;
99            writeln!(writer, r#"        "datatype": "{}""#, lit.datatype_iri())?;
100            return write!(writer, r#"      }}"#);
101        }
102
103        // 3. IRI: lexicon-resolved, else did:q42 pointer (MSB set), else hash
104        //    fallback. (Blank nodes are hashed into the IRI space at ingest and
105        //    cannot be distinguished here — a known ingest-layer limitation.)
106        let uri = if let Some(bytes) = crate::resolver::resolve_hash(value) {
107            String::from_utf8_lossy(bytes).into_owned()
108        } else if (value & crate::resolver::MSB_FLAG) != 0 {
109            format!("did:q42:ptr/{:016x}", value & !crate::resolver::MSB_FLAG)
110        } else {
111            format!("urn:hash:{:016x}", value)
112        };
113        writeln!(writer, r#"      {{"#)?;
114        writeln!(writer, r#"        "type": "uri","#)?;
115        writeln!(writer, r#"        "value": "{}""#, uri)?;
116        write!(writer, r#"      }}"#)
117    }
118
119    fn format_value_tsv<W: Write>(
120        writer: &mut W,
121        value: u64,
122        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
123    ) -> std::io::Result<()> {
124        // 1. SPARQL-Star embedded triple — only when the lexicon resolves it
125        //    (the tag bits collide with xsd:integer, so a failed lookup must
126        //    fall through to the inline-literal case, not be emitted as `<<…>>`).
127        if (value & crate::resolver::MSB_FLAG) == 0
128            && (value & crate::resolver::INLINE_TAG_MASK) == crate::resolver::TAG_EMBEDDED
129        {
130            if let Some(lex) = lexicon {
131                if let Some([s, p, o]) = lex.lookup_embedded_triple(value) {
132                    write!(writer, "<<")?;
133                    Self::format_value_tsv(writer, s, lexicon)?;
134                    write!(writer, " ")?;
135                    Self::format_value_tsv(writer, p, lexicon)?;
136                    write!(writer, " ")?;
137                    Self::format_value_tsv(writer, o, lexicon)?;
138                    return write!(writer, ">>");
139                }
140            }
141        }
142
143        // 2. Inline-typed literal → SPARQL term syntax (TSV/CSV encode RDF terms
144        //    as in the query language), e.g. `"42"^^<…#integer>`.
145        if let Some(lit) = crate::resolver::classify_inline_literal(value) {
146            return write!(writer, "\"{}\"^^<{}>", lit, lit.datatype_iri());
147        }
148
149        // 3. IRI: lexicon-resolved, else did:q42 pointer, else hash fallback.
150        let uri = if let Some(bytes) = crate::resolver::resolve_hash(value) {
151            String::from_utf8_lossy(bytes).into_owned()
152        } else if (value & crate::resolver::MSB_FLAG) != 0 {
153            format!("did:q42:ptr/{:016x}", value & !crate::resolver::MSB_FLAG)
154        } else {
155            format!("urn:hash:{:016x}", value)
156        };
157        write!(writer, "<{}>", uri)
158    }
159
160    /// Format results as SPARQL XML
161    pub fn format_xml<W: Write>(
162        writer: &mut W,
163        variables: &[VariableId],
164        results: &[BindingRow],
165        ctx: &SparqlQueryContext,
166        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
167    ) -> std::io::Result<()> {
168        writeln!(writer, r#"<?xml version="1.0"?>"#)?;
169        writeln!(
170            writer,
171            r#"<sparql xmlns="http://www.w3.org/2005/sparql-results#">"#
172        )?;
173        writeln!(writer, r#"  <head>"#)?;
174        writeln!(writer, r#"    <variables>"#)?;
175
176        for var in variables {
177            let var_name = ctx.variable_hashes[*var as usize];
178            writeln!(writer, r#"      <variable name="{}"/>"#, var_name)?;
179        }
180
181        writeln!(writer, r#"    </variables>"#)?;
182        writeln!(writer, r#"  </head>"#)?;
183        writeln!(writer, r#"  <results>"#)?;
184
185        for row in results {
186            writeln!(writer, r#"    <result>"#)?;
187            for var in variables {
188                let var_id = *var;
189                if let Some(value) = row.get(var_id) {
190                    let var_name = ctx.variable_hashes[var_id as usize];
191                    writeln!(writer, r#"      <binding name="{}">"#, var_name)?;
192                    Self::format_value_xml(writer, value, lexicon)?;
193                    writeln!(writer, r#"      </binding>"#)?;
194                }
195            }
196            writeln!(writer, r#"    </result>"#)?;
197        }
198
199        writeln!(writer, r#"  </results>"#)?;
200        writeln!(writer, r#"</sparql>"#)?;
201
202        Ok(())
203    }
204
205    /// Format results as SPARQL JSON
206    pub fn format_json<W: Write>(
207        writer: &mut W,
208        variables: &[VariableId],
209        results: &[BindingRow],
210        ctx: &SparqlQueryContext,
211        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
212    ) -> std::io::Result<()> {
213        writeln!(writer, r#"{{"#)?;
214        writeln!(writer, r#"  "head": {{"vars": ["#)?;
215
216        for (i, var) in variables.iter().enumerate() {
217            let var_name = ctx.variable_hashes[*var as usize];
218            if i > 0 {
219                write!(writer, r#", "#)?;
220            }
221            write!(writer, r#""{}""#, var_name)?;
222        }
223
224        writeln!(writer, r#"]}},"#)?;
225        writeln!(writer, r#"  "results": {{"#)?;
226        writeln!(writer, r#"    "bindings": ["#)?;
227
228        for (i, row) in results.iter().enumerate() {
229            if i > 0 {
230                writeln!(writer, r#","#)?;
231            }
232            writeln!(writer, r#"      {{"#)?;
233
234            let mut first = true;
235            for var in variables {
236                let var_id = *var;
237                if let Some(value) = row.get(var_id) {
238                    if !first {
239                        writeln!(writer, r#","#)?;
240                    }
241                    first = false;
242                    let var_name = ctx.variable_hashes[var_id as usize];
243                    writeln!(writer, r#"        "{}": "#, var_name)?;
244                    Self::format_value_json(writer, value, lexicon)?;
245                }
246            }
247
248            if !first {
249                writeln!(writer)?
250            };
251            write!(writer, r#"      }}"#)?;
252        }
253        writeln!(writer)?;
254
255        writeln!(writer, r#"    ]"#)?;
256        writeln!(writer, r#"  }}"#)?;
257        writeln!(writer, r#"}}"#)?;
258
259        Ok(())
260    }
261
262    /// Format results as TSV
263    pub fn format_tsv<W: Write>(
264        writer: &mut W,
265        variables: &[VariableId],
266        results: &[BindingRow],
267        ctx: &SparqlQueryContext,
268        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
269    ) -> std::io::Result<()> {
270        for (i, var) in variables.iter().enumerate() {
271            if i > 0 {
272                write!(writer, "\t")?;
273            }
274            write!(writer, "?{}", ctx.variable_hashes[*var as usize])?;
275        }
276        writeln!(writer)?;
277
278        for row in results {
279            for (i, var) in variables.iter().enumerate() {
280                if i > 0 {
281                    write!(writer, "\t")?;
282                }
283                if let Some(value) = row.get(*var) {
284                    Self::format_value_tsv(writer, value, lexicon)?;
285                }
286            }
287            writeln!(writer)?;
288        }
289        Ok(())
290    }
291
292    /// Format results as CSV
293    pub fn format_csv<W: Write>(
294        writer: &mut W,
295        variables: &[VariableId],
296        results: &[BindingRow],
297        ctx: &SparqlQueryContext,
298        lexicon: Option<&crate::q42_lex::Q42LexMmap>,
299    ) -> std::io::Result<()> {
300        for (i, var) in variables.iter().enumerate() {
301            if i > 0 {
302                write!(writer, ",")?;
303            }
304            write!(writer, "{}", ctx.variable_hashes[*var as usize])?;
305        }
306        writeln!(writer)?;
307
308        for row in results {
309            for (i, var) in variables.iter().enumerate() {
310                if i > 0 {
311                    write!(writer, ",")?;
312                }
313                if let Some(value) = row.get(*var) {
314                    let mut temp = Vec::new();
315                    Self::format_value_tsv(&mut temp, value, lexicon)?;
316                    let s = String::from_utf8_lossy(&temp);
317                    if s.contains(',') || s.contains('"') || s.contains('\n') {
318                        write!(writer, "\"{}\"", s.replace('"', "\"\""))?;
319                    } else {
320                        write!(writer, "{}", s)?;
321                    }
322                }
323            }
324            writeln!(writer)?;
325        }
326        Ok(())
327    }
328
329    pub fn format_ntriples<W: Write>(
330        writer: &mut W,
331        results: &[BindingRow],
332    ) -> std::io::Result<()> {
333        for row in results {
334            let s = row.get(0).unwrap_or(0);
335            let p = row.get(1).unwrap_or(0);
336            let o = row.get(2).unwrap_or(0);
337            let quin = crate::NQuin {
338                subject: s,
339                predicate: p,
340                object: o,
341                context: 0,
342                metadata: 0,
343                parity: 0,
344            };
345            crate::resolver::format_ntriples_to(&[quin], writer)?;
346        }
347        Ok(())
348    }
349
350    pub fn format_ask_xml<W: Write>(writer: &mut W, result: bool) -> std::io::Result<()> {
351        writeln!(writer, r#"<?xml version="1.0"?>"#)?;
352        writeln!(
353            writer,
354            r#"<sparql xmlns="http://www.w3.org/2005/sparql-results#">"#
355        )?;
356        writeln!(writer, r#"  <head></head>"#)?;
357        writeln!(writer, r#"  <boolean>{}</boolean>"#, result)?;
358        writeln!(writer, r#"</sparql>"#)?;
359        Ok(())
360    }
361
362    #[cfg(test)]
363    fn value_json_string(value: u64) -> String {
364        let mut buf = Vec::new();
365        Self::format_value_json(&mut buf, value, None).unwrap();
366        String::from_utf8(buf).unwrap()
367    }
368
369    #[cfg(test)]
370    fn value_xml_string(value: u64) -> String {
371        let mut buf = Vec::new();
372        Self::format_value_xml(&mut buf, value, None).unwrap();
373        String::from_utf8(buf).unwrap()
374    }
375
376    #[cfg(test)]
377    fn value_tsv_string(value: u64) -> String {
378        let mut buf = Vec::new();
379        Self::format_value_tsv(&mut buf, value, None).unwrap();
380        String::from_utf8(buf).unwrap()
381    }
382
383    pub fn format_ask_json<W: Write>(writer: &mut W, result: bool) -> std::io::Result<()> {
384        writeln!(writer, r#"{{"#)?;
385        writeln!(writer, r#"  "head": {{}},"#)?;
386        writeln!(writer, r#"  "boolean": {}"#, result)?;
387        writeln!(writer, r#"}}"#)?;
388        Ok(())
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::ResultFormatter;
395    use crate::resolver::{
396        INLINE_TAG_BOOLEAN, INLINE_TAG_DECIMAL, INLINE_TAG_FLOAT, INLINE_TAG_INTEGER,
397    };
398
399    // Regression: inline-typed literals were previously emitted as `"type":"uri"`.
400    // These assert the SPARQL 1.1 Results form now reports literal + xsd datatype.
401
402    #[test]
403    fn inline_integer_serialises_as_typed_literal_json() {
404        let json = ResultFormatter::value_json_string(INLINE_TAG_INTEGER | 42);
405        assert!(json.contains(r#""type": "literal""#), "got: {json}");
406        assert!(json.contains(r#""value": "42""#), "got: {json}");
407        assert!(
408            json.contains("XMLSchema#integer"),
409            "expected xsd:integer datatype, got: {json}"
410        );
411        assert!(
412            !json.contains(r#""type": "uri""#),
413            "must not be a uri: {json}"
414        );
415    }
416
417    #[test]
418    fn inline_boolean_serialises_as_typed_literal_json() {
419        let json = ResultFormatter::value_json_string(INLINE_TAG_BOOLEAN | 1);
420        assert!(json.contains(r#""type": "literal""#), "got: {json}");
421        assert!(json.contains(r#""value": "true""#), "got: {json}");
422        assert!(json.contains("XMLSchema#boolean"), "got: {json}");
423    }
424
425    #[test]
426    fn inline_decimal_serialises_as_typed_literal_json() {
427        // 3.5 encoded as fixed-point ×10^6 = 3_500_000.
428        let json = ResultFormatter::value_json_string(INLINE_TAG_DECIMAL | 3_500_000);
429        assert!(json.contains("XMLSchema#decimal"), "got: {json}");
430        assert!(json.contains(r#""value": "3.500000""#), "got: {json}");
431    }
432
433    #[test]
434    fn inline_float_serialises_as_typed_literal_json() {
435        let val = INLINE_TAG_FLOAT | (0.5f32.to_bits() as u64);
436        let json = ResultFormatter::value_json_string(val);
437        assert!(json.contains("XMLSchema#float"), "got: {json}");
438        assert!(json.contains(r#""value": "0.5""#), "got: {json}");
439    }
440
441    #[test]
442    fn inline_integer_serialises_as_typed_literal_xml() {
443        let xml = ResultFormatter::value_xml_string(INLINE_TAG_INTEGER | 7);
444        assert!(xml.contains("<literal"), "expected <literal>, got: {xml}");
445        assert!(xml.contains("XMLSchema#integer"), "got: {xml}");
446        assert!(xml.contains(">7</literal>"), "got: {xml}");
447        assert!(!xml.contains("<uri>"), "must not be a <uri>: {xml}");
448    }
449
450    #[test]
451    fn unresolved_hash_still_serialises_as_uri_json() {
452        // A plain (untagged, non-lexicon) hash keeps the uri fallback.
453        let json = ResultFormatter::value_json_string(0x0123_4567_89ab_cdef);
454        assert!(json.contains(r#""type": "uri""#), "got: {json}");
455    }
456
457    // Regression: an inline xsd:integer shares the embedded-triple tag bits.
458    // Without a lexicon it must serialise as a typed literal in TSV/CSV, NOT be
459    // mis-emitted as an `<<…>>` embedded triple.
460    #[test]
461    fn inline_integer_serialises_as_typed_literal_tsv() {
462        let tsv = ResultFormatter::value_tsv_string(INLINE_TAG_INTEGER | 42);
463        assert!(tsv.contains(r#""42"^^<"#), "got: {tsv}");
464        assert!(tsv.contains("XMLSchema#integer"), "got: {tsv}");
465        assert!(!tsv.contains("<<"), "must not be an embedded triple: {tsv}");
466    }
467}