1use std::collections::HashMap;
4
5use crate::data::{HCol, HDict, HGrid};
6use crate::filter::{matches_with_ns, parse_filter};
7use crate::kinds::{HRef, Kind};
8use crate::ontology::{DefNamespace, ValidationIssue};
9
10use super::adjacency::RefAdjacency;
11use super::bitmap::TagBitmapIndex;
12use super::changelog::{DiffOp, GraphDiff};
13use super::query_planner;
14
15#[derive(Debug, thiserror::Error)]
17pub enum GraphError {
18 #[error("entity missing 'id' tag")]
19 MissingId,
20 #[error("entity id must be a Ref")]
21 InvalidId,
22 #[error("entity already exists: {0}")]
23 DuplicateRef(String),
24 #[error("entity not found: {0}")]
25 NotFound(String),
26 #[error("filter error: {0}")]
27 Filter(String),
28}
29
30pub struct EntityGraph {
32 entities: HashMap<String, HDict>,
34 id_map: HashMap<String, usize>,
36 reverse_id: HashMap<usize, String>,
38 next_id: usize,
40 tag_index: TagBitmapIndex,
42 adjacency: RefAdjacency,
44 namespace: Option<DefNamespace>,
46 version: u64,
48 changelog: Vec<GraphDiff>,
50}
51
52const MAX_CHANGELOG: usize = 10_000;
53
54impl EntityGraph {
55 pub fn new() -> Self {
57 Self {
58 entities: HashMap::new(),
59 id_map: HashMap::new(),
60 reverse_id: HashMap::new(),
61 next_id: 0,
62 tag_index: TagBitmapIndex::new(),
63 adjacency: RefAdjacency::new(),
64 namespace: None,
65 version: 0,
66 changelog: Vec::new(),
67 }
68 }
69
70 pub fn with_namespace(ns: DefNamespace) -> Self {
72 Self {
73 namespace: Some(ns),
74 ..Self::new()
75 }
76 }
77
78 pub fn add(&mut self, entity: HDict) -> Result<String, GraphError> {
85 let ref_val = extract_ref_val(&entity)?;
86
87 if self.entities.contains_key(&ref_val) {
88 return Err(GraphError::DuplicateRef(ref_val));
89 }
90
91 let eid = self.next_id;
92 self.next_id += 1;
93
94 self.id_map.insert(ref_val.clone(), eid);
95 self.reverse_id.insert(eid, ref_val.clone());
96
97 self.index_tags(eid, &entity);
99 self.index_refs(eid, &entity);
100
101 let entity_for_log = entity.clone();
103 self.entities.insert(ref_val.clone(), entity);
104
105 self.version += 1;
106 self.push_changelog(GraphDiff {
107 version: self.version,
108 op: DiffOp::Add,
109 ref_val: ref_val.clone(),
110 old: None,
111 new: Some(entity_for_log),
112 });
113
114 Ok(ref_val)
115 }
116
117 pub fn get(&self, ref_val: &str) -> Option<&HDict> {
119 self.entities.get(ref_val)
120 }
121
122 pub fn update(&mut self, ref_val: &str, changes: HDict) -> Result<(), GraphError> {
127 let eid = *self
128 .id_map
129 .get(ref_val)
130 .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
131
132 let mut old_entity = self
134 .entities
135 .remove(ref_val)
136 .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
137
138 self.remove_indexing(eid, &old_entity);
140
141 let old_snapshot = old_entity.clone();
143 old_entity.merge(&changes);
144
145 self.index_tags(eid, &old_entity);
147 self.index_refs(eid, &old_entity);
148
149 let updated_for_log = old_entity.clone();
151 self.entities.insert(ref_val.to_string(), old_entity);
152
153 self.version += 1;
154 self.push_changelog(GraphDiff {
155 version: self.version,
156 op: DiffOp::Update,
157 ref_val: ref_val.to_string(),
158 old: Some(old_snapshot),
159 new: Some(updated_for_log),
160 });
161
162 Ok(())
163 }
164
165 pub fn remove(&mut self, ref_val: &str) -> Result<HDict, GraphError> {
167 let eid = self
168 .id_map
169 .remove(ref_val)
170 .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
171
172 self.reverse_id.remove(&eid);
173
174 let entity = self
175 .entities
176 .remove(ref_val)
177 .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
178
179 self.remove_indexing(eid, &entity);
180
181 self.version += 1;
182 self.push_changelog(GraphDiff {
183 version: self.version,
184 op: DiffOp::Remove,
185 ref_val: ref_val.to_string(),
186 old: Some(entity.clone()),
187 new: None,
188 });
189
190 Ok(entity)
191 }
192
193 pub fn read(&self, filter_expr: &str, limit: usize) -> Result<HGrid, GraphError> {
197 let results = self.read_all(filter_expr, limit)?;
198
199 if results.is_empty() {
200 return Ok(HGrid::new());
201 }
202
203 let mut col_set: Vec<String> = Vec::new();
205 let mut seen = std::collections::HashSet::new();
206 for entity in &results {
207 for name in entity.tag_names() {
208 if seen.insert(name.to_string()) {
209 col_set.push(name.to_string());
210 }
211 }
212 }
213 col_set.sort();
214 let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
215 let rows: Vec<HDict> = results.into_iter().cloned().collect();
216
217 Ok(HGrid::from_parts(HDict::new(), cols, rows))
218 }
219
220 pub fn read_all(&self, filter_expr: &str, limit: usize) -> Result<Vec<&HDict>, GraphError> {
222 let ast = parse_filter(filter_expr).map_err(|e| GraphError::Filter(e.to_string()))?;
223 let effective_limit = if limit == 0 { usize::MAX } else { limit };
224
225 let max_id = self.next_id;
227 let candidates = query_planner::bitmap_candidates(&ast, &self.tag_index, max_id);
228
229 let resolver = |r: &HRef| -> Option<HDict> { self.entities.get(&r.val).cloned() };
231 let ns = self.namespace.as_ref();
232
233 let mut results = Vec::new();
234
235 if let Some(ref bitmap) = candidates {
236 for eid in TagBitmapIndex::iter_set_bits(bitmap) {
238 if results.len() >= effective_limit {
239 break;
240 }
241 if let Some(ref_val) = self.reverse_id.get(&eid)
242 && let Some(entity) = self.entities.get(ref_val)
243 && matches_with_ns(&ast, entity, Some(&resolver), ns)
244 {
245 results.push(entity);
246 }
247 }
248 } else {
249 for entity in self.entities.values() {
251 if results.len() >= effective_limit {
252 break;
253 }
254 if matches_with_ns(&ast, entity, Some(&resolver), ns) {
255 results.push(entity);
256 }
257 }
258 }
259
260 Ok(results)
261 }
262
263 pub fn refs_from(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
267 match self.id_map.get(ref_val) {
268 Some(&eid) => self.adjacency.targets_from(eid, ref_type),
269 None => Vec::new(),
270 }
271 }
272
273 pub fn refs_to(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
275 self.adjacency
276 .sources_to(ref_val, ref_type)
277 .iter()
278 .filter_map(|eid| self.reverse_id.get(eid).cloned())
279 .collect()
280 }
281
282 pub fn entities_fitting(&self, spec_name: &str) -> Vec<&HDict> {
288 match &self.namespace {
289 Some(ns) => self
290 .entities
291 .values()
292 .filter(|e| ns.fits(e, spec_name))
293 .collect(),
294 None => Vec::new(),
295 }
296 }
297
298 pub fn validate(&self) -> Vec<ValidationIssue> {
302 let mut issues: Vec<ValidationIssue> = match &self.namespace {
303 Some(ns) => self
304 .entities
305 .values()
306 .flat_map(|e| ns.validate_entity(e))
307 .collect(),
308 None => Vec::new(),
309 };
310
311 for entity in self.entities.values() {
314 let entity_ref = entity.id().map(|r| r.val.as_str());
315 for (name, val) in entity.iter() {
316 if name == "id" {
317 continue;
318 }
319 if let Kind::Ref(r) = val
320 && !self.entities.contains_key(&r.val)
321 {
322 issues.push(ValidationIssue {
323 entity: entity_ref.map(|s| s.to_string()),
324 issue_type: "dangling_ref".to_string(),
325 detail: format!(
326 "tag '{}' references '{}' which does not exist in the graph",
327 name, r.val
328 ),
329 });
330 }
331 }
332 }
333
334 issues
335 }
336
337 pub fn to_grid(&self, filter_expr: &str) -> Result<HGrid, GraphError> {
344 if filter_expr.is_empty() {
345 let entities: Vec<&HDict> = self.entities.values().collect();
346 return Ok(Self::entities_to_grid(&entities));
347 }
348 self.read(filter_expr, 0)
349 }
350
351 fn entities_to_grid(entities: &[&HDict]) -> HGrid {
353 if entities.is_empty() {
354 return HGrid::new();
355 }
356
357 let mut col_set: Vec<String> = Vec::new();
358 let mut seen = std::collections::HashSet::new();
359 for entity in entities {
360 for name in entity.tag_names() {
361 if seen.insert(name.to_string()) {
362 col_set.push(name.to_string());
363 }
364 }
365 }
366 col_set.sort();
367 let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
368 let rows: Vec<HDict> = entities.iter().map(|e| (*e).clone()).collect();
369
370 HGrid::from_parts(HDict::new(), cols, rows)
371 }
372
373 pub fn from_grid(grid: &HGrid, namespace: Option<DefNamespace>) -> Result<Self, GraphError> {
377 let mut graph = match namespace {
378 Some(ns) => Self::with_namespace(ns),
379 None => Self::new(),
380 };
381 for row in &grid.rows {
382 if row.id().is_some() {
383 graph.add(row.clone())?;
384 }
385 }
386 Ok(graph)
387 }
388
389 pub fn changes_since(&self, version: u64) -> &[GraphDiff] {
393 match self
394 .changelog
395 .binary_search_by_key(&(version + 1), |d| d.version)
396 {
397 Ok(idx) => &self.changelog[idx..],
398 Err(idx) => &self.changelog[idx..],
399 }
400 }
401
402 pub fn version(&self) -> u64 {
404 self.version
405 }
406
407 pub fn len(&self) -> usize {
411 self.entities.len()
412 }
413
414 pub fn is_empty(&self) -> bool {
416 self.entities.is_empty()
417 }
418
419 pub fn contains(&self, ref_val: &str) -> bool {
421 self.entities.contains_key(ref_val)
422 }
423
424 fn index_tags(&mut self, entity_id: usize, entity: &HDict) {
428 let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
429 self.tag_index.add(entity_id, &tags);
430 }
431
432 fn index_refs(&mut self, entity_id: usize, entity: &HDict) {
434 for (name, val) in entity.iter() {
435 if let Kind::Ref(r) = val {
436 if name != "id" {
439 self.adjacency.add(entity_id, name, &r.val);
440 }
441 }
442 }
443 }
444
445 fn remove_indexing(&mut self, entity_id: usize, entity: &HDict) {
447 let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
448 self.tag_index.remove(entity_id, &tags);
449 self.adjacency.remove(entity_id);
450 }
451
452 fn push_changelog(&mut self, diff: GraphDiff) {
454 self.changelog.push(diff);
455 if self.changelog.len() > MAX_CHANGELOG {
456 self.changelog.drain(..self.changelog.len() - MAX_CHANGELOG);
457 }
458 }
459}
460
461impl Default for EntityGraph {
462 fn default() -> Self {
463 Self::new()
464 }
465}
466
467fn extract_ref_val(entity: &HDict) -> Result<String, GraphError> {
469 match entity.get("id") {
470 Some(Kind::Ref(r)) => Ok(r.val.clone()),
471 Some(_) => Err(GraphError::InvalidId),
472 None => Err(GraphError::MissingId),
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::kinds::Number;
480
481 fn make_site(id: &str) -> HDict {
482 let mut d = HDict::new();
483 d.set("id", Kind::Ref(HRef::from_val(id)));
484 d.set("site", Kind::Marker);
485 d.set("dis", Kind::Str(format!("Site {id}")));
486 d.set(
487 "area",
488 Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
489 );
490 d
491 }
492
493 fn make_equip(id: &str, site_ref: &str) -> HDict {
494 let mut d = HDict::new();
495 d.set("id", Kind::Ref(HRef::from_val(id)));
496 d.set("equip", Kind::Marker);
497 d.set("dis", Kind::Str(format!("Equip {id}")));
498 d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
499 d
500 }
501
502 fn make_point(id: &str, equip_ref: &str) -> HDict {
503 let mut d = HDict::new();
504 d.set("id", Kind::Ref(HRef::from_val(id)));
505 d.set("point", Kind::Marker);
506 d.set("sensor", Kind::Marker);
507 d.set("temp", Kind::Marker);
508 d.set("dis", Kind::Str(format!("Point {id}")));
509 d.set("equipRef", Kind::Ref(HRef::from_val(equip_ref)));
510 d.set(
511 "curVal",
512 Kind::Number(Number::new(72.5, Some("\u{00b0}F".into()))),
513 );
514 d
515 }
516
517 #[test]
520 fn add_entity_with_valid_id() {
521 let mut g = EntityGraph::new();
522 let result = g.add(make_site("site-1"));
523 assert!(result.is_ok());
524 assert_eq!(result.unwrap(), "site-1");
525 assert_eq!(g.len(), 1);
526 }
527
528 #[test]
529 fn add_entity_missing_id_fails() {
530 let mut g = EntityGraph::new();
531 let entity = HDict::new();
532 let err = g.add(entity).unwrap_err();
533 assert!(matches!(err, GraphError::MissingId));
534 }
535
536 #[test]
537 fn add_entity_non_ref_id_fails() {
538 let mut g = EntityGraph::new();
539 let mut entity = HDict::new();
540 entity.set("id", Kind::Str("not-a-ref".into()));
541 let err = g.add(entity).unwrap_err();
542 assert!(matches!(err, GraphError::InvalidId));
543 }
544
545 #[test]
546 fn add_duplicate_ref_fails() {
547 let mut g = EntityGraph::new();
548 g.add(make_site("site-1")).unwrap();
549 let err = g.add(make_site("site-1")).unwrap_err();
550 assert!(matches!(err, GraphError::DuplicateRef(_)));
551 }
552
553 #[test]
556 fn get_existing_entity() {
557 let mut g = EntityGraph::new();
558 g.add(make_site("site-1")).unwrap();
559 let entity = g.get("site-1").unwrap();
560 assert!(entity.has("site"));
561 assert_eq!(entity.get("dis"), Some(&Kind::Str("Site site-1".into())));
562 }
563
564 #[test]
565 fn get_missing_entity_returns_none() {
566 let g = EntityGraph::new();
567 assert!(g.get("nonexistent").is_none());
568 }
569
570 #[test]
573 fn update_merges_changes() {
574 let mut g = EntityGraph::new();
575 g.add(make_site("site-1")).unwrap();
576
577 let mut changes = HDict::new();
578 changes.set("dis", Kind::Str("Updated Site".into()));
579 changes.set("geoCity", Kind::Str("Richmond".into()));
580 g.update("site-1", changes).unwrap();
581
582 let entity = g.get("site-1").unwrap();
583 assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
584 assert_eq!(entity.get("geoCity"), Some(&Kind::Str("Richmond".into())));
585 assert!(entity.has("site")); }
587
588 #[test]
589 fn update_missing_entity_fails() {
590 let mut g = EntityGraph::new();
591 let err = g.update("nonexistent", HDict::new()).unwrap_err();
592 assert!(matches!(err, GraphError::NotFound(_)));
593 }
594
595 #[test]
598 fn remove_entity() {
599 let mut g = EntityGraph::new();
600 g.add(make_site("site-1")).unwrap();
601 let removed = g.remove("site-1").unwrap();
602 assert!(removed.has("site"));
603 assert!(g.get("site-1").is_none());
604 assert_eq!(g.len(), 0);
605 }
606
607 #[test]
608 fn remove_missing_entity_fails() {
609 let mut g = EntityGraph::new();
610 let err = g.remove("nonexistent").unwrap_err();
611 assert!(matches!(err, GraphError::NotFound(_)));
612 }
613
614 #[test]
617 fn version_increments_on_mutations() {
618 let mut g = EntityGraph::new();
619 assert_eq!(g.version(), 0);
620
621 g.add(make_site("site-1")).unwrap();
622 assert_eq!(g.version(), 1);
623
624 g.update("site-1", HDict::new()).unwrap();
625 assert_eq!(g.version(), 2);
626
627 g.remove("site-1").unwrap();
628 assert_eq!(g.version(), 3);
629 }
630
631 #[test]
632 fn changelog_records_add_update_remove() {
633 let mut g = EntityGraph::new();
634 g.add(make_site("site-1")).unwrap();
635 g.update("site-1", HDict::new()).unwrap();
636 g.remove("site-1").unwrap();
637
638 let changes = g.changes_since(0);
639 assert_eq!(changes.len(), 3);
640 assert_eq!(changes[0].op, DiffOp::Add);
641 assert_eq!(changes[0].ref_val, "site-1");
642 assert!(changes[0].old.is_none());
643 assert!(changes[0].new.is_some());
644
645 assert_eq!(changes[1].op, DiffOp::Update);
646 assert!(changes[1].old.is_some());
647 assert!(changes[1].new.is_some());
648
649 assert_eq!(changes[2].op, DiffOp::Remove);
650 assert!(changes[2].old.is_some());
651 assert!(changes[2].new.is_none());
652 }
653
654 #[test]
655 fn changes_since_returns_subset() {
656 let mut g = EntityGraph::new();
657 g.add(make_site("site-1")).unwrap(); g.add(make_site("site-2")).unwrap(); g.add(make_site("site-3")).unwrap(); let since_v2 = g.changes_since(2);
662 assert_eq!(since_v2.len(), 1);
663 assert_eq!(since_v2[0].ref_val, "site-3");
664 }
665
666 #[test]
669 fn contains_check() {
670 let mut g = EntityGraph::new();
671 g.add(make_site("site-1")).unwrap();
672 assert!(g.contains("site-1"));
673 assert!(!g.contains("site-2"));
674 }
675
676 #[test]
677 fn len_and_is_empty() {
678 let mut g = EntityGraph::new();
679 assert!(g.is_empty());
680 assert_eq!(g.len(), 0);
681
682 g.add(make_site("site-1")).unwrap();
683 assert!(!g.is_empty());
684 assert_eq!(g.len(), 1);
685 }
686
687 #[test]
690 fn read_with_simple_has_filter() {
691 let mut g = EntityGraph::new();
692 g.add(make_site("site-1")).unwrap();
693 g.add(make_equip("equip-1", "site-1")).unwrap();
694
695 let results = g.read_all("site", 0).unwrap();
696 assert_eq!(results.len(), 1);
697 assert!(results[0].has("site"));
698 }
699
700 #[test]
701 fn read_with_comparison_filter() {
702 let mut g = EntityGraph::new();
703 g.add(make_point("pt-1", "equip-1")).unwrap();
704
705 let results = g.read_all("curVal > 70\u{00b0}F", 0).unwrap();
706 assert_eq!(results.len(), 1);
707 }
708
709 #[test]
710 fn read_with_and_filter() {
711 let mut g = EntityGraph::new();
712 g.add(make_point("pt-1", "equip-1")).unwrap();
713 g.add(make_equip("equip-1", "site-1")).unwrap();
714
715 let results = g.read_all("point and sensor", 0).unwrap();
716 assert_eq!(results.len(), 1);
717 }
718
719 #[test]
720 fn read_with_or_filter() {
721 let mut g = EntityGraph::new();
722 g.add(make_site("site-1")).unwrap();
723 g.add(make_equip("equip-1", "site-1")).unwrap();
724
725 let results = g.read_all("site or equip", 0).unwrap();
726 assert_eq!(results.len(), 2);
727 }
728
729 #[test]
730 fn read_limit_parameter_works() {
731 let mut g = EntityGraph::new();
732 g.add(make_site("site-1")).unwrap();
733 g.add(make_site("site-2")).unwrap();
734 g.add(make_site("site-3")).unwrap();
735
736 let results = g.read_all("site", 2).unwrap();
737 assert_eq!(results.len(), 2);
738 }
739
740 #[test]
741 fn read_returns_grid() {
742 let mut g = EntityGraph::new();
743 g.add(make_site("site-1")).unwrap();
744 g.add(make_site("site-2")).unwrap();
745
746 let grid = g.read("site", 0).unwrap();
747 assert_eq!(grid.len(), 2);
748 assert!(grid.col("site").is_some());
749 assert!(grid.col("id").is_some());
750 }
751
752 #[test]
753 fn read_invalid_filter() {
754 let g = EntityGraph::new();
755 let err = g.read("!!!", 0).unwrap_err();
756 assert!(matches!(err, GraphError::Filter(_)));
757 }
758
759 #[test]
762 fn refs_from_returns_targets() {
763 let mut g = EntityGraph::new();
764 g.add(make_site("site-1")).unwrap();
765 g.add(make_equip("equip-1", "site-1")).unwrap();
766
767 let targets = g.refs_from("equip-1", None);
768 assert_eq!(targets, vec!["site-1".to_string()]);
769 }
770
771 #[test]
772 fn refs_to_returns_sources() {
773 let mut g = EntityGraph::new();
774 g.add(make_site("site-1")).unwrap();
775 g.add(make_equip("equip-1", "site-1")).unwrap();
776 g.add(make_equip("equip-2", "site-1")).unwrap();
777
778 let mut sources = g.refs_to("site-1", None);
779 sources.sort();
780 assert_eq!(sources.len(), 2);
781 }
782
783 #[test]
784 fn type_filtered_ref_queries() {
785 let mut g = EntityGraph::new();
786 g.add(make_site("site-1")).unwrap();
787 g.add(make_equip("equip-1", "site-1")).unwrap();
788
789 let targets = g.refs_from("equip-1", Some("siteRef"));
790 assert_eq!(targets, vec!["site-1".to_string()]);
791
792 let targets = g.refs_from("equip-1", Some("equipRef"));
793 assert!(targets.is_empty());
794 }
795
796 #[test]
797 fn refs_from_nonexistent_entity() {
798 let g = EntityGraph::new();
799 assert!(g.refs_from("nonexistent", None).is_empty());
800 }
801
802 #[test]
803 fn refs_to_nonexistent_entity() {
804 let g = EntityGraph::new();
805 assert!(g.refs_to("nonexistent", None).is_empty());
806 }
807
808 #[test]
811 fn from_grid_round_trip() {
812 let mut g = EntityGraph::new();
813 g.add(make_site("site-1")).unwrap();
814 g.add(make_equip("equip-1", "site-1")).unwrap();
815
816 let grid = g.to_grid("site or equip").unwrap();
817 assert_eq!(grid.len(), 2);
818
819 let g2 = EntityGraph::from_grid(&grid, None).unwrap();
820 assert_eq!(g2.len(), 2);
821 assert!(g2.contains("site-1"));
822 assert!(g2.contains("equip-1"));
823 }
824
825 #[test]
826 fn to_grid_empty_result() {
827 let g = EntityGraph::new();
828 let grid = g.to_grid("site").unwrap();
829 assert!(grid.is_empty());
830 }
831
832 #[test]
835 fn update_reindexes_tags() {
836 let mut g = EntityGraph::new();
837 g.add(make_site("site-1")).unwrap();
838
839 assert_eq!(g.read_all("site", 0).unwrap().len(), 1);
841
842 let mut changes = HDict::new();
844 changes.set("site", Kind::Remove);
845 g.update("site-1", changes).unwrap();
846
847 assert_eq!(g.read_all("site", 0).unwrap().len(), 0);
849 }
850
851 #[test]
852 fn update_reindexes_refs() {
853 let mut g = EntityGraph::new();
854 g.add(make_site("site-1")).unwrap();
855 g.add(make_site("site-2")).unwrap();
856 g.add(make_equip("equip-1", "site-1")).unwrap();
857
858 assert_eq!(g.refs_from("equip-1", None), vec!["site-1".to_string()]);
860
861 let mut changes = HDict::new();
863 changes.set("siteRef", Kind::Ref(HRef::from_val("site-2")));
864 g.update("equip-1", changes).unwrap();
865
866 assert_eq!(g.refs_from("equip-1", None), vec!["site-2".to_string()]);
867 assert!(g.refs_to("site-1", None).is_empty());
868 }
869
870 #[test]
873 fn validate_detects_dangling_refs() {
874 let mut g = EntityGraph::new();
875 g.add(make_site("site-1")).unwrap();
876 g.add(make_equip("equip-1", "site-1")).unwrap();
878 g.add(make_equip("equip-2", "site-999")).unwrap();
880
881 let issues = g.validate();
882 assert!(!issues.is_empty());
883
884 let dangling: Vec<_> = issues
885 .iter()
886 .filter(|i| i.issue_type == "dangling_ref")
887 .collect();
888 assert_eq!(dangling.len(), 1);
889 assert_eq!(dangling[0].entity.as_deref(), Some("equip-2"));
890 assert!(dangling[0].detail.contains("site-999"));
891 assert!(dangling[0].detail.contains("siteRef"));
892 }
893
894 #[test]
897 fn to_grid_empty_filter_exports_all() {
898 let mut g = EntityGraph::new();
899 g.add(make_site("site-1")).unwrap();
900 g.add(make_equip("equip-1", "site-1")).unwrap();
901 g.add(make_point("pt-1", "equip-1")).unwrap();
902
903 let grid = g.to_grid("").unwrap();
904 assert_eq!(grid.len(), 3);
905 assert!(grid.col("id").is_some());
906 }
907
908 #[test]
911 fn changelog_bounded_to_max_size() {
912 let mut graph = EntityGraph::new();
913 for i in 0..12_000 {
915 let mut d = HDict::new();
916 d.set("id", Kind::Ref(HRef::from_val(format!("e{i}"))));
917 d.set("dis", Kind::Str(format!("Entity {i}")));
918 graph.add(d).unwrap();
919 }
920 assert!(graph.changes_since(0).len() <= 10_000);
922 assert!(graph.changes_since(11_999).len() <= 1);
924 }
925
926 #[test]
927 fn from_grid_skips_rows_without_id() {
928 let cols = vec![HCol::new("id"), HCol::new("dis"), HCol::new("site")];
929
930 let mut row_with_id = HDict::new();
931 row_with_id.set("id", Kind::Ref(HRef::from_val("site-1")));
932 row_with_id.set("site", Kind::Marker);
933 row_with_id.set("dis", Kind::Str("Has ID".into()));
934
935 let mut row_bad_id = HDict::new();
937 row_bad_id.set("id", Kind::Str("not-a-ref".into()));
938 row_bad_id.set("dis", Kind::Str("Bad ID".into()));
939
940 let mut row_no_id = HDict::new();
942 row_no_id.set("dis", Kind::Str("No ID".into()));
943
944 let grid = HGrid::from_parts(HDict::new(), cols, vec![row_with_id, row_bad_id, row_no_id]);
945 let g = EntityGraph::from_grid(&grid, None).unwrap();
946
947 assert_eq!(g.len(), 1);
948 assert!(g.contains("site-1"));
949 }
950}