1use super::descriptors::StaticResourceDescriptors;
4use super::tile::StaticTile;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7
8#[derive(Clone, Debug, Serialize, Deserialize)]
10pub struct StaticResourceMetadata {
11 pub descriptors: StaticResourceDescriptors,
12 pub graph_id: String,
13 pub name: String,
14 pub resourceinstanceid: String,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub publication_id: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub principaluser_id: Option<i32>,
19 #[serde(default)]
20 pub legacyid: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub graph_publication_id: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub createdtime: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub lastmodified: Option<String>,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize)]
31pub struct StaticResourceSummary {
32 pub resourceinstanceid: String,
33 pub graph_id: String,
34 pub name: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub descriptors: Option<StaticResourceDescriptors>,
37 #[serde(default)]
38 pub metadata: HashMap<String, String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub createdtime: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub lastmodified: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub publication_id: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub principaluser_id: Option<i32>,
47 #[serde(default)]
48 pub legacyid: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub graph_publication_id: Option<String>,
51}
52
53impl StaticResourceSummary {
54 pub fn to_metadata(&self) -> StaticResourceMetadata {
56 StaticResourceMetadata {
57 descriptors: self.descriptors.clone().unwrap_or_default(),
58 graph_id: self.graph_id.clone(),
59 name: self.name.clone(),
60 resourceinstanceid: self.resourceinstanceid.clone(),
61 publication_id: self.publication_id.clone(),
62 principaluser_id: self.principaluser_id,
63 legacyid: self.legacyid.clone(),
64 graph_publication_id: self.graph_publication_id.clone(),
65 createdtime: self.createdtime.clone(),
66 lastmodified: self.lastmodified.clone(),
67 }
68 }
69}
70
71#[derive(Clone, Debug, Serialize, Deserialize)]
76pub struct StaticResourceReference {
77 pub id: String,
79 #[serde(rename = "graphId")]
81 pub graph_id: String,
82 #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
84 pub resource_type: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub title: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub root: Option<serde_json::Value>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub meta: Option<HashMap<String, serde_json::Value>>,
94}
95
96impl StaticResourceReference {
97 pub fn new(id: String, graph_id: String) -> Self {
99 StaticResourceReference {
100 id,
101 graph_id,
102 resource_type: None,
103 title: None,
104 root: None,
105 meta: None,
106 }
107 }
108
109 pub fn with_type(id: String, graph_id: String, resource_type: String) -> Self {
111 StaticResourceReference {
112 id,
113 graph_id,
114 resource_type: Some(resource_type),
115 title: None,
116 root: None,
117 meta: None,
118 }
119 }
120
121 pub fn with_title(mut self, title: String) -> Self {
123 self.title = Some(title);
124 self
125 }
126
127 pub fn with_meta(mut self, meta: HashMap<String, serde_json::Value>) -> Self {
129 self.meta = Some(meta);
130 self
131 }
132
133 pub fn with_root(mut self, root: serde_json::Value) -> Self {
135 self.root = Some(root);
136 self
137 }
138}
139
140#[derive(Clone, Debug, Serialize, Deserialize)]
142pub struct StaticResource {
143 pub resourceinstance: StaticResourceMetadata,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub tiles: Option<Vec<StaticTile>>,
146 #[serde(default)]
147 pub metadata: HashMap<String, String>,
148
149 #[serde(skip_serializing_if = "Option::is_none", default, rename = "__cache")]
151 pub cache: Option<serde_json::Value>,
152 #[serde(skip_serializing_if = "Option::is_none", default, rename = "__scopes")]
153 pub scopes: Option<serde_json::Value>,
154
155 #[serde(skip_serializing_if = "Option::is_none", default)]
157 pub tiles_loaded: Option<bool>,
158}
159
160impl StaticResource {
161 pub fn to_summary(&self) -> StaticResourceSummary {
163 StaticResourceSummary {
164 resourceinstanceid: self.resourceinstance.resourceinstanceid.clone(),
165 graph_id: self.resourceinstance.graph_id.clone(),
166 name: self.resourceinstance.name.clone(),
167 descriptors: Some(self.resourceinstance.descriptors.clone()),
168 metadata: self.metadata.clone(),
169 createdtime: self.resourceinstance.createdtime.clone(),
170 lastmodified: self.resourceinstance.lastmodified.clone(),
171 publication_id: self.resourceinstance.publication_id.clone(),
172 principaluser_id: self.resourceinstance.principaluser_id,
173 legacyid: self.resourceinstance.legacyid.clone(),
174 graph_publication_id: self.resourceinstance.graph_publication_id.clone(),
175 }
176 }
177}
178
179#[derive(Clone, Debug, Serialize, Deserialize)]
184pub struct RelatedResourceEntry {
185 pub datatype: String,
187 pub id: String,
189 #[serde(rename = "type")]
191 pub resource_type: String,
192 #[serde(rename = "graphId")]
194 pub graph_id: String,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub title: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub descriptors: Option<StaticResourceDescriptors>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub meta: Option<HashMap<String, serde_json::Value>>,
204}
205
206impl RelatedResourceEntry {
207 pub fn from_resource_entry(entry: &ResourceEntry, model_class_name: Option<&str>) -> Self {
209 RelatedResourceEntry {
210 datatype: "resource-instance".to_string(),
211 id: entry.resourceinstanceid().to_string(),
212 resource_type: model_class_name
213 .map(|s| s.to_string())
214 .unwrap_or_else(|| entry.graph_id().to_string()),
215 graph_id: entry.graph_id().to_string(),
216 title: Some(entry.name().to_string()),
217 descriptors: entry.descriptors().cloned(),
218 meta: None,
219 }
220 }
221
222 pub fn from_summary(summary: &StaticResourceSummary, model_class_name: Option<&str>) -> Self {
224 RelatedResourceEntry {
225 datatype: "resource-instance".to_string(),
226 id: summary.resourceinstanceid.clone(),
227 resource_type: model_class_name
228 .map(|s| s.to_string())
229 .unwrap_or_else(|| summary.graph_id.clone()),
230 graph_id: summary.graph_id.clone(),
231 title: Some(summary.name.clone()),
232 descriptors: summary.descriptors.clone(),
233 meta: if summary.metadata.is_empty() {
234 None
235 } else {
236 Some(
237 summary
238 .metadata
239 .iter()
240 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
241 .collect(),
242 )
243 },
244 }
245 }
246
247 pub fn resourceinstanceid(&self) -> &str {
249 &self.id
250 }
251}
252
253impl From<&StaticResourceSummary> for RelatedResourceEntry {
254 fn from(summary: &StaticResourceSummary) -> Self {
255 RelatedResourceEntry::from_summary(summary, None)
256 }
257}
258
259impl From<&StaticResource> for RelatedResourceEntry {
260 fn from(resource: &StaticResource) -> Self {
261 let descriptors = &resource.resourceinstance.descriptors;
262 RelatedResourceEntry {
263 datatype: "resource-instance".to_string(),
264 id: resource.resourceinstance.resourceinstanceid.clone(),
265 resource_type: resource.resourceinstance.graph_id.clone(),
266 graph_id: resource.resourceinstance.graph_id.clone(),
267 title: Some(resource.resourceinstance.name.clone()),
268 descriptors: if descriptors.is_empty() {
269 None
270 } else {
271 Some(descriptors.clone())
272 },
273 meta: None,
274 }
275 }
276}
277
278impl From<RelatedResourceEntry> for StaticResourceSummary {
279 fn from(entry: RelatedResourceEntry) -> Self {
281 StaticResourceSummary {
282 resourceinstanceid: entry.id,
283 graph_id: entry.graph_id,
284 name: entry.title.unwrap_or_default(),
285 descriptors: entry.descriptors,
286 metadata: HashMap::new(),
287 createdtime: None,
288 lastmodified: None,
289 publication_id: None,
290 principaluser_id: None,
291 legacyid: None,
292 graph_publication_id: None,
293 }
294 }
295}
296
297impl From<&ResourceEntry> for RelatedResourceEntry {
298 fn from(entry: &ResourceEntry) -> Self {
299 RelatedResourceEntry::from_resource_entry(entry, None)
300 }
301}
302
303#[derive(Clone, Debug, Serialize, Deserialize)]
307pub struct RelatedResourceListEntry {
308 pub datatype: String,
310 #[serde(rename = "_")]
312 pub entries: Vec<RelatedResourceEntry>,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub meta: Option<HashMap<String, serde_json::Value>>,
316}
317
318impl RelatedResourceListEntry {
319 pub fn new() -> Self {
321 RelatedResourceListEntry {
322 datatype: "resource-instance-list".to_string(),
323 entries: Vec::new(),
324 meta: None,
325 }
326 }
327
328 pub fn push(&mut self, entry: RelatedResourceEntry) {
330 self.entries.push(entry);
331 }
332
333 pub fn is_empty(&self) -> bool {
335 self.entries.is_empty()
336 }
337}
338
339impl Default for RelatedResourceListEntry {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345#[derive(Clone, Debug, Serialize, Deserialize)]
351#[serde(untagged)]
352pub enum CacheEntry {
353 Single(RelatedResourceEntry),
355 List(RelatedResourceListEntry),
357}
358
359pub type ResourceCache = HashMap<String, HashMap<String, CacheEntry>>;
366
367struct ProcessResourceContext<'a> {
369 cache: &'a mut ResourceCache,
370 enrich_relationships: bool,
371 source_resource_id: &'a str,
372 result: &'a mut PopulateCachesResult,
373}
374
375#[derive(Clone, Debug, Serialize, Deserialize)]
377pub struct UnknownReference {
378 pub source_resource_id: String,
380 pub node_id: String,
382 pub node_alias: Option<String>,
384 pub referenced_id: String,
386}
387
388#[derive(Clone, Debug, Default, Serialize, Deserialize)]
390pub struct PopulateCachesResult {
391 pub unknown_references: Vec<UnknownReference>,
393}
394
395impl PopulateCachesResult {
396 pub fn has_unknown_references(&self) -> bool {
398 !self.unknown_references.is_empty()
399 }
400
401 pub fn error_messages(&self) -> Vec<String> {
403 self.unknown_references
404 .iter()
405 .map(|r| {
406 let node_desc = r
407 .node_alias
408 .as_ref()
409 .map(|a| format!("node '{}' ({})", a, r.node_id))
410 .unwrap_or_else(|| format!("node '{}'", r.node_id));
411 format!(
412 "Resource '{}': {} references unknown resource '{}'",
413 r.source_resource_id, node_desc, r.referenced_id
414 )
415 })
416 .collect()
417 }
418}
419
420#[derive(Clone, Debug)]
425pub enum ResourceEntry {
426 Summary(Box<StaticResourceSummary>),
428 Full(Box<StaticResource>),
430}
431
432impl ResourceEntry {
433 pub fn resourceinstanceid(&self) -> &str {
435 match self {
436 ResourceEntry::Summary(s) => &s.resourceinstanceid,
437 ResourceEntry::Full(r) => &r.resourceinstance.resourceinstanceid,
438 }
439 }
440
441 pub fn graph_id(&self) -> &str {
443 match self {
444 ResourceEntry::Summary(s) => &s.graph_id,
445 ResourceEntry::Full(r) => &r.resourceinstance.graph_id,
446 }
447 }
448
449 pub fn name(&self) -> &str {
451 match self {
452 ResourceEntry::Summary(s) => &s.name,
453 ResourceEntry::Full(r) => &r.resourceinstance.name,
454 }
455 }
456
457 pub fn descriptors(&self) -> Option<&StaticResourceDescriptors> {
459 match self {
460 ResourceEntry::Summary(s) => s.descriptors.as_ref(),
461 ResourceEntry::Full(r) => Some(&r.resourceinstance.descriptors),
462 }
463 }
464
465 pub fn has_tiles(&self) -> bool {
467 match self {
468 ResourceEntry::Summary(_) => false,
469 ResourceEntry::Full(r) => r.tiles.as_ref().map(|t| !t.is_empty()).unwrap_or(false),
470 }
471 }
472
473 pub fn is_full(&self) -> bool {
475 matches!(self, ResourceEntry::Full(_))
476 }
477
478 pub fn as_full(&self) -> Option<&StaticResource> {
480 match self {
481 ResourceEntry::Full(r) => Some(r),
482 ResourceEntry::Summary(_) => None,
483 }
484 }
485
486 pub fn as_full_mut(&mut self) -> Option<&mut StaticResource> {
488 match self {
489 ResourceEntry::Full(r) => Some(r),
490 ResourceEntry::Summary(_) => None,
491 }
492 }
493
494 pub fn to_summary(&self) -> StaticResourceSummary {
496 match self {
497 ResourceEntry::Summary(s) => *s.clone(),
498 ResourceEntry::Full(r) => r.to_summary(),
499 }
500 }
501
502 pub fn to_cache_entry(&self) -> RelatedResourceEntry {
504 match self {
505 ResourceEntry::Summary(s) => RelatedResourceEntry::from(s.as_ref()),
506 ResourceEntry::Full(r) => RelatedResourceEntry::from(r.as_ref()),
507 }
508 }
509}
510
511impl From<StaticResourceSummary> for ResourceEntry {
512 fn from(summary: StaticResourceSummary) -> Self {
513 ResourceEntry::Summary(Box::new(summary))
514 }
515}
516
517impl From<StaticResource> for ResourceEntry {
518 fn from(resource: StaticResource) -> Self {
519 ResourceEntry::Full(Box::new(resource))
520 }
521}
522
523#[derive(Clone, Debug, Serialize)]
525pub struct RegistryMemoryStats {
526 pub total: usize,
527 pub full_count: usize,
528 pub summary_count: usize,
529 pub total_tiles: usize,
530 pub cache_entries: usize,
531 pub cache_bytes_est: usize,
533 pub tile_bytes_est: usize,
535}
536
537#[derive(Clone, Debug, Default)]
549pub struct StaticResourceRegistry {
550 resources: HashMap<String, ResourceEntry>,
551}
552
553impl StaticResourceRegistry {
554 pub fn new() -> Self {
556 Self {
557 resources: HashMap::new(),
558 }
559 }
560
561 pub fn get_graph_id(&self, resource_id: &str) -> Option<&str> {
563 self.resources.get(resource_id).map(|e| e.graph_id())
564 }
565
566 pub fn get(&self, resource_id: &str) -> Option<&ResourceEntry> {
568 self.resources.get(resource_id)
569 }
570
571 pub fn get_mut(&mut self, resource_id: &str) -> Option<&mut ResourceEntry> {
573 self.resources.get_mut(resource_id)
574 }
575
576 pub fn get_full(&self, resource_id: &str) -> Option<&StaticResource> {
578 self.resources.get(resource_id).and_then(|e| e.as_full())
579 }
580
581 pub fn get_summary(&self, resource_id: &str) -> Option<StaticResourceSummary> {
583 self.resources.get(resource_id).map(|e| e.to_summary())
584 }
585
586 pub fn contains(&self, resource_id: &str) -> bool {
588 self.resources.contains_key(resource_id)
589 }
590
591 pub fn has_full(&self, resource_id: &str) -> bool {
593 self.resources
594 .get(resource_id)
595 .map(|e| e.is_full())
596 .unwrap_or(false)
597 }
598
599 pub fn memory_stats(&self) -> RegistryMemoryStats {
604 let mut full_count: usize = 0;
605 let mut summary_count: usize = 0;
606 let mut total_tiles: usize = 0;
607 let mut cache_entries: usize = 0;
608
609 for entry in self.resources.values() {
610 match entry {
611 ResourceEntry::Full(r) => {
612 full_count += 1;
613 total_tiles += r.tiles.as_ref().map(|t| t.len()).unwrap_or(0);
614 if r.cache.is_some() {
615 cache_entries += 1;
616 }
617 }
618 ResourceEntry::Summary(_) => {
619 summary_count += 1;
620 }
621 }
622 }
623
624 RegistryMemoryStats {
625 total: self.resources.len(),
626 full_count,
627 summary_count,
628 total_tiles,
629 cache_entries,
630 cache_bytes_est: 0,
631 tile_bytes_est: 0,
632 }
633 }
634
635 pub fn memory_stats_detailed(&self) -> RegistryMemoryStats {
638 let mut stats = self.memory_stats();
639 let mut cache_bytes_est: usize = 0;
640 let mut tile_bytes_est: usize = 0;
641
642 for entry in self.resources.values() {
643 if let ResourceEntry::Full(r) = entry {
644 if let Some(ref cache) = r.cache {
645 cache_bytes_est += serde_json::to_string(cache).map(|s| s.len()).unwrap_or(0);
646 }
647 if let Some(ref tiles) = r.tiles {
648 tile_bytes_est += serde_json::to_string(tiles).map(|s| s.len()).unwrap_or(0);
649 }
650 }
651 }
652
653 stats.cache_bytes_est = cache_bytes_est;
654 stats.tile_bytes_est = tile_bytes_est;
655 stats
656 }
657
658 pub fn len(&self) -> usize {
660 self.resources.len()
661 }
662
663 pub fn is_empty(&self) -> bool {
665 self.resources.is_empty()
666 }
667
668 pub fn insert_summary(&mut self, summary: StaticResourceSummary) {
670 let id = summary.resourceinstanceid.clone();
671 if !self.has_full(&id) {
673 self.resources
674 .insert(id, ResourceEntry::Summary(Box::new(summary)));
675 }
676 }
677
678 pub fn insert(&mut self, summary: StaticResourceSummary) {
680 self.insert_summary(summary);
681 }
682
683 pub fn insert_full(&mut self, resource: StaticResource) {
685 let id = resource.resourceinstance.resourceinstanceid.clone();
686 self.resources
687 .insert(id, ResourceEntry::Full(Box::new(resource)));
688 }
689
690 pub fn upgrade_to_full(&mut self, resource: StaticResource) {
692 let id = resource.resourceinstance.resourceinstanceid.clone();
693 self.resources
694 .insert(id, ResourceEntry::Full(Box::new(resource)));
695 }
696
697 pub fn merge_from_resources(
703 &mut self,
704 resources: &[StaticResource],
705 store_full: bool,
706 include_caches: bool,
707 ) {
708 for resource in resources {
709 if store_full {
711 self.insert_full(resource.clone());
712 } else {
713 self.insert_summary(resource.to_summary());
714 }
715
716 if include_caches {
718 if let Some(ref cache_json) = resource.cache {
719 if let Ok(cache) = serde_json::from_value::<ResourceCache>(cache_json.clone()) {
720 for (_tile_id, node_entries) in cache {
722 for (_node_id, cache_entry) in node_entries {
723 let entries: Vec<&RelatedResourceEntry> = match &cache_entry {
725 CacheEntry::Single(entry) => vec![entry],
726 CacheEntry::List(list) => list.entries.iter().collect(),
727 };
728
729 for entry in entries {
730 let id = entry.id.clone();
731 self.resources.entry(id).or_insert_with(|| {
733 ResourceEntry::Summary(Box::new(
734 StaticResourceSummary::from(entry.clone()),
735 ))
736 });
737 }
738 }
739 }
740 }
741 }
742 }
743 }
744 }
745
746 pub fn iter(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
748 self.resources.iter()
749 }
750
751 pub fn iter_full(&self) -> impl Iterator<Item = (&String, &StaticResource)> {
753 self.resources
754 .iter()
755 .filter_map(|(id, entry)| entry.as_full().map(|r| (id, r)))
756 }
757
758 pub fn ids(&self) -> impl Iterator<Item = &String> {
760 self.resources.keys()
761 }
762
763 pub fn populate_caches(
773 &self,
774 resources: &mut [StaticResource],
775 graph: &super::StaticGraph,
776 enrich_relationships: bool,
777 strict: bool,
778 recompute_descriptors: bool,
779 ) -> Result<PopulateCachesResult, String> {
780 let mut result = PopulateCachesResult::default();
781
782 for resource in resources.iter_mut() {
783 let mut cache: ResourceCache = HashMap::new();
784 let resource_id = resource.resourceinstance.resourceinstanceid.clone();
785
786 if let Some(ref mut tiles) = resource.tiles {
787 for tile in tiles.iter_mut() {
788 let tile_id = tile
790 .tileid
791 .clone()
792 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
793
794 let nodes = graph.get_nodes_in_nodegroup(&tile.nodegroup_id);
796
797 for node in nodes {
798 if node.datatype != "resource-instance"
800 && node.datatype != "resource-instance-list"
801 {
802 continue;
803 }
804
805 if let Some(data) = tile.data.get_mut(&node.nodeid) {
807 let mut ctx = ProcessResourceContext {
808 cache: &mut cache,
809 enrich_relationships,
810 source_resource_id: &resource_id,
811 result: &mut result,
812 };
813 self.process_resource_instance_data(data, node, &tile_id, &mut ctx);
814 }
815 }
816 }
817 }
818
819 if !cache.is_empty() {
821 if let Some(ref existing_json) = resource.cache {
822 if let Ok(existing) =
823 serde_json::from_value::<ResourceCache>(existing_json.clone())
824 {
825 for (tile_id, node_entries) in existing {
827 let tile_cache = cache.entry(tile_id).or_default();
828 for (node_id, entry) in node_entries {
829 tile_cache.entry(node_id).or_insert(entry);
830 }
831 }
832 }
833 }
834 resource.cache = serde_json::to_value(&cache).ok();
835 }
836 }
837
838 if recompute_descriptors {
840 let indexed = super::static_graph::IndexedGraph::new(graph.clone());
841 for resource in resources.iter_mut() {
842 let tiles = resource.tiles.as_deref().unwrap_or(&[]);
843 let cache: Option<ResourceCache> = resource
844 .cache
845 .as_ref()
846 .and_then(|v| serde_json::from_value(v.clone()).ok());
847 let descriptors = indexed.build_descriptors_with_diagnostics(
848 tiles,
849 &mut Vec::new(),
850 cache.as_ref(),
851 );
852 if let Some(ref name) = descriptors.name {
853 if !name.is_empty() {
854 resource.resourceinstance.name = name.clone();
855 }
856 }
857 resource.resourceinstance.descriptors = descriptors;
858 }
859 }
860
861 if strict && result.has_unknown_references() {
862 let msgs = result.error_messages();
863 return Err(format!("Unknown resource references:\n{}", msgs.join("\n")));
864 }
865
866 Ok(result)
867 }
868
869 fn process_resource_instance_data(
871 &self,
872 data: &mut serde_json::Value,
873 node: &super::StaticNode,
874 tile_id: &str,
875 ctx: &mut ProcessResourceContext<'_>,
876 ) {
877 let is_list = node.datatype == "resource-instance-list";
878
879 let mut list_entries: Vec<RelatedResourceEntry> = Vec::new();
881
882 if let Some(arr) = data.as_array_mut() {
884 for entry in arr.iter_mut() {
885 if let Some(resource_id) = entry.get("resourceId").and_then(|r| r.as_str()) {
886 if let Some(resource_entry) = self.resources.get(resource_id) {
888 let model_class_name = crate::get_graph(resource_entry.graph_id())
890 .and_then(|g| g.get_model_class_name());
891
892 let related_entry = RelatedResourceEntry::from_resource_entry(
893 resource_entry,
894 model_class_name.as_deref(),
895 );
896
897 if is_list {
898 list_entries.push(related_entry);
900 } else {
901 let tile_cache = ctx.cache.entry(tile_id.to_string()).or_default();
903 tile_cache
904 .insert(node.nodeid.clone(), CacheEntry::Single(related_entry));
905 }
906
907 if ctx.enrich_relationships {
909 self.enrich_entry_with_relationship(entry, resource_entry, node);
910 }
911 } else {
912 ctx.result.unknown_references.push(UnknownReference {
914 source_resource_id: ctx.source_resource_id.to_string(),
915 node_id: node.nodeid.clone(),
916 node_alias: node.alias.clone(),
917 referenced_id: resource_id.to_string(),
918 });
919 }
920 }
921 }
922 }
923
924 if is_list && !list_entries.is_empty() {
926 let tile_cache = ctx.cache.entry(tile_id.to_string()).or_default();
927 tile_cache.insert(
928 node.nodeid.clone(),
929 CacheEntry::List(RelatedResourceListEntry {
930 datatype: "resource-instance-list".to_string(),
931 entries: list_entries,
932 meta: None,
933 }),
934 );
935 }
936 }
937
938 fn enrich_entry_with_relationship(
941 &self,
942 entry: &mut serde_json::Value,
943 target_entry: &ResourceEntry,
944 node: &super::StaticNode,
945 ) {
946 if entry.get("ontologyProperty").is_some() {
948 return;
949 }
950
951 let graphs = match node.config.get("graphs").and_then(|g| g.as_array()) {
953 Some(g) => g,
954 None => return,
955 };
956
957 let target_graph_id = target_entry.graph_id();
959 let graph_config = graphs.iter().find(|g| {
960 g.get("graphid")
961 .and_then(|id| id.as_str())
962 .map(|id| id == target_graph_id)
963 .unwrap_or(false)
964 });
965
966 let graph_config = match graph_config {
967 Some(g) => g,
968 None => return, };
970
971 let use_ontology = graph_config
973 .get("useOntologyRelationship")
974 .and_then(|v| v.as_bool())
975 .unwrap_or(false);
976
977 let (ont_key, inv_key) = if use_ontology {
978 ("ontologyProperty", "inverseOntologyProperty")
979 } else {
980 ("relationshipConcept", "inverseRelationshipConcept")
981 };
982
983 if let Some(prop) = graph_config.get(ont_key).and_then(|v| v.as_str()) {
985 if !prop.is_empty() {
986 entry["ontologyProperty"] = serde_json::json!(prop);
987 }
988 }
989 if let Some(prop) = graph_config.get(inv_key).and_then(|v| v.as_str()) {
990 if !prop.is_empty() {
991 entry["inverseOntologyProperty"] = serde_json::json!(prop);
992 }
993 }
994 }
995
996 pub fn get_node_values_index(
1009 &self,
1010 graph: &super::StaticGraph,
1011 node_identifier: &str,
1012 ) -> Result<HashMap<String, Vec<serde_json::Value>>, String> {
1013 let node = graph
1015 .nodes
1016 .iter()
1017 .find(|n| n.alias.as_deref() == Some(node_identifier) || n.nodeid == node_identifier)
1018 .ok_or_else(|| {
1019 format!(
1020 "Node '{}' not found in graph {}",
1021 node_identifier, graph.graphid
1022 )
1023 })?;
1024
1025 let node_id = &node.nodeid;
1026 let nodegroup_id = node
1027 .nodegroup_id
1028 .as_ref()
1029 .ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_identifier))?;
1030
1031 let mut index: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
1032
1033 for (_, resource) in self.iter_full() {
1034 if resource.resourceinstance.graph_id != graph.graphid {
1036 continue;
1037 }
1038
1039 let resource_id = &resource.resourceinstance.resourceinstanceid;
1040
1041 if let Some(ref tiles) = resource.tiles {
1043 for tile in tiles {
1044 if tile.nodegroup_id.as_str() == nodegroup_id {
1045 if let Some(value) = tile.data.get(node_id) {
1046 index
1047 .entry(resource_id.clone())
1048 .or_default()
1049 .push(value.clone());
1050 }
1051 }
1052 }
1053 }
1054 }
1055
1056 Ok(index)
1057 }
1058
1059 pub fn get_value_to_resources_index(
1074 &self,
1075 graph: &super::StaticGraph,
1076 node_identifier: &str,
1077 flatten_localized: bool,
1078 ) -> Result<HashMap<String, Vec<String>>, String> {
1079 self.get_value_to_resources_index_with_context(
1080 graph,
1081 node_identifier,
1082 flatten_localized,
1083 None,
1084 )
1085 }
1086
1087 pub fn get_value_to_resources_index_with_context(
1100 &self,
1101 graph: &super::StaticGraph,
1102 node_identifier: &str,
1103 flatten_localized: bool,
1104 ctx: Option<&crate::type_serialization::SerializationContext>,
1105 ) -> Result<HashMap<String, Vec<String>>, String> {
1106 use crate::node_config::NodeConfigManager;
1107 use crate::type_serialization::{
1108 serialize_value, SerializationContext, SerializationOptions,
1109 };
1110
1111 let node = graph
1113 .nodes
1114 .iter()
1115 .find(|n| n.alias.as_deref() == Some(node_identifier) || n.nodeid == node_identifier)
1116 .ok_or_else(|| {
1117 format!(
1118 "Node '{}' not found in graph {}",
1119 node_identifier, graph.graphid
1120 )
1121 })?;
1122
1123 let node_id = &node.nodeid;
1124 let datatype = &node.datatype;
1125 let nodegroup_id = node
1126 .nodegroup_id
1127 .as_ref()
1128 .ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_identifier))?;
1129
1130 let mut ncm = NodeConfigManager::new();
1132 ncm.build_from_graph(graph);
1133 let node_config = ncm.get(node_id);
1134
1135 let language = if flatten_localized { "en" } else { "" };
1136 let opts = SerializationOptions::display(language);
1137
1138 let empty_ctx = SerializationContext::empty();
1139 let base_ctx = ctx.unwrap_or(&empty_ctx);
1140 let ser_ctx = SerializationContext {
1141 node_config,
1142 external_resolver: base_ctx.external_resolver,
1143 resource_resolver: base_ctx.resource_resolver,
1144 extension_registry: base_ctx.extension_registry,
1145 };
1146
1147 let mut index: HashMap<String, Vec<String>> = HashMap::new();
1148
1149 for (_, resource) in self.iter_full() {
1150 if resource.resourceinstance.graph_id != graph.graphid {
1152 continue;
1153 }
1154
1155 let resource_id = &resource.resourceinstance.resourceinstanceid;
1156
1157 if let Some(ref tiles) = resource.tiles {
1159 for tile in tiles {
1160 if tile.nodegroup_id.as_str() == nodegroup_id {
1161 if let Some(value) = tile.data.get(node_id) {
1162 let result = serialize_value(datatype, value, &opts, Some(&ser_ctx));
1163 if result.is_error() {
1164 continue;
1165 }
1166 let keys = extract_display_keys(&result.value);
1167 for k in keys {
1168 index.entry(k).or_default().push(resource_id.clone());
1169 }
1170 }
1171 }
1172 }
1173 }
1174 }
1175
1176 Ok(index)
1177 }
1178
1179 #[allow(clippy::too_many_arguments)]
1196 pub fn get_filtered_tile_values(
1197 &self,
1198 graph: &super::StaticGraph,
1199 filter_node: &str,
1200 filter_values: &[&str],
1201 extract_node: &str,
1202 flatten_localized: bool,
1203 ctx: Option<&crate::type_serialization::SerializationContext>,
1204 required_scope: Option<&str>,
1205 ) -> Result<Vec<serde_json::Value>, String> {
1206 use crate::node_config::NodeConfigManager;
1207 use crate::type_serialization::{
1208 serialize_value, SerializationContext, SerializationOptions,
1209 };
1210
1211 let find_node = |identifier: &str| {
1212 graph
1213 .nodes
1214 .iter()
1215 .find(|n| n.alias.as_deref() == Some(identifier) || n.nodeid == identifier)
1216 .ok_or_else(|| {
1217 format!("Node '{}' not found in graph {}", identifier, graph.graphid)
1218 })
1219 };
1220
1221 let filter = find_node(filter_node)?;
1222 let extract = find_node(extract_node)?;
1223
1224 let filter_node_id = &filter.nodeid;
1225 let filter_datatype = &filter.datatype;
1226 let filter_nodegroup_id = filter
1227 .nodegroup_id
1228 .as_ref()
1229 .ok_or_else(|| format!("Node '{}' has no nodegroup_id", filter_node))?;
1230
1231 let extract_node_id = &extract.nodeid;
1232 let extract_nodegroup_id = extract
1233 .nodegroup_id
1234 .as_ref()
1235 .ok_or_else(|| format!("Node '{}' has no nodegroup_id", extract_node))?;
1236
1237 if filter_nodegroup_id != extract_nodegroup_id {
1238 return Err(format!(
1239 "Filter node '{}' (nodegroup {}) and extract node '{}' (nodegroup {}) are not in the same nodegroup",
1240 filter_node, filter_nodegroup_id, extract_node, extract_nodegroup_id
1241 ));
1242 }
1243
1244 let mut ncm = NodeConfigManager::new();
1245 ncm.build_from_graph(graph);
1246 let node_config = ncm.get(filter_node_id);
1247
1248 let language = if flatten_localized { "en" } else { "" };
1249 let opts = SerializationOptions::display(language);
1250
1251 let empty_ctx = SerializationContext::empty();
1252 let base_ctx = ctx.unwrap_or(&empty_ctx);
1253 let ser_ctx = SerializationContext {
1254 node_config,
1255 external_resolver: base_ctx.external_resolver,
1256 resource_resolver: base_ctx.resource_resolver,
1257 extension_registry: base_ctx.extension_registry,
1258 };
1259
1260 let mut results: Vec<serde_json::Value> = Vec::new();
1261
1262 for (_, resource) in self.iter_full() {
1263 if resource.resourceinstance.graph_id != graph.graphid {
1264 continue;
1265 }
1266
1267 if let Some(scope) = required_scope {
1269 let has_scope = resource
1270 .scopes
1271 .as_ref()
1272 .and_then(|s| s.as_array())
1273 .map(|arr| arr.iter().any(|v| v.as_str() == Some(scope)))
1274 .unwrap_or(false);
1275 if !has_scope {
1276 continue;
1277 }
1278 }
1279
1280 if let Some(ref tiles) = resource.tiles {
1281 for tile in tiles {
1282 if tile.nodegroup_id.as_str() != filter_nodegroup_id.as_str() {
1283 continue;
1284 }
1285
1286 let matches = if let Some(filter_value) = tile.data.get(filter_node_id) {
1288 let result =
1289 serialize_value(filter_datatype, filter_value, &opts, Some(&ser_ctx));
1290 if result.is_error() {
1291 false
1292 } else {
1293 let keys = extract_display_keys(&result.value);
1294 keys.iter().any(|display| {
1295 let tags: Vec<&str> =
1296 display.split(',').map(|t| t.trim()).collect();
1297 filter_values.iter().any(|fv| tags.contains(fv))
1298 })
1299 }
1300 } else {
1301 false
1302 };
1303
1304 if matches {
1305 if let Some(extract_value) = tile.data.get(extract_node_id) {
1306 results.push(extract_value.clone());
1307 }
1308 }
1309 }
1310 }
1311 }
1312
1313 Ok(results)
1314 }
1315}
1316
1317fn extract_display_keys(value: &serde_json::Value) -> Vec<String> {
1326 match value {
1327 serde_json::Value::String(s) => vec![s.clone()],
1328 serde_json::Value::Array(arr) => arr
1329 .iter()
1330 .filter_map(|v| match v {
1331 serde_json::Value::String(s) => Some(s.clone()),
1332 serde_json::Value::Null => None,
1333 other => Some(other.to_string()),
1334 })
1335 .collect(),
1336 serde_json::Value::Null => vec![],
1337 other => vec![other.to_string()],
1338 }
1339}
1340
1341impl crate::type_serialization::ResourceDisplayResolver for StaticResourceRegistry {
1342 fn resolve_resource_display(&self, resource_id: &str, _language: &str) -> Option<String> {
1343 let summary = self.get_summary(resource_id)?;
1344 if let Some(ref descriptors) = summary.descriptors {
1346 if let Some(ref name) = descriptors.name {
1347 if !name.is_empty() {
1348 return Some(name.clone());
1349 }
1350 }
1351 }
1352 if !summary.name.is_empty() {
1353 Some(summary.name)
1354 } else {
1355 None
1356 }
1357 }
1358}
1359
1360#[derive(Clone, Debug, Serialize, Deserialize)]
1362pub struct MergeResult {
1363 pub resource: StaticResource,
1365 pub warnings: Vec<String>,
1367}
1368
1369#[derive(Clone, Debug, Serialize, Deserialize)]
1371pub struct BatchMergeResult {
1372 pub resources: Vec<StaticResource>,
1374 pub warnings: Vec<String>,
1376 #[serde(skip_serializing_if = "Option::is_none")]
1378 pub error: Option<String>,
1379}
1380
1381pub fn merge_resources(resources: Vec<StaticResource>) -> Result<MergeResult, String> {
1402 if resources.is_empty() {
1403 return Err("No resources to merge".to_string());
1404 }
1405
1406 let first_instance = resources[0].resourceinstance.clone();
1408 let resource_id = first_instance.resourceinstanceid.clone();
1409
1410 for (i, r) in resources.iter().enumerate().skip(1) {
1412 if r.resourceinstance.resourceinstanceid != resource_id {
1413 return Err(format!(
1414 "Resource ID mismatch at index {}: expected '{}', found '{}'",
1415 i, resource_id, r.resourceinstance.resourceinstanceid
1416 ));
1417 }
1418 }
1419
1420 let mut seen_tileids: HashSet<String> = HashSet::new();
1421 let mut merged_tiles: Vec<StaticTile> = Vec::new();
1422 let mut warnings: Vec<String> = Vec::new();
1423 let mut merged_metadata: HashMap<String, String> = HashMap::new();
1424 let mut merged_cache: ResourceCache = ResourceCache::default();
1425 let mut merged_scopes: Option<serde_json::Value> = None;
1426 let mut first_scopes_index: Option<usize> = None;
1427
1428 for (i, resource) in resources.into_iter().enumerate() {
1429 if let Some(tiles) = resource.tiles {
1431 for tile in tiles {
1432 if let Some(ref tileid) = tile.tileid {
1433 if seen_tileids.contains(tileid) {
1434 continue;
1435 }
1436 seen_tileids.insert(tileid.clone());
1437 }
1438 merged_tiles.push(tile);
1439 }
1440 }
1441
1442 for (key, value) in resource.metadata {
1444 if let Some(existing) = merged_metadata.get(&key) {
1445 if existing != &value {
1446 warnings.push(format!(
1447 "Metadata key '{}' has conflicting values: '{}' vs '{}' (using latter)",
1448 key, existing, value
1449 ));
1450 }
1451 }
1452 merged_metadata.insert(key, value);
1453 }
1454
1455 if let Some(scopes) = resource.scopes {
1457 match &merged_scopes {
1458 None => {
1459 merged_scopes = Some(scopes);
1460 first_scopes_index = Some(i);
1461 }
1462 Some(existing) if existing != &scopes => {
1463 warnings.push(format!(
1464 "Scopes mismatch: resource {} has different scopes than resource {} (using first)",
1465 i, first_scopes_index.unwrap_or(0)
1466 ));
1467 }
1468 _ => {}
1469 }
1470 }
1471
1472 if let Some(cache_json) = resource.cache {
1475 if let Ok(cache) = serde_json::from_value::<ResourceCache>(cache_json) {
1476 for (tile_id, node_entries) in cache {
1477 let tile_cache = merged_cache.entry(tile_id).or_default();
1478 for (node_id, entry) in node_entries {
1479 tile_cache.entry(node_id).or_insert(entry);
1481 }
1482 }
1483 }
1484 }
1485 }
1486
1487 merged_tiles.sort_by(|a, b| {
1489 let ng_cmp = a.nodegroup_id.cmp(&b.nodegroup_id);
1490 if ng_cmp != std::cmp::Ordering::Equal {
1491 return ng_cmp;
1492 }
1493 let a_sort = a.sortorder.unwrap_or(i32::MAX);
1494 let b_sort = b.sortorder.unwrap_or(i32::MAX);
1495 a_sort.cmp(&b_sort)
1496 });
1497
1498 let final_cache = if merged_cache.is_empty() {
1500 None
1501 } else {
1502 serde_json::to_value(&merged_cache).ok()
1503 };
1504
1505 Ok(MergeResult {
1506 resource: StaticResource {
1507 resourceinstance: first_instance,
1508 tiles: Some(merged_tiles),
1509 metadata: merged_metadata,
1510 cache: final_cache,
1511 scopes: merged_scopes,
1512 tiles_loaded: Some(true),
1513 },
1514 warnings,
1515 })
1516}
1517
1518pub fn parse_resources_from_json_str(json_str: &str) -> Result<Vec<StaticResource>, String> {
1527 let value: serde_json::Value =
1528 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1529
1530 match value {
1531 serde_json::Value::Array(_) => serde_json::from_value(value)
1532 .map_err(|e| format!("Failed to parse resource array: {}", e)),
1533 serde_json::Value::Object(mut map) => {
1534 if let Some(bd) = map.remove("business_data") {
1535 if let serde_json::Value::Object(mut bd_map) = bd {
1536 if let Some(resources) = bd_map.remove("resources") {
1537 serde_json::from_value(resources)
1538 .map_err(|e| format!("Failed to parse business_data.resources: {}", e))
1539 } else {
1540 Err("business_data missing 'resources' field".to_string())
1541 }
1542 } else {
1543 Err("business_data is not an object".to_string())
1544 }
1545 } else if map.contains_key("resourceinstance") {
1546 let resource: StaticResource =
1547 serde_json::from_value(serde_json::Value::Object(map))
1548 .map_err(|e| format!("Failed to parse as single resource: {}", e))?;
1549 Ok(vec![resource])
1550 } else {
1551 Err(
1552 "Unrecognized format - expected array, BusinessDataWrapper, or StaticResource"
1553 .to_string(),
1554 )
1555 }
1556 }
1557 _ => Err("Expected array or object".to_string()),
1558 }
1559}
1560
1561pub struct MergeAccumulator {
1570 accumulated: Vec<StaticResource>,
1571 warnings: Vec<String>,
1572 chunk_size: usize,
1573 strict: bool,
1574 pending: Vec<Vec<StaticResource>>,
1575 error: Option<String>,
1576}
1577
1578impl MergeAccumulator {
1579 pub fn new(chunk_size: usize, strict: bool) -> Self {
1580 Self {
1581 accumulated: Vec::new(),
1582 warnings: Vec::new(),
1583 chunk_size: if chunk_size == 0 { 10 } else { chunk_size },
1584 strict,
1585 pending: Vec::new(),
1586 error: None,
1587 }
1588 }
1589
1590 pub fn add_json(&mut self, json_str: &str) -> Result<(), String> {
1593 if let Some(ref e) = self.error {
1594 return Err(format!("Accumulator already in error state: {}", e));
1595 }
1596 let resources = parse_resources_from_json_str(json_str)?;
1597 self.pending.push(resources);
1598 if self.pending.len() >= self.chunk_size {
1599 self.flush()?;
1600 }
1601 Ok(())
1602 }
1603
1604 pub fn add_resources(&mut self, resources: Vec<StaticResource>) -> Result<(), String> {
1606 if let Some(ref e) = self.error {
1607 return Err(format!("Accumulator already in error state: {}", e));
1608 }
1609 self.pending.push(resources);
1610 if self.pending.len() >= self.chunk_size {
1611 self.flush()?;
1612 }
1613 Ok(())
1614 }
1615
1616 fn flush(&mut self) -> Result<(), String> {
1618 if self.pending.is_empty() {
1619 return Ok(());
1620 }
1621
1622 let mut batches: Vec<Vec<StaticResource>> = Vec::new();
1623 if !self.accumulated.is_empty() {
1624 batches.push(std::mem::take(&mut self.accumulated));
1625 }
1626 batches.append(&mut self.pending);
1627
1628 let result = batch_merge_resources(batches, false, self.strict);
1629
1630 self.warnings.extend(result.warnings);
1631 if let Some(error) = result.error {
1632 self.error = Some(error.clone());
1633 self.accumulated = result.resources;
1634 return Err(error);
1635 }
1636 self.accumulated = result.resources;
1637 Ok(())
1638 }
1639
1640 pub fn finish(mut self, recompute_descriptors: bool) -> BatchMergeResult {
1642 if let Err(e) = self.flush() {
1643 return BatchMergeResult {
1644 resources: self.accumulated,
1645 warnings: self.warnings,
1646 error: Some(e),
1647 };
1648 }
1649
1650 if recompute_descriptors && !self.accumulated.is_empty() {
1651 let result = batch_merge_resources(
1652 vec![std::mem::take(&mut self.accumulated)],
1653 true,
1654 self.strict,
1655 );
1656 self.warnings.extend(result.warnings);
1657 if let Some(ref error) = result.error {
1658 return BatchMergeResult {
1659 resources: result.resources,
1660 warnings: self.warnings,
1661 error: Some(error.clone()),
1662 };
1663 }
1664 self.accumulated = result.resources;
1665 }
1666
1667 BatchMergeResult {
1668 resources: self.accumulated,
1669 warnings: self.warnings,
1670 error: None,
1671 }
1672 }
1673}
1674
1675pub fn batch_merge_resources(
1697 resource_batches: Vec<Vec<StaticResource>>,
1698 recompute_descriptors: bool,
1699 strict: bool,
1700) -> BatchMergeResult {
1701 use crate::registry::get_graph;
1702 use crate::IndexedGraph;
1703 use std::collections::BTreeMap;
1704
1705 let mut grouped: BTreeMap<String, Vec<StaticResource>> = BTreeMap::new();
1707
1708 for batch in resource_batches {
1709 for resource in batch {
1710 let id = resource.resourceinstance.resourceinstanceid.clone();
1711 grouped.entry(id).or_default().push(resource);
1712 }
1713 }
1714
1715 let mut merged_resources = Vec::new();
1716 let mut all_warnings = Vec::new();
1717
1718 let mut indexed_graphs: BTreeMap<String, IndexedGraph> = BTreeMap::new();
1720
1721 for (resource_id, resources) in grouped {
1723 match merge_resources(resources) {
1724 Ok(result) => {
1725 for warning in result.warnings {
1727 all_warnings.push(format!("[{}] {}", resource_id, warning));
1728 }
1729
1730 let mut resource = result.resource;
1731 let graph_id = resource.resourceinstance.graph_id.clone();
1732
1733 if !indexed_graphs.contains_key(&graph_id) {
1735 if let Some(graph) = get_graph(&graph_id) {
1736 indexed_graphs
1737 .insert(graph_id.clone(), IndexedGraph::new((*graph).clone()));
1738 }
1739 }
1740
1741 if let Some(indexed) = indexed_graphs.get(&graph_id) {
1743 if let Some(ref mut tiles) = resource.tiles {
1744 match unify_cardinality_one_tiles(tiles, indexed, strict) {
1745 Ok(unify_warnings) => {
1746 for warning in unify_warnings {
1747 all_warnings.push(format!("[{}] {}", resource_id, warning));
1748 }
1749 }
1750 Err(e) => {
1751 all_warnings.push(format!("[{}] Unify error: {}", resource_id, e));
1752 if strict {
1753 return BatchMergeResult {
1754 resources: merged_resources,
1755 warnings: all_warnings,
1756 error: Some(format!("[{}] {}", resource_id, e)),
1757 };
1758 }
1759 }
1760 }
1761 }
1762 }
1763
1764 if recompute_descriptors {
1766 if let Some(indexed) = indexed_graphs.get(&graph_id) {
1767 let tiles = resource.tiles.as_deref().unwrap_or(&[]);
1769 let mut descriptor_warnings = Vec::new();
1770 let cache: Option<ResourceCache> = resource
1772 .cache
1773 .as_ref()
1774 .and_then(|v| serde_json::from_value(v.clone()).ok());
1775 let descriptors = indexed.build_descriptors_with_diagnostics(
1776 tiles,
1777 &mut descriptor_warnings,
1778 cache.as_ref(),
1779 );
1780
1781 for warning in descriptor_warnings {
1783 all_warnings.push(format!("[{}] Descriptor: {}", resource_id, warning));
1784 }
1785
1786 resource.resourceinstance.descriptors = descriptors.clone();
1788
1789 if let Some(ref name) = descriptors.name {
1791 if !name.is_empty() {
1792 resource.resourceinstance.name = name.clone();
1793 }
1794 }
1795 } else {
1796 all_warnings.push(format!(
1797 "[{}] Graph not found in registry for descriptor computation: {}",
1798 resource_id, graph_id
1799 ));
1800 }
1801 }
1802
1803 merged_resources.push(resource);
1804 }
1805 Err(e) => {
1806 all_warnings.push(format!("[{}] Merge error: {}", resource_id, e));
1808 }
1809 }
1810 }
1811
1812 BatchMergeResult {
1813 resources: merged_resources,
1814 warnings: all_warnings,
1815 error: None,
1816 }
1817}
1818
1819type TileDataMergeMap = HashMap<usize, Vec<(String, HashMap<String, serde_json::Value>)>>;
1821
1822pub fn unify_cardinality_one_tiles(
1838 tiles: &mut Vec<StaticTile>,
1839 indexed_graph: &crate::IndexedGraph,
1840 strict: bool,
1841) -> Result<Vec<String>, String> {
1842 use std::collections::BTreeMap;
1843
1844 let mut warnings = Vec::new();
1845
1846 let mut tiles_by_context: BTreeMap<(String, Option<String>), Vec<usize>> = BTreeMap::new();
1850 for (idx, tile) in tiles.iter().enumerate() {
1851 tiles_by_context
1852 .entry((tile.nodegroup_id.clone(), tile.parenttile_id.clone()))
1853 .or_default()
1854 .push(idx);
1855 }
1856
1857 let mut tile_redirect: HashMap<String, String> = HashMap::new();
1859 let mut tiles_to_remove: HashSet<usize> = HashSet::new();
1860 let mut data_to_merge: TileDataMergeMap = HashMap::new();
1862
1863 for ((nodegroup_id, _parent_tile_id), tile_indices) in &tiles_by_context {
1864 if tile_indices.len() <= 1 {
1865 continue; }
1867
1868 let nodegroup = match indexed_graph.graph.get_nodegroup_by_id(nodegroup_id) {
1870 Some(ng) => ng,
1871 None => continue,
1872 };
1873
1874 let is_single = nodegroup
1875 .cardinality
1876 .as_ref()
1877 .map(|c| c != "n")
1878 .unwrap_or(true);
1879
1880 if !is_single {
1881 continue; }
1883
1884 let canonical_idx = tile_indices[0];
1886 let canonical_tile_id = tiles[canonical_idx].tileid.clone();
1887
1888 for &idx in tile_indices.iter().skip(1) {
1889 let tile = &tiles[idx];
1890 let tile_id = tile
1891 .tileid
1892 .clone()
1893 .unwrap_or_else(|| format!("(index {})", idx));
1894
1895 if let Some(ref old_tile_id) = tile.tileid {
1897 if let Some(ref canon_id) = canonical_tile_id {
1898 tile_redirect.insert(old_tile_id.clone(), canon_id.clone());
1899 }
1900 }
1901
1902 if !tile.data.is_empty() {
1904 data_to_merge
1905 .entry(canonical_idx)
1906 .or_default()
1907 .push((tile_id, tile.data.clone()));
1908 }
1909
1910 tiles_to_remove.insert(idx);
1911 }
1912
1913 }
1916
1917 for (canonical_idx, sources) in data_to_merge {
1919 let canonical_tile = &mut tiles[canonical_idx];
1920 let canonical_tile_id = canonical_tile
1921 .tileid
1922 .clone()
1923 .unwrap_or_else(|| format!("(index {})", canonical_idx));
1924
1925 for (source_tile_id, source_data) in sources {
1926 for (key, value) in source_data {
1927 if let Some(existing) = canonical_tile.data.get(&key) {
1928 if existing != &value {
1929 let msg = format!(
1930 "Data conflict in nodegroup '{}': key '{}' has different values in tiles '{}' and '{}'",
1931 canonical_tile.nodegroup_id,
1932 key,
1933 canonical_tile_id,
1934 source_tile_id
1935 );
1936 if strict {
1937 return Err(msg);
1938 }
1939 warnings.push(format!("{} (keeping first)", msg));
1940 }
1941 } else {
1943 canonical_tile.data.insert(key, value);
1945 }
1946 }
1947 }
1948 }
1949
1950 for tile in tiles.iter_mut() {
1952 if let Some(ref old_parent_id) = tile.parenttile_id {
1953 if let Some(new_parent_id) = tile_redirect.get(old_parent_id) {
1954 tile.parenttile_id = Some(new_parent_id.clone());
1955 }
1956 }
1957 }
1958
1959 let mut indices: Vec<usize> = tiles_to_remove.into_iter().collect();
1961 indices.sort_by(|a, b| b.cmp(a)); for idx in indices {
1963 tiles.remove(idx);
1964 }
1965
1966 Ok(warnings)
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971 use super::*;
1972
1973 #[test]
1974 fn test_static_resource_serialization() {
1975 let resource = StaticResource {
1976 resourceinstance: StaticResourceMetadata {
1977 descriptors: StaticResourceDescriptors::default(),
1978 graph_id: "test-graph".to_string(),
1979 name: "Test".to_string(),
1980 resourceinstanceid: "test-id".to_string(),
1981 publication_id: None,
1982 principaluser_id: None,
1983 legacyid: None,
1984 graph_publication_id: None,
1985 createdtime: None,
1986 lastmodified: None,
1987 },
1988 tiles: Some(vec![]),
1989 metadata: HashMap::new(),
1990 cache: None,
1991 scopes: None,
1992 tiles_loaded: None,
1993 };
1994
1995 let json = serde_json::to_string_pretty(&resource).unwrap();
1996 println!("StaticResource JSON:\n{}", json);
1997
1998 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
2000 assert!(
2001 value.get("resourceinstance").is_some(),
2002 "Should have nested resourceinstance"
2003 );
2004 }
2005
2006 fn make_test_resource(resource_id: &str, tile_ids: &[&str]) -> StaticResource {
2007 let tiles: Vec<StaticTile> = tile_ids
2008 .iter()
2009 .map(|id| StaticTile {
2010 tileid: Some(id.to_string()),
2011 nodegroup_id: "ng1".to_string(),
2012 resourceinstance_id: resource_id.to_string(),
2013 parenttile_id: None,
2014 data: HashMap::new(),
2015 provisionaledits: None,
2016 sortorder: None,
2017 })
2018 .collect();
2019
2020 StaticResource {
2021 resourceinstance: StaticResourceMetadata {
2022 descriptors: StaticResourceDescriptors::default(),
2023 graph_id: "test-graph".to_string(),
2024 name: "Test".to_string(),
2025 resourceinstanceid: resource_id.to_string(),
2026 publication_id: None,
2027 principaluser_id: None,
2028 legacyid: None,
2029 graph_publication_id: None,
2030 createdtime: None,
2031 lastmodified: None,
2032 },
2033 tiles: Some(tiles),
2034 metadata: HashMap::new(),
2035 cache: None,
2036 scopes: None,
2037 tiles_loaded: None,
2038 }
2039 }
2040
2041 #[test]
2042 fn test_merge_resources_basic() {
2043 let r1 = make_test_resource("res-1", &["tile-a", "tile-b"]);
2044 let r2 = make_test_resource("res-1", &["tile-c", "tile-d"]);
2045
2046 let result = merge_resources(vec![r1, r2]).unwrap();
2047
2048 assert_eq!(result.resource.resourceinstance.resourceinstanceid, "res-1");
2049 let tiles = result.resource.tiles.unwrap();
2050 assert_eq!(tiles.len(), 4);
2051 assert!(result.warnings.is_empty());
2052 }
2053
2054 #[test]
2055 fn test_merge_resources_duplicate_detection() {
2056 let r1 = make_test_resource("res-1", &["tile-a", "tile-b"]);
2057 let r2 = make_test_resource("res-1", &["tile-b", "tile-c"]); let result = merge_resources(vec![r1, r2]).unwrap();
2060
2061 let tiles = result.resource.tiles.unwrap();
2062 assert_eq!(tiles.len(), 3); assert!(result.warnings.is_empty()); }
2065
2066 #[test]
2067 fn test_merge_resources_id_mismatch() {
2068 let r1 = make_test_resource("res-1", &["tile-a"]);
2069 let r2 = make_test_resource("res-2", &["tile-b"]); let result = merge_resources(vec![r1, r2]);
2072
2073 assert!(result.is_err());
2074 assert!(result.unwrap_err().contains("mismatch"));
2075 }
2076
2077 #[test]
2078 fn test_merge_resources_empty() {
2079 let result = merge_resources(vec![]);
2080
2081 assert!(result.is_err());
2082 assert!(result.unwrap_err().contains("No resources"));
2083 }
2084
2085 #[test]
2086 fn test_merge_resources_preserves_cache() {
2087 let mut r1 = make_test_resource("res-1", &["tile-a"]);
2089 let mut r2 = make_test_resource("res-1", &["tile-b"]);
2090
2091 let mut cache1: ResourceCache = HashMap::new();
2093 let mut tile_a_entries: HashMap<String, CacheEntry> = HashMap::new();
2094 tile_a_entries.insert(
2095 "node-1".to_string(),
2096 CacheEntry::Single(RelatedResourceEntry {
2097 datatype: "resource-instance".to_string(),
2098 id: "related-1".to_string(),
2099 resource_type: "TestModel".to_string(),
2100 graph_id: "graph-a".to_string(),
2101 title: Some("Related 1".to_string()),
2102 descriptors: None,
2103 meta: None,
2104 }),
2105 );
2106 tile_a_entries.insert(
2107 "node-2".to_string(),
2108 CacheEntry::Single(RelatedResourceEntry {
2109 datatype: "resource-instance".to_string(),
2110 id: "related-2".to_string(),
2111 resource_type: "TestModel".to_string(),
2112 graph_id: "graph-a".to_string(),
2113 title: Some("Related 2".to_string()),
2114 descriptors: None,
2115 meta: None,
2116 }),
2117 );
2118 cache1.insert("tile-a".to_string(), tile_a_entries);
2119 r1.cache = serde_json::to_value(&cache1).ok();
2120
2121 let mut cache2: ResourceCache = HashMap::new();
2123 let mut tile_a_entries_2: HashMap<String, CacheEntry> = HashMap::new();
2124 tile_a_entries_2.insert(
2125 "node-2".to_string(),
2126 CacheEntry::Single(RelatedResourceEntry {
2127 datatype: "resource-instance".to_string(),
2128 id: "related-2".to_string(),
2129 resource_type: "TestModel".to_string(),
2130 graph_id: "graph-a".to_string(),
2131 title: Some("Related 2 - Different Name".to_string()), descriptors: None,
2133 meta: None,
2134 }),
2135 );
2136 cache2.insert("tile-a".to_string(), tile_a_entries_2);
2137
2138 let mut tile_b_entries: HashMap<String, CacheEntry> = HashMap::new();
2139 tile_b_entries.insert(
2140 "node-3".to_string(),
2141 CacheEntry::Single(RelatedResourceEntry {
2142 datatype: "resource-instance".to_string(),
2143 id: "related-3".to_string(),
2144 resource_type: "OtherModel".to_string(),
2145 graph_id: "graph-b".to_string(),
2146 title: Some("Related 3".to_string()),
2147 descriptors: None,
2148 meta: None,
2149 }),
2150 );
2151 cache2.insert("tile-b".to_string(), tile_b_entries);
2152 r2.cache = serde_json::to_value(&cache2).ok();
2153
2154 let result = merge_resources(vec![r1, r2]).unwrap();
2155
2156 assert!(result.resource.cache.is_some(), "Cache should be present");
2158
2159 let merged_cache: ResourceCache =
2160 serde_json::from_value(result.resource.cache.unwrap()).unwrap();
2161
2162 assert_eq!(merged_cache.len(), 2);
2164 assert!(merged_cache.contains_key("tile-a"));
2165 assert!(merged_cache.contains_key("tile-b"));
2166
2167 let tile_a = merged_cache.get("tile-a").unwrap();
2169 assert_eq!(tile_a.len(), 2);
2170 assert!(tile_a.contains_key("node-1"));
2171 assert!(tile_a.contains_key("node-2"));
2172
2173 if let CacheEntry::Single(entry) = tile_a.get("node-2").unwrap() {
2175 assert_eq!(entry.title.as_deref(), Some("Related 2"));
2176 } else {
2177 panic!("Expected CacheEntry::Single");
2178 }
2179
2180 let tile_b = merged_cache.get("tile-b").unwrap();
2182 assert_eq!(tile_b.len(), 1);
2183 assert!(tile_b.contains_key("node-3"));
2184 }
2185}