Skip to main content

qualia_core_db/sparql_library/serialisers/
rdf_serializers.rs

1//! RDF Format Serializers for QualiaDB
2//!
3//! Serializes NQuin data to standard RDF formats: N-Triples, Turtle, N-Quads,
4//! TriG, N3, JSON-LD.
5//!
6//! Terms are resolved through the shared resolver primitives
7//! (`write_iri_term` / `write_object_term`), so subjects/predicates render as
8//! `<iri>` and objects render as `<iri>` **or** a typed literal
9//! (`"42"^^<…#integer>`) exactly as in the N-Triples path. The grouped
10//! formats (Turtle/TriG/N3) additionally produce *valid* surface syntax —
11//! subject joined to its predicate–object list with `;` separators and a single
12//! trailing `.`, not the previous malformed "subject `.`" per line.
13//!
14//! Known boundary (shared by every path here, including N-Triples): a plain or
15//! language-tagged **string** literal that was interned into the lexicon at
16//! ingest is indistinguishable from an IRI at this layer and renders as `<…>`.
17//! Inline-typed literals (integer/decimal/boolean/float) *are* distinguished and
18//! render correctly. Resolving interned string literals to `"…"@lang` output is
19//! an ingest-layer concern (the term must carry its literal flag) tracked
20//! separately; it is not silently faked here.
21
22use std::collections::HashMap;
23use std::io::Write;
24
25use crate::query::resolver::{classify_inline_literal, write_iri_term, write_object_term};
26use crate::NQuin;
27
28/// Serialize Quins to N-Triples format (zero-heap via resolver).
29pub fn serialize_to_ntriples<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
30    crate::resolver::format_ntriples_to(quins, writer)
31        .map_err(|e| format!("Failed to write N-Triples: {e}"))
32}
33
34/// Group quins by a key field, preserving first-seen key order for stable,
35/// deterministic output (HashMap iteration order is not stable).
36fn group_by<'a>(quins: &'a [NQuin], key: impl Fn(&NQuin) -> u64) -> Vec<(u64, Vec<&'a NQuin>)> {
37    let mut order: Vec<u64> = Vec::new();
38    let mut map: HashMap<u64, Vec<&NQuin>> = HashMap::new();
39    for quin in quins {
40        let k = key(quin);
41        let bucket = map.entry(k).or_default();
42        if bucket.is_empty() {
43            order.push(k);
44        }
45        bucket.push(quin);
46    }
47    order
48        .into_iter()
49        .map(|k| {
50            let v = map.remove(&k).unwrap_or_default();
51            (k, v)
52        })
53        .collect()
54}
55
56/// Write a subject and its predicate–object list as one valid Turtle/TriG
57/// statement: `<s> <p1> <o1> ;` … `<pn> <on> .`, with `indent` spaces before
58/// each continuation predicate.
59fn write_subject_block<W: Write>(
60    writer: &mut W,
61    subject: u64,
62    rows: &[&NQuin],
63    indent: &str,
64) -> std::io::Result<()> {
65    write!(writer, "{indent}")?;
66    write_iri_term(subject, writer)?;
67    for (i, quin) in rows.iter().enumerate() {
68        if i == 0 {
69            write!(writer, " ")?;
70        } else {
71            write!(writer, " ;\n{indent}    ")?;
72        }
73        write_iri_term(quin.predicate, writer)?;
74        write!(writer, " ")?;
75        write_object_term(quin.object, writer)?;
76    }
77    writeln!(writer, " .")
78}
79
80/// Serialize Quins to Turtle format.
81pub fn serialize_to_turtle<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
82    for (subject, rows) in group_by(quins, |q| q.subject) {
83        write_subject_block(writer, subject, &rows, "")
84            .map_err(|e| format!("Failed to write Turtle: {e}"))?;
85    }
86    Ok(())
87}
88
89/// Serialize Quins to N-Quads format (zero-heap via resolver).
90pub fn serialize_to_nquads<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
91    crate::resolver::format_nquads_to(quins, writer)
92        .map_err(|e| format!("Failed to write N-Quads: {e}"))
93}
94
95/// Serialize Quins to TriG format (named graphs of Turtle blocks).
96pub fn serialize_to_trig<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
97    for (context, ctx_quins) in group_by(quins, |q| q.context) {
98        write!(writer, "").map_err(|e| format!("Failed to write TriG: {e}"))?;
99        write_iri_term(context, writer).map_err(|e| format!("Failed to write TriG graph: {e}"))?;
100        writeln!(writer, " {{").map_err(|e| format!("Failed to write TriG graph: {e}"))?;
101
102        // Re-group this graph's quins by subject.
103        let owned: Vec<NQuin> = ctx_quins.iter().map(|q| **q).collect();
104        for (subject, rows) in group_by(&owned, |q| q.subject) {
105            write_subject_block(writer, subject, &rows, "    ")
106                .map_err(|e| format!("Failed to write TriG statement: {e}"))?;
107        }
108
109        writeln!(writer, "}}").map_err(|e| format!("Failed to write TriG graph end: {e}"))?;
110    }
111    Ok(())
112}
113
114/// Serialize Quins to N3 format.
115///
116/// N3's core triple syntax is Turtle-compatible; this emits the Turtle subset
117/// (subject with a `;`-separated predicate–object list) which is valid N3.
118pub fn serialize_to_n3<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
119    for (subject, rows) in group_by(quins, |q| q.subject) {
120        write_subject_block(writer, subject, &rows, "")
121            .map_err(|e| format!("Failed to write N3: {e}"))?;
122    }
123    Ok(())
124}
125
126/// A resolved JSON-LD node: an IRI reference or a typed literal value.
127enum JsonLdTerm {
128    Iri(String),
129    Literal { value: String, datatype: String },
130}
131
132/// Resolve a term hash for JSON-LD (bare IRI string, no angle brackets; or a
133/// typed literal). Mirrors the resolver's lexicon-first priority.
134fn jsonld_term(hash: u64) -> JsonLdTerm {
135    if let Some(bytes) = crate::resolver::resolve_hash(hash) {
136        return JsonLdTerm::Iri(String::from_utf8_lossy(bytes).into_owned());
137    }
138    if (hash & crate::resolver::MSB_FLAG) != 0 {
139        return JsonLdTerm::Iri(format!(
140            "did:q42:ptr/{:016x}",
141            hash & !crate::resolver::MSB_FLAG
142        ));
143    }
144    if let Some(lit) = classify_inline_literal(hash) {
145        return JsonLdTerm::Literal {
146            value: lit.to_string(),
147            datatype: lit.datatype_iri().to_string(),
148        };
149    }
150    JsonLdTerm::Iri(format!("quin:hash/{hash:016x}"))
151}
152
153/// Minimal JSON string escaping (quote, backslash, control chars).
154fn json_escape(s: &str) -> String {
155    let mut out = String::with_capacity(s.len() + 2);
156    for c in s.chars() {
157        match c {
158            '"' => out.push_str("\\\""),
159            '\\' => out.push_str("\\\\"),
160            '\n' => out.push_str("\\n"),
161            '\r' => out.push_str("\\r"),
162            '\t' => out.push_str("\\t"),
163            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
164            c => out.push(c),
165        }
166    }
167    out
168}
169
170/// Serialize Quins to JSON-LD (expanded node objects, grouped by subject).
171///
172/// Each subject becomes one node object; predicates map to arrays of value
173/// objects (`{"@id": …}` for IRIs, `{"@value": …, "@type": …}` for literals).
174pub fn serialize_to_jsonld<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
175    let err = |e: std::io::Error| format!("Failed to write JSON-LD: {e}");
176    writeln!(writer, "[").map_err(err)?;
177
178    let subjects = group_by(quins, |q| q.subject);
179    for (si, (subject, rows)) in subjects.iter().enumerate() {
180        if si > 0 {
181            writeln!(writer, ",").map_err(err)?;
182        }
183        let subj_iri = match jsonld_term(*subject) {
184            JsonLdTerm::Iri(s) => s,
185            // A subject can only be an IRI/blank node; a literal here is a
186            // malformed inline value — surface its lexical form as the @id
187            // rather than dropping the statement.
188            JsonLdTerm::Literal { value, .. } => value,
189        };
190        writeln!(writer, "  {{").map_err(err)?;
191        write!(writer, "    \"@id\": \"{}\"", json_escape(&subj_iri)).map_err(err)?;
192
193        // Group this subject's rows by predicate to build one array per predicate.
194        let owned: Vec<NQuin> = rows.iter().map(|q| **q).collect();
195        let by_pred = group_by(&owned, |q| q.predicate);
196        for (predicate, pred_rows) in &by_pred {
197            let pred_iri = match jsonld_term(*predicate) {
198                JsonLdTerm::Iri(s) => s,
199                JsonLdTerm::Literal { value, .. } => value,
200            };
201            writeln!(writer, ",").map_err(err)?;
202            writeln!(writer, "    \"{}\": [", json_escape(&pred_iri)).map_err(err)?;
203            for (oi, quin) in pred_rows.iter().enumerate() {
204                if oi > 0 {
205                    writeln!(writer, ",").map_err(err)?;
206                }
207                match jsonld_term(quin.object) {
208                    JsonLdTerm::Iri(iri) => {
209                        write!(writer, "      {{ \"@id\": \"{}\" }}", json_escape(&iri))
210                            .map_err(err)?;
211                    }
212                    JsonLdTerm::Literal { value, datatype } => {
213                        write!(
214                            writer,
215                            "      {{ \"@value\": \"{}\", \"@type\": \"{}\" }}",
216                            json_escape(&value),
217                            json_escape(&datatype)
218                        )
219                        .map_err(err)?;
220                    }
221                }
222            }
223            write!(writer, "\n    ]").map_err(err)?;
224        }
225        write!(writer, "\n  }}").map_err(err)?;
226    }
227
228    writeln!(writer, "\n]").map_err(err)?;
229    Ok(())
230}
231
232/// Serialize Quins to CBOR-LD: a CBOR array of JSON-LD-shaped node maps
233/// (`{"@id": <subject>, <predicate>: <object>}`), one map per triple.
234///
235/// One map per triple (rather than grouping a subject's predicates or using
236/// array values) is deliberate: the streaming CBOR-LD parser
237/// (`cbor_parser::parse_cbor_ld_stream`) reads exactly one value per map key
238/// and re-hashes term *strings* with the same `generate_60bit_token`, so this
239/// shape round-trips to identical term hashes. CBOR array values and duplicate
240/// keys are not re-hashable by that parser and would silently drop objects.
241///
242/// Fidelity boundary (honest, not a substitution): IRIs resolved through the
243/// lexicon round-trip to the identical hash. Inline-typed literals are written
244/// in their lexical form (e.g. `"42"`); their tag-encoded identity cannot be
245/// reconstructed through a string-hashing CBOR-LD reader. Terms that resolve to
246/// neither (unknown hashes) are written as their `quin:hash/…` /
247/// `did:q42:ptr/…` surface form.
248pub fn serialize_to_cborld<W: Write>(writer: &mut W, quins: &[NQuin]) -> Result<(), String> {
249    use ciborium::value::Value;
250
251    let term_string = |h: u64| -> String {
252        match jsonld_term(h) {
253            JsonLdTerm::Iri(s) => s,
254            JsonLdTerm::Literal { value, .. } => value,
255        }
256    };
257
258    let mut arr: Vec<Value> = Vec::with_capacity(quins.len());
259    for q in quins {
260        arr.push(Value::Map(vec![
261            (
262                Value::Text("@id".to_string()),
263                Value::Text(term_string(q.subject)),
264            ),
265            (
266                Value::Text(term_string(q.predicate)),
267                Value::Text(term_string(q.object)),
268            ),
269        ]));
270    }
271
272    ciborium::ser::into_writer(&Value::Array(arr), writer)
273        .map_err(|e| format!("Failed to write CBOR-LD: {e}"))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::query::resolver::{INLINE_TAG_INTEGER, MSB_FLAG};
280
281    fn s(quins: &[NQuin], f: impl Fn(&mut Vec<u8>, &[NQuin]) -> Result<(), String>) -> String {
282        let mut buf = Vec::new();
283        f(&mut buf, quins).unwrap();
284        String::from_utf8(buf).unwrap()
285    }
286
287    // A quin whose object is an inline-typed integer literal.
288    fn quin(subject: u64, predicate: u64, object: u64) -> NQuin {
289        NQuin {
290            subject,
291            predicate,
292            object,
293            context: 0,
294            metadata: 0,
295            parity: 0,
296        }
297    }
298
299    #[test]
300    fn turtle_is_valid_and_groups_by_subject() {
301        // Same subject, two predicates → one block ending in a single '.'.
302        let s1 = MSB_FLAG | 0x11;
303        let quins = [
304            quin(s1, MSB_FLAG | 0x22, MSB_FLAG | 0x33),
305            quin(s1, MSB_FLAG | 0x44, MSB_FLAG | 0x55),
306        ];
307        let out = s(&quins, serialize_to_turtle);
308        // Exactly one statement terminator.
309        assert_eq!(out.matches(" .\n").count(), 1, "one '.' per subject: {out}");
310        // Predicate separator present.
311        assert!(out.contains(" ;\n"), "predicate list uses ';': {out}");
312        // No malformed 'subject .' line (the old bug).
313        assert!(
314            !out.lines().next().unwrap().trim_end().ends_with("> ."),
315            "subject must not be terminated alone: {out}"
316        );
317    }
318
319    #[test]
320    fn turtle_object_integer_is_typed_literal_not_iri() {
321        let quins = [quin(
322            MSB_FLAG | 0x11,
323            MSB_FLAG | 0x22,
324            INLINE_TAG_INTEGER | 42,
325        )];
326        let out = s(&quins, serialize_to_turtle);
327        assert!(
328            out.contains(r#""42"^^<"#),
329            "integer object as typed literal: {out}"
330        );
331        assert!(out.contains("XMLSchema#integer"), "{out}");
332    }
333
334    #[test]
335    fn jsonld_literal_object_uses_value_and_type() {
336        let quins = [quin(
337            MSB_FLAG | 0x11,
338            MSB_FLAG | 0x22,
339            INLINE_TAG_INTEGER | 7,
340        )];
341        let out = s(&quins, serialize_to_jsonld);
342        assert!(out.contains(r#""@value": "7""#), "{out}");
343        assert!(out.contains(r#""@type""#), "{out}");
344        assert!(out.contains("XMLSchema#integer"), "{out}");
345    }
346
347    #[test]
348    fn jsonld_iri_object_uses_id() {
349        let quins = [quin(MSB_FLAG | 0x11, MSB_FLAG | 0x22, MSB_FLAG | 0x33)];
350        let out = s(&quins, serialize_to_jsonld);
351        assert!(out.contains(r#""@id""#), "{out}");
352        // did:q42 pointer form for an unresolved MSB term.
353        assert!(out.contains("did:q42:ptr/"), "{out}");
354    }
355
356    #[test]
357    fn trig_wraps_statements_in_graph_braces() {
358        let quins = [quin(MSB_FLAG | 0x11, MSB_FLAG | 0x22, MSB_FLAG | 0x33)];
359        let out = s(&quins, serialize_to_trig);
360        assert!(out.contains(" {\n"), "graph opens with '{{': {out}");
361        assert!(
362            out.trim_end().ends_with('}'),
363            "graph closes with '}}': {out}"
364        );
365    }
366
367    #[test]
368    fn cborld_emits_decodable_cbor_array_of_maps() {
369        // CBOR is binary, so this asserts on bytes directly (not via `s`).
370        let quins = [
371            quin(MSB_FLAG | 0x11, MSB_FLAG | 0x22, MSB_FLAG | 0x33),
372            quin(MSB_FLAG | 0x44, MSB_FLAG | 0x55, INLINE_TAG_INTEGER | 9),
373        ];
374        let mut buf = Vec::new();
375        serialize_to_cborld(&mut buf, &quins).unwrap();
376        // Top-level CBOR array of length 2 (0x82).
377        assert_eq!(buf[0], 0x82, "expected CBOR array(2), got {:#x}", buf[0]);
378        // Decodes cleanly as an array of two 2-entry maps.
379        let val: ciborium::value::Value = ciborium::de::from_reader(&buf[..]).unwrap();
380        match val {
381            ciborium::value::Value::Array(a) => {
382                assert_eq!(a.len(), 2, "one map per triple");
383                match &a[0] {
384                    ciborium::value::Value::Map(m) => assert_eq!(m.len(), 2, "@id + one predicate"),
385                    other => panic!("expected map, got {other:?}"),
386                }
387            }
388            other => panic!("expected array, got {other:?}"),
389        }
390    }
391}