use super::descriptors::StaticResourceDescriptors;
use super::tile::StaticTile;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StaticResourceMetadata {
pub descriptors: StaticResourceDescriptors,
pub graph_id: String,
pub name: String,
pub resourceinstanceid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub publication_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub principaluser_id: Option<i32>,
#[serde(default)]
pub legacyid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub graph_publication_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub createdtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lastmodified: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StaticResourceSummary {
pub resourceinstanceid: String,
pub graph_id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub descriptors: Option<StaticResourceDescriptors>,
#[serde(default)]
pub metadata: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub createdtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lastmodified: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub publication_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub principaluser_id: Option<i32>,
#[serde(default)]
pub legacyid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub graph_publication_id: Option<String>,
}
impl StaticResourceSummary {
pub fn to_metadata(&self) -> StaticResourceMetadata {
StaticResourceMetadata {
descriptors: self.descriptors.clone().unwrap_or_default(),
graph_id: self.graph_id.clone(),
name: self.name.clone(),
resourceinstanceid: self.resourceinstanceid.clone(),
publication_id: self.publication_id.clone(),
principaluser_id: self.principaluser_id,
legacyid: self.legacyid.clone(),
graph_publication_id: self.graph_publication_id.clone(),
createdtime: self.createdtime.clone(),
lastmodified: self.lastmodified.clone(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StaticResourceReference {
pub id: String,
#[serde(rename = "graphId")]
pub graph_id: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "type")]
pub resource_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
impl StaticResourceReference {
pub fn new(id: String, graph_id: String) -> Self {
StaticResourceReference {
id,
graph_id,
resource_type: None,
title: None,
root: None,
meta: None,
}
}
pub fn with_type(id: String, graph_id: String, resource_type: String) -> Self {
StaticResourceReference {
id,
graph_id,
resource_type: Some(resource_type),
title: None,
root: None,
meta: None,
}
}
pub fn with_title(mut self, title: String) -> Self {
self.title = Some(title);
self
}
pub fn with_meta(mut self, meta: HashMap<String, serde_json::Value>) -> Self {
self.meta = Some(meta);
self
}
pub fn with_root(mut self, root: serde_json::Value) -> Self {
self.root = Some(root);
self
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StaticResource {
pub resourceinstance: StaticResourceMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub tiles: Option<Vec<StaticTile>>,
#[serde(default)]
pub metadata: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none", default, rename = "__cache")]
pub cache: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none", default, rename = "__scopes")]
pub scopes: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tiles_loaded: Option<bool>,
}
impl StaticResource {
pub fn to_summary(&self) -> StaticResourceSummary {
StaticResourceSummary {
resourceinstanceid: self.resourceinstance.resourceinstanceid.clone(),
graph_id: self.resourceinstance.graph_id.clone(),
name: self.resourceinstance.name.clone(),
descriptors: Some(self.resourceinstance.descriptors.clone()),
metadata: self.metadata.clone(),
createdtime: self.resourceinstance.createdtime.clone(),
lastmodified: self.resourceinstance.lastmodified.clone(),
publication_id: self.resourceinstance.publication_id.clone(),
principaluser_id: self.resourceinstance.principaluser_id,
legacyid: self.resourceinstance.legacyid.clone(),
graph_publication_id: self.resourceinstance.graph_publication_id.clone(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelatedResourceEntry {
pub datatype: String,
pub id: String,
#[serde(rename = "type")]
pub resource_type: String,
#[serde(rename = "graphId")]
pub graph_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub descriptors: Option<StaticResourceDescriptors>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
impl RelatedResourceEntry {
pub fn from_resource_entry(entry: &ResourceEntry, model_class_name: Option<&str>) -> Self {
RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: entry.resourceinstanceid().to_string(),
resource_type: model_class_name
.map(|s| s.to_string())
.unwrap_or_else(|| entry.graph_id().to_string()),
graph_id: entry.graph_id().to_string(),
title: Some(entry.name().to_string()),
descriptors: entry.descriptors().cloned(),
meta: None,
}
}
pub fn from_summary(summary: &StaticResourceSummary, model_class_name: Option<&str>) -> Self {
RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: summary.resourceinstanceid.clone(),
resource_type: model_class_name
.map(|s| s.to_string())
.unwrap_or_else(|| summary.graph_id.clone()),
graph_id: summary.graph_id.clone(),
title: Some(summary.name.clone()),
descriptors: summary.descriptors.clone(),
meta: if summary.metadata.is_empty() {
None
} else {
Some(
summary
.metadata
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect(),
)
},
}
}
pub fn resourceinstanceid(&self) -> &str {
&self.id
}
}
impl From<&StaticResourceSummary> for RelatedResourceEntry {
fn from(summary: &StaticResourceSummary) -> Self {
RelatedResourceEntry::from_summary(summary, None)
}
}
impl From<&StaticResource> for RelatedResourceEntry {
fn from(resource: &StaticResource) -> Self {
let descriptors = &resource.resourceinstance.descriptors;
RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: resource.resourceinstance.resourceinstanceid.clone(),
resource_type: resource.resourceinstance.graph_id.clone(),
graph_id: resource.resourceinstance.graph_id.clone(),
title: Some(resource.resourceinstance.name.clone()),
descriptors: if descriptors.is_empty() {
None
} else {
Some(descriptors.clone())
},
meta: None,
}
}
}
impl From<RelatedResourceEntry> for StaticResourceSummary {
fn from(entry: RelatedResourceEntry) -> Self {
StaticResourceSummary {
resourceinstanceid: entry.id,
graph_id: entry.graph_id,
name: entry.title.unwrap_or_default(),
descriptors: entry.descriptors,
metadata: HashMap::new(),
createdtime: None,
lastmodified: None,
publication_id: None,
principaluser_id: None,
legacyid: None,
graph_publication_id: None,
}
}
}
impl From<&ResourceEntry> for RelatedResourceEntry {
fn from(entry: &ResourceEntry) -> Self {
RelatedResourceEntry::from_resource_entry(entry, None)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RelatedResourceListEntry {
pub datatype: String,
#[serde(rename = "_")]
pub entries: Vec<RelatedResourceEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
impl RelatedResourceListEntry {
pub fn new() -> Self {
RelatedResourceListEntry {
datatype: "resource-instance-list".to_string(),
entries: Vec::new(),
meta: None,
}
}
pub fn push(&mut self, entry: RelatedResourceEntry) {
self.entries.push(entry);
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for RelatedResourceListEntry {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CacheEntry {
Single(RelatedResourceEntry),
List(RelatedResourceListEntry),
}
pub type ResourceCache = HashMap<String, HashMap<String, CacheEntry>>;
struct ProcessResourceContext<'a> {
cache: &'a mut ResourceCache,
enrich_relationships: bool,
source_resource_id: &'a str,
result: &'a mut PopulateCachesResult,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UnknownReference {
pub source_resource_id: String,
pub node_id: String,
pub node_alias: Option<String>,
pub referenced_id: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct PopulateCachesResult {
pub unknown_references: Vec<UnknownReference>,
}
impl PopulateCachesResult {
pub fn has_unknown_references(&self) -> bool {
!self.unknown_references.is_empty()
}
pub fn error_messages(&self) -> Vec<String> {
self.unknown_references
.iter()
.map(|r| {
let node_desc = r
.node_alias
.as_ref()
.map(|a| format!("node '{}' ({})", a, r.node_id))
.unwrap_or_else(|| format!("node '{}'", r.node_id));
format!(
"Resource '{}': {} references unknown resource '{}'",
r.source_resource_id, node_desc, r.referenced_id
)
})
.collect()
}
}
#[derive(Clone, Debug)]
pub enum ResourceEntry {
Summary(Box<StaticResourceSummary>),
Full(Box<StaticResource>),
}
impl ResourceEntry {
pub fn resourceinstanceid(&self) -> &str {
match self {
ResourceEntry::Summary(s) => &s.resourceinstanceid,
ResourceEntry::Full(r) => &r.resourceinstance.resourceinstanceid,
}
}
pub fn graph_id(&self) -> &str {
match self {
ResourceEntry::Summary(s) => &s.graph_id,
ResourceEntry::Full(r) => &r.resourceinstance.graph_id,
}
}
pub fn name(&self) -> &str {
match self {
ResourceEntry::Summary(s) => &s.name,
ResourceEntry::Full(r) => &r.resourceinstance.name,
}
}
pub fn descriptors(&self) -> Option<&StaticResourceDescriptors> {
match self {
ResourceEntry::Summary(s) => s.descriptors.as_ref(),
ResourceEntry::Full(r) => Some(&r.resourceinstance.descriptors),
}
}
pub fn has_tiles(&self) -> bool {
match self {
ResourceEntry::Summary(_) => false,
ResourceEntry::Full(r) => r.tiles.as_ref().map(|t| !t.is_empty()).unwrap_or(false),
}
}
pub fn is_full(&self) -> bool {
matches!(self, ResourceEntry::Full(_))
}
pub fn as_full(&self) -> Option<&StaticResource> {
match self {
ResourceEntry::Full(r) => Some(r),
ResourceEntry::Summary(_) => None,
}
}
pub fn as_full_mut(&mut self) -> Option<&mut StaticResource> {
match self {
ResourceEntry::Full(r) => Some(r),
ResourceEntry::Summary(_) => None,
}
}
pub fn to_summary(&self) -> StaticResourceSummary {
match self {
ResourceEntry::Summary(s) => *s.clone(),
ResourceEntry::Full(r) => r.to_summary(),
}
}
pub fn to_cache_entry(&self) -> RelatedResourceEntry {
match self {
ResourceEntry::Summary(s) => RelatedResourceEntry::from(s.as_ref()),
ResourceEntry::Full(r) => RelatedResourceEntry::from(r.as_ref()),
}
}
}
impl From<StaticResourceSummary> for ResourceEntry {
fn from(summary: StaticResourceSummary) -> Self {
ResourceEntry::Summary(Box::new(summary))
}
}
impl From<StaticResource> for ResourceEntry {
fn from(resource: StaticResource) -> Self {
ResourceEntry::Full(Box::new(resource))
}
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistryMemoryStats {
pub total: usize,
pub full_count: usize,
pub summary_count: usize,
pub total_tiles: usize,
pub cache_entries: usize,
pub cache_bytes_est: usize,
pub tile_bytes_est: usize,
}
#[derive(Clone, Debug, Default)]
pub struct StaticResourceRegistry {
resources: HashMap<String, ResourceEntry>,
}
impl StaticResourceRegistry {
pub fn new() -> Self {
Self {
resources: HashMap::new(),
}
}
pub fn get_graph_id(&self, resource_id: &str) -> Option<&str> {
self.resources.get(resource_id).map(|e| e.graph_id())
}
pub fn get(&self, resource_id: &str) -> Option<&ResourceEntry> {
self.resources.get(resource_id)
}
pub fn get_mut(&mut self, resource_id: &str) -> Option<&mut ResourceEntry> {
self.resources.get_mut(resource_id)
}
pub fn get_full(&self, resource_id: &str) -> Option<&StaticResource> {
self.resources.get(resource_id).and_then(|e| e.as_full())
}
pub fn get_summary(&self, resource_id: &str) -> Option<StaticResourceSummary> {
self.resources.get(resource_id).map(|e| e.to_summary())
}
pub fn contains(&self, resource_id: &str) -> bool {
self.resources.contains_key(resource_id)
}
pub fn has_full(&self, resource_id: &str) -> bool {
self.resources
.get(resource_id)
.map(|e| e.is_full())
.unwrap_or(false)
}
pub fn memory_stats(&self) -> RegistryMemoryStats {
let mut full_count: usize = 0;
let mut summary_count: usize = 0;
let mut total_tiles: usize = 0;
let mut cache_entries: usize = 0;
for entry in self.resources.values() {
match entry {
ResourceEntry::Full(r) => {
full_count += 1;
total_tiles += r.tiles.as_ref().map(|t| t.len()).unwrap_or(0);
if r.cache.is_some() {
cache_entries += 1;
}
}
ResourceEntry::Summary(_) => {
summary_count += 1;
}
}
}
RegistryMemoryStats {
total: self.resources.len(),
full_count,
summary_count,
total_tiles,
cache_entries,
cache_bytes_est: 0,
tile_bytes_est: 0,
}
}
pub fn memory_stats_detailed(&self) -> RegistryMemoryStats {
let mut stats = self.memory_stats();
let mut cache_bytes_est: usize = 0;
let mut tile_bytes_est: usize = 0;
for entry in self.resources.values() {
if let ResourceEntry::Full(r) = entry {
if let Some(ref cache) = r.cache {
cache_bytes_est += serde_json::to_string(cache).map(|s| s.len()).unwrap_or(0);
}
if let Some(ref tiles) = r.tiles {
tile_bytes_est += serde_json::to_string(tiles).map(|s| s.len()).unwrap_or(0);
}
}
}
stats.cache_bytes_est = cache_bytes_est;
stats.tile_bytes_est = tile_bytes_est;
stats
}
pub fn len(&self) -> usize {
self.resources.len()
}
pub fn is_empty(&self) -> bool {
self.resources.is_empty()
}
pub fn insert_summary(&mut self, summary: StaticResourceSummary) {
let id = summary.resourceinstanceid.clone();
if !self.has_full(&id) {
self.resources
.insert(id, ResourceEntry::Summary(Box::new(summary)));
}
}
pub fn insert(&mut self, summary: StaticResourceSummary) {
self.insert_summary(summary);
}
pub fn insert_full(&mut self, resource: StaticResource) {
let id = resource.resourceinstance.resourceinstanceid.clone();
self.resources
.insert(id, ResourceEntry::Full(Box::new(resource)));
}
pub fn upgrade_to_full(&mut self, resource: StaticResource) {
let id = resource.resourceinstance.resourceinstanceid.clone();
self.resources
.insert(id, ResourceEntry::Full(Box::new(resource)));
}
pub fn merge_from_resources(
&mut self,
resources: &[StaticResource],
store_full: bool,
include_caches: bool,
) {
for resource in resources {
if store_full {
self.insert_full(resource.clone());
} else {
self.insert_summary(resource.to_summary());
}
if include_caches {
if let Some(ref cache_json) = resource.cache {
if let Ok(cache) = serde_json::from_value::<ResourceCache>(cache_json.clone()) {
for (_tile_id, node_entries) in cache {
for (_node_id, cache_entry) in node_entries {
let entries: Vec<&RelatedResourceEntry> = match &cache_entry {
CacheEntry::Single(entry) => vec![entry],
CacheEntry::List(list) => list.entries.iter().collect(),
};
for entry in entries {
let id = entry.id.clone();
self.resources.entry(id).or_insert_with(|| {
ResourceEntry::Summary(Box::new(
StaticResourceSummary::from(entry.clone()),
))
});
}
}
}
}
}
}
}
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
self.resources.iter()
}
pub fn iter_full(&self) -> impl Iterator<Item = (&String, &StaticResource)> {
self.resources
.iter()
.filter_map(|(id, entry)| entry.as_full().map(|r| (id, r)))
}
pub fn ids(&self) -> impl Iterator<Item = &String> {
self.resources.keys()
}
pub fn populate_caches(
&self,
resources: &mut [StaticResource],
graph: &super::StaticGraph,
enrich_relationships: bool,
strict: bool,
recompute_descriptors: bool,
) -> Result<PopulateCachesResult, String> {
let mut result = PopulateCachesResult::default();
for resource in resources.iter_mut() {
let mut cache: ResourceCache = HashMap::new();
let resource_id = resource.resourceinstance.resourceinstanceid.clone();
if let Some(ref mut tiles) = resource.tiles {
for tile in tiles.iter_mut() {
let tile_id = tile
.tileid
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let nodes = graph.get_nodes_in_nodegroup(&tile.nodegroup_id);
for node in nodes {
if node.datatype != "resource-instance"
&& node.datatype != "resource-instance-list"
{
continue;
}
if let Some(data) = tile.data.get_mut(&node.nodeid) {
let mut ctx = ProcessResourceContext {
cache: &mut cache,
enrich_relationships,
source_resource_id: &resource_id,
result: &mut result,
};
self.process_resource_instance_data(data, node, &tile_id, &mut ctx);
}
}
}
}
if !cache.is_empty() {
if let Some(ref existing_json) = resource.cache {
if let Ok(existing) =
serde_json::from_value::<ResourceCache>(existing_json.clone())
{
for (tile_id, node_entries) in existing {
let tile_cache = cache.entry(tile_id).or_default();
for (node_id, entry) in node_entries {
tile_cache.entry(node_id).or_insert(entry);
}
}
}
}
resource.cache = serde_json::to_value(&cache).ok();
}
}
if recompute_descriptors {
let indexed = super::static_graph::IndexedGraph::new(graph.clone());
for resource in resources.iter_mut() {
let tiles = resource.tiles.as_deref().unwrap_or(&[]);
let cache: Option<ResourceCache> = resource
.cache
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let descriptors = indexed.build_descriptors_with_diagnostics(
tiles,
&mut Vec::new(),
cache.as_ref(),
);
if let Some(ref name) = descriptors.name {
if !name.is_empty() {
resource.resourceinstance.name = name.clone();
}
}
resource.resourceinstance.descriptors = descriptors;
}
}
if strict && result.has_unknown_references() {
let msgs = result.error_messages();
return Err(format!("Unknown resource references:\n{}", msgs.join("\n")));
}
Ok(result)
}
fn process_resource_instance_data(
&self,
data: &mut serde_json::Value,
node: &super::StaticNode,
tile_id: &str,
ctx: &mut ProcessResourceContext<'_>,
) {
let is_list = node.datatype == "resource-instance-list";
let mut list_entries: Vec<RelatedResourceEntry> = Vec::new();
if let Some(arr) = data.as_array_mut() {
for entry in arr.iter_mut() {
if let Some(resource_id) = entry.get("resourceId").and_then(|r| r.as_str()) {
if let Some(resource_entry) = self.resources.get(resource_id) {
let model_class_name = crate::get_graph(resource_entry.graph_id())
.and_then(|g| g.get_model_class_name());
let related_entry = RelatedResourceEntry::from_resource_entry(
resource_entry,
model_class_name.as_deref(),
);
if is_list {
list_entries.push(related_entry);
} else {
let tile_cache = ctx.cache.entry(tile_id.to_string()).or_default();
tile_cache
.insert(node.nodeid.clone(), CacheEntry::Single(related_entry));
}
if ctx.enrich_relationships {
self.enrich_entry_with_relationship(entry, resource_entry, node);
}
} else {
ctx.result.unknown_references.push(UnknownReference {
source_resource_id: ctx.source_resource_id.to_string(),
node_id: node.nodeid.clone(),
node_alias: node.alias.clone(),
referenced_id: resource_id.to_string(),
});
}
}
}
}
if is_list && !list_entries.is_empty() {
let tile_cache = ctx.cache.entry(tile_id.to_string()).or_default();
tile_cache.insert(
node.nodeid.clone(),
CacheEntry::List(RelatedResourceListEntry {
datatype: "resource-instance-list".to_string(),
entries: list_entries,
meta: None,
}),
);
}
}
fn enrich_entry_with_relationship(
&self,
entry: &mut serde_json::Value,
target_entry: &ResourceEntry,
node: &super::StaticNode,
) {
if entry.get("ontologyProperty").is_some() {
return;
}
let graphs = match node.config.get("graphs").and_then(|g| g.as_array()) {
Some(g) => g,
None => return,
};
let target_graph_id = target_entry.graph_id();
let graph_config = graphs.iter().find(|g| {
g.get("graphid")
.and_then(|id| id.as_str())
.map(|id| id == target_graph_id)
.unwrap_or(false)
});
let graph_config = match graph_config {
Some(g) => g,
None => return, };
let use_ontology = graph_config
.get("useOntologyRelationship")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let (ont_key, inv_key) = if use_ontology {
("ontologyProperty", "inverseOntologyProperty")
} else {
("relationshipConcept", "inverseRelationshipConcept")
};
if let Some(prop) = graph_config.get(ont_key).and_then(|v| v.as_str()) {
if !prop.is_empty() {
entry["ontologyProperty"] = serde_json::json!(prop);
}
}
if let Some(prop) = graph_config.get(inv_key).and_then(|v| v.as_str()) {
if !prop.is_empty() {
entry["inverseOntologyProperty"] = serde_json::json!(prop);
}
}
}
pub fn get_node_values_index(
&self,
graph: &super::StaticGraph,
node_identifier: &str,
) -> Result<HashMap<String, Vec<serde_json::Value>>, String> {
let node = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some(node_identifier) || n.nodeid == node_identifier)
.ok_or_else(|| {
format!(
"Node '{}' not found in graph {}",
node_identifier, graph.graphid
)
})?;
let node_id = &node.nodeid;
let nodegroup_id = node
.nodegroup_id
.as_ref()
.ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_identifier))?;
let mut index: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for (_, resource) in self.iter_full() {
if resource.resourceinstance.graph_id != graph.graphid {
continue;
}
let resource_id = &resource.resourceinstance.resourceinstanceid;
if let Some(ref tiles) = resource.tiles {
for tile in tiles {
if tile.nodegroup_id.as_str() == nodegroup_id {
if let Some(value) = tile.data.get(node_id) {
index
.entry(resource_id.clone())
.or_default()
.push(value.clone());
}
}
}
}
}
Ok(index)
}
pub fn get_value_to_resources_index(
&self,
graph: &super::StaticGraph,
node_identifier: &str,
flatten_localized: bool,
) -> Result<HashMap<String, Vec<String>>, String> {
self.get_value_to_resources_index_with_context(
graph,
node_identifier,
flatten_localized,
None,
)
}
pub fn get_value_to_resources_index_with_context(
&self,
graph: &super::StaticGraph,
node_identifier: &str,
flatten_localized: bool,
ctx: Option<&crate::type_serialization::SerializationContext>,
) -> Result<HashMap<String, Vec<String>>, String> {
use crate::node_config::NodeConfigManager;
use crate::type_serialization::{
serialize_value, SerializationContext, SerializationOptions,
};
let node = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some(node_identifier) || n.nodeid == node_identifier)
.ok_or_else(|| {
format!(
"Node '{}' not found in graph {}",
node_identifier, graph.graphid
)
})?;
let node_id = &node.nodeid;
let datatype = &node.datatype;
let nodegroup_id = node
.nodegroup_id
.as_ref()
.ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_identifier))?;
let mut ncm = NodeConfigManager::new();
ncm.build_from_graph(graph);
let node_config = ncm.get(node_id);
let language = if flatten_localized { "en" } else { "" };
let opts = SerializationOptions::display(language);
let empty_ctx = SerializationContext::empty();
let base_ctx = ctx.unwrap_or(&empty_ctx);
let ser_ctx = SerializationContext {
node_config,
external_resolver: base_ctx.external_resolver,
resource_resolver: base_ctx.resource_resolver,
extension_registry: base_ctx.extension_registry,
};
let mut index: HashMap<String, Vec<String>> = HashMap::new();
for (_, resource) in self.iter_full() {
if resource.resourceinstance.graph_id != graph.graphid {
continue;
}
let resource_id = &resource.resourceinstance.resourceinstanceid;
if let Some(ref tiles) = resource.tiles {
for tile in tiles {
if tile.nodegroup_id.as_str() == nodegroup_id {
if let Some(value) = tile.data.get(node_id) {
let result = serialize_value(datatype, value, &opts, Some(&ser_ctx));
if result.is_error() {
continue;
}
let keys = extract_display_keys(&result.value);
for k in keys {
index.entry(k).or_default().push(resource_id.clone());
}
}
}
}
}
}
Ok(index)
}
#[allow(clippy::too_many_arguments)]
pub fn get_filtered_tile_values(
&self,
graph: &super::StaticGraph,
filter_node: &str,
filter_values: &[&str],
extract_node: &str,
flatten_localized: bool,
ctx: Option<&crate::type_serialization::SerializationContext>,
required_scope: Option<&str>,
) -> Result<Vec<serde_json::Value>, String> {
use crate::node_config::NodeConfigManager;
use crate::type_serialization::{
serialize_value, SerializationContext, SerializationOptions,
};
let find_node = |identifier: &str| {
graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some(identifier) || n.nodeid == identifier)
.ok_or_else(|| {
format!("Node '{}' not found in graph {}", identifier, graph.graphid)
})
};
let filter = find_node(filter_node)?;
let extract = find_node(extract_node)?;
let filter_node_id = &filter.nodeid;
let filter_datatype = &filter.datatype;
let filter_nodegroup_id = filter
.nodegroup_id
.as_ref()
.ok_or_else(|| format!("Node '{}' has no nodegroup_id", filter_node))?;
let extract_node_id = &extract.nodeid;
let extract_nodegroup_id = extract
.nodegroup_id
.as_ref()
.ok_or_else(|| format!("Node '{}' has no nodegroup_id", extract_node))?;
if filter_nodegroup_id != extract_nodegroup_id {
return Err(format!(
"Filter node '{}' (nodegroup {}) and extract node '{}' (nodegroup {}) are not in the same nodegroup",
filter_node, filter_nodegroup_id, extract_node, extract_nodegroup_id
));
}
let mut ncm = NodeConfigManager::new();
ncm.build_from_graph(graph);
let node_config = ncm.get(filter_node_id);
let language = if flatten_localized { "en" } else { "" };
let opts = SerializationOptions::display(language);
let empty_ctx = SerializationContext::empty();
let base_ctx = ctx.unwrap_or(&empty_ctx);
let ser_ctx = SerializationContext {
node_config,
external_resolver: base_ctx.external_resolver,
resource_resolver: base_ctx.resource_resolver,
extension_registry: base_ctx.extension_registry,
};
let mut results: Vec<serde_json::Value> = Vec::new();
for (_, resource) in self.iter_full() {
if resource.resourceinstance.graph_id != graph.graphid {
continue;
}
if let Some(scope) = required_scope {
let has_scope = resource
.scopes
.as_ref()
.and_then(|s| s.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some(scope)))
.unwrap_or(false);
if !has_scope {
continue;
}
}
if let Some(ref tiles) = resource.tiles {
for tile in tiles {
if tile.nodegroup_id.as_str() != filter_nodegroup_id.as_str() {
continue;
}
let matches = if let Some(filter_value) = tile.data.get(filter_node_id) {
let result =
serialize_value(filter_datatype, filter_value, &opts, Some(&ser_ctx));
if result.is_error() {
false
} else {
let keys = extract_display_keys(&result.value);
keys.iter().any(|display| {
let tags: Vec<&str> =
display.split(',').map(|t| t.trim()).collect();
filter_values.iter().any(|fv| tags.contains(fv))
})
}
} else {
false
};
if matches {
if let Some(extract_value) = tile.data.get(extract_node_id) {
results.push(extract_value.clone());
}
}
}
}
}
Ok(results)
}
}
fn extract_display_keys(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
})
.collect(),
serde_json::Value::Null => vec![],
other => vec![other.to_string()],
}
}
impl crate::type_serialization::ResourceDisplayResolver for StaticResourceRegistry {
fn resolve_resource_display(&self, resource_id: &str, _language: &str) -> Option<String> {
let summary = self.get_summary(resource_id)?;
if let Some(ref descriptors) = summary.descriptors {
if let Some(ref name) = descriptors.name {
if !name.is_empty() {
return Some(name.clone());
}
}
}
if !summary.name.is_empty() {
Some(summary.name)
} else {
None
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MergeResult {
pub resource: StaticResource,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchMergeResult {
pub resources: Vec<StaticResource>,
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn merge_resources(resources: Vec<StaticResource>) -> Result<MergeResult, String> {
if resources.is_empty() {
return Err("No resources to merge".to_string());
}
let first_instance = resources[0].resourceinstance.clone();
let resource_id = first_instance.resourceinstanceid.clone();
for (i, r) in resources.iter().enumerate().skip(1) {
if r.resourceinstance.resourceinstanceid != resource_id {
return Err(format!(
"Resource ID mismatch at index {}: expected '{}', found '{}'",
i, resource_id, r.resourceinstance.resourceinstanceid
));
}
}
let mut seen_tileids: HashSet<String> = HashSet::new();
let mut merged_tiles: Vec<StaticTile> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut merged_metadata: HashMap<String, String> = HashMap::new();
let mut merged_cache: ResourceCache = ResourceCache::default();
let mut merged_scopes: Option<serde_json::Value> = None;
let mut first_scopes_index: Option<usize> = None;
for (i, resource) in resources.into_iter().enumerate() {
if let Some(tiles) = resource.tiles {
for tile in tiles {
if let Some(ref tileid) = tile.tileid {
if seen_tileids.contains(tileid) {
continue;
}
seen_tileids.insert(tileid.clone());
}
merged_tiles.push(tile);
}
}
for (key, value) in resource.metadata {
if let Some(existing) = merged_metadata.get(&key) {
if existing != &value {
warnings.push(format!(
"Metadata key '{}' has conflicting values: '{}' vs '{}' (using latter)",
key, existing, value
));
}
}
merged_metadata.insert(key, value);
}
if let Some(scopes) = resource.scopes {
match &merged_scopes {
None => {
merged_scopes = Some(scopes);
first_scopes_index = Some(i);
}
Some(existing) if existing != &scopes => {
warnings.push(format!(
"Scopes mismatch: resource {} has different scopes than resource {} (using first)",
i, first_scopes_index.unwrap_or(0)
));
}
_ => {}
}
}
if let Some(cache_json) = resource.cache {
if let Ok(cache) = serde_json::from_value::<ResourceCache>(cache_json) {
for (tile_id, node_entries) in cache {
let tile_cache = merged_cache.entry(tile_id).or_default();
for (node_id, entry) in node_entries {
tile_cache.entry(node_id).or_insert(entry);
}
}
}
}
}
merged_tiles.sort_by(|a, b| {
let ng_cmp = a.nodegroup_id.cmp(&b.nodegroup_id);
if ng_cmp != std::cmp::Ordering::Equal {
return ng_cmp;
}
let a_sort = a.sortorder.unwrap_or(i32::MAX);
let b_sort = b.sortorder.unwrap_or(i32::MAX);
a_sort.cmp(&b_sort)
});
let final_cache = if merged_cache.is_empty() {
None
} else {
serde_json::to_value(&merged_cache).ok()
};
Ok(MergeResult {
resource: StaticResource {
resourceinstance: first_instance,
tiles: Some(merged_tiles),
metadata: merged_metadata,
cache: final_cache,
scopes: merged_scopes,
tiles_loaded: Some(true),
},
warnings,
})
}
pub fn parse_resources_from_json_str(json_str: &str) -> Result<Vec<StaticResource>, String> {
let value: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
match value {
serde_json::Value::Array(_) => serde_json::from_value(value)
.map_err(|e| format!("Failed to parse resource array: {}", e)),
serde_json::Value::Object(mut map) => {
if let Some(bd) = map.remove("business_data") {
if let serde_json::Value::Object(mut bd_map) = bd {
if let Some(resources) = bd_map.remove("resources") {
serde_json::from_value(resources)
.map_err(|e| format!("Failed to parse business_data.resources: {}", e))
} else {
Err("business_data missing 'resources' field".to_string())
}
} else {
Err("business_data is not an object".to_string())
}
} else if map.contains_key("resourceinstance") {
let resource: StaticResource =
serde_json::from_value(serde_json::Value::Object(map))
.map_err(|e| format!("Failed to parse as single resource: {}", e))?;
Ok(vec![resource])
} else {
Err(
"Unrecognized format - expected array, BusinessDataWrapper, or StaticResource"
.to_string(),
)
}
}
_ => Err("Expected array or object".to_string()),
}
}
pub struct MergeAccumulator {
accumulated: Vec<StaticResource>,
warnings: Vec<String>,
chunk_size: usize,
strict: bool,
pending: Vec<Vec<StaticResource>>,
error: Option<String>,
}
impl MergeAccumulator {
pub fn new(chunk_size: usize, strict: bool) -> Self {
Self {
accumulated: Vec::new(),
warnings: Vec::new(),
chunk_size: if chunk_size == 0 { 10 } else { chunk_size },
strict,
pending: Vec::new(),
error: None,
}
}
pub fn add_json(&mut self, json_str: &str) -> Result<(), String> {
if let Some(ref e) = self.error {
return Err(format!("Accumulator already in error state: {}", e));
}
let resources = parse_resources_from_json_str(json_str)?;
self.pending.push(resources);
if self.pending.len() >= self.chunk_size {
self.flush()?;
}
Ok(())
}
pub fn add_resources(&mut self, resources: Vec<StaticResource>) -> Result<(), String> {
if let Some(ref e) = self.error {
return Err(format!("Accumulator already in error state: {}", e));
}
self.pending.push(resources);
if self.pending.len() >= self.chunk_size {
self.flush()?;
}
Ok(())
}
fn flush(&mut self) -> Result<(), String> {
if self.pending.is_empty() {
return Ok(());
}
let mut batches: Vec<Vec<StaticResource>> = Vec::new();
if !self.accumulated.is_empty() {
batches.push(std::mem::take(&mut self.accumulated));
}
batches.append(&mut self.pending);
let result = batch_merge_resources(batches, false, self.strict);
self.warnings.extend(result.warnings);
if let Some(error) = result.error {
self.error = Some(error.clone());
self.accumulated = result.resources;
return Err(error);
}
self.accumulated = result.resources;
Ok(())
}
pub fn finish(mut self, recompute_descriptors: bool) -> BatchMergeResult {
if let Err(e) = self.flush() {
return BatchMergeResult {
resources: self.accumulated,
warnings: self.warnings,
error: Some(e),
};
}
if recompute_descriptors && !self.accumulated.is_empty() {
let result = batch_merge_resources(
vec![std::mem::take(&mut self.accumulated)],
true,
self.strict,
);
self.warnings.extend(result.warnings);
if let Some(ref error) = result.error {
return BatchMergeResult {
resources: result.resources,
warnings: self.warnings,
error: Some(error.clone()),
};
}
self.accumulated = result.resources;
}
BatchMergeResult {
resources: self.accumulated,
warnings: self.warnings,
error: None,
}
}
}
pub fn batch_merge_resources(
resource_batches: Vec<Vec<StaticResource>>,
recompute_descriptors: bool,
strict: bool,
) -> BatchMergeResult {
use crate::registry::get_graph;
use crate::IndexedGraph;
use std::collections::BTreeMap;
let mut grouped: BTreeMap<String, Vec<StaticResource>> = BTreeMap::new();
for batch in resource_batches {
for resource in batch {
let id = resource.resourceinstance.resourceinstanceid.clone();
grouped.entry(id).or_default().push(resource);
}
}
let mut merged_resources = Vec::new();
let mut all_warnings = Vec::new();
let mut indexed_graphs: BTreeMap<String, IndexedGraph> = BTreeMap::new();
for (resource_id, resources) in grouped {
match merge_resources(resources) {
Ok(result) => {
for warning in result.warnings {
all_warnings.push(format!("[{}] {}", resource_id, warning));
}
let mut resource = result.resource;
let graph_id = resource.resourceinstance.graph_id.clone();
if !indexed_graphs.contains_key(&graph_id) {
if let Some(graph) = get_graph(&graph_id) {
indexed_graphs
.insert(graph_id.clone(), IndexedGraph::new((*graph).clone()));
}
}
if let Some(indexed) = indexed_graphs.get(&graph_id) {
if let Some(ref mut tiles) = resource.tiles {
match unify_cardinality_one_tiles(tiles, indexed, strict) {
Ok(unify_warnings) => {
for warning in unify_warnings {
all_warnings.push(format!("[{}] {}", resource_id, warning));
}
}
Err(e) => {
all_warnings.push(format!("[{}] Unify error: {}", resource_id, e));
if strict {
return BatchMergeResult {
resources: merged_resources,
warnings: all_warnings,
error: Some(format!("[{}] {}", resource_id, e)),
};
}
}
}
}
}
if recompute_descriptors {
if let Some(indexed) = indexed_graphs.get(&graph_id) {
let tiles = resource.tiles.as_deref().unwrap_or(&[]);
let mut descriptor_warnings = Vec::new();
let cache: Option<ResourceCache> = resource
.cache
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let descriptors = indexed.build_descriptors_with_diagnostics(
tiles,
&mut descriptor_warnings,
cache.as_ref(),
);
for warning in descriptor_warnings {
all_warnings.push(format!("[{}] Descriptor: {}", resource_id, warning));
}
resource.resourceinstance.descriptors = descriptors.clone();
if let Some(ref name) = descriptors.name {
if !name.is_empty() {
resource.resourceinstance.name = name.clone();
}
}
} else {
all_warnings.push(format!(
"[{}] Graph not found in registry for descriptor computation: {}",
resource_id, graph_id
));
}
}
merged_resources.push(resource);
}
Err(e) => {
all_warnings.push(format!("[{}] Merge error: {}", resource_id, e));
}
}
}
BatchMergeResult {
resources: merged_resources,
warnings: all_warnings,
error: None,
}
}
type TileDataMergeMap = HashMap<usize, Vec<(String, HashMap<String, serde_json::Value>)>>;
pub fn unify_cardinality_one_tiles(
tiles: &mut Vec<StaticTile>,
indexed_graph: &crate::IndexedGraph,
strict: bool,
) -> Result<Vec<String>, String> {
use std::collections::BTreeMap;
let mut warnings = Vec::new();
let mut tiles_by_context: BTreeMap<(String, Option<String>), Vec<usize>> = BTreeMap::new();
for (idx, tile) in tiles.iter().enumerate() {
tiles_by_context
.entry((tile.nodegroup_id.clone(), tile.parenttile_id.clone()))
.or_default()
.push(idx);
}
let mut tile_redirect: HashMap<String, String> = HashMap::new();
let mut tiles_to_remove: HashSet<usize> = HashSet::new();
let mut data_to_merge: TileDataMergeMap = HashMap::new();
for ((nodegroup_id, _parent_tile_id), tile_indices) in &tiles_by_context {
if tile_indices.len() <= 1 {
continue; }
let nodegroup = match indexed_graph.graph.get_nodegroup_by_id(nodegroup_id) {
Some(ng) => ng,
None => continue,
};
let is_single = nodegroup
.cardinality
.as_ref()
.map(|c| c != "n")
.unwrap_or(true);
if !is_single {
continue; }
let canonical_idx = tile_indices[0];
let canonical_tile_id = tiles[canonical_idx].tileid.clone();
for &idx in tile_indices.iter().skip(1) {
let tile = &tiles[idx];
let tile_id = tile
.tileid
.clone()
.unwrap_or_else(|| format!("(index {})", idx));
if let Some(ref old_tile_id) = tile.tileid {
if let Some(ref canon_id) = canonical_tile_id {
tile_redirect.insert(old_tile_id.clone(), canon_id.clone());
}
}
if !tile.data.is_empty() {
data_to_merge
.entry(canonical_idx)
.or_default()
.push((tile_id, tile.data.clone()));
}
tiles_to_remove.insert(idx);
}
}
for (canonical_idx, sources) in data_to_merge {
let canonical_tile = &mut tiles[canonical_idx];
let canonical_tile_id = canonical_tile
.tileid
.clone()
.unwrap_or_else(|| format!("(index {})", canonical_idx));
for (source_tile_id, source_data) in sources {
for (key, value) in source_data {
if let Some(existing) = canonical_tile.data.get(&key) {
if existing != &value {
let msg = format!(
"Data conflict in nodegroup '{}': key '{}' has different values in tiles '{}' and '{}'",
canonical_tile.nodegroup_id,
key,
canonical_tile_id,
source_tile_id
);
if strict {
return Err(msg);
}
warnings.push(format!("{} (keeping first)", msg));
}
} else {
canonical_tile.data.insert(key, value);
}
}
}
}
for tile in tiles.iter_mut() {
if let Some(ref old_parent_id) = tile.parenttile_id {
if let Some(new_parent_id) = tile_redirect.get(old_parent_id) {
tile.parenttile_id = Some(new_parent_id.clone());
}
}
}
let mut indices: Vec<usize> = tiles_to_remove.into_iter().collect();
indices.sort_by(|a, b| b.cmp(a)); for idx in indices {
tiles.remove(idx);
}
Ok(warnings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_static_resource_serialization() {
let resource = StaticResource {
resourceinstance: StaticResourceMetadata {
descriptors: StaticResourceDescriptors::default(),
graph_id: "test-graph".to_string(),
name: "Test".to_string(),
resourceinstanceid: "test-id".to_string(),
publication_id: None,
principaluser_id: None,
legacyid: None,
graph_publication_id: None,
createdtime: None,
lastmodified: None,
},
tiles: Some(vec![]),
metadata: HashMap::new(),
cache: None,
scopes: None,
tiles_loaded: None,
};
let json = serde_json::to_string_pretty(&resource).unwrap();
println!("StaticResource JSON:\n{}", json);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
value.get("resourceinstance").is_some(),
"Should have nested resourceinstance"
);
}
fn make_test_resource(resource_id: &str, tile_ids: &[&str]) -> StaticResource {
let tiles: Vec<StaticTile> = tile_ids
.iter()
.map(|id| StaticTile {
tileid: Some(id.to_string()),
nodegroup_id: "ng1".to_string(),
resourceinstance_id: resource_id.to_string(),
parenttile_id: None,
data: HashMap::new(),
provisionaledits: None,
sortorder: None,
})
.collect();
StaticResource {
resourceinstance: StaticResourceMetadata {
descriptors: StaticResourceDescriptors::default(),
graph_id: "test-graph".to_string(),
name: "Test".to_string(),
resourceinstanceid: resource_id.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: None,
}
}
#[test]
fn test_merge_resources_basic() {
let r1 = make_test_resource("res-1", &["tile-a", "tile-b"]);
let r2 = make_test_resource("res-1", &["tile-c", "tile-d"]);
let result = merge_resources(vec![r1, r2]).unwrap();
assert_eq!(result.resource.resourceinstance.resourceinstanceid, "res-1");
let tiles = result.resource.tiles.unwrap();
assert_eq!(tiles.len(), 4);
assert!(result.warnings.is_empty());
}
#[test]
fn test_merge_resources_duplicate_detection() {
let r1 = make_test_resource("res-1", &["tile-a", "tile-b"]);
let r2 = make_test_resource("res-1", &["tile-b", "tile-c"]);
let result = merge_resources(vec![r1, r2]).unwrap();
let tiles = result.resource.tiles.unwrap();
assert_eq!(tiles.len(), 3); assert!(result.warnings.is_empty()); }
#[test]
fn test_merge_resources_id_mismatch() {
let r1 = make_test_resource("res-1", &["tile-a"]);
let r2 = make_test_resource("res-2", &["tile-b"]);
let result = merge_resources(vec![r1, r2]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("mismatch"));
}
#[test]
fn test_merge_resources_empty() {
let result = merge_resources(vec![]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No resources"));
}
#[test]
fn test_merge_resources_preserves_cache() {
let mut r1 = make_test_resource("res-1", &["tile-a"]);
let mut r2 = make_test_resource("res-1", &["tile-b"]);
let mut cache1: ResourceCache = HashMap::new();
let mut tile_a_entries: HashMap<String, CacheEntry> = HashMap::new();
tile_a_entries.insert(
"node-1".to_string(),
CacheEntry::Single(RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: "related-1".to_string(),
resource_type: "TestModel".to_string(),
graph_id: "graph-a".to_string(),
title: Some("Related 1".to_string()),
descriptors: None,
meta: None,
}),
);
tile_a_entries.insert(
"node-2".to_string(),
CacheEntry::Single(RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: "related-2".to_string(),
resource_type: "TestModel".to_string(),
graph_id: "graph-a".to_string(),
title: Some("Related 2".to_string()),
descriptors: None,
meta: None,
}),
);
cache1.insert("tile-a".to_string(), tile_a_entries);
r1.cache = serde_json::to_value(&cache1).ok();
let mut cache2: ResourceCache = HashMap::new();
let mut tile_a_entries_2: HashMap<String, CacheEntry> = HashMap::new();
tile_a_entries_2.insert(
"node-2".to_string(),
CacheEntry::Single(RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: "related-2".to_string(),
resource_type: "TestModel".to_string(),
graph_id: "graph-a".to_string(),
title: Some("Related 2 - Different Name".to_string()), descriptors: None,
meta: None,
}),
);
cache2.insert("tile-a".to_string(), tile_a_entries_2);
let mut tile_b_entries: HashMap<String, CacheEntry> = HashMap::new();
tile_b_entries.insert(
"node-3".to_string(),
CacheEntry::Single(RelatedResourceEntry {
datatype: "resource-instance".to_string(),
id: "related-3".to_string(),
resource_type: "OtherModel".to_string(),
graph_id: "graph-b".to_string(),
title: Some("Related 3".to_string()),
descriptors: None,
meta: None,
}),
);
cache2.insert("tile-b".to_string(), tile_b_entries);
r2.cache = serde_json::to_value(&cache2).ok();
let result = merge_resources(vec![r1, r2]).unwrap();
assert!(result.resource.cache.is_some(), "Cache should be present");
let merged_cache: ResourceCache =
serde_json::from_value(result.resource.cache.unwrap()).unwrap();
assert_eq!(merged_cache.len(), 2);
assert!(merged_cache.contains_key("tile-a"));
assert!(merged_cache.contains_key("tile-b"));
let tile_a = merged_cache.get("tile-a").unwrap();
assert_eq!(tile_a.len(), 2);
assert!(tile_a.contains_key("node-1"));
assert!(tile_a.contains_key("node-2"));
if let CacheEntry::Single(entry) = tile_a.get("node-2").unwrap() {
assert_eq!(entry.title.as_deref(), Some("Related 2"));
} else {
panic!("Expected CacheEntry::Single");
}
let tile_b = merged_cache.get("tile-b").unwrap();
assert_eq!(tile_b.len(), 1);
assert!(tile_b.contains_key("node-3"));
}
}