1use crate::entity_api::SubclassEdge;
4use crate::OntologyCatalog;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashSet, VecDeque};
7use strixonomy_core::{
8 limits::{MAX_GRAPH_EDGES, MAX_GRAPH_NODES},
9 Entity, EntityKind, AXIOM_KIND_CLASS_ASSERTION, AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS,
10 AXIOM_KIND_OBJECT_PROPERTY_ASSERTION, AXIOM_KIND_RANGE, AXIOM_KIND_SUB_CLASS_OF,
11 AXIOM_KIND_SUB_DATA_PROPERTY_OF, AXIOM_KIND_SUB_OBJECT_PROPERTY_OF,
12};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16#[error("{0}")]
17pub struct GraphError(String);
18
19impl From<String> for GraphError {
20 fn from(value: String) -> Self {
21 Self(value)
22 }
23}
24
25pub type GraphResult<T> = std::result::Result<T, GraphError>;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum GraphKind {
30 Class,
31 Property,
32 ObjectProperty,
33 DataProperty,
34 Individual,
35 Import,
36 Dependency,
37 Neighborhood,
38 QueryResult,
39 RefactorPreview,
40}
41
42impl GraphKind {
43 pub fn parse(s: &str) -> Option<Self> {
44 match s {
45 "class" => Some(Self::Class),
46 "property" => Some(Self::Property),
47 "object_property" => Some(Self::ObjectProperty),
48 "data_property" => Some(Self::DataProperty),
49 "individual" => Some(Self::Individual),
50 "import" => Some(Self::Import),
51 "dependency" => Some(Self::Dependency),
52 "neighborhood" => Some(Self::Neighborhood),
53 "query_result" => Some(Self::QueryResult),
54 "refactor_preview" => Some(Self::RefactorPreview),
55 _ => None,
56 }
57 }
58
59 pub fn as_str(&self) -> &'static str {
60 match self {
61 Self::Class => "class",
62 Self::Property => "property",
63 Self::ObjectProperty => "object_property",
64 Self::DataProperty => "data_property",
65 Self::Individual => "individual",
66 Self::Import => "import",
67 Self::Dependency => "dependency",
68 Self::Neighborhood => "neighborhood",
69 Self::QueryResult => "query_result",
70 Self::RefactorPreview => "refactor_preview",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Default, Deserialize, Serialize)]
76pub struct GraphFilters {
77 pub ontology_iri: Option<String>,
78 #[serde(default)]
79 pub hide_deprecated: bool,
80 #[serde(default)]
81 pub entity_kinds: Vec<String>,
82 #[serde(default)]
83 pub namespaces: Vec<String>,
84 #[serde(default)]
85 pub relationship_kinds: Vec<String>,
86 pub search_text: Option<String>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
90pub struct GraphRequest {
91 pub graph_kind: String,
92 pub root_iri: Option<String>,
93 #[serde(default)]
95 pub root_iris: Vec<String>,
96 #[serde(default = "default_depth")]
97 pub depth: u32,
98 #[serde(default)]
99 pub include_inferred: bool,
100 #[serde(default)]
101 pub filters: GraphFilters,
102}
103
104fn default_depth() -> u32 {
105 2
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GraphNode {
110 pub id: String,
111 pub label: String,
112 pub kind: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub namespace: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub ontology_iri: Option<String>,
117 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
118 pub deprecated: bool,
119 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120 pub unsatisfiable: bool,
121 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122 pub equivalent: bool,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct GraphEdge {
127 pub source: String,
128 pub target: String,
129 pub kind: String,
130 pub inferred: bool,
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct GraphPayload {
135 pub nodes: Vec<GraphNode>,
136 pub edges: Vec<GraphEdge>,
137 pub truncated: bool,
138 pub graph_kind: String,
139}
140
141pub struct GraphBuilder<'a> {
142 catalog: &'a OntologyCatalog,
143 inferred_edges: Option<&'a [SubclassEdge]>,
144}
145
146impl<'a> GraphBuilder<'a> {
147 pub fn new(catalog: &'a OntologyCatalog) -> Self {
148 Self { catalog, inferred_edges: None }
149 }
150
151 pub fn with_inferred_edges(mut self, edges: &'a [SubclassEdge]) -> Self {
152 self.inferred_edges = Some(edges);
153 self
154 }
155
156 pub fn build(&self, request: &GraphRequest) -> GraphResult<GraphPayload> {
157 let kind = GraphKind::parse(&request.graph_kind)
158 .ok_or_else(|| GraphError(format!("unknown graph_kind: {}", request.graph_kind)))?;
159 let depth = request.depth.clamp(1, 5);
160
161 let mut payload = match kind {
162 GraphKind::Class => self.build_class_graph(request),
163 GraphKind::Property => self.build_property_graph(request, None),
164 GraphKind::ObjectProperty => {
165 self.build_property_hierarchy_graph(request, EntityKind::ObjectProperty)
166 }
167 GraphKind::DataProperty => {
168 self.build_property_hierarchy_graph(request, EntityKind::DataProperty)
169 }
170 GraphKind::Individual => {
171 let roots = collect_roots(request);
172 if roots.is_empty() {
173 return Err(GraphError(
174 "individual graph requires root_iri or root_iris".to_string(),
175 ));
176 }
177 self.build_individual_graph(request, &roots, depth)
178 }
179 GraphKind::Import => self.build_import_graph(request, false),
180 GraphKind::Dependency => self.build_import_graph(request, true),
181 GraphKind::Neighborhood => {
182 let root = request.root_iri.as_deref().ok_or_else(|| {
183 GraphError("neighborhood graph requires root_iri".to_string())
184 })?;
185 self.build_neighborhood_graph(request, root, depth)
186 }
187 GraphKind::QueryResult | GraphKind::RefactorPreview => {
188 let roots = collect_roots(request);
189 if roots.is_empty() {
190 return Err(GraphError(format!(
191 "{} graph requires root_iri or root_iris",
192 kind.as_str()
193 )));
194 }
195 self.build_seeded_result_graph(request, &roots, depth, kind)
196 }
197 }?;
198
199 payload.graph_kind = kind.as_str().to_string();
200 Ok(payload)
201 }
202
203 fn entity_allowed(&self, iri: &str, filters: &GraphFilters) -> bool {
204 let Some(entity) = self.catalog.find_entity(iri) else {
205 if filters.ontology_iri.is_some() {
208 return false;
209 }
210 if !filters.entity_kinds.is_empty() || !filters.namespaces.is_empty() {
211 return false;
212 }
213 if let Some(ref q) = filters.search_text {
214 let q = q.trim().to_lowercase();
215 if !q.is_empty()
216 && !iri.to_lowercase().contains(&q)
217 && !short_name(iri).to_lowercase().contains(&q)
218 {
219 return false;
220 }
221 }
222 return !filters.hide_deprecated;
223 };
224 if filters.hide_deprecated && entity.deprecated {
225 return false;
226 }
227 if let Some(ref ont) = filters.ontology_iri {
228 if entity.ontology_id != *ont {
229 return false;
230 }
231 }
232 if !filters.entity_kinds.is_empty() {
233 let kind = entity.kind.as_str();
234 if !filters.entity_kinds.iter().any(|k| k == kind) {
235 return false;
236 }
237 }
238 if !filters.namespaces.is_empty() {
239 let ns = namespace_of(iri);
240 if !filters.namespaces.iter().any(|n| n == &ns || iri.starts_with(n)) {
241 return false;
242 }
243 }
244 if let Some(ref q) = filters.search_text {
245 let q = q.trim().to_lowercase();
246 if !q.is_empty() {
247 let label_hit = entity.labels.iter().any(|l| l.to_lowercase().contains(&q));
248 let iri_hit = entity.iri.to_lowercase().contains(&q)
249 || entity.short_name.to_lowercase().contains(&q);
250 if !label_hit && !iri_hit {
251 return false;
252 }
253 }
254 }
255 true
256 }
257
258 fn relationship_allowed(&self, kind: &str, filters: &GraphFilters) -> bool {
259 if filters.relationship_kinds.is_empty() {
260 return true;
261 }
262 filters.relationship_kinds.iter().any(|k| k == kind)
263 }
264
265 fn add_node_for(
266 &self,
267 nodes: &mut Vec<GraphNode>,
268 node_ids: &mut HashSet<String>,
269 truncated: &mut bool,
270 iri: &str,
271 ) {
272 if node_ids.contains(iri) {
273 return;
274 }
275 if nodes.len() >= MAX_GRAPH_NODES {
276 *truncated = true;
277 return;
278 }
279 node_ids.insert(iri.to_string());
280 nodes.push(self.make_node(iri));
281 }
282
283 fn make_node(&self, iri: &str) -> GraphNode {
284 if let Some(entity) = self.catalog.find_entity(iri) {
285 return graph_node_from_entity(entity);
286 }
287 GraphNode {
288 id: iri.to_string(),
289 label: short_name(iri),
290 kind: "other".to_string(),
291 namespace: Some(namespace_of(iri)),
292 ontology_iri: None,
293 deprecated: false,
294 unsatisfiable: false,
295 equivalent: false,
296 }
297 }
298
299 fn add_edge(
300 &self,
301 edges: &mut Vec<GraphEdge>,
302 truncated: &mut bool,
303 filters: &GraphFilters,
304 edge: GraphEdge,
305 ) {
306 if !self.relationship_allowed(&edge.kind, filters) {
307 return;
308 }
309 if edges.len() >= MAX_GRAPH_EDGES {
310 *truncated = true;
311 return;
312 }
313 edges.push(edge);
314 }
315
316 fn build_class_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
317 let mut nodes = Vec::new();
318 let mut edges = Vec::new();
319 let mut node_ids = HashSet::new();
320 let mut truncated = false;
321
322 let hierarchy = self.catalog.class_hierarchy();
323 for edge in &hierarchy.edges {
324 if !self.entity_allowed(&edge.child, &request.filters)
325 || !self.entity_allowed(&edge.parent, &request.filters)
326 {
327 continue;
328 }
329 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.child);
330 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.parent);
331 self.add_edge(
332 &mut edges,
333 &mut truncated,
334 &request.filters,
335 GraphEdge {
336 source: edge.child.clone(),
337 target: edge.parent.clone(),
338 kind: "sub_class_of".to_string(),
339 inferred: false,
340 },
341 );
342 }
343
344 if request.include_inferred {
345 if let Some(inferred) = self.inferred_edges {
346 for edge in inferred {
347 if !self.entity_allowed(&edge.child, &request.filters)
348 || !self.entity_allowed(&edge.parent, &request.filters)
349 {
350 continue;
351 }
352 let is_new = !hierarchy
353 .edges
354 .iter()
355 .any(|e| e.child == edge.child && e.parent == edge.parent);
356 if !is_new {
357 continue;
358 }
359 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.child);
360 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &edge.parent);
361 self.add_edge(
362 &mut edges,
363 &mut truncated,
364 &request.filters,
365 GraphEdge {
366 source: edge.child.clone(),
367 target: edge.parent.clone(),
368 kind: "sub_class_of".to_string(),
369 inferred: true,
370 },
371 );
372 }
373 }
374 }
375
376 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
377 }
378
379 fn build_property_graph(
380 &self,
381 request: &GraphRequest,
382 kind_filter: Option<EntityKind>,
383 ) -> Result<GraphPayload, String> {
384 let mut nodes = Vec::new();
385 let mut edges = Vec::new();
386 let mut node_ids = HashSet::new();
387 let mut truncated = false;
388
389 for entity in &self.catalog.data().entities {
390 let matches_kind = match kind_filter {
391 Some(k) => entity.kind == k,
392 None => {
393 entity.kind == EntityKind::ObjectProperty
394 || entity.kind == EntityKind::DataProperty
395 }
396 };
397 if !matches_kind {
398 continue;
399 }
400 if !self.entity_allowed(&entity.iri, &request.filters) {
401 continue;
402 }
403 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &entity.iri);
404 }
405
406 for axiom in &self.catalog.data().axioms {
407 let edge_kind = match axiom.axiom_kind.as_str() {
408 AXIOM_KIND_DOMAIN => "domain",
409 AXIOM_KIND_RANGE => "range",
410 _ => continue,
411 };
412 let Some(prop) = self.catalog.find_entity(&axiom.subject) else {
413 continue;
414 };
415 let matches_kind = match kind_filter {
416 Some(k) => prop.kind == k,
417 None => {
418 prop.kind == EntityKind::ObjectProperty || prop.kind == EntityKind::DataProperty
419 }
420 };
421 if !matches_kind {
422 continue;
423 }
424 if !self.entity_allowed(&axiom.subject, &request.filters) {
425 continue;
426 }
427 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, &axiom.object);
428 self.add_edge(
429 &mut edges,
430 &mut truncated,
431 &request.filters,
432 GraphEdge {
433 source: axiom.subject.clone(),
434 target: axiom.object.clone(),
435 kind: edge_kind.to_string(),
436 inferred: false,
437 },
438 );
439 }
440
441 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
442 }
443
444 fn build_property_hierarchy_graph(
445 &self,
446 request: &GraphRequest,
447 prop_kind: EntityKind,
448 ) -> Result<GraphPayload, String> {
449 let mut payload = self.build_property_graph(request, Some(prop_kind))?;
450 let axiom_kind = match prop_kind {
451 EntityKind::ObjectProperty => AXIOM_KIND_SUB_OBJECT_PROPERTY_OF,
452 EntityKind::DataProperty => AXIOM_KIND_SUB_DATA_PROPERTY_OF,
453 _ => return Ok(payload),
454 };
455 let mut node_ids: HashSet<String> = payload.nodes.iter().map(|n| n.id.clone()).collect();
456 let mut truncated = payload.truncated;
457
458 for axiom in &self.catalog.data().axioms {
459 if axiom.axiom_kind != axiom_kind {
460 continue;
461 }
462 if !self.entity_allowed(&axiom.subject, &request.filters)
463 || !self.entity_allowed(&axiom.object, &request.filters)
464 {
465 continue;
466 }
467 self.add_node_for(&mut payload.nodes, &mut node_ids, &mut truncated, &axiom.subject);
468 self.add_node_for(&mut payload.nodes, &mut node_ids, &mut truncated, &axiom.object);
469 self.add_edge(
470 &mut payload.edges,
471 &mut truncated,
472 &request.filters,
473 GraphEdge {
474 source: axiom.subject.clone(),
475 target: axiom.object.clone(),
476 kind: "sub_property_of".to_string(),
477 inferred: false,
478 },
479 );
480 }
481
482 payload.truncated = truncated;
485 Ok(payload)
486 }
487
488 fn build_import_graph(
489 &self,
490 request: &GraphRequest,
491 full_closure: bool,
492 ) -> Result<GraphPayload, String> {
493 let mut nodes = Vec::new();
494 let mut edges = Vec::new();
495 let mut node_ids = HashSet::new();
496 let mut truncated = false;
497
498 let mut adjacency: Vec<(String, String)> = Vec::new();
499 for doc in &self.catalog.data().documents {
500 let ont_iri = doc.base_iri.clone().unwrap_or_else(|| doc.id.clone());
501 for import in &doc.imports {
502 adjacency.push((ont_iri.clone(), import.clone()));
503 }
504 }
505
506 let seeds: Vec<String> = if let Some(ref filter) = request.filters.ontology_iri {
507 self.catalog
508 .data()
509 .documents
510 .iter()
511 .filter_map(|doc| {
512 let ont_iri = doc.base_iri.clone().unwrap_or_else(|| doc.id.clone());
513 if &ont_iri == filter || &doc.id == filter {
514 Some(ont_iri)
515 } else {
516 None
517 }
518 })
519 .collect()
520 } else {
521 self.catalog
522 .data()
523 .documents
524 .iter()
525 .map(|doc| doc.base_iri.clone().unwrap_or_else(|| doc.id.clone()))
526 .collect()
527 };
528
529 if full_closure {
530 let mut visited = HashSet::new();
531 let mut queue = VecDeque::new();
532 for seed in &seeds {
533 queue.push_back(seed.clone());
534 visited.insert(seed.clone());
535 }
536 while let Some(current) = queue.pop_front() {
537 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, ¤t);
538 for (src, tgt) in &adjacency {
539 if src != ¤t {
540 continue;
541 }
542 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, tgt);
543 self.add_edge(
544 &mut edges,
545 &mut truncated,
546 &request.filters,
547 GraphEdge {
548 source: src.clone(),
549 target: tgt.clone(),
550 kind: "imports".to_string(),
551 inferred: false,
552 },
553 );
554 if visited.insert(tgt.clone()) {
555 queue.push_back(tgt.clone());
556 }
557 }
558 }
559 } else {
560 for seed in &seeds {
561 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, seed);
562 }
563 for (src, tgt) in &adjacency {
564 if request.filters.ontology_iri.is_some() && !seeds.iter().any(|s| s == src) {
565 continue;
566 }
567 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, src);
568 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, tgt);
569 self.add_edge(
570 &mut edges,
571 &mut truncated,
572 &request.filters,
573 GraphEdge {
574 source: src.clone(),
575 target: tgt.clone(),
576 kind: "imports".to_string(),
577 inferred: false,
578 },
579 );
580 }
581 }
582
583 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
584 }
585
586 fn build_individual_graph(
587 &self,
588 request: &GraphRequest,
589 roots: &[String],
590 depth: u32,
591 ) -> Result<GraphPayload, String> {
592 let mut adjacency: Vec<(String, String, String, bool)> = Vec::new();
593 for axiom in &self.catalog.data().axioms {
594 match axiom.axiom_kind.as_str() {
595 AXIOM_KIND_CLASS_ASSERTION => {
596 adjacency.push((
597 axiom.subject.clone(),
598 axiom.object.clone(),
599 "type".to_string(),
600 false,
601 ));
602 adjacency.push((
603 axiom.object.clone(),
604 axiom.subject.clone(),
605 "instance".to_string(),
606 false,
607 ));
608 }
609 AXIOM_KIND_OBJECT_PROPERTY_ASSERTION => {
610 adjacency.push((
611 axiom.subject.clone(),
612 axiom.object.clone(),
613 "assertion".to_string(),
614 false,
615 ));
616 adjacency.push((
617 axiom.object.clone(),
618 axiom.subject.clone(),
619 "assertion".to_string(),
620 false,
621 ));
622 let _ = &axiom.predicate;
624 }
625 _ => {}
626 }
627 }
628 self.bfs_from_roots(request, roots, depth, &adjacency, "individual")
629 }
630
631 fn build_neighborhood_graph(
632 &self,
633 request: &GraphRequest,
634 root: &str,
635 depth: u32,
636 ) -> Result<GraphPayload, String> {
637 let adjacency = self.class_neighborhood_adjacency(request.include_inferred);
638 self.bfs_from_roots(request, &[root.to_string()], depth, &adjacency, "neighborhood")
639 }
640
641 fn build_seeded_result_graph(
642 &self,
643 request: &GraphRequest,
644 roots: &[String],
645 depth: u32,
646 kind: GraphKind,
647 ) -> Result<GraphPayload, String> {
648 let adjacency = self.class_neighborhood_adjacency(request.include_inferred);
649 let edge_kind_prefix = match kind {
650 GraphKind::QueryResult => "query",
651 GraphKind::RefactorPreview => "refactor",
652 _ => "seed",
653 };
654 let mut payload =
655 self.bfs_from_roots(request, roots, depth, &adjacency, edge_kind_prefix)?;
656 let mut node_ids: HashSet<String> = payload.nodes.iter().map(|n| n.id.clone()).collect();
658 let mut truncated = payload.truncated;
659 for root in roots {
660 self.add_node_for(&mut payload.nodes, &mut node_ids, &mut truncated, root);
661 }
662 if roots.len() > 1 {
664 for window in roots.windows(2) {
665 self.add_edge(
666 &mut payload.edges,
667 &mut truncated,
668 &request.filters,
669 GraphEdge {
670 source: window[0].clone(),
671 target: window[1].clone(),
672 kind: format!("{edge_kind_prefix}_result"),
673 inferred: false,
674 },
675 );
676 }
677 }
678 payload.truncated = truncated;
679 Ok(payload)
680 }
681
682 fn class_neighborhood_adjacency(
683 &self,
684 include_inferred: bool,
685 ) -> Vec<(String, String, String, bool)> {
686 let hierarchy = self.catalog.class_hierarchy();
687 let mut adjacency: Vec<(String, String, String, bool)> = Vec::new();
688
689 for edge in &hierarchy.edges {
690 adjacency.push((
691 edge.child.clone(),
692 edge.parent.clone(),
693 "sub_class_of".to_string(),
694 false,
695 ));
696 adjacency.push((
697 edge.parent.clone(),
698 edge.child.clone(),
699 "super_class_of".to_string(),
700 false,
701 ));
702 }
703
704 if include_inferred {
705 if let Some(inferred) = self.inferred_edges {
706 for edge in inferred {
707 adjacency.push((
708 edge.child.clone(),
709 edge.parent.clone(),
710 "sub_class_of".to_string(),
711 true,
712 ));
713 adjacency.push((
714 edge.parent.clone(),
715 edge.child.clone(),
716 "super_class_of".to_string(),
717 true,
718 ));
719 }
720 }
721 }
722
723 for axiom in &self.catalog.data().axioms {
724 if axiom.axiom_kind == AXIOM_KIND_EQUIVALENT_CLASS
725 && (axiom.object.starts_with("http://") || axiom.object.starts_with("https://"))
726 {
727 adjacency.push((
728 axiom.subject.clone(),
729 axiom.object.clone(),
730 "equivalent_class".to_string(),
731 false,
732 ));
733 adjacency.push((
734 axiom.object.clone(),
735 axiom.subject.clone(),
736 "equivalent_class".to_string(),
737 false,
738 ));
739 } else if axiom.axiom_kind == AXIOM_KIND_SUB_CLASS_OF
740 && !axiom.object.starts_with("http://")
741 && !axiom.object.starts_with("https://")
742 {
743 for filler in restriction_fillers_in_expr(&axiom.object, self.catalog) {
744 adjacency.push((
745 axiom.subject.clone(),
746 filler,
747 "some_values_from".to_string(),
748 false,
749 ));
750 }
751 }
752 }
753 adjacency
754 }
755
756 fn bfs_from_roots(
757 &self,
758 request: &GraphRequest,
759 roots: &[String],
760 depth: u32,
761 adjacency: &[(String, String, String, bool)],
762 _tag: &str,
763 ) -> Result<GraphPayload, String> {
764 let mut nodes = Vec::new();
765 let mut edges = Vec::new();
766 let mut node_ids = HashSet::new();
767 let mut truncated = false;
768 let mut visited = HashSet::new();
769 let mut queue = VecDeque::new();
770
771 for root in roots {
772 queue.push_back((root.clone(), 0u32));
773 visited.insert(root.clone());
774 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, root);
775 }
776
777 while let Some((current, d)) = queue.pop_front() {
778 if d >= depth {
779 continue;
780 }
781 for (src, tgt, kind, inferred) in adjacency {
782 if src != ¤t {
783 continue;
784 }
785 if !self.entity_allowed(tgt, &request.filters) {
786 continue;
787 }
788 self.add_node_for(&mut nodes, &mut node_ids, &mut truncated, tgt);
789 self.add_edge(
790 &mut edges,
791 &mut truncated,
792 &request.filters,
793 GraphEdge {
794 source: src.clone(),
795 target: tgt.clone(),
796 kind: kind.clone(),
797 inferred: *inferred,
798 },
799 );
800 if visited.insert(tgt.clone()) {
801 queue.push_back((tgt.clone(), d + 1));
802 }
803 }
804 }
805
806 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
807 }
808}
809
810fn collect_roots(request: &GraphRequest) -> Vec<String> {
811 let mut roots = request.root_iris.clone();
812 if let Some(ref root) = request.root_iri {
813 if !roots.iter().any(|r| r == root) {
814 roots.insert(0, root.clone());
815 }
816 }
817 roots
818}
819
820fn graph_node_from_entity(entity: &Entity) -> GraphNode {
821 GraphNode {
822 id: entity.iri.clone(),
823 label: entity.labels.first().cloned().unwrap_or_else(|| entity.short_name.clone()),
824 kind: entity.kind.as_str().to_string(),
825 namespace: Some(namespace_of(&entity.iri)),
826 ontology_iri: Some(entity.ontology_id.clone()),
827 deprecated: entity.deprecated,
828 unsatisfiable: false,
829 equivalent: false,
830 }
831}
832
833fn namespace_of(iri: &str) -> String {
834 if let Some(h) = iri.rfind('#') {
835 return iri[..=h].to_string();
836 }
837 if let Some(s) = iri.rfind('/') {
838 return iri[..=s].to_string();
839 }
840 iri.to_string()
841}
842
843fn short_name(iri: &str) -> String {
844 let hash = iri.rfind('#');
845 let slash = iri.rfind('/');
846 match (hash, slash) {
847 (Some(h), Some(s)) => iri[h.max(s) + 1..].to_string(),
848 (Some(h), None) => iri[h + 1..].to_string(),
849 (None, Some(s)) => iri[s + 1..].to_string(),
850 _ => iri.to_string(),
851 }
852}
853
854fn restriction_fillers_in_expr(expr: &str, catalog: &OntologyCatalog) -> Vec<String> {
855 catalog
856 .data()
857 .entities
858 .iter()
859 .filter(|e| e.kind == EntityKind::Class)
860 .filter(|e| expr.contains(&e.iri) || expr.contains(&format!(":{}", e.short_name)))
861 .map(|e| e.iri.clone())
862 .collect()
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 use crate::IndexBuilder;
869 use std::path::Path;
870
871 fn default_request(kind: &str) -> GraphRequest {
872 GraphRequest {
873 graph_kind: kind.to_string(),
874 root_iri: None,
875 root_iris: vec![],
876 depth: 2,
877 include_inferred: false,
878 filters: GraphFilters::default(),
879 }
880 }
881
882 #[test]
883 fn class_graph_from_fixtures() {
884 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
885 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
886 let payload = GraphBuilder::new(&catalog).build(&default_request("class")).expect("graph");
887 assert!(!payload.nodes.is_empty());
888 assert!(!payload.edges.is_empty());
889 assert!(payload.nodes.iter().any(|n| n.namespace.is_some()));
890 }
891
892 #[test]
893 fn import_graph_from_fixtures() {
894 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
895 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
896 let payload = GraphBuilder::new(&catalog).build(&default_request("import")).expect("graph");
897 assert!(!payload.nodes.is_empty());
898 }
899
900 #[test]
901 fn dependency_graph_from_fixtures() {
902 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
903 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
904 let payload =
905 GraphBuilder::new(&catalog).build(&default_request("dependency")).expect("graph");
906 assert!(!payload.nodes.is_empty());
907 assert_eq!(payload.graph_kind, "dependency");
908 }
909
910 #[test]
911 fn property_graph_includes_domain_range_from_axioms() {
912 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
913 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
914 let payload =
915 GraphBuilder::new(&catalog).build(&default_request("property")).expect("graph");
916 assert!(
917 payload.edges.iter().any(|e| e.kind == "domain"),
918 "expected domain edges from axioms"
919 );
920 assert!(
921 payload.edges.iter().any(|e| e.kind == "range"),
922 "expected range edges from axioms"
923 );
924 }
925
926 #[test]
927 fn object_property_hierarchy_includes_sub_property_edges() {
928 let dir = tempfile::tempdir().expect("tempdir");
929 std::fs::write(
930 dir.path().join("props.ttl"),
931 "@prefix ex: <http://ex#> .\n\
932 @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
933 @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
934 <http://ex/onto> a owl:Ontology .\n\
935 ex:hasRelative a owl:ObjectProperty .\n\
936 ex:hasParent a owl:ObjectProperty ; rdfs:subPropertyOf ex:hasRelative .\n",
937 )
938 .expect("write");
939 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
940 let payload =
941 GraphBuilder::new(&catalog).build(&default_request("object_property")).expect("graph");
942 assert!(
943 payload.edges.iter().any(|e| e.kind == "sub_property_of"),
944 "expected sub_property_of edges: {:?}",
945 payload.edges
946 );
947 }
948
949 #[test]
950 fn individual_graph_includes_type_and_assertions() {
951 let dir = tempfile::tempdir().expect("tempdir");
952 std::fs::write(
953 dir.path().join("inds.ttl"),
954 "@prefix ex: <http://ex#> .\n\
955 @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
956 @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n\
957 <http://ex/onto> a owl:Ontology .\n\
958 ex:Person a owl:Class .\n\
959 ex:knows a owl:ObjectProperty .\n\
960 ex:Alice a owl:NamedIndividual , ex:Person .\n\
961 ex:Bob a owl:NamedIndividual .\n\
962 ex:Alice ex:knows ex:Bob .\n",
963 )
964 .expect("write");
965 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
966 let mut req = default_request("individual");
967 req.root_iri = Some("http://ex#Alice".to_string());
968 let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
969 assert!(payload.nodes.iter().any(|n| n.id == "http://ex#Alice"));
970 assert!(
971 payload.edges.iter().any(|e| e.kind == "type" || e.kind == "assertion"),
972 "expected type/assertion edges: {:?}",
973 payload.edges
974 );
975 }
976
977 #[test]
978 fn query_result_graph_seeds_roots() {
979 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
980 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
981 let mut req = default_request("query_result");
982 req.root_iris = vec![
983 "http://example.org/clinic#Patient".to_string(),
984 "http://example.org/clinic#MedicalRecord".to_string(),
985 ];
986 let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
987 assert!(payload.nodes.iter().any(|n| n.id.contains("Patient")));
988 assert_eq!(payload.graph_kind, "query_result");
989 }
990
991 #[test]
992 fn neighborhood_graph_includes_restriction_fillers_from_axioms() {
993 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
994 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
995 let patient = "http://example.org/clinic#Patient";
996 let record = "http://example.org/clinic#MedicalRecord";
997 let mut req = default_request("neighborhood");
998 req.root_iri = Some(patient.to_string());
999 let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
1000 assert!(
1001 payload.edges.iter().any(|e| e.source == patient && e.target == record),
1002 "expected Patient -> MedicalRecord restriction edge"
1003 );
1004 }
1005
1006 #[test]
1007 fn ontology_iri_filter_excludes_unknown_entities() {
1008 let dir = tempfile::tempdir().expect("tempdir");
1009 std::fs::write(
1010 dir.path().join("local.ttl"),
1011 "@prefix ex: <http://ex#> .\n\
1012 @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
1013 @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
1014 <http://ex/onto> a owl:Ontology .\n\
1015 ex:Child a owl:Class ; rdfs:subClassOf <http://external.example/Parent> .\n",
1016 )
1017 .expect("write");
1018 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1019 let ont_id = catalog.data().documents.first().map(|d| d.id.clone()).expect("doc");
1020 let unfiltered =
1021 GraphBuilder::new(&catalog).build(&default_request("class")).expect("unfiltered");
1022 assert!(
1023 unfiltered.nodes.iter().any(|n| n.id == "http://external.example/Parent"),
1024 "unknown parent should appear without ontology filter"
1025 );
1026
1027 let mut filtered_req = default_request("class");
1028 filtered_req.filters =
1029 GraphFilters { ontology_iri: Some(ont_id), ..GraphFilters::default() };
1030 let filtered = GraphBuilder::new(&catalog).build(&filtered_req).expect("filtered");
1031 assert!(
1032 filtered.nodes.iter().all(|n| n.id != "http://external.example/Parent"),
1033 "unknown parent must not bypass ontology_iri filter"
1034 );
1035 assert!(
1036 filtered.edges.iter().all(|e| {
1037 e.source != "http://external.example/Parent"
1038 && e.target != "http://external.example/Parent"
1039 }),
1040 "edges to unknown parent must be excluded when filtering"
1041 );
1042 }
1043
1044 #[test]
1045 fn search_text_and_entity_kind_filters() {
1046 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
1047 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
1048 let mut req = default_request("class");
1049 req.filters.search_text = Some("Patient".to_string());
1050 req.filters.entity_kinds = vec!["class".to_string()];
1051 let payload = GraphBuilder::new(&catalog).build(&req).expect("graph");
1052 assert!(payload.nodes.iter().all(|n| {
1053 n.kind == "class"
1054 && (n.label.to_lowercase().contains("patient")
1055 || n.id.to_lowercase().contains("patient"))
1056 }));
1057 }
1058
1059 #[test]
1060 fn large_graph_truncation_cap_is_honored() {
1061 let dir = tempfile::tempdir().expect("tempdir");
1062 let mut ttl = String::from(
1063 "@prefix ex: <http://ex#> .\n\
1064 @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
1065 @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
1066 <http://ex/onto> a owl:Ontology .\n\
1067 ex:Root a owl:Class .\n",
1068 );
1069 for i in 0..200 {
1072 ttl.push_str(&format!("ex:C{i} a owl:Class ; rdfs:subClassOf ex:Root .\n"));
1073 }
1074 std::fs::write(dir.path().join("large.ttl"), ttl).expect("write");
1075 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1076 let started = std::time::Instant::now();
1077 let payload = GraphBuilder::new(&catalog).build(&default_request("class")).expect("graph");
1078 assert!(started.elapsed().as_secs() < 5, "class graph build too slow");
1079 assert!(!payload.nodes.is_empty());
1080 assert!(payload.nodes.len() <= MAX_GRAPH_NODES);
1081 assert!(payload.edges.len() <= MAX_GRAPH_EDGES);
1082 }
1083}