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 pub fn all(&self) -> Vec<&HDict> {
426 self.entities.values().collect()
427 }
428
429 fn index_tags(&mut self, entity_id: usize, entity: &HDict) {
433 let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
434 self.tag_index.add(entity_id, &tags);
435 }
436
437 fn index_refs(&mut self, entity_id: usize, entity: &HDict) {
439 for (name, val) in entity.iter() {
440 if let Kind::Ref(r) = val {
441 if name != "id" {
444 self.adjacency.add(entity_id, name, &r.val);
445 }
446 }
447 }
448 }
449
450 fn remove_indexing(&mut self, entity_id: usize, entity: &HDict) {
452 let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
453 self.tag_index.remove(entity_id, &tags);
454 self.adjacency.remove(entity_id);
455 }
456
457 fn push_changelog(&mut self, diff: GraphDiff) {
459 self.changelog.push(diff);
460 if self.changelog.len() > MAX_CHANGELOG {
461 self.changelog.drain(..self.changelog.len() - MAX_CHANGELOG);
462 }
463 }
464}
465
466impl Default for EntityGraph {
467 fn default() -> Self {
468 Self::new()
469 }
470}
471
472fn extract_ref_val(entity: &HDict) -> Result<String, GraphError> {
474 match entity.get("id") {
475 Some(Kind::Ref(r)) => Ok(r.val.clone()),
476 Some(_) => Err(GraphError::InvalidId),
477 None => Err(GraphError::MissingId),
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::kinds::Number;
485
486 fn make_site(id: &str) -> HDict {
487 let mut d = HDict::new();
488 d.set("id", Kind::Ref(HRef::from_val(id)));
489 d.set("site", Kind::Marker);
490 d.set("dis", Kind::Str(format!("Site {id}")));
491 d.set(
492 "area",
493 Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
494 );
495 d
496 }
497
498 fn make_equip(id: &str, site_ref: &str) -> HDict {
499 let mut d = HDict::new();
500 d.set("id", Kind::Ref(HRef::from_val(id)));
501 d.set("equip", Kind::Marker);
502 d.set("dis", Kind::Str(format!("Equip {id}")));
503 d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
504 d
505 }
506
507 fn make_point(id: &str, equip_ref: &str) -> HDict {
508 let mut d = HDict::new();
509 d.set("id", Kind::Ref(HRef::from_val(id)));
510 d.set("point", Kind::Marker);
511 d.set("sensor", Kind::Marker);
512 d.set("temp", Kind::Marker);
513 d.set("dis", Kind::Str(format!("Point {id}")));
514 d.set("equipRef", Kind::Ref(HRef::from_val(equip_ref)));
515 d.set(
516 "curVal",
517 Kind::Number(Number::new(72.5, Some("\u{00b0}F".into()))),
518 );
519 d
520 }
521
522 #[test]
525 fn add_entity_with_valid_id() {
526 let mut g = EntityGraph::new();
527 let result = g.add(make_site("site-1"));
528 assert!(result.is_ok());
529 assert_eq!(result.unwrap(), "site-1");
530 assert_eq!(g.len(), 1);
531 }
532
533 #[test]
534 fn add_entity_missing_id_fails() {
535 let mut g = EntityGraph::new();
536 let entity = HDict::new();
537 let err = g.add(entity).unwrap_err();
538 assert!(matches!(err, GraphError::MissingId));
539 }
540
541 #[test]
542 fn add_entity_non_ref_id_fails() {
543 let mut g = EntityGraph::new();
544 let mut entity = HDict::new();
545 entity.set("id", Kind::Str("not-a-ref".into()));
546 let err = g.add(entity).unwrap_err();
547 assert!(matches!(err, GraphError::InvalidId));
548 }
549
550 #[test]
551 fn add_duplicate_ref_fails() {
552 let mut g = EntityGraph::new();
553 g.add(make_site("site-1")).unwrap();
554 let err = g.add(make_site("site-1")).unwrap_err();
555 assert!(matches!(err, GraphError::DuplicateRef(_)));
556 }
557
558 #[test]
561 fn get_existing_entity() {
562 let mut g = EntityGraph::new();
563 g.add(make_site("site-1")).unwrap();
564 let entity = g.get("site-1").unwrap();
565 assert!(entity.has("site"));
566 assert_eq!(entity.get("dis"), Some(&Kind::Str("Site site-1".into())));
567 }
568
569 #[test]
570 fn get_missing_entity_returns_none() {
571 let g = EntityGraph::new();
572 assert!(g.get("nonexistent").is_none());
573 }
574
575 #[test]
578 fn update_merges_changes() {
579 let mut g = EntityGraph::new();
580 g.add(make_site("site-1")).unwrap();
581
582 let mut changes = HDict::new();
583 changes.set("dis", Kind::Str("Updated Site".into()));
584 changes.set("geoCity", Kind::Str("Richmond".into()));
585 g.update("site-1", changes).unwrap();
586
587 let entity = g.get("site-1").unwrap();
588 assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
589 assert_eq!(entity.get("geoCity"), Some(&Kind::Str("Richmond".into())));
590 assert!(entity.has("site")); }
592
593 #[test]
594 fn update_missing_entity_fails() {
595 let mut g = EntityGraph::new();
596 let err = g.update("nonexistent", HDict::new()).unwrap_err();
597 assert!(matches!(err, GraphError::NotFound(_)));
598 }
599
600 #[test]
603 fn remove_entity() {
604 let mut g = EntityGraph::new();
605 g.add(make_site("site-1")).unwrap();
606 let removed = g.remove("site-1").unwrap();
607 assert!(removed.has("site"));
608 assert!(g.get("site-1").is_none());
609 assert_eq!(g.len(), 0);
610 }
611
612 #[test]
613 fn remove_missing_entity_fails() {
614 let mut g = EntityGraph::new();
615 let err = g.remove("nonexistent").unwrap_err();
616 assert!(matches!(err, GraphError::NotFound(_)));
617 }
618
619 #[test]
622 fn version_increments_on_mutations() {
623 let mut g = EntityGraph::new();
624 assert_eq!(g.version(), 0);
625
626 g.add(make_site("site-1")).unwrap();
627 assert_eq!(g.version(), 1);
628
629 g.update("site-1", HDict::new()).unwrap();
630 assert_eq!(g.version(), 2);
631
632 g.remove("site-1").unwrap();
633 assert_eq!(g.version(), 3);
634 }
635
636 #[test]
637 fn changelog_records_add_update_remove() {
638 let mut g = EntityGraph::new();
639 g.add(make_site("site-1")).unwrap();
640 g.update("site-1", HDict::new()).unwrap();
641 g.remove("site-1").unwrap();
642
643 let changes = g.changes_since(0);
644 assert_eq!(changes.len(), 3);
645 assert_eq!(changes[0].op, DiffOp::Add);
646 assert_eq!(changes[0].ref_val, "site-1");
647 assert!(changes[0].old.is_none());
648 assert!(changes[0].new.is_some());
649
650 assert_eq!(changes[1].op, DiffOp::Update);
651 assert!(changes[1].old.is_some());
652 assert!(changes[1].new.is_some());
653
654 assert_eq!(changes[2].op, DiffOp::Remove);
655 assert!(changes[2].old.is_some());
656 assert!(changes[2].new.is_none());
657 }
658
659 #[test]
660 fn changes_since_returns_subset() {
661 let mut g = EntityGraph::new();
662 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);
667 assert_eq!(since_v2.len(), 1);
668 assert_eq!(since_v2[0].ref_val, "site-3");
669 }
670
671 #[test]
674 fn contains_check() {
675 let mut g = EntityGraph::new();
676 g.add(make_site("site-1")).unwrap();
677 assert!(g.contains("site-1"));
678 assert!(!g.contains("site-2"));
679 }
680
681 #[test]
682 fn len_and_is_empty() {
683 let mut g = EntityGraph::new();
684 assert!(g.is_empty());
685 assert_eq!(g.len(), 0);
686
687 g.add(make_site("site-1")).unwrap();
688 assert!(!g.is_empty());
689 assert_eq!(g.len(), 1);
690 }
691
692 #[test]
695 fn read_with_simple_has_filter() {
696 let mut g = EntityGraph::new();
697 g.add(make_site("site-1")).unwrap();
698 g.add(make_equip("equip-1", "site-1")).unwrap();
699
700 let results = g.read_all("site", 0).unwrap();
701 assert_eq!(results.len(), 1);
702 assert!(results[0].has("site"));
703 }
704
705 #[test]
706 fn read_with_comparison_filter() {
707 let mut g = EntityGraph::new();
708 g.add(make_point("pt-1", "equip-1")).unwrap();
709
710 let results = g.read_all("curVal > 70\u{00b0}F", 0).unwrap();
711 assert_eq!(results.len(), 1);
712 }
713
714 #[test]
715 fn read_with_and_filter() {
716 let mut g = EntityGraph::new();
717 g.add(make_point("pt-1", "equip-1")).unwrap();
718 g.add(make_equip("equip-1", "site-1")).unwrap();
719
720 let results = g.read_all("point and sensor", 0).unwrap();
721 assert_eq!(results.len(), 1);
722 }
723
724 #[test]
725 fn read_with_or_filter() {
726 let mut g = EntityGraph::new();
727 g.add(make_site("site-1")).unwrap();
728 g.add(make_equip("equip-1", "site-1")).unwrap();
729
730 let results = g.read_all("site or equip", 0).unwrap();
731 assert_eq!(results.len(), 2);
732 }
733
734 #[test]
735 fn read_limit_parameter_works() {
736 let mut g = EntityGraph::new();
737 g.add(make_site("site-1")).unwrap();
738 g.add(make_site("site-2")).unwrap();
739 g.add(make_site("site-3")).unwrap();
740
741 let results = g.read_all("site", 2).unwrap();
742 assert_eq!(results.len(), 2);
743 }
744
745 #[test]
746 fn read_returns_grid() {
747 let mut g = EntityGraph::new();
748 g.add(make_site("site-1")).unwrap();
749 g.add(make_site("site-2")).unwrap();
750
751 let grid = g.read("site", 0).unwrap();
752 assert_eq!(grid.len(), 2);
753 assert!(grid.col("site").is_some());
754 assert!(grid.col("id").is_some());
755 }
756
757 #[test]
758 fn read_invalid_filter() {
759 let g = EntityGraph::new();
760 let err = g.read("!!!", 0).unwrap_err();
761 assert!(matches!(err, GraphError::Filter(_)));
762 }
763
764 #[test]
767 fn refs_from_returns_targets() {
768 let mut g = EntityGraph::new();
769 g.add(make_site("site-1")).unwrap();
770 g.add(make_equip("equip-1", "site-1")).unwrap();
771
772 let targets = g.refs_from("equip-1", None);
773 assert_eq!(targets, vec!["site-1".to_string()]);
774 }
775
776 #[test]
777 fn refs_to_returns_sources() {
778 let mut g = EntityGraph::new();
779 g.add(make_site("site-1")).unwrap();
780 g.add(make_equip("equip-1", "site-1")).unwrap();
781 g.add(make_equip("equip-2", "site-1")).unwrap();
782
783 let mut sources = g.refs_to("site-1", None);
784 sources.sort();
785 assert_eq!(sources.len(), 2);
786 }
787
788 #[test]
789 fn type_filtered_ref_queries() {
790 let mut g = EntityGraph::new();
791 g.add(make_site("site-1")).unwrap();
792 g.add(make_equip("equip-1", "site-1")).unwrap();
793
794 let targets = g.refs_from("equip-1", Some("siteRef"));
795 assert_eq!(targets, vec!["site-1".to_string()]);
796
797 let targets = g.refs_from("equip-1", Some("equipRef"));
798 assert!(targets.is_empty());
799 }
800
801 #[test]
802 fn refs_from_nonexistent_entity() {
803 let g = EntityGraph::new();
804 assert!(g.refs_from("nonexistent", None).is_empty());
805 }
806
807 #[test]
808 fn refs_to_nonexistent_entity() {
809 let g = EntityGraph::new();
810 assert!(g.refs_to("nonexistent", None).is_empty());
811 }
812
813 #[test]
816 fn from_grid_round_trip() {
817 let mut g = EntityGraph::new();
818 g.add(make_site("site-1")).unwrap();
819 g.add(make_equip("equip-1", "site-1")).unwrap();
820
821 let grid = g.to_grid("site or equip").unwrap();
822 assert_eq!(grid.len(), 2);
823
824 let g2 = EntityGraph::from_grid(&grid, None).unwrap();
825 assert_eq!(g2.len(), 2);
826 assert!(g2.contains("site-1"));
827 assert!(g2.contains("equip-1"));
828 }
829
830 #[test]
831 fn to_grid_empty_result() {
832 let g = EntityGraph::new();
833 let grid = g.to_grid("site").unwrap();
834 assert!(grid.is_empty());
835 }
836
837 #[test]
840 fn update_reindexes_tags() {
841 let mut g = EntityGraph::new();
842 g.add(make_site("site-1")).unwrap();
843
844 assert_eq!(g.read_all("site", 0).unwrap().len(), 1);
846
847 let mut changes = HDict::new();
849 changes.set("site", Kind::Remove);
850 g.update("site-1", changes).unwrap();
851
852 assert_eq!(g.read_all("site", 0).unwrap().len(), 0);
854 }
855
856 #[test]
857 fn update_reindexes_refs() {
858 let mut g = EntityGraph::new();
859 g.add(make_site("site-1")).unwrap();
860 g.add(make_site("site-2")).unwrap();
861 g.add(make_equip("equip-1", "site-1")).unwrap();
862
863 assert_eq!(g.refs_from("equip-1", None), vec!["site-1".to_string()]);
865
866 let mut changes = HDict::new();
868 changes.set("siteRef", Kind::Ref(HRef::from_val("site-2")));
869 g.update("equip-1", changes).unwrap();
870
871 assert_eq!(g.refs_from("equip-1", None), vec!["site-2".to_string()]);
872 assert!(g.refs_to("site-1", None).is_empty());
873 }
874
875 #[test]
878 fn validate_detects_dangling_refs() {
879 let mut g = EntityGraph::new();
880 g.add(make_site("site-1")).unwrap();
881 g.add(make_equip("equip-1", "site-1")).unwrap();
883 g.add(make_equip("equip-2", "site-999")).unwrap();
885
886 let issues = g.validate();
887 assert!(!issues.is_empty());
888
889 let dangling: Vec<_> = issues
890 .iter()
891 .filter(|i| i.issue_type == "dangling_ref")
892 .collect();
893 assert_eq!(dangling.len(), 1);
894 assert_eq!(dangling[0].entity.as_deref(), Some("equip-2"));
895 assert!(dangling[0].detail.contains("site-999"));
896 assert!(dangling[0].detail.contains("siteRef"));
897 }
898
899 #[test]
902 fn to_grid_empty_filter_exports_all() {
903 let mut g = EntityGraph::new();
904 g.add(make_site("site-1")).unwrap();
905 g.add(make_equip("equip-1", "site-1")).unwrap();
906 g.add(make_point("pt-1", "equip-1")).unwrap();
907
908 let grid = g.to_grid("").unwrap();
909 assert_eq!(grid.len(), 3);
910 assert!(grid.col("id").is_some());
911 }
912
913 #[test]
916 fn changelog_bounded_to_max_size() {
917 let mut graph = EntityGraph::new();
918 for i in 0..12_000 {
920 let mut d = HDict::new();
921 d.set("id", Kind::Ref(HRef::from_val(format!("e{i}"))));
922 d.set("dis", Kind::Str(format!("Entity {i}")));
923 graph.add(d).unwrap();
924 }
925 assert!(graph.changes_since(0).len() <= 10_000);
927 assert!(graph.changes_since(11_999).len() <= 1);
929 }
930
931 #[test]
932 fn from_grid_skips_rows_without_id() {
933 let cols = vec![HCol::new("id"), HCol::new("dis"), HCol::new("site")];
934
935 let mut row_with_id = HDict::new();
936 row_with_id.set("id", Kind::Ref(HRef::from_val("site-1")));
937 row_with_id.set("site", Kind::Marker);
938 row_with_id.set("dis", Kind::Str("Has ID".into()));
939
940 let mut row_bad_id = HDict::new();
942 row_bad_id.set("id", Kind::Str("not-a-ref".into()));
943 row_bad_id.set("dis", Kind::Str("Bad ID".into()));
944
945 let mut row_no_id = HDict::new();
947 row_no_id.set("dis", Kind::Str("No ID".into()));
948
949 let grid = HGrid::from_parts(HDict::new(), cols, vec![row_with_id, row_bad_id, row_no_id]);
950 let g = EntityGraph::from_grid(&grid, None).unwrap();
951
952 assert_eq!(g.len(), 1);
953 assert!(g.contains("site-1"));
954 }
955}