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::schemas::longstring::LongString;
7use crate::id::Id;
8use crate::metadata;
9use crate::metadata::ConstId;
10use crate::prelude::{find, pattern};
11use crate::query::TriblePattern;
12use crate::repo::BlobStoreGet;
13use crate::temp;
14use crate::trible::TribleSet;
15use crate::value::schemas::boolean::Boolean;
16use crate::value::schemas::f64::F64;
17use crate::value::schemas::genid::GenId;
18use crate::value::schemas::hash::{Blake3, Handle, Hash};
19use crate::value::schemas::UnknownValue;
20use crate::value::RawValue;
21use crate::value::ToValue;
22use crate::value::Value;
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<Blake3>,
63    out: &mut impl FmtWrite,
64) -> Result<(), ExportError> {
65    let mut multi_flags = HashSet::new();
66    find!(
67        (name_handle: Value<Handle<Blake3, 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<Blake3>>,
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        RawValue,
106        Value<Handle<Blake3, LongString>>,
107        Id,
108        Value<UnknownValue>,
109    )> = Vec::new();
110    find!(
111        (name_handle: Value<Handle<Blake3, LongString>>, schema_value: Value<GenId>, value: Value<UnknownValue>),
112        temp!((e, attr), and!(
113            e.is(entity.to_value()),
114            merged.pattern(e, attr, value),
115            pattern!(merged, [
116                { ?attr @ metadata::name: ?name_handle },
117                { ?attr @ metadata::value_schema: ?schema_value }
118            ])
119        ))
120    )
121    .filter_map(|(name_handle, schema_value, value)| {
122        let schema: Id = schema_value.try_from_value().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: Value<UnknownValue>,
174    visited: &mut HashSet<Id>,
175    ctx: &mut ExportCtx<'_, impl BlobStoreGet<Blake3>>,
176    out: &mut impl FmtWrite,
177) -> Result<(), ExportError> {
178    if schema == Boolean::ID {
179        let value = value.transmute::<Boolean>();
180        if let Ok(b) = value.try_from_value::<bool>() {
181            let _ = out.write_str(if b { "true" } else { "false" });
182        } else {
183            let _ = out.write_str("null");
184        }
185        return Ok(());
186    }
187    if schema == F64::ID {
188        let value = value.transmute::<F64>();
189        let number = value.from_value::<f64>();
190        if !number.is_finite() {
191            let _ = out.write_str("null");
192            return Ok(());
193        }
194        if number.fract() == 0.0 {
195            let _ = write!(out, "{number:.0}");
196        } else {
197            let mut buf = Buffer::new();
198            let s = buf.format_finite(number);
199            let _ = out.write_str(s);
200        }
201        return Ok(());
202    }
203    if schema == GenId::ID {
204        if let Ok(child_id) = value.transmute::<GenId>().try_from_value::<Id>() {
205            return write_entity(merged, child_id, visited, ctx, out);
206        }
207        return Ok(());
208    }
209    if schema == Handle::<Blake3, LongString>::ID {
210        let handle = value.transmute::<Handle<Blake3, LongString>>();
211        let text = resolve_string(ctx, handle)?;
212        write_escaped_str(text.as_ref(), out);
213        return Ok(());
214    }
215
216    Ok(())
217}
218
219fn write_escaped_str(text: &str, out: &mut impl FmtWrite) {
220    let _ = out.write_char('"');
221    let bytes = text.as_bytes();
222    let mut idx = 0;
223    while idx < bytes.len() {
224        let b = bytes[idx];
225        if b >= 0x20 && b != b'\\' && b != b'"' {
226            // Fast path: copy contiguous ASCII chunk.
227            let start = idx;
228            idx += 1;
229            while idx < bytes.len() {
230                let b2 = bytes[idx];
231                if b2 < 0x20 || b2 == b'\\' || b2 == b'"' {
232                    break;
233                }
234                idx += 1;
235            }
236            let _ = out.write_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..idx]) });
237            continue;
238        }
239        match b {
240            b'"' => {
241                let _ = out.write_str("\\\"");
242            }
243            b'\\' => {
244                let _ = out.write_str("\\\\");
245            }
246            b'\n' => {
247                let _ = out.write_str("\\n");
248            }
249            b'\r' => {
250                let _ = out.write_str("\\r");
251            }
252            b'\t' => {
253                let _ = out.write_str("\\t");
254            }
255            0x08 => {
256                let _ = out.write_str("\\b");
257            }
258            0x0c => {
259                let _ = out.write_str("\\f");
260            }
261            _ if b < 0x20 => {
262                let _ = write!(out, "\\u{:04x}", b);
263            }
264            _ => {
265                let _ = out.write_char(b as char);
266            }
267        }
268        idx += 1;
269    }
270    let _ = out.write_char('"');
271}
272
273struct ExportCtx<'a, Store: BlobStoreGet<Blake3>> {
274    store: &'a Store,
275    name_cache: HashMap<RawValue, String>,
276    string_cache: HashMap<RawValue, View<str>>,
277    multi_flags: HashSet<RawValue>,
278}
279
280fn resolve_name(
281    ctx: &mut ExportCtx<'_, impl BlobStoreGet<Blake3>>,
282    handle: Value<Handle<Blake3, LongString>>,
283) -> Result<String, ExportError> {
284    if let Some(cached) = ctx.name_cache.get(&handle.raw) {
285        return Ok(cached.clone());
286    }
287
288    let hash: Value<Hash<Blake3>> = Handle::to_hash(handle);
289    let text = ctx
290        .store
291        .get::<View<str>, LongString>(handle)
292        .map_err(|err| ExportError::BlobStore {
293            hash: hex::encode(hash.raw),
294            source: err.to_string(),
295        })?
296        .to_string();
297    ctx.name_cache.insert(handle.raw, text.clone());
298    Ok(text)
299}
300
301fn resolve_string(
302    ctx: &mut ExportCtx<'_, impl BlobStoreGet<Blake3>>,
303    handle: Value<Handle<Blake3, LongString>>,
304) -> Result<View<str>, ExportError> {
305    if let Some(cached) = ctx.string_cache.get(&handle.raw) {
306        return Ok(cached.clone());
307    }
308
309    let hash: Value<Hash<Blake3>> = Handle::to_hash(handle);
310    let text: View<str> = ctx
311        .store
312        .get::<View<str>, LongString>(handle)
313        .map_err(|err| ExportError::BlobStore {
314            hash: hex::encode(hash.raw),
315            source: err.to_string(),
316        })?;
317    ctx.string_cache.insert(handle.raw, text.clone());
318    Ok(text)
319}