Skip to main content

mdmodels_core/jsonld/
export.rs

1//! This module provides utilities for generating JSON-LD contexts from a `DataModel`
2//! definition. It constructs nested JSON-LD contexts by traversing the data model's
3//! object graph and encoding type, term, and container information suited
4//! for consumption by JSON-LD tools.
5
6use crate::{
7    jsonld::schema::{
8        JsonLdContext, JsonLdHeader, OneOrMany, SimpleContext, TermDef, TermDetail, TypeOrVec,
9    },
10    object::Object,
11    prelude::DataModel,
12    tree,
13};
14use petgraph::Direction;
15use std::{
16    collections::{HashMap, HashSet},
17    error::Error,
18};
19
20/// Generate a full JSON-LD document header for the given data model.
21///
22/// This function constructs a [`JsonLdHeader`] for a [`DataModel`], using its prefix,
23/// object name, and any configured context prefixes. It builds the `@context`, `@id`,
24/// and `@type` fields required for compliant JSON-LD output. An explicit root object
25/// can be chosen by its name; otherwise, the first object in the model is used.
26///
27/// # Arguments
28/// * `model` - Reference to the source [`DataModel`].
29/// * `root` - Optional name of the root object; if `None`, the model's first object is used.
30///
31/// # Returns
32/// * `Ok(JsonLdHeader)` containing complete JSON-LD metadata for the root object.
33/// * `Err(Box<dyn Error>)` if the root cannot be found or context creation fails.
34///
35/// # Example
36/// ```rust,ignore
37/// let header = to_json_ld(&model, Some("EnzymeMLDocument")).unwrap();
38/// ```
39pub fn to_json_ld(model: &DataModel, root: Option<&str>) -> Result<JsonLdHeader, Box<dyn Error>> {
40    let context = create_context(model, root)?;
41    let config = model.config.clone().unwrap_or_default();
42    let object = match root {
43        Some(name) => model
44            .objects
45            .iter()
46            .find(|o| o.name == name)
47            .ok_or_else(|| format!("Object {name} not found"))?,
48        None => model.objects.first().ok_or("No objects found in model")?,
49    };
50
51    let object_type = match object.term.clone() {
52        Some(term) => TypeOrVec::Single(term),
53        None => TypeOrVec::Single(format!("{}:{}", config.prefix, object.name)),
54    };
55
56    Ok(JsonLdHeader {
57        context: Some(JsonLdContext::Object(context)),
58        type_: Some(object_type),
59        ..Default::default()
60    })
61}
62
63/// Generate a [`SimpleContext`] for use as the JSON-LD `@context` for the given data model.
64///
65/// # Arguments
66///
67/// * `model` - The data model from which to derive the context.
68/// * `root` - Optional. The name of the root object. If not provided, the function uses the first object.
69///
70/// # Returns
71///
72/// * `Ok(SimpleContext)` containing the JSON-LD context terms.
73/// * `Err(String)` if there is an error building the context.
74///
75/// # Example
76///
77/// ```rust,ignore
78/// let context = create_context(&model, Some("EnzymeMLDocument")).unwrap();
79/// ```
80fn create_context(model: &DataModel, root: Option<&str>) -> Result<SimpleContext, String> {
81    let mut context = SimpleContext::default();
82    let config = model.config.clone().unwrap_or_default();
83    let model_id = if !config.prefix.is_empty() {
84        config.prefix
85    } else if config.id.is_some() {
86        config.id.unwrap()
87    } else {
88        "model".to_string()
89    };
90
91    // Add the model prefix
92    context
93        .terms
94        .insert(model_id.clone(), TermDef::Simple(config.repo.clone()));
95
96    // Add prefixes from DataModel config
97    if let Some(prefixes) = &config.prefixes {
98        for (prefix, uri) in prefixes {
99            context
100                .terms
101                .insert(prefix.clone(), TermDef::Simple(uri.clone()));
102        }
103    }
104
105    let mut context_cache: HashMap<String, SimpleContext> = HashMap::new();
106
107    // Determine which root to use: explicit or first object
108    let root_name = match root {
109        Some(name) => name.to_string(),
110        None => model
111            .objects
112            .first()
113            .ok_or("No objects found in model")?
114            .name
115            .clone(),
116    };
117
118    // Build the graph based on the determined root
119    let graph = tree::object_graph(model, &root_name)?;
120
121    // Process only the root object's attributes at the top level
122    // Nested contexts will be built automatically via build_nested_for_attr
123    if let Some(root_idx) = graph
124        .node_indices()
125        .find(|&idx| graph[idx].name == root_name)
126    {
127        let obj_context = build_object_context(&graph, root_idx, &model_id, &mut context_cache);
128        for (term_name, term_def) in obj_context.terms {
129            context.terms.insert(term_name, term_def);
130        }
131    }
132    Ok(context)
133}
134
135/// Build a [`SimpleContext`] for a given object within the graph.
136///
137/// Caches contexts per-object to allow sharing/nesting without recomputation.
138///
139/// # Arguments
140/// - `graph`: The object type dependency graph.
141/// - `node_idx`: The current object's graph node index.
142/// - `model_id`: The base prefix/id for the model.
143/// - `cache`: Cache of previously-built contexts.
144fn build_object_context(
145    graph: &petgraph::graph::DiGraph<Object, ()>,
146    node_idx: petgraph::graph::NodeIndex,
147    model_id: &str,
148    cache: &mut HashMap<String, SimpleContext>,
149) -> SimpleContext {
150    let object = &graph[node_idx];
151
152    if let Some(cached) = cache.get(&object.name) {
153        return cached.clone();
154    }
155
156    let mut context = SimpleContext::default();
157    let object_names: HashSet<String> = graph
158        .node_indices()
159        .map(|idx| graph[idx].name.clone())
160        .collect();
161
162    for attr in &object.attributes {
163        let has_nested = attr.dtypes.iter().any(|dt| object_names.contains(dt));
164        let term_def = if has_nested || attr.is_array {
165            build_detailed_term_def(graph, node_idx, attr, model_id, has_nested, cache)
166        } else {
167            let term_id = get_attr_term_id(attr, object, model_id);
168            TermDef::Simple(term_id)
169        };
170
171        context.terms.insert(attr.name.clone(), term_def);
172    }
173
174    cache.insert(object.name.clone(), context.clone());
175    context
176}
177
178/// Build a [`TermDef`] in detailed form, for array or nested attributes.
179fn build_detailed_term_def(
180    graph: &petgraph::graph::DiGraph<Object, ()>,
181    node_idx: petgraph::graph::NodeIndex,
182    attr: &crate::attribute::Attribute,
183    model_id: &str,
184    has_nested: bool,
185    cache: &mut HashMap<String, SimpleContext>,
186) -> TermDef {
187    // Determine type using the first dtype, falling back to parent_object if needed
188    let object_type = attr
189        .dtypes
190        .first()
191        .and_then(|dtype| find_sub_object(graph, dtype))
192        .map(|_idx| "@id".to_string());
193
194    // Build term detail, setting @container and nested @context as needed
195    let mut detail = TermDetail {
196        type_: object_type,
197        container: if attr.is_array {
198            Some(OneOrMany::One("@set".to_string()))
199        } else {
200            None
201        },
202        ..Default::default()
203    };
204
205    if has_nested {
206        let nested = build_nested_for_attr(graph, node_idx, attr, model_id, cache);
207        if !nested.terms.is_empty() {
208            detail.context = Some(Box::new(JsonLdContext::Object(nested)));
209        }
210    }
211
212    TermDef::Detailed(detail)
213}
214
215/// Find the node index for a child (sub-)object in the graph by name (data type).
216///
217/// Returns index if the dtype matches an object in the graph, or `None` if not found.
218fn find_sub_object(
219    graph: &petgraph::graph::DiGraph<Object, ()>,
220    dtype: &str,
221) -> Option<petgraph::graph::NodeIndex> {
222    graph.node_indices().find(|&idx| graph[idx].name == dtype)
223}
224
225/// Get the JSON-LD term id/IRI for a concrete attribute, using an override if present.
226///
227/// If no explicit term is present, returns a default constructed IRI
228/// of the form `"{model_id}:{object}/{attr}"`.
229fn get_attr_term_id(attr: &crate::attribute::Attribute, object: &Object, model_id: &str) -> String {
230    match &attr.term {
231        Some(term) => term.clone(),
232        None => format!("{}:{}/{}", model_id, object.name, attr.name),
233    }
234}
235
236/// Retrieve a term for an attribute from its explicit term, or default to constructed IRI.
237///
238/// Accepts the explicit annotated term or synthesizes a term if missing.
239fn get_object_term_or_default(term: &Option<String>, model_id: &str, object_name: &str) -> String {
240    match term {
241        Some(term) => term.clone(),
242        None => format!("{}:{}", model_id, object_name),
243    }
244}
245
246/// Recursively build the nested context for an attribute's referenced object(s).
247///
248/// Returns a [`SimpleContext`] capturing nested keys/objects for JSON-LD.
249///
250/// # Arguments
251/// - `graph`: The object type dependency graph.
252/// - `parent_idx`: The parent object's node index in the graph.
253/// - `attr`: The attribute whose nested context is built.
254/// - `model_id`: The base model prefix/IRI.
255/// - `cache`: Reference to context cache to permit recursion.
256fn build_nested_for_attr(
257    graph: &petgraph::graph::DiGraph<Object, ()>,
258    parent_idx: petgraph::graph::NodeIndex,
259    attr: &crate::attribute::Attribute,
260    model_id: &str,
261    cache: &mut HashMap<String, SimpleContext>,
262) -> SimpleContext {
263    let mut context = SimpleContext::default();
264
265    for neighbor_idx in graph.neighbors_directed(parent_idx, Direction::Outgoing) {
266        let neighbor_obj = &graph[neighbor_idx];
267
268        if attr.dtypes.contains(&neighbor_obj.name) {
269            let nested_context = build_object_context(graph, neighbor_idx, model_id, cache);
270
271            // We need to add the type as `@id` to tell JSON-LD that this attribute references a  IRI
272            let term = get_object_term_or_default(&neighbor_obj.term, model_id, &neighbor_obj.name);
273            context.type_ = Some(TypeOrVec::Single(term));
274
275            for (key, value) in nested_context.terms {
276                context.terms.insert(key, value);
277            }
278        }
279    }
280
281    context
282}