1use crate::entity_api::SubclassEdge;
4use crate::OntologyCatalog;
5use ontocore_core::{
6 limits::{MAX_GRAPH_EDGES, MAX_GRAPH_NODES},
7 EntityKind, AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS, AXIOM_KIND_RANGE,
8 AXIOM_KIND_SUB_CLASS_OF,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashSet, VecDeque};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15#[error("{0}")]
16pub struct GraphError(String);
17
18impl From<String> for GraphError {
19 fn from(value: String) -> Self {
20 Self(value)
21 }
22}
23
24pub type GraphResult<T> = std::result::Result<T, GraphError>;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum GraphKind {
29 Class,
30 Property,
31 Import,
32 Neighborhood,
33}
34
35impl GraphKind {
36 pub fn parse(s: &str) -> Option<Self> {
37 match s {
38 "class" => Some(Self::Class),
39 "property" => Some(Self::Property),
40 "import" => Some(Self::Import),
41 "neighborhood" => Some(Self::Neighborhood),
42 _ => None,
43 }
44 }
45
46 pub fn as_str(&self) -> &'static str {
47 match self {
48 Self::Class => "class",
49 Self::Property => "property",
50 Self::Import => "import",
51 Self::Neighborhood => "neighborhood",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Default, Deserialize)]
57pub struct GraphFilters {
58 pub ontology_iri: Option<String>,
59 #[serde(default)]
60 pub hide_deprecated: bool,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct GraphRequest {
65 pub graph_kind: String,
66 pub root_iri: Option<String>,
67 #[serde(default = "default_depth")]
68 pub depth: u32,
69 #[serde(default)]
70 pub include_inferred: bool,
71 #[serde(default)]
72 pub filters: GraphFilters,
73}
74
75fn default_depth() -> u32 {
76 2
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct GraphNode {
81 pub id: String,
82 pub label: String,
83 pub kind: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct GraphEdge {
88 pub source: String,
89 pub target: String,
90 pub kind: String,
91 pub inferred: bool,
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct GraphPayload {
96 pub nodes: Vec<GraphNode>,
97 pub edges: Vec<GraphEdge>,
98 pub truncated: bool,
99 pub graph_kind: String,
100}
101
102pub struct GraphBuilder<'a> {
103 catalog: &'a OntologyCatalog,
104 inferred_edges: Option<&'a [SubclassEdge]>,
105}
106
107impl<'a> GraphBuilder<'a> {
108 pub fn new(catalog: &'a OntologyCatalog) -> Self {
109 Self { catalog, inferred_edges: None }
110 }
111
112 pub fn with_inferred_edges(mut self, edges: &'a [SubclassEdge]) -> Self {
113 self.inferred_edges = Some(edges);
114 self
115 }
116
117 pub fn build(&self, request: &GraphRequest) -> GraphResult<GraphPayload> {
118 let kind = GraphKind::parse(&request.graph_kind)
119 .ok_or_else(|| GraphError(format!("unknown graph_kind: {}", request.graph_kind)))?;
120 let depth = request.depth.clamp(1, 5);
121
122 let mut payload = match kind {
123 GraphKind::Class => self.build_class_graph(request),
124 GraphKind::Property => self.build_property_graph(request),
125 GraphKind::Import => self.build_import_graph(request),
126 GraphKind::Neighborhood => {
127 let root = request.root_iri.as_deref().ok_or_else(|| {
128 GraphError("neighborhood graph requires root_iri".to_string())
129 })?;
130 self.build_neighborhood_graph(request, root, depth)
131 }
132 }?;
133
134 payload.graph_kind = kind.as_str().to_string();
135 Ok(payload)
136 }
137
138 fn entity_allowed(&self, iri: &str, filters: &GraphFilters) -> bool {
139 let Some(entity) = self.catalog.find_entity(iri) else {
140 return !filters.hide_deprecated;
141 };
142 if filters.hide_deprecated && entity.deprecated {
143 return false;
144 }
145 if let Some(ref ont) = filters.ontology_iri {
146 if entity.ontology_id != *ont {
147 return false;
148 }
149 }
150 true
151 }
152
153 fn add_node(
154 nodes: &mut Vec<GraphNode>,
155 node_ids: &mut HashSet<String>,
156 truncated: &mut bool,
157 id: String,
158 label: String,
159 kind: String,
160 ) {
161 if node_ids.contains(&id) {
162 return;
163 }
164 if nodes.len() >= MAX_GRAPH_NODES {
165 *truncated = true;
166 return;
167 }
168 node_ids.insert(id.clone());
169 nodes.push(GraphNode { id, label, kind });
170 }
171
172 fn add_edge(
173 edges: &mut Vec<GraphEdge>,
174 truncated: &mut bool,
175 source: String,
176 target: String,
177 kind: String,
178 inferred: bool,
179 ) {
180 if edges.len() >= MAX_GRAPH_EDGES {
181 *truncated = true;
182 return;
183 }
184 edges.push(GraphEdge { source, target, kind, inferred });
185 }
186
187 fn label_for(&self, iri: &str) -> String {
188 self.catalog
189 .find_entity(iri)
190 .and_then(|e| e.labels.first().cloned())
191 .or_else(|| self.catalog.find_entity(iri).map(|e| e.short_name.clone()))
192 .unwrap_or_else(|| short_name(iri))
193 }
194
195 fn kind_for(&self, iri: &str) -> String {
196 self.catalog
197 .find_entity(iri)
198 .map(|e| e.kind.as_str().to_string())
199 .unwrap_or_else(|| "other".to_string())
200 }
201
202 fn build_class_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
203 let mut nodes = Vec::new();
204 let mut edges = Vec::new();
205 let mut node_ids = HashSet::new();
206 let mut truncated = false;
207
208 let hierarchy = self.catalog.class_hierarchy();
209 for edge in &hierarchy.edges {
210 if !self.entity_allowed(&edge.child, &request.filters)
211 || !self.entity_allowed(&edge.parent, &request.filters)
212 {
213 continue;
214 }
215 Self::add_node(
216 &mut nodes,
217 &mut node_ids,
218 &mut truncated,
219 edge.child.clone(),
220 self.label_for(&edge.child),
221 self.kind_for(&edge.child),
222 );
223 Self::add_node(
224 &mut nodes,
225 &mut node_ids,
226 &mut truncated,
227 edge.parent.clone(),
228 self.label_for(&edge.parent),
229 self.kind_for(&edge.parent),
230 );
231 Self::add_edge(
232 &mut edges,
233 &mut truncated,
234 edge.child.clone(),
235 edge.parent.clone(),
236 "sub_class_of".to_string(),
237 false,
238 );
239 }
240
241 if request.include_inferred {
242 if let Some(inferred) = self.inferred_edges {
243 for edge in inferred {
244 if !self.entity_allowed(&edge.child, &request.filters)
245 || !self.entity_allowed(&edge.parent, &request.filters)
246 {
247 continue;
248 }
249 let is_new = !hierarchy
250 .edges
251 .iter()
252 .any(|e| e.child == edge.child && e.parent == edge.parent);
253 if !is_new {
254 continue;
255 }
256 Self::add_node(
257 &mut nodes,
258 &mut node_ids,
259 &mut truncated,
260 edge.child.clone(),
261 self.label_for(&edge.child),
262 self.kind_for(&edge.child),
263 );
264 Self::add_node(
265 &mut nodes,
266 &mut node_ids,
267 &mut truncated,
268 edge.parent.clone(),
269 self.label_for(&edge.parent),
270 self.kind_for(&edge.parent),
271 );
272 Self::add_edge(
273 &mut edges,
274 &mut truncated,
275 edge.child.clone(),
276 edge.parent.clone(),
277 "sub_class_of".to_string(),
278 true,
279 );
280 }
281 }
282 }
283
284 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
285 }
286
287 fn build_property_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
288 let mut nodes = Vec::new();
289 let mut edges = Vec::new();
290 let mut node_ids = HashSet::new();
291 let mut truncated = false;
292
293 for entity in &self.catalog.data().entities {
294 if entity.kind != EntityKind::ObjectProperty && entity.kind != EntityKind::DataProperty
295 {
296 continue;
297 }
298 if !self.entity_allowed(&entity.iri, &request.filters) {
299 continue;
300 }
301 Self::add_node(
302 &mut nodes,
303 &mut node_ids,
304 &mut truncated,
305 entity.iri.clone(),
306 entity.labels.first().cloned().unwrap_or_else(|| entity.short_name.clone()),
307 entity.kind.as_str().to_string(),
308 );
309 }
310
311 for axiom in &self.catalog.data().axioms {
312 let edge_kind = match axiom.axiom_kind.as_str() {
313 AXIOM_KIND_DOMAIN => "domain",
314 AXIOM_KIND_RANGE => "range",
315 _ => continue,
316 };
317 let Some(prop) = self.catalog.find_entity(&axiom.subject) else {
318 continue;
319 };
320 if prop.kind != EntityKind::ObjectProperty && prop.kind != EntityKind::DataProperty {
321 continue;
322 }
323 if !self.entity_allowed(&axiom.subject, &request.filters) {
324 continue;
325 }
326 Self::add_node(
327 &mut nodes,
328 &mut node_ids,
329 &mut truncated,
330 axiom.object.clone(),
331 self.label_for(&axiom.object),
332 self.kind_for(&axiom.object),
333 );
334 Self::add_edge(
335 &mut edges,
336 &mut truncated,
337 axiom.subject.clone(),
338 axiom.object.clone(),
339 edge_kind.to_string(),
340 false,
341 );
342 }
343
344 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
345 }
346
347 fn build_import_graph(&self, request: &GraphRequest) -> Result<GraphPayload, String> {
348 let mut nodes = Vec::new();
349 let mut edges = Vec::new();
350 let mut node_ids = HashSet::new();
351 let mut truncated = false;
352
353 for doc in &self.catalog.data().documents {
354 let ont_iri = doc.base_iri.clone().unwrap_or_else(|| doc.id.clone());
355 if let Some(ref filter) = request.filters.ontology_iri {
356 if &ont_iri != filter && &doc.id != filter {
357 continue;
358 }
359 }
360 Self::add_node(
361 &mut nodes,
362 &mut node_ids,
363 &mut truncated,
364 ont_iri.clone(),
365 short_name(&ont_iri),
366 "ontology".to_string(),
367 );
368 for import in &doc.imports {
369 Self::add_node(
370 &mut nodes,
371 &mut node_ids,
372 &mut truncated,
373 import.clone(),
374 short_name(import),
375 "ontology".to_string(),
376 );
377 Self::add_edge(
378 &mut edges,
379 &mut truncated,
380 ont_iri.clone(),
381 import.clone(),
382 "imports".to_string(),
383 false,
384 );
385 }
386 }
387
388 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
389 }
390
391 fn build_neighborhood_graph(
392 &self,
393 request: &GraphRequest,
394 root: &str,
395 depth: u32,
396 ) -> Result<GraphPayload, String> {
397 let mut nodes = Vec::new();
398 let mut edges = Vec::new();
399 let mut node_ids = HashSet::new();
400 let mut truncated = false;
401
402 let hierarchy = self.catalog.class_hierarchy();
403 let mut adjacency: Vec<(String, String, String, bool)> = Vec::new();
404
405 for edge in &hierarchy.edges {
406 adjacency.push((
407 edge.child.clone(),
408 edge.parent.clone(),
409 "sub_class_of".to_string(),
410 false,
411 ));
412 adjacency.push((
413 edge.parent.clone(),
414 edge.child.clone(),
415 "super_class_of".to_string(),
416 false,
417 ));
418 }
419
420 if request.include_inferred {
421 if let Some(inferred) = self.inferred_edges {
422 for edge in inferred {
423 adjacency.push((
424 edge.child.clone(),
425 edge.parent.clone(),
426 "sub_class_of".to_string(),
427 true,
428 ));
429 adjacency.push((
430 edge.parent.clone(),
431 edge.child.clone(),
432 "super_class_of".to_string(),
433 true,
434 ));
435 }
436 }
437 }
438
439 for axiom in &self.catalog.data().axioms {
440 if axiom.axiom_kind == AXIOM_KIND_EQUIVALENT_CLASS
441 && (axiom.object.starts_with("http://") || axiom.object.starts_with("https://"))
442 {
443 adjacency.push((
444 axiom.subject.clone(),
445 axiom.object.clone(),
446 "equivalent_class".to_string(),
447 false,
448 ));
449 adjacency.push((
450 axiom.object.clone(),
451 axiom.subject.clone(),
452 "equivalent_class".to_string(),
453 false,
454 ));
455 } else if axiom.axiom_kind == AXIOM_KIND_SUB_CLASS_OF
456 && !axiom.object.starts_with("http://")
457 && !axiom.object.starts_with("https://")
458 {
459 for filler in restriction_fillers_in_expr(&axiom.object, self.catalog) {
460 adjacency.push((
461 axiom.subject.clone(),
462 filler,
463 "some_values_from".to_string(),
464 false,
465 ));
466 }
467 }
468 }
469
470 let mut visited = HashSet::new();
471 let mut queue = VecDeque::new();
472 queue.push_back((root.to_string(), 0u32));
473 visited.insert(root.to_string());
474
475 Self::add_node(
476 &mut nodes,
477 &mut node_ids,
478 &mut truncated,
479 root.to_string(),
480 self.label_for(root),
481 self.kind_for(root),
482 );
483
484 while let Some((current, d)) = queue.pop_front() {
485 if d >= depth {
486 continue;
487 }
488 for (src, tgt, kind, inferred) in &adjacency {
489 if src != ¤t {
490 continue;
491 }
492 if !self.entity_allowed(tgt, &request.filters) {
493 continue;
494 }
495 Self::add_node(
496 &mut nodes,
497 &mut node_ids,
498 &mut truncated,
499 tgt.clone(),
500 self.label_for(tgt),
501 self.kind_for(tgt),
502 );
503 Self::add_edge(
504 &mut edges,
505 &mut truncated,
506 src.clone(),
507 tgt.clone(),
508 kind.clone(),
509 *inferred,
510 );
511 if visited.insert(tgt.clone()) {
512 queue.push_back((tgt.clone(), d + 1));
513 }
514 }
515 }
516
517 Ok(GraphPayload { nodes, edges, truncated, graph_kind: String::new() })
518 }
519}
520
521fn short_name(iri: &str) -> String {
522 let hash = iri.rfind('#');
523 let slash = iri.rfind('/');
524 match (hash, slash) {
525 (Some(h), Some(s)) => iri[h.max(s) + 1..].to_string(),
526 (Some(h), None) => iri[h + 1..].to_string(),
527 (None, Some(s)) => iri[s + 1..].to_string(),
528 _ => iri.to_string(),
529 }
530}
531
532fn restriction_fillers_in_expr(expr: &str, catalog: &OntologyCatalog) -> Vec<String> {
533 catalog
534 .data()
535 .entities
536 .iter()
537 .filter(|e| e.kind == EntityKind::Class)
538 .filter(|e| expr.contains(&e.iri) || expr.contains(&format!(":{}", e.short_name)))
539 .map(|e| e.iri.clone())
540 .collect()
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use crate::IndexBuilder;
547 use std::path::Path;
548
549 #[test]
550 fn class_graph_from_fixtures() {
551 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
552 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
553 let payload = GraphBuilder::new(&catalog)
554 .build(&GraphRequest {
555 graph_kind: "class".to_string(),
556 root_iri: None,
557 depth: 2,
558 include_inferred: false,
559 filters: GraphFilters::default(),
560 })
561 .expect("graph");
562 assert!(!payload.nodes.is_empty());
563 assert!(!payload.edges.is_empty());
564 }
565
566 #[test]
567 fn import_graph_from_fixtures() {
568 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
569 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
570 let payload = GraphBuilder::new(&catalog)
571 .build(&GraphRequest {
572 graph_kind: "import".to_string(),
573 root_iri: None,
574 depth: 2,
575 include_inferred: false,
576 filters: GraphFilters::default(),
577 })
578 .expect("graph");
579 assert!(!payload.nodes.is_empty());
580 }
581
582 #[test]
583 fn property_graph_includes_domain_range_from_axioms() {
584 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
585 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
586 let payload = GraphBuilder::new(&catalog)
587 .build(&GraphRequest {
588 graph_kind: "property".to_string(),
589 root_iri: None,
590 depth: 2,
591 include_inferred: false,
592 filters: GraphFilters::default(),
593 })
594 .expect("graph");
595 assert!(
596 payload.edges.iter().any(|e| e.kind == "domain"),
597 "expected domain edges from axioms"
598 );
599 assert!(
600 payload.edges.iter().any(|e| e.kind == "range"),
601 "expected range edges from axioms"
602 );
603 }
604
605 #[test]
606 fn neighborhood_graph_includes_restriction_fillers_from_axioms() {
607 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
608 let catalog = IndexBuilder::new().workspace(&root).build().expect("build");
609 let patient = "http://example.org/clinic#Patient";
610 let record = "http://example.org/clinic#MedicalRecord";
611 let payload = GraphBuilder::new(&catalog)
612 .build(&GraphRequest {
613 graph_kind: "neighborhood".to_string(),
614 root_iri: Some(patient.to_string()),
615 depth: 2,
616 include_inferred: false,
617 filters: GraphFilters::default(),
618 })
619 .expect("graph");
620 assert!(
621 payload.edges.iter().any(|e| e.source == patient && e.target == record),
622 "expected Patient -> MedicalRecord restriction edge"
623 );
624 }
625}