use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::extension_type_registry::ExtensionTypeRegistry;
use crate::graph::{IndexedGraph, StaticGraph};
use crate::graph::{StaticResource, StaticResourceMetadata};
use crate::graph_mutator::generate_uuid_v5;
use crate::instance_wrapper_core::is_node_single_cardinality_with;
use crate::pseudo_value_core::{
PseudoListCore, PseudoValueCore, TileBuilder, TileBuilderContext, VisitorContext,
};
use crate::registry::is_list_datatype;
use crate::string_utils::snake_to_camel;
use crate::type_coercion::coerce_value_with_registry;
use crate::{StaticNode, StaticTile};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusinessDataWrapper {
pub business_data: BusinessData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusinessData {
pub resources: Vec<StaticResource>,
}
pub fn create_static_resource(
resourceinstanceid: String,
graph_id: String,
tiles: Vec<StaticTile>,
graph: &StaticGraph,
) -> StaticResource {
let indexed = IndexedGraph::new(graph.clone());
let descriptors = indexed.build_descriptors(&tiles);
let name = descriptors
.name
.clone()
.unwrap_or_else(|| resourceinstanceid.clone());
StaticResource {
resourceinstance: StaticResourceMetadata {
descriptors,
graph_id,
name,
resourceinstanceid,
publication_id: None,
principaluser_id: None,
legacyid: None,
graph_publication_id: None,
createdtime: None,
lastmodified: None,
},
tiles: Some(tiles),
metadata: HashMap::new(),
cache: None,
scopes: None,
tiles_loaded: Some(true),
}
}
pub fn tiles_to_tree(input: &Value, graph: &StaticGraph) -> Result<Value, String> {
let resources = extract_resources(input)?;
let mut tree_resources = Vec::new();
for resource in resources {
let tiles = resource
.tiles
.as_ref()
.ok_or_else(|| "Resource has no tiles".to_string())?;
let tree = resource_tiles_to_tree(tiles, &resource.resourceinstance, graph)?;
tree_resources.push(tree);
}
Ok(Value::Array(tree_resources))
}
pub fn resource_tiles_to_tree(
tiles: &[StaticTile],
metadata: &StaticResourceMetadata,
graph: &StaticGraph,
) -> Result<Value, String> {
let nodes_by_alias = graph
.nodes_by_alias_arc()
.ok_or_else(|| "Graph indices not built - call build_indices() first".to_string())?;
let edges = graph
.edges_map()
.ok_or_else(|| "Graph indices not built - call build_indices() first".to_string())?;
let pseudo_cache = build_pseudo_cache_from_tiles(tiles, nodes_by_alias, graph, edges);
let root = graph.root_node();
let root_alias = root.alias.clone().unwrap_or_default();
let child_node_ids = graph
.get_child_ids(&root.nodeid)
.cloned()
.unwrap_or_default();
let root_pseudo =
PseudoValueCore::from_node_and_tile(Arc::new(root.clone()), None, None, child_node_ids);
let root_list =
PseudoListCore::from_values_with_cardinality(root_alias.clone(), vec![root_pseudo], true);
let mut full_cache = pseudo_cache;
full_cache.insert(root_alias.clone(), root_list.clone());
let ctx = VisitorContext::new(&full_cache, nodes_by_alias, edges);
let mut tree = if let Some(root_value) = root_list.values.first() {
root_value.to_json(&ctx)
} else {
Value::Object(Map::new())
};
if let Some(obj) = tree.as_object_mut() {
obj.insert(
"resourceinstanceid".to_string(),
Value::String(metadata.resourceinstanceid.clone()),
);
obj.insert(
"graph_id".to_string(),
Value::String(metadata.graph_id.clone()),
);
if let Some(ref name) = metadata.descriptors.name {
obj.insert("_name".to_string(), Value::String(name.clone()));
}
if let Some(ref desc) = metadata.descriptors.description {
obj.insert("_description".to_string(), Value::String(desc.clone()));
}
if let Some(ref slug) = metadata.descriptors.slug {
obj.insert("_slug".to_string(), Value::String(slug.clone()));
}
match metadata.legacyid {
Some(ref legacyid) => {
obj.insert("legacyid".to_string(), Value::String(legacyid.clone()));
}
None => {
obj.insert("legacyid".to_string(), Value::Null);
}
}
}
Ok(tree)
}
pub fn build_tree_from_tiles(
tiles_json: &str,
resource_id: &str,
graph_id: &str,
) -> Result<Value, String> {
let tiles: Vec<StaticTile> =
serde_json::from_str(tiles_json).map_err(|e| format!("Failed to parse tiles: {e}"))?;
let graph = crate::get_graph(graph_id).ok_or_else(|| {
format!("Graph '{graph_id}' not registered. Call register_graph() first.")
})?;
let metadata = StaticResourceMetadata {
descriptors: crate::graph::StaticResourceDescriptors {
name: None,
description: None,
map_popup: None,
slug: None,
},
graph_id: graph_id.to_string(),
name: resource_id.to_string(),
resourceinstanceid: resource_id.to_string(),
publication_id: None,
principaluser_id: None,
legacyid: None,
graph_publication_id: None,
createdtime: None,
lastmodified: None,
};
resource_tiles_to_tree(&tiles, &metadata, &graph)
}
pub(crate) fn extract_resources(input: &Value) -> Result<Vec<StaticResource>, String> {
if let Some(bd) = input.get("business_data") {
if let Some(resources) = bd.get("resources") {
if let Some(arr) = resources.as_array() {
let mut result = Vec::new();
for r in arr {
let resource: StaticResource = serde_json::from_value(r.clone())
.map_err(|e| format!("Failed to parse resource: {}", e))?;
result.push(resource);
}
return Ok(result);
}
}
}
if let Ok(resource) = serde_json::from_value::<StaticResource>(input.clone()) {
return Ok(vec![resource]);
}
Err("Input must be BusinessDataWrapper or StaticResource".to_string())
}
pub(crate) fn build_pseudo_cache_from_tiles(
tiles: &[StaticTile],
nodes_by_alias: &HashMap<String, Arc<StaticNode>>,
graph: &StaticGraph,
edges: &HashMap<String, Vec<String>>,
) -> HashMap<String, PseudoListCore> {
use std::collections::HashSet;
let mut pseudo_cache: HashMap<String, PseudoListCore> = HashMap::new();
let mut sorted_tiles: Vec<&StaticTile> = tiles.iter().collect();
sorted_tiles.sort_by_key(|t| t.sortorder.unwrap_or(i32::MAX));
let nodegroups_with_tiles: HashSet<&str> =
tiles.iter().map(|t| t.nodegroup_id.as_str()).collect();
for tile in sorted_tiles {
let tile_arc = Arc::new(tile.clone());
let nodes_in_ng = graph.get_nodes_in_nodegroup(&tile.nodegroup_id);
for node in nodes_in_ng {
let alias = match &node.alias {
Some(a) if !a.is_empty() => a.clone(),
_ => continue,
};
let child_node_ids = edges.get(&node.nodeid).cloned().unwrap_or_default();
let tile_data = tile.data.get(&node.nodeid).cloned();
let node_arc = nodes_by_alias
.get(&alias)
.map(Arc::clone)
.unwrap_or_else(|| Arc::new(node.clone()));
let pv = PseudoValueCore::from_node_and_tile(
node_arc,
Some(Arc::clone(&tile_arc)),
tile_data,
child_node_ids,
);
let is_single = is_node_single_cardinality_with(node, |ng_id| {
graph
.get_nodegroup_by_id(ng_id)
.and_then(|ng| ng.cardinality.clone())
});
pseudo_cache
.entry(alias.clone())
.and_modify(|existing| {
let new_list = PseudoListCore::from_values_with_cardinality(
alias.clone(),
vec![pv.clone()],
is_single,
);
existing.merge(new_list);
})
.or_insert_with(|| {
PseudoListCore::from_values_with_cardinality(alias.clone(), vec![pv], is_single)
});
}
let mut current_ng_id = Some(tile.nodegroup_id.clone());
while let Some(ng_id) = current_ng_id {
let nodegroup = match graph.get_nodegroup_by_id(&ng_id) {
Some(ng) => ng,
None => break,
};
let parent_ng_id = match &nodegroup.parentnodegroup_id {
Some(pid) => pid.clone(),
None => break, };
if nodegroups_with_tiles.contains(parent_ng_id.as_str()) {
current_ng_id = Some(parent_ng_id);
continue;
}
let parent_nodegroup = match graph.get_nodegroup_by_id(&parent_ng_id) {
Some(ng) => ng,
None => break,
};
let grouping_node_id = parent_nodegroup
.grouping_node_id
.as_ref()
.unwrap_or(&parent_ng_id);
let semantic_node = graph
.nodes_slice()
.iter()
.find(|n| n.nodeid == *grouping_node_id);
if let Some(semantic_node) = semantic_node {
if let Some(ref alias) = semantic_node.alias {
if !alias.is_empty() && !pseudo_cache.contains_key(alias) {
let child_node_ids = edges
.get(&semantic_node.nodeid)
.cloned()
.unwrap_or_default();
let node_arc = nodes_by_alias
.get(alias)
.map(Arc::clone)
.unwrap_or_else(|| Arc::new(semantic_node.clone()));
let pv = PseudoValueCore::from_node_and_tile(
node_arc,
None, None, child_node_ids,
);
let is_single = parent_nodegroup
.cardinality
.as_ref()
.map(|c| c != "n")
.unwrap_or(true);
pseudo_cache.insert(
alias.clone(),
PseudoListCore::from_values_with_cardinality(
alias.clone(),
vec![pv],
is_single,
),
);
}
}
}
current_ng_id = Some(parent_ng_id);
}
}
pseudo_cache
}
pub fn tree_to_tiles(
json: &Value,
graph: &StaticGraph,
strict: bool,
id_key: Option<&str>,
) -> Result<BusinessDataWrapper, String> {
tree_to_tiles_with_options(json, graph, strict, id_key, false, true, false, None)
}
#[allow(clippy::too_many_arguments)]
pub fn tree_to_tiles_with_options(
json: &Value,
graph: &StaticGraph,
strict: bool,
id_key: Option<&str>,
from_camel: bool,
random_ids: bool,
has_extension_handlers: bool,
extension_registry: Option<&ExtensionTypeRegistry>,
) -> Result<BusinessDataWrapper, String> {
let trees = extract_tree_resources(json)?;
let mut resources = Vec::new();
for tree in trees {
let resource = single_tree_to_resource(
&tree,
graph,
strict,
id_key,
from_camel,
random_ids,
has_extension_handlers,
extension_registry,
)?;
resources.push(resource);
}
Ok(BusinessDataWrapper {
business_data: BusinessData { resources },
})
}
fn extract_tree_resources(json: &Value) -> Result<Vec<Value>, String> {
if let Some(arr) = json.as_array() {
return Ok(arr.clone());
}
if json.is_object() {
return Ok(vec![json.clone()]);
}
Err("Input must be array of tree objects or single tree object".to_string())
}
#[allow(clippy::too_many_arguments)]
fn single_tree_to_resource(
json: &Value,
graph: &StaticGraph,
strict: bool,
id_key: Option<&str>,
from_camel: bool,
random_ids: bool,
has_extension_handlers: bool,
extension_registry: Option<&ExtensionTypeRegistry>,
) -> Result<StaticResource, String> {
let obj = json
.as_object()
.ok_or_else(|| "JSON must be an object".to_string())?;
let explicit_id = obj
.get("resourceinstanceid")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let id_from_key = id_key.map(|key| generate_uuid_v5(("resource", Some(&graph.graphid)), key));
let needs_slug_id = explicit_id.is_none() && id_from_key.is_none() && !random_ids;
let resource_id = explicit_id
.clone()
.or(id_from_key)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let graph_id = obj
.get("graph_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| graph.graphid.clone());
let legacyid = obj
.get("legacyid")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let nodes_by_alias = graph
.nodes_by_alias_arc()
.ok_or_else(|| "Graph indices not built - call build_indices() first".to_string())?;
let edges = graph
.edges_map()
.ok_or_else(|| "Graph indices not built - call build_indices() first".to_string())?;
let known_metadata = [
"resourceinstanceid",
"graph_id",
"legacyid",
"_name",
"_description",
"_map_popup",
"_slug",
];
if strict {
for key in obj.keys() {
let key_matches = known_metadata.contains(&key.as_str())
|| nodes_by_alias.contains_key(key)
|| (from_camel && nodes_by_alias.contains_key(&crate::camel_to_snake(key)));
if !key_matches {
return Err(format!(
"Unknown field '{}' not found in graph aliases",
key
));
}
}
}
let mut pseudo_cache: HashMap<String, PseudoListCore> = HashMap::new();
let root = graph.root_node();
build_pseudo_values_from_json(
obj,
&Arc::new(root.clone()),
nodes_by_alias,
graph,
edges,
&resource_id,
None,
&mut pseudo_cache,
strict,
from_camel,
has_extension_handlers,
extension_registry,
)?;
let visited_aliases = RefCell::new(HashSet::new());
let ctx = TileBuilderContext {
pseudo_cache: &pseudo_cache,
nodes_by_alias,
edges,
resourceinstance_id: resource_id.clone(),
depth: 0,
max_depth: 100,
visited_aliases: &visited_aliases,
};
let mut tiles_map: HashMap<String, TileBuilder> = HashMap::new();
if let Some(child_ids) = edges.get(&root.nodeid) {
for child_id in child_ids {
let child_node = nodes_by_alias.values().find(|n| n.nodeid == *child_id);
if let Some(child_node) = child_node {
if let Some(alias) = &child_node.alias {
visited_aliases.borrow_mut().insert(alias.clone());
if let Some(pseudo_list) = pseudo_cache.get(alias) {
pseudo_list.collect_tiles(&ctx, &mut tiles_map);
}
}
}
}
}
let mut tiles: Vec<StaticTile> = tiles_map
.values()
.map(|builder| builder.to_static_tile())
.collect();
let indexed = IndexedGraph::new(graph.clone());
let descriptors =
indexed.build_descriptors_with_context(&tiles, &mut Vec::new(), None, extension_registry);
let resource_id = if needs_slug_id {
let slug = descriptors.slug.as_ref().ok_or_else(|| {
"No slug descriptor configured but slug-based ID generation requested. \
Configure a slug template, provide id_keys, or set random_ids=true."
.to_string()
})?;
if slug.contains('<') {
return Err(format!(
"Slug contains unresolved placeholder(s): '{}'. \
Check that all <Node Name> references in the slug template have matching tile data.",
slug
));
}
let real_id = generate_uuid_v5(("resource", Some(&graph.graphid)), slug);
for tile in &mut tiles {
tile.resourceinstance_id = real_id.clone();
}
real_id
} else {
resource_id
};
let name = descriptors
.name
.clone()
.unwrap_or_else(|| resource_id.clone());
Ok(StaticResource {
resourceinstance: StaticResourceMetadata {
descriptors,
graph_id,
name,
resourceinstanceid: resource_id,
publication_id: None,
principaluser_id: None,
legacyid,
graph_publication_id: None,
createdtime: None,
lastmodified: None,
},
tiles: Some(tiles),
metadata: HashMap::new(),
cache: None,
scopes: None,
tiles_loaded: Some(true),
})
}
#[allow(clippy::too_many_arguments)]
fn build_pseudo_values_from_json(
json_obj: &Map<String, Value>,
current_node: &Arc<StaticNode>,
nodes_by_alias: &HashMap<String, Arc<StaticNode>>,
graph: &StaticGraph,
edges: &HashMap<String, Vec<String>>,
resource_id: &str,
parent_tile: Option<Arc<StaticTile>>,
pseudo_cache: &mut HashMap<String, PseudoListCore>,
strict: bool,
from_camel: bool,
has_extension_handlers: bool,
extension_registry: Option<&ExtensionTypeRegistry>,
) -> Result<(), String> {
let child_ids = edges.get(¤t_node.nodeid).cloned().unwrap_or_default();
for child_id in child_ids {
let child_node = nodes_by_alias.values().find(|n| n.nodeid == child_id);
let child_node = match child_node {
Some(n) => Arc::clone(n),
None => continue,
};
let child_alias = match &child_node.alias {
Some(a) if !a.is_empty() => a.clone(),
_ => continue,
};
let json_value = json_obj.get(&child_alias).or_else(|| {
if from_camel {
let camel_key = snake_to_camel(&child_alias);
json_obj.get(&camel_key)
} else {
None
}
});
let json_value = match json_value {
Some(v) => v,
None => continue,
};
let nodegroup_id = child_node
.nodegroup_id
.as_ref()
.ok_or_else(|| format!("Node {} has no nodegroup_id", child_id))?;
let is_single = is_node_single_cardinality_with(&child_node, |ng_id| {
graph
.get_nodegroup_by_id(ng_id)
.and_then(|ng| ng.cardinality.clone())
});
let config_value = if child_node.config.is_empty() {
None
} else {
Some(serde_json::Value::Object(
child_node
.config
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
))
};
let child_child_ids = edges.get(&child_node.nodeid).cloned().unwrap_or_default();
let shares_nodegroup = parent_tile
.as_ref()
.map(|pt| pt.nodegroup_id == *nodegroup_id)
.unwrap_or(false);
let parent_tile_id = if shares_nodegroup {
None
} else {
parent_tile.as_ref().and_then(|pt| pt.tileid.as_ref())
};
let has_graph_children = !child_child_ids.is_empty();
let mut values: Vec<PseudoValueCore> = Vec::new();
let is_outer_node = has_graph_children && child_node.datatype != "semantic";
let valid_child_aliases: std::collections::HashSet<&str> = if strict && has_graph_children {
let mut aliases: std::collections::HashSet<&str> = child_child_ids
.iter()
.filter_map(|id| nodes_by_alias.values().find(|n| n.nodeid == *id))
.filter_map(|n| n.alias.as_deref())
.collect();
if is_outer_node {
aliases.insert("_");
}
aliases
} else {
std::collections::HashSet::new()
};
let is_list_type = is_list_datatype(&child_node.datatype);
if json_value.is_array() && !is_list_type {
let array = json_value.as_array().expect("checked is_array()");
for (idx, item) in array.iter().enumerate() {
let sortorder = Some(idx as i32);
if has_graph_children {
if let Some(item_obj) = item.as_object() {
if strict {
for key in item_obj.keys() {
let key_matches = valid_child_aliases.contains(key.as_str())
|| (from_camel
&& valid_child_aliases
.contains(crate::camel_to_snake(key).as_str()));
if !key_matches {
return Err(format!(
"Unknown field '{}' in '{}' - valid fields: {:?}",
key, child_alias, valid_child_aliases
));
}
}
}
let (pv, tile) = create_pseudo_value_from_json(
item_obj,
&child_node,
&child_child_ids,
nodegroup_id,
&config_value,
resource_id,
if shares_nodegroup {
parent_tile.clone()
} else {
None
},
parent_tile_id,
sortorder,
extension_registry,
);
values.push(pv);
build_pseudo_values_from_json(
item_obj,
&child_node,
nodes_by_alias,
graph,
edges,
resource_id,
Some(tile),
pseudo_cache,
strict,
from_camel,
has_extension_handlers,
extension_registry,
)?;
}
} else {
let (pv, _tile) = create_pseudo_value_from_leaf(
item,
&child_node,
&child_child_ids,
nodegroup_id,
&config_value,
resource_id,
if shares_nodegroup {
parent_tile.clone()
} else {
None
},
parent_tile_id,
sortorder,
strict,
has_extension_handlers,
extension_registry,
)?;
values.push(pv);
}
}
} else if has_graph_children && json_value.is_object() {
let item_obj = json_value.as_object().expect("checked is_object()");
if strict {
for key in item_obj.keys() {
let key_matches = valid_child_aliases.contains(key.as_str())
|| (from_camel
&& valid_child_aliases.contains(crate::camel_to_snake(key).as_str()));
if !key_matches {
return Err(format!(
"Unknown field '{}' in '{}' - valid fields: {:?}",
key, child_alias, valid_child_aliases
));
}
}
}
let (pv, tile) = create_pseudo_value_from_json(
item_obj,
&child_node,
&child_child_ids,
nodegroup_id,
&config_value,
resource_id,
if shares_nodegroup {
parent_tile.clone()
} else {
None
},
parent_tile_id,
Some(0),
extension_registry,
);
values.push(pv);
build_pseudo_values_from_json(
item_obj,
&child_node,
nodes_by_alias,
graph,
edges,
resource_id,
Some(tile),
pseudo_cache,
strict,
from_camel,
has_extension_handlers,
extension_registry,
)?;
} else {
let (pv, _tile) = create_pseudo_value_from_leaf(
json_value,
&child_node,
&child_child_ids,
nodegroup_id,
&config_value,
resource_id,
if shares_nodegroup {
parent_tile.clone()
} else {
None
},
parent_tile_id,
Some(0),
strict,
has_extension_handlers,
extension_registry,
)?;
values.push(pv);
}
if !values.is_empty() {
let list = PseudoListCore::from_values_with_cardinality(
child_alias.clone(),
values,
is_single,
);
pseudo_cache
.entry(child_alias)
.and_modify(|existing| existing.merge(list.clone()))
.or_insert(list);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn create_pseudo_value_from_json(
json_obj: &Map<String, Value>,
node: &Arc<StaticNode>,
child_node_ids: &[String],
nodegroup_id: &str,
config: &Option<Value>,
resource_id: &str,
shared_tile: Option<Arc<StaticTile>>,
parent_tile_id: Option<&String>,
sortorder: Option<i32>,
extension_registry: Option<&ExtensionTypeRegistry>,
) -> (PseudoValueCore, Arc<StaticTile>) {
let tile = shared_tile.unwrap_or_else(|| {
let tile_id = uuid::Uuid::new_v4().to_string();
let mut new_tile = StaticTile::new_empty(nodegroup_id.to_string());
new_tile.tileid = Some(tile_id);
new_tile.resourceinstance_id = resource_id.to_string();
new_tile.parenttile_id = parent_tile_id.cloned();
new_tile.sortorder = sortorder;
Arc::new(new_tile)
});
let tile_data = json_obj
.get("_")
.map(|value| {
let coerced = coerce_value_with_registry(
&node.datatype,
value,
config.as_ref(),
extension_registry,
);
if !coerced.is_null() && coerced.error.is_none() {
coerced.tile_data
} else {
Value::Null
}
})
.filter(|v| !v.is_null());
let pv = PseudoValueCore::from_node_and_tile(
Arc::clone(node),
Some(Arc::clone(&tile)),
tile_data,
child_node_ids.to_vec(),
);
(pv, tile)
}
#[allow(clippy::too_many_arguments)]
fn create_pseudo_value_from_leaf(
json_value: &Value,
node: &Arc<StaticNode>,
child_node_ids: &[String],
nodegroup_id: &str,
config: &Option<Value>,
resource_id: &str,
shared_tile: Option<Arc<StaticTile>>,
parent_tile_id: Option<&String>,
sortorder: Option<i32>,
strict: bool,
has_extension_handlers: bool,
extension_registry: Option<&ExtensionTypeRegistry>,
) -> Result<(PseudoValueCore, Arc<StaticTile>), String> {
let tile = shared_tile.unwrap_or_else(|| {
let tile_id = uuid::Uuid::new_v4().to_string();
let mut new_tile = StaticTile::new_empty(nodegroup_id.to_string());
new_tile.tileid = Some(tile_id);
new_tile.resourceinstance_id = resource_id.to_string();
new_tile.parenttile_id = parent_tile_id.cloned();
new_tile.sortorder = sortorder;
Arc::new(new_tile)
});
let value_to_coerce = json_value
.as_object()
.and_then(|obj| obj.get("_").or_else(|| obj.get("_value")))
.unwrap_or(json_value);
let coerced = coerce_value_with_registry(
&node.datatype,
value_to_coerce,
config.as_ref(),
extension_registry,
);
if strict {
if let Some(ref error) = coerced.error {
let node_alias = node.alias.as_deref().unwrap_or(&node.nodeid);
return Err(format!(
"Coercion error for '{}' ({}): {}",
node_alias, node.datatype, error
));
}
if coerced.passthrough && !has_extension_handlers {
let node_alias = node.alias.as_deref().unwrap_or(&node.nodeid);
return Err(format!(
"Unknown datatype '{}' for node '{}'. If this is an extension type \
(e.g. 'reference'), ensure the extension is imported and skip_extensions \
is not set.",
node.datatype, node_alias
));
}
}
let tile_data = if !coerced.is_null() && coerced.error.is_none() {
Some(coerced.tile_data)
} else {
None
};
let pv = PseudoValueCore::from_node_and_tile(
Arc::clone(node),
Some(Arc::clone(&tile)),
tile_data,
child_node_ids.to_vec(),
);
Ok((pv, tile))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn load_group_graph() -> StaticGraph {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let test_file = workspace_root.join("tests/data/models/Group.json");
let json_str = fs::read_to_string(&test_file).expect("Failed to read Group.json");
let json: serde_json::Value =
serde_json::from_str(&json_str).expect("Failed to parse Group.json");
let graph_data = json["graph"][0].clone();
let mut core_graph: StaticGraph =
serde_json::from_value(graph_data).expect("Failed to deserialize StaticGraph");
core_graph.build_indices();
core_graph
}
fn create_test_business_data(graph: &StaticGraph) -> BusinessDataWrapper {
let mut tiles = Vec::new();
let basic_info_ng = graph
.nodegroups_slice()
.iter()
.find(|ng| {
graph.nodes_slice().iter().any(|n| {
n.alias.as_deref() == Some("basic_info")
&& n.nodegroup_id.as_ref() == Some(&ng.nodegroupid)
})
})
.expect("Could not find basic_info nodegroup");
let mut tile = StaticTile::new_empty(basic_info_ng.nodegroupid.clone());
tile.resourceinstance_id = "test-resource-123".to_string();
tile.tileid = Some("test-tile-1".to_string());
if let Some(name_node) = graph
.nodes_slice()
.iter()
.find(|n| n.alias.as_deref() == Some("name"))
{
tile.data.insert(
name_node.nodeid.clone(),
serde_json::json!({"en": "Test Group", "ga": "Grúpa Tástála"}),
);
}
if let Some(desc_node) = graph
.nodes_slice()
.iter()
.find(|n| n.alias.as_deref() == Some("description"))
{
tile.data.insert(
desc_node.nodeid.clone(),
serde_json::json!("A test group for unit testing"),
);
}
tiles.push(tile);
let indexed = IndexedGraph::new(graph.clone());
let descriptors = indexed.build_descriptors(&tiles);
let name = descriptors
.name
.clone()
.unwrap_or_else(|| "test-resource-123".to_string());
BusinessDataWrapper {
business_data: BusinessData {
resources: vec![StaticResource {
resourceinstance: StaticResourceMetadata {
descriptors,
graph_id: graph.graphid.clone(),
name,
resourceinstanceid: "test-resource-123".to_string(),
publication_id: None,
principaluser_id: None,
legacyid: None,
graph_publication_id: None,
createdtime: None,
lastmodified: None,
},
tiles: Some(tiles),
metadata: HashMap::new(),
cache: None,
scopes: None,
tiles_loaded: Some(true),
}],
},
}
}
#[test]
fn test_tiles_to_tree_basic() {
let graph = load_group_graph();
let business_data = create_test_business_data(&graph);
let input = serde_json::to_value(&business_data).unwrap();
let tree = tiles_to_tree(&input, &graph).expect("tiles_to_tree failed");
assert!(tree.is_array(), "Result should be an array");
let resources = tree.as_array().unwrap();
assert_eq!(resources.len(), 1, "Should have one resource");
let resource_tree = &resources[0];
assert!(
resource_tree.get("resourceinstanceid").is_some(),
"Should include resourceinstanceid"
);
assert!(
resource_tree.get("graph_id").is_some(),
"Should include graph_id"
);
}
#[test]
fn test_tree_to_tiles_array() {
let graph = load_group_graph();
let trees = serde_json::json!([{
"resourceinstanceid": "test-resource-456",
"graph_id": graph.graphid,
"basic_info": [{
"name": {"en": "JSON Test Group", "ga": "Grúpa Tástála JSON"}
}],
"statement": [{
"description": "Created from JSON tree"
}]
}]);
let result = tree_to_tiles(&trees, &graph, true, None).expect("tree_to_tiles failed");
assert_eq!(result.business_data.resources.len(), 1);
let resource = &result.business_data.resources[0];
assert_eq!(
resource.resourceinstance.resourceinstanceid,
"test-resource-456"
);
assert_eq!(resource.resourceinstance.graph_id, graph.graphid);
assert!(
resource
.tiles
.as_ref()
.map(|t| !t.is_empty())
.unwrap_or(false),
"Should have created tiles"
);
}
#[test]
fn test_tree_to_tiles_single_object() {
let graph = load_group_graph();
let tree = serde_json::json!({
"resourceinstanceid": "test-resource-789",
"graph_id": graph.graphid,
"basic_info": [{
"name": {"en": "Single Resource Test"}
}],
"statement": [{
"description": "Testing single resource input"
}]
});
let result = tree_to_tiles(&tree, &graph, true, None).expect("tree_to_tiles failed");
assert_eq!(result.business_data.resources.len(), 1);
let resource = &result.business_data.resources[0];
assert_eq!(
resource.resourceinstance.resourceinstanceid,
"test-resource-789"
);
}
#[test]
fn test_round_trip() {
let graph = load_group_graph();
let initial_trees = serde_json::json!([{
"resourceinstanceid": "round-trip-test",
"graph_id": graph.graphid,
"basic_info": [{
"name": {"en": "Round Trip Test", "ga": "Tástáil Timpeall"}
}],
"statement": [{
"description": "Testing round trip conversion"
}]
}]);
let tiles_result =
tree_to_tiles(&initial_trees, &graph, true, None).expect("tree_to_tiles failed");
let tiles_json = serde_json::to_value(&tiles_result).unwrap();
let tree_result = tiles_to_tree(&tiles_json, &graph).expect("tiles_to_tree failed");
assert!(tree_result.is_array());
let resources = tree_result.as_array().unwrap();
assert_eq!(resources.len(), 1);
assert_eq!(resources[0]["resourceinstanceid"], "round-trip-test");
}
#[test]
fn test_tree_to_tiles_serialization_format() {
let graph = load_group_graph();
let tree = serde_json::json!({
"resourceinstanceid": "test-serialize",
"graph_id": graph.graphid,
"basic_info": [{
"name": {"en": "Serialize Test"}
}]
});
let result = tree_to_tiles(&tree, &graph, true, None).expect("tree_to_tiles failed");
let resource = &result.business_data.resources[0];
let json = serde_json::to_string_pretty(resource).unwrap();
println!("Serialized StaticResource:\n{}", json);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
parsed.get("resourceinstance").is_some(),
"Expected nested 'resourceinstance', got: {}",
json
);
assert!(
parsed.get("resourceinstanceid").is_none(),
"Should NOT have 'resourceinstanceid' at root level, got: {}",
json
);
}
#[test]
fn test_parent_semantic_collector_without_tile() {
let graph_json = serde_json::json!({
"graphid": "test-graph",
"name": {"en": "Test Graph"},
"root": {
"nodeid": "root-id",
"name": "Heritage Item",
"alias": "heritage_item",
"datatype": "semantic",
"graph_id": "test-graph",
"istopnode": true
},
"nodes": [
{
"nodeid": "root-id",
"name": "Heritage Item",
"alias": "heritage_item",
"datatype": "semantic",
"graph_id": "test-graph",
"istopnode": true
},
{
"nodeid": "location-data-id",
"name": "Location Data",
"alias": "location_data",
"datatype": "semantic",
"nodegroup_id": "parent-ng",
"graph_id": "test-graph",
"is_collector": true
},
{
"nodeid": "geometry-id",
"name": "Geometry",
"alias": "geometry",
"datatype": "semantic",
"nodegroup_id": "child-ng",
"graph_id": "test-graph"
},
{
"nodeid": "geospatial-id",
"name": "Geospatial Coordinates",
"alias": "geospatial_coordinates",
"datatype": "geojson-feature-collection",
"nodegroup_id": "child-ng",
"graph_id": "test-graph"
}
],
"nodegroups": [
{
"nodegroupid": "parent-ng",
"cardinality": "1",
"grouping_node_id": "location-data-id"
},
{
"nodegroupid": "child-ng",
"cardinality": "1",
"parentnodegroup_id": "parent-ng",
"grouping_node_id": "geometry-id"
}
],
"edges": [
{"edgeid": "edge-1", "domainnode_id": "root-id", "rangenode_id": "location-data-id"},
{"edgeid": "edge-2", "domainnode_id": "location-data-id", "rangenode_id": "geometry-id"},
{"edgeid": "edge-3", "domainnode_id": "geometry-id", "rangenode_id": "geospatial-id"}
]
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("Failed to deserialize graph");
graph.build_indices();
let mut tile = StaticTile::new_empty("child-ng".to_string());
tile.resourceinstance_id = "test-resource".to_string();
tile.tileid = Some("tile-1".to_string());
tile.data.insert(
"geospatial-id".to_string(),
serde_json::json!({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [151.84, -26.54]
}
}),
);
let business_data = serde_json::json!({
"business_data": {
"resources": [{
"resourceinstance": {
"resourceinstanceid": "test-resource",
"graph_id": "test-graph",
"name": "Test Resource",
"descriptors": {}
},
"tiles": [tile]
}]
}
});
let nodes_in_child_ng = graph.get_nodes_in_nodegroup("child-ng");
println!(
"Nodes in child-ng: {:?}",
nodes_in_child_ng
.iter()
.map(|n| &n.alias)
.collect::<Vec<_>>()
);
let edges = graph.edges_map().unwrap();
println!("Edges: {:?}", edges);
let nodes_by_alias = graph.nodes_by_alias_arc().unwrap();
println!(
"Nodes by alias: {:?}",
nodes_by_alias.keys().collect::<Vec<_>>()
);
let tree = tiles_to_tree(&business_data, &graph).expect("tiles_to_tree failed");
println!(
"Tree output:\n{}",
serde_json::to_string_pretty(&tree).unwrap()
);
let resources = tree.as_array().expect("Should be array");
assert_eq!(resources.len(), 1);
let resource = &resources[0];
assert!(
resource.get("location_data").is_some(),
"location_data should appear in output even without its own tile. Got: {}",
serde_json::to_string_pretty(resource).unwrap()
);
let location_data = resource.get("location_data").unwrap();
assert!(
location_data.get("geometry").is_some(),
"geometry should be nested under location_data. Got: {}",
serde_json::to_string_pretty(location_data).unwrap()
);
}
#[test]
fn test_tree_to_tiles_with_id_key_deterministic() {
let graph = load_group_graph();
let tree = serde_json::json!({
"basic_info": [{
"name": {"en": "Test"},
"description": "Test description"
}]
});
let result1 = tree_to_tiles(&tree, &graph, false, Some("my-unique-key"))
.expect("First conversion failed");
let result2 = tree_to_tiles(&tree, &graph, false, Some("my-unique-key"))
.expect("Second conversion failed");
let id1 = &result1.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
let id2 = &result2.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert_eq!(
id1, id2,
"Same id_key should produce same resourceinstanceid"
);
let result3 = tree_to_tiles(&tree, &graph, false, Some("different-key"))
.expect("Third conversion failed");
let id3 = &result3.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert_ne!(
id1, id3,
"Different id_key should produce different resourceinstanceid"
);
let result4 = tree_to_tiles(&tree, &graph, false, None).expect("Fourth conversion failed");
let id4 = &result4.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert!(uuid::Uuid::parse_str(id4).is_ok(), "Should be valid UUID");
}
#[test]
fn test_tree_to_tiles_explicit_id_takes_precedence() {
let graph = load_group_graph();
let tree = serde_json::json!({
"resourceinstanceid": "explicit-id-123",
"basic_info": [{
"name": {"en": "Test"},
"description": "Test description"
}]
});
let result =
tree_to_tiles(&tree, &graph, false, Some("ignored-key")).expect("Conversion failed");
let id = &result.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert_eq!(
id, "explicit-id-123",
"Explicit resourceinstanceid should take precedence"
);
}
#[test]
fn test_nested_semantic_with_data_child() {
let graph_json = serde_json::json!({
"graphid": "test-nested-graph",
"name": {"en": "Test Nested Graph"},
"root": {
"nodeid": "root-id",
"name": "Heritage Item",
"alias": "heritage_item",
"datatype": "semantic",
"graph_id": "test-nested-graph",
"istopnode": true
},
"nodes": [
{
"nodeid": "root-id",
"name": "Heritage Item",
"alias": "heritage_item",
"datatype": "semantic",
"graph_id": "test-nested-graph",
"istopnode": true
},
{
"nodeid": "sys-ref-id",
"name": "System Reference Numbers",
"alias": "system_reference_numbers",
"datatype": "semantic",
"nodegroup_id": "sys-ref-ng",
"graph_id": "test-nested-graph",
"is_collector": true
},
{
"nodeid": "uuid-id",
"name": "UUID",
"alias": "uuid",
"datatype": "semantic",
"nodegroup_id": "sys-ref-ng",
"graph_id": "test-nested-graph"
},
{
"nodeid": "resourceid-id",
"name": "ResourceID",
"alias": "resourceid",
"datatype": "string",
"nodegroup_id": "sys-ref-ng",
"graph_id": "test-nested-graph"
}
],
"nodegroups": [
{
"nodegroupid": "sys-ref-ng",
"cardinality": "n",
"grouping_node_id": "sys-ref-id"
}
],
"edges": [
{"edgeid": "edge-1", "domainnode_id": "root-id", "rangenode_id": "sys-ref-id"},
{"edgeid": "edge-2", "domainnode_id": "sys-ref-id", "rangenode_id": "uuid-id"},
{"edgeid": "edge-3", "domainnode_id": "uuid-id", "rangenode_id": "resourceid-id"}
]
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("Failed to deserialize graph");
graph.build_indices();
let tree = serde_json::json!({
"system_reference_numbers": {
"uuid": {
"resourceid": "650284"
}
}
});
println!(
"Input tree:\n{}",
serde_json::to_string_pretty(&tree).unwrap()
);
let result = tree_to_tiles(&tree, &graph, true, None).expect("tree_to_tiles failed");
let resource = &result.business_data.resources[0];
let tiles = resource.tiles.as_ref().expect("Should have tiles");
println!("Output tiles:");
for tile in tiles {
println!(" Tile nodegroup_id: {}", tile.nodegroup_id);
println!(
" Tile data: {}",
serde_json::to_string_pretty(&tile.data).unwrap()
);
}
let sys_ref_tile = tiles
.iter()
.find(|t| t.nodegroup_id == "sys-ref-ng")
.expect("Should have sys-ref-ng tile");
let resourceid_value = sys_ref_tile.data.get("resourceid-id");
assert!(
resourceid_value.is_some(),
"Tile data should contain resourceid-id. Got: {}",
serde_json::to_string_pretty(&sys_ref_tile.data).unwrap()
);
let expected = serde_json::json!({"en": {"value": "650284", "direction": "ltr"}});
assert_eq!(
resourceid_value.unwrap(),
&expected,
"resourceid should be i18n formatted '650284'. Got: {:?}",
resourceid_value
);
}
#[test]
fn test_nested_semantic_with_array_wrapper() {
let graph_json = serde_json::json!({
"graphid": "test-nested-graph",
"name": {"en": "Test Nested Graph"},
"root": {
"nodeid": "root-id",
"name": "Heritage Item",
"alias": "heritage_item",
"datatype": "semantic",
"graph_id": "test-nested-graph",
"istopnode": true
},
"nodes": [
{
"nodeid": "root-id",
"name": "Heritage Item",
"alias": "heritage_item",
"datatype": "semantic",
"graph_id": "test-nested-graph",
"istopnode": true
},
{
"nodeid": "sys-ref-id",
"name": "System Reference Numbers",
"alias": "system_reference_numbers",
"datatype": "semantic",
"nodegroup_id": "sys-ref-ng",
"graph_id": "test-nested-graph",
"is_collector": true
},
{
"nodeid": "uuid-id",
"name": "UUID",
"alias": "uuid",
"datatype": "semantic",
"nodegroup_id": "sys-ref-ng",
"graph_id": "test-nested-graph"
},
{
"nodeid": "resourceid-id",
"name": "ResourceID",
"alias": "resourceid",
"datatype": "string",
"nodegroup_id": "sys-ref-ng",
"graph_id": "test-nested-graph"
}
],
"nodegroups": [
{
"nodegroupid": "sys-ref-ng",
"cardinality": "n",
"grouping_node_id": "sys-ref-id"
}
],
"edges": [
{"edgeid": "edge-1", "domainnode_id": "root-id", "rangenode_id": "sys-ref-id"},
{"edgeid": "edge-2", "domainnode_id": "sys-ref-id", "rangenode_id": "uuid-id"},
{"edgeid": "edge-3", "domainnode_id": "uuid-id", "rangenode_id": "resourceid-id"}
]
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("Failed to deserialize graph");
graph.build_indices();
let tree = serde_json::json!({
"system_reference_numbers": [{
"uuid": {
"resourceid": "650284"
}
}]
});
println!(
"Input tree (array wrapper):\n{}",
serde_json::to_string_pretty(&tree).unwrap()
);
let result = tree_to_tiles(&tree, &graph, true, None).expect("tree_to_tiles failed");
let resource = &result.business_data.resources[0];
let tiles = resource.tiles.as_ref().expect("Should have tiles");
println!("Output tiles (array wrapper):");
for tile in tiles {
println!(" Tile nodegroup_id: {}", tile.nodegroup_id);
println!(
" Tile data: {}",
serde_json::to_string_pretty(&tile.data).unwrap()
);
}
let sys_ref_tile = tiles
.iter()
.find(|t| t.nodegroup_id == "sys-ref-ng")
.expect("Should have sys-ref-ng tile");
let resourceid_value = sys_ref_tile.data.get("resourceid-id");
assert!(
resourceid_value.is_some(),
"Tile data should contain resourceid-id. Got: {}",
serde_json::to_string_pretty(&sys_ref_tile.data).unwrap()
);
}
#[test]
fn test_real_heritage_item_system_ref_numbers() {
let heritage_item_path = PathBuf::from("/home/philtweir/Cód/Cliant/Quartz/tmp.quartz-starches-buildings-test/prebuild/graphs/resource_models/Heritage Item.json");
let json_str = match fs::read_to_string(&heritage_item_path) {
Ok(s) => s,
Err(_) => {
println!(
"Heritage Item.json not found at {:?}, skipping test",
heritage_item_path
);
return;
}
};
let json: serde_json::Value =
serde_json::from_str(&json_str).expect("Failed to parse Heritage Item.json");
let graph_data = json["graph"][0].clone();
let mut graph: StaticGraph =
serde_json::from_value(graph_data).expect("Failed to deserialize StaticGraph");
graph.build_indices();
let tree = serde_json::json!({
"system_reference_numbers": {
"uuid": {
"resourceid": 650284 }
}
});
println!(
"Input tree:\n{}",
serde_json::to_string_pretty(&tree).unwrap()
);
println!("Graph ID: {}", graph.graphid);
let nodes_by_alias = graph.nodes_by_alias_arc().unwrap();
println!(
"system_reference_numbers node exists: {}",
nodes_by_alias.contains_key("system_reference_numbers")
);
println!("uuid node exists: {}", nodes_by_alias.contains_key("uuid"));
println!(
"resourceid node exists: {}",
nodes_by_alias.contains_key("resourceid")
);
if let Some(uuid_node) = nodes_by_alias.get("uuid") {
println!("uuid node datatype: {}", uuid_node.datatype);
println!("uuid node nodegroup_id: {:?}", uuid_node.nodegroup_id);
}
if let Some(resourceid_node) = nodes_by_alias.get("resourceid") {
println!("resourceid node datatype: {}", resourceid_node.datatype);
println!(
"resourceid node nodegroup_id: {:?}",
resourceid_node.nodegroup_id
);
}
let edges = graph.edges_map().unwrap();
if let Some(sys_ref_node) = nodes_by_alias.get("system_reference_numbers") {
println!(
"system_reference_numbers -> children: {:?}",
edges.get(&sys_ref_node.nodeid)
);
}
if let Some(uuid_node) = nodes_by_alias.get("uuid") {
println!("uuid -> children: {:?}", edges.get(&uuid_node.nodeid));
}
let result = tree_to_tiles(&tree, &graph, true, None).expect("tree_to_tiles failed");
let resource = &result.business_data.resources[0];
let tiles = resource.tiles.as_ref().expect("Should have tiles");
println!("\nOutput tiles:");
for tile in tiles {
println!(" Tile nodegroup_id: {}", tile.nodegroup_id);
println!(
" Tile data: {}",
serde_json::to_string_pretty(&tile.data).unwrap()
);
}
let sys_ref_ng = "325a2f2f-efe4-11eb-9b0c-a87eeabdefba";
let sys_ref_tile = tiles.iter().find(|t| t.nodegroup_id == sys_ref_ng);
assert!(
sys_ref_tile.is_some(),
"Should have System Reference Numbers tile. Got tiles: {:?}",
tiles.iter().map(|t| &t.nodegroup_id).collect::<Vec<_>>()
);
let sys_ref_tile = sys_ref_tile.unwrap();
let resourceid_node_id = "325a430a-efe4-11eb-810b-a87eeabdefba";
let resourceid_value = sys_ref_tile.data.get(resourceid_node_id);
assert!(
resourceid_value.is_some(),
"Tile data should contain resourceid node. Got: {}",
serde_json::to_string_pretty(&sys_ref_tile.data).unwrap()
);
}
#[test]
fn test_parenttile_id_set_for_nested_nodegroups() {
let graph_json = serde_json::json!({
"graphid": "test-parenttile-graph",
"name": {"en": "Test Parenttile Graph"},
"root": {
"nodeid": "root-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"graph_id": "test-parenttile-graph",
"istopnode": true
},
"nodes": [
{
"nodeid": "root-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"graph_id": "test-parenttile-graph",
"istopnode": true
},
{
"nodeid": "location-data-id",
"name": "Location Data",
"alias": "location_data",
"datatype": "semantic",
"nodegroup_id": "parent-ng",
"graph_id": "test-parenttile-graph",
"is_collector": true
},
{
"nodeid": "geometry-id",
"name": "Geometry",
"alias": "geometry",
"datatype": "semantic",
"nodegroup_id": "child-ng",
"graph_id": "test-parenttile-graph"
},
{
"nodeid": "coordinates-id",
"name": "Coordinates",
"alias": "coordinates",
"datatype": "geojson-feature-collection",
"nodegroup_id": "child-ng",
"graph_id": "test-parenttile-graph"
}
],
"nodegroups": [
{
"nodegroupid": "parent-ng",
"cardinality": "1",
"grouping_node_id": "location-data-id"
},
{
"nodegroupid": "child-ng",
"cardinality": "1",
"parentnodegroup_id": "parent-ng",
"grouping_node_id": "geometry-id"
}
],
"edges": [
{"edgeid": "edge-1", "domainnode_id": "root-id", "rangenode_id": "location-data-id"},
{"edgeid": "edge-2", "domainnode_id": "location-data-id", "rangenode_id": "geometry-id"},
{"edgeid": "edge-3", "domainnode_id": "geometry-id", "rangenode_id": "coordinates-id"}
]
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("Failed to deserialize graph");
graph.build_indices();
let tree = serde_json::json!({
"location_data": {
"geometry": {
"coordinates": {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-6.26, 53.35]
}
}]
}
}
}
});
let result = tree_to_tiles(&tree, &graph, true, None).expect("tree_to_tiles failed");
let resource = &result.business_data.resources[0];
let tiles = resource.tiles.as_ref().expect("Should have tiles");
println!("Generated tiles:");
for tile in tiles {
println!(
" nodegroup: {}, tileid: {:?}, parenttile_id: {:?}",
tile.nodegroup_id, tile.tileid, tile.parenttile_id
);
}
let parent_tile = tiles
.iter()
.find(|t| t.nodegroup_id == "parent-ng")
.expect("Should have parent-ng tile");
let child_tile = tiles
.iter()
.find(|t| t.nodegroup_id == "child-ng")
.expect("Should have child-ng tile");
assert!(
parent_tile.parenttile_id.is_none(),
"Parent tile should have no parenttile_id. Got: {:?}",
parent_tile.parenttile_id
);
assert_eq!(
child_tile.parenttile_id.as_ref(),
parent_tile.tileid.as_ref(),
"Child tile's parenttile_id should match parent tile's tileid. \
Child parenttile_id: {:?}, Parent tileid: {:?}",
child_tile.parenttile_id,
parent_tile.tileid
);
}
fn build_slug_test_graph(slug_template: Option<&str>) -> StaticGraph {
use crate::graph::DESCRIPTOR_FUNCTION_ID;
let mut descriptor_types = serde_json::json!({
"name": {
"nodegroup_id": "name-ng",
"string_template": "<Name>"
}
});
if let Some(tmpl) = slug_template {
descriptor_types["slug"] = serde_json::json!({
"nodegroup_id": "name-ng",
"string_template": tmpl
});
}
let graph_json = serde_json::json!({
"graphid": "slug-test-graph",
"name": {"en": "Slug Test Graph"},
"root": {
"nodeid": "root-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"graph_id": "slug-test-graph",
"istopnode": true
},
"nodes": [
{
"nodeid": "root-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"graph_id": "slug-test-graph",
"istopnode": true
},
{
"nodeid": "name-node-id",
"name": "Name",
"alias": "name",
"datatype": "string",
"nodegroup_id": "name-ng",
"graph_id": "slug-test-graph"
}
],
"nodegroups": [
{ "nodegroupid": "name-ng", "cardinality": "1" }
],
"edges": [
{ "domainnode_id": "root-id", "rangenode_id": "name-node-id" }
],
"functions_x_graphs": [
{
"config": { "descriptor_types": descriptor_types },
"function_id": DESCRIPTOR_FUNCTION_ID,
"graph_id": "slug-test-graph",
"id": "fxg-1"
}
]
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("slug test graph JSON");
graph.build_indices();
graph
}
#[test]
fn test_slug_based_id_is_deterministic() {
let graph = build_slug_test_graph(Some("<Name>"));
let tree = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "My Test Resource", "direction": "ltr"}}
});
let result1 =
tree_to_tiles_with_options(&tree, &graph, false, None, false, false, false, None)
.expect("First conversion failed");
let result2 =
tree_to_tiles_with_options(&tree, &graph, false, None, false, false, false, None)
.expect("Second conversion failed");
let id1 = &result1.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
let id2 = &result2.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert_eq!(id1, id2, "Same tree data should produce same resource ID");
assert!(uuid::Uuid::parse_str(id1).is_ok(), "Should be valid UUID");
}
#[test]
fn test_slug_based_id_differs_for_different_data() {
let graph = build_slug_test_graph(Some("<Name>"));
let tree1 = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Resource Alpha", "direction": "ltr"}}
});
let tree2 = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Resource Beta", "direction": "ltr"}}
});
let result1 =
tree_to_tiles_with_options(&tree1, &graph, false, None, false, false, false, None)
.expect("First conversion failed");
let result2 =
tree_to_tiles_with_options(&tree2, &graph, false, None, false, false, false, None)
.expect("Second conversion failed");
let id1 = &result1.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
let id2 = &result2.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert_ne!(
id1, id2,
"Different data should produce different resource IDs"
);
}
#[test]
fn test_slug_based_id_errors_without_slug_configured() {
let graph = build_slug_test_graph(None);
let tree = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Test", "direction": "ltr"}}
});
let result =
tree_to_tiles_with_options(&tree, &graph, false, None, false, false, false, None);
assert!(result.is_err(), "Should error without slug configured");
let err = result.unwrap_err();
assert!(
err.contains("No slug descriptor configured"),
"Error should mention slug not configured. Got: {}",
err
);
}
#[test]
fn test_random_ids_bypasses_slug() {
let graph = build_slug_test_graph(None);
let tree = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Test", "direction": "ltr"}}
});
let result =
tree_to_tiles_with_options(&tree, &graph, false, None, false, true, false, None)
.expect("random_ids=true should not require slug");
let id = &result.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert!(uuid::Uuid::parse_str(id).is_ok(), "Should be valid UUID");
}
#[test]
fn test_explicit_id_takes_precedence_over_slug() {
let graph = build_slug_test_graph(Some("<Name>"));
let tree = serde_json::json!({
"resourceinstanceid": "my-explicit-id",
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Test", "direction": "ltr"}}
});
let result =
tree_to_tiles_with_options(&tree, &graph, false, None, false, false, false, None)
.expect("Explicit ID should work");
let id = &result.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
assert_eq!(id, "my-explicit-id", "Explicit ID should take precedence");
}
#[test]
fn test_id_key_takes_precedence_over_slug() {
let graph = build_slug_test_graph(Some("<Name>"));
let tree = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Test", "direction": "ltr"}}
});
let result = tree_to_tiles_with_options(
&tree,
&graph,
false,
Some("my-key"),
false,
false,
false,
None,
)
.expect("id_key should work");
let id = &result.business_data.resources[0]
.resourceinstance
.resourceinstanceid;
let expected_id = crate::generate_uuid_v5(("resource", Some("slug-test-graph")), "my-key");
assert_eq!(id, &expected_id, "id_key should produce uuid5 from key");
}
#[test]
fn test_slug_based_id_patches_all_tiles() {
let graph = build_slug_test_graph(Some("<Name>"));
let tree = serde_json::json!({
"graph_id": "slug-test-graph",
"name": {"en": {"value": "Tile Patch Test", "direction": "ltr"}}
});
let result =
tree_to_tiles_with_options(&tree, &graph, false, None, false, false, false, None)
.expect("Conversion failed");
let resource = &result.business_data.resources[0];
let resource_id = &resource.resourceinstance.resourceinstanceid;
if let Some(tiles) = &resource.tiles {
assert!(!tiles.is_empty(), "Should have at least one tile");
for tile in tiles {
assert_eq!(
&tile.resourceinstance_id, resource_id,
"Tile resourceinstance_id should match resource ID"
);
}
}
}
fn build_two_node_slug_graph(slug_template: &str) -> StaticGraph {
use crate::graph::DESCRIPTOR_FUNCTION_ID;
let descriptor_types = serde_json::json!({
"name": {
"nodegroup_id": "info-ng",
"string_template": "<Name>"
},
"slug": {
"nodegroup_id": "info-ng",
"string_template": slug_template
}
});
let graph_json = serde_json::json!({
"graphid": "two-node-slug-graph",
"name": {"en": "Two Node Slug Graph"},
"root": {
"nodeid": "root-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"graph_id": "two-node-slug-graph",
"istopnode": true
},
"nodes": [
{
"nodeid": "root-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"graph_id": "two-node-slug-graph",
"istopnode": true
},
{
"nodeid": "name-node-id",
"name": "Name",
"alias": "name",
"datatype": "string",
"nodegroup_id": "info-ng",
"graph_id": "two-node-slug-graph"
},
{
"nodeid": "code-node-id",
"name": "Code",
"alias": "code",
"datatype": "string",
"nodegroup_id": "info-ng",
"graph_id": "two-node-slug-graph"
}
],
"nodegroups": [
{ "nodegroupid": "info-ng", "cardinality": "1" }
],
"edges": [
{ "domainnode_id": "root-id", "rangenode_id": "name-node-id" },
{ "domainnode_id": "root-id", "rangenode_id": "code-node-id" }
],
"functions_x_graphs": [
{
"config": { "descriptor_types": descriptor_types },
"function_id": DESCRIPTOR_FUNCTION_ID,
"graph_id": "two-node-slug-graph",
"id": "fxg-1"
}
]
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("two-node slug test graph JSON");
graph.build_indices();
graph
}
#[test]
fn test_slug_unresolved_placeholder_errors() {
let graph = build_two_node_slug_graph("<Name>-<Code>");
let tree = serde_json::json!({
"graph_id": "two-node-slug-graph",
"name": {"en": {"value": "Test", "direction": "ltr"}}
});
let result =
tree_to_tiles_with_options(&tree, &graph, false, None, false, false, false, None);
assert!(result.is_err(), "Should error on unresolved placeholder");
let err = result.unwrap_err();
assert!(
err.contains("unresolved placeholder"),
"Error should mention unresolved placeholder. Got: {}",
err
);
}
}