Skip to main content

triblespace_core/export/
json.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::fmt::Write as FmtWrite;
4
5use crate::and;
6use crate::blob::encodings::longstring::LongString;
7use crate::id::Id;
8use crate::metadata;
9use crate::metadata::MetaDescribe;
10use crate::prelude::{find, pattern};
11use crate::query::TriblePattern;
12use crate::repo::BlobStoreGet;
13use crate::temp;
14use crate::trible::TribleSet;
15use crate::inline::encodings::boolean::Boolean;
16use crate::inline::encodings::f64::F64;
17use crate::inline::encodings::genid::GenId;
18use crate::inline::encodings::hash::{Blake3, Handle, Hash};
19use crate::inline::encodings::UnknownInline;
20use crate::inline::RawInline;
21use crate::inline::IntoInline;
22use crate::inline::Inline;
23use anybytes::View;
24use ryu::Buffer;
25
26/// Error returned by [`export_to_json`].
27#[derive(Debug)]
28pub enum ExportError {
29    /// The blob handle has no corresponding entry in the blob store.
30    MissingBlob {
31        /// Hex-encoded hash of the missing blob.
32        hash: String,
33    },
34    /// The blob store returned an error while loading the blob.
35    BlobStore {
36        /// Hex-encoded hash of the blob.
37        hash: String,
38        /// Stringified underlying error.
39        source: String,
40    },
41}
42
43impl fmt::Display for ExportError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::MissingBlob { hash } => {
47                write!(f, "missing blob for handle hash {hash}")
48            }
49            Self::BlobStore { hash, source } => {
50                write!(f, "failed to load blob {hash}: {source}")
51            }
52        }
53    }
54}
55
56impl std::error::Error for ExportError {}
57
58/// Streamed exporter that writes JSON text directly (avoids serde_json Numbers).
59pub fn export_to_json(
60    merged: &TribleSet,
61    root: Id,
62    store: &impl BlobStoreGet,
63    out: &mut impl FmtWrite,
64) -> Result<(), ExportError> {
65    let mut multi_flags = HashSet::new();
66    find!(
67        (name_handle: Inline<Handle<LongString>>),
68        temp!((field), pattern!(merged, [
69            { ?field @ metadata::name: ?name_handle },
70            { ?field @ metadata::tag: metadata::KIND_MULTI }
71        ]))
72    )
73    .for_each(|(name_handle,)| {
74        multi_flags.insert(name_handle.raw);
75    });
76
77    let mut ctx = ExportCtx {
78        store,
79        name_cache: HashMap::new(),
80        string_cache: HashMap::new(),
81        multi_flags,
82    };
83    let mut visited = HashSet::new();
84    write_entity(merged, root, &mut visited, &mut ctx, out)?;
85    Ok(())
86}
87
88fn write_entity(
89    merged: &TribleSet,
90    entity: Id,
91    visited: &mut HashSet<Id>,
92    ctx: &mut ExportCtx<'_, impl BlobStoreGet>,
93    out: &mut impl FmtWrite,
94) -> Result<(), ExportError> {
95    if !visited.insert(entity) {
96        let _ = out.write_str("{\"$ref\":\"");
97        let _ = write!(out, "{entity:x}");
98        let _ = out.write_str("\"}");
99        return Ok(());
100    }
101
102    let _ = out.write_char('{');
103
104    let mut field_values: Vec<(
105        RawInline,
106        Inline<Handle<LongString>>,
107        Id,
108        Inline<UnknownInline>,
109    )> = Vec::new();
110    find!(
111        (name_handle: Inline<Handle<LongString>>, schema_value: Inline<GenId>, value: Inline<UnknownInline>),
112        temp!((e, attr), and!(
113            e.is(entity.to_inline()),
114            merged.pattern(e, attr, value),
115            pattern!(merged, [
116                { ?attr @ metadata::name: ?name_handle },
117                { ?attr @ metadata::value_encoding: ?schema_value }
118            ])
119        ))
120    )
121    .filter_map(|(name_handle, schema_value, value)| {
122        let schema: Id = schema_value.try_from_inline().ok()?;
123        Some((name_handle.raw, name_handle, schema, value))
124    })
125    .for_each(|(raw, name_handle, schema, value)| {
126        field_values.push((raw, name_handle, schema, value));
127    });
128
129    field_values.sort_by(|(a, _, _, _), (b, _, _, _)| a.cmp(b));
130
131    let mut iter = field_values.into_iter().peekable();
132    let mut field_idx = 0usize;
133    while let Some((name_raw, name_handle, schema, value)) = iter.next() {
134        let mut values = vec![(schema, value)];
135        while let Some((next_raw, _, _, _)) = iter.peek() {
136            if *next_raw != name_raw {
137                break;
138            }
139            let (_, _, s, v) = iter.next().expect("peeked element exists");
140            values.push((s, v));
141        }
142
143        let name = resolve_name(ctx, name_handle)?;
144
145        if field_idx > 0 {
146            let _ = out.write_char(',');
147        }
148        write_escaped_str(&name, out);
149        let _ = out.write_char(':');
150
151        let card_multi = ctx.multi_flags.contains(&name_raw) || values.len() > 1;
152        if card_multi {
153            let _ = out.write_char('[');
154            for (i, (schema, value)) in values.into_iter().enumerate() {
155                if i > 0 {
156                    let _ = out.write_char(',');
157                }
158                render_schema_value(merged, schema, value, visited, ctx, out)?;
159            }
160            let _ = out.write_char(']');
161        } else if let Some((schema, value)) = values.into_iter().next() {
162            render_schema_value(merged, schema, value, visited, ctx, out)?;
163        }
164        field_idx += 1;
165    }
166    let _ = out.write_char('}');
167    Ok(())
168}
169
170fn render_schema_value(
171    merged: &TribleSet,
172    schema: Id,
173    value: Inline<UnknownInline>,
174    visited: &mut HashSet<Id>,
175    ctx: &mut ExportCtx<'_, impl BlobStoreGet>,
176    out: &mut impl FmtWrite,
177) -> Result<(), ExportError> {
178    // Hoisted: id() is not free (re-runs describe per call), so cache the
179    // schema ids this dispatch checks against once per process.
180    use std::sync::LazyLock;
181    static BOOLEAN_ID: LazyLock<Id> = LazyLock::new(Boolean::id);
182    static F64_ID: LazyLock<Id> = LazyLock::new(F64::id);
183    static GENID_ID: LazyLock<Id> = LazyLock::new(GenId::id);
184    static HANDLE_BLAKE3_LONGSTRING_ID: LazyLock<Id> =
185        LazyLock::new(Handle::<LongString>::id);
186
187    if schema == *BOOLEAN_ID {
188        let value = value.transmute::<Boolean>();
189        if let Ok(b) = value.try_from_inline::<bool>() {
190            let _ = out.write_str(if b { "true" } else { "false" });
191        } else {
192            let _ = out.write_str("null");
193        }
194        return Ok(());
195    }
196    if schema == *F64_ID {
197        let value = value.transmute::<F64>();
198        let number = value.from_inline::<f64>();
199        if !number.is_finite() {
200            let _ = out.write_str("null");
201            return Ok(());
202        }
203        if number.fract() == 0.0 {
204            let _ = write!(out, "{number:.0}");
205        } else {
206            let mut buf = Buffer::new();
207            let s = buf.format_finite(number);
208            let _ = out.write_str(s);
209        }
210        return Ok(());
211    }
212    if schema == *GENID_ID {
213        if let Ok(child_id) = value.transmute::<GenId>().try_from_inline::<Id>() {
214            return write_entity(merged, child_id, visited, ctx, out);
215        }
216        return Ok(());
217    }
218    if schema == *HANDLE_BLAKE3_LONGSTRING_ID {
219        let handle = value.transmute::<Handle<LongString>>();
220        let text = resolve_string(ctx, handle)?;
221        write_escaped_str(text.as_ref(), out);
222        return Ok(());
223    }
224
225    Ok(())
226}
227
228fn write_escaped_str(text: &str, out: &mut impl FmtWrite) {
229    let _ = out.write_char('"');
230    let bytes = text.as_bytes();
231    let mut idx = 0;
232    while idx < bytes.len() {
233        let b = bytes[idx];
234        if b >= 0x20 && b != b'\\' && b != b'"' {
235            // Fast path: copy contiguous ASCII chunk.
236            let start = idx;
237            idx += 1;
238            while idx < bytes.len() {
239                let b2 = bytes[idx];
240                if b2 < 0x20 || b2 == b'\\' || b2 == b'"' {
241                    break;
242                }
243                idx += 1;
244            }
245            let _ = out.write_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..idx]) });
246            continue;
247        }
248        match b {
249            b'"' => {
250                let _ = out.write_str("\\\"");
251            }
252            b'\\' => {
253                let _ = out.write_str("\\\\");
254            }
255            b'\n' => {
256                let _ = out.write_str("\\n");
257            }
258            b'\r' => {
259                let _ = out.write_str("\\r");
260            }
261            b'\t' => {
262                let _ = out.write_str("\\t");
263            }
264            0x08 => {
265                let _ = out.write_str("\\b");
266            }
267            0x0c => {
268                let _ = out.write_str("\\f");
269            }
270            _ if b < 0x20 => {
271                let _ = write!(out, "\\u{:04x}", b);
272            }
273            _ => {
274                let _ = out.write_char(b as char);
275            }
276        }
277        idx += 1;
278    }
279    let _ = out.write_char('"');
280}
281
282struct ExportCtx<'a, Store: BlobStoreGet> {
283    store: &'a Store,
284    name_cache: HashMap<RawInline, String>,
285    string_cache: HashMap<RawInline, View<str>>,
286    multi_flags: HashSet<RawInline>,
287}
288
289fn resolve_name(
290    ctx: &mut ExportCtx<'_, impl BlobStoreGet>,
291    handle: Inline<Handle<LongString>>,
292) -> Result<String, ExportError> {
293    if let Some(cached) = ctx.name_cache.get(&handle.raw) {
294        return Ok(cached.clone());
295    }
296
297    let hash: Inline<Hash<Blake3>> = Handle::to_hash(handle);
298    let text = ctx
299        .store
300        .get::<View<str>, LongString>(handle)
301        .map_err(|err| ExportError::BlobStore {
302            hash: hex::encode(hash.raw),
303            source: err.to_string(),
304        })?
305        .to_string();
306    ctx.name_cache.insert(handle.raw, text.clone());
307    Ok(text)
308}
309
310fn resolve_string(
311    ctx: &mut ExportCtx<'_, impl BlobStoreGet>,
312    handle: Inline<Handle<LongString>>,
313) -> Result<View<str>, ExportError> {
314    if let Some(cached) = ctx.string_cache.get(&handle.raw) {
315        return Ok(cached.clone());
316    }
317
318    let hash: Inline<Hash<Blake3>> = Handle::to_hash(handle);
319    let text: View<str> = ctx
320        .store
321        .get::<View<str>, LongString>(handle)
322        .map_err(|err| ExportError::BlobStore {
323            hash: hex::encode(hash.raw),
324            source: err.to_string(),
325        })?;
326    ctx.string_cache.insert(handle.raw, text.clone());
327    Ok(text)
328}