Skip to main content

oxirs_shacl/
shape_graph_loader.rs

1/// Loading and indexing of SHACL shape graphs.
2///
3/// Provides in-memory representation and index structures for SHACL shapes,
4/// supporting efficient lookup by class target, type, and property path.
5use std::collections::HashMap;
6
7/// Discriminator for shape kinds.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ShapeType {
10    /// An `sh:NodeShape`.
11    NodeShape,
12    /// An `sh:PropertyShape`.
13    PropertyShape,
14}
15
16/// A single SHACL constraint attached to a shape.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ShapeConstraint {
19    /// Constraint keyword (e.g. "sh:minCount", "sh:datatype").
20    pub constraint_type: String,
21    /// Constraint value as string.
22    pub value: String,
23}
24
25/// A node in the SHACL shape graph.
26#[derive(Debug, Clone)]
27pub struct ShapeNode {
28    /// IRI identifier of the shape.
29    pub id: String,
30    /// Whether this is a node or property shape.
31    pub shape_type: ShapeType,
32    /// `sh:targetClass` IRI (NodeShape only).
33    pub target_class: Option<String>,
34    /// `sh:targetNode` IRI (NodeShape only).
35    pub target_node: Option<String>,
36    /// Constraints attached directly to this shape.
37    pub constraints: Vec<ShapeConstraint>,
38    /// IRIs of child property shapes (for NodeShape).
39    pub property_shapes: Vec<String>,
40    /// `sh:path` (PropertyShape only).
41    pub path: Option<String>,
42    /// Whether the shape is `sh:deactivated true`.
43    pub deactivated: bool,
44}
45
46/// An in-memory SHACL shape graph with fast lookup indices.
47#[derive(Debug, Default)]
48pub struct ShapeGraph {
49    shapes: HashMap<String, ShapeNode>,
50}
51
52/// Result of loading shapes into the graph.
53#[derive(Debug, Clone)]
54pub struct LoadResult {
55    /// Number of shapes successfully loaded.
56    pub shapes_loaded: usize,
57    /// Non-fatal warnings (e.g. deprecated features).
58    pub warnings: Vec<String>,
59}
60
61impl ShapeGraph {
62    /// Create an empty shape graph.
63    pub fn new() -> Self {
64        Self {
65            shapes: HashMap::new(),
66        }
67    }
68
69    /// Add a shape to the graph.
70    ///
71    /// Returns `Err(ShapeGraphError::DuplicateId)` if a shape with the same ID already exists.
72    pub fn add_shape(&mut self, shape: ShapeNode) -> Result<(), ShapeGraphError> {
73        if self.shapes.contains_key(&shape.id) {
74            return Err(ShapeGraphError::DuplicateId(shape.id.clone()));
75        }
76        self.shapes.insert(shape.id.clone(), shape);
77        Ok(())
78    }
79
80    /// Look up a shape by IRI.
81    pub fn get_shape(&self, id: &str) -> Option<&ShapeNode> {
82        self.shapes.get(id)
83    }
84
85    /// Return all `NodeShape`s.
86    pub fn node_shapes(&self) -> Vec<&ShapeNode> {
87        self.shapes
88            .values()
89            .filter(|s| s.shape_type == ShapeType::NodeShape)
90            .collect()
91    }
92
93    /// Return all `PropertyShape`s.
94    pub fn property_shapes(&self) -> Vec<&ShapeNode> {
95        self.shapes
96            .values()
97            .filter(|s| s.shape_type == ShapeType::PropertyShape)
98            .collect()
99    }
100
101    /// Return all node shapes whose `sh:targetClass` matches `class_iri`.
102    pub fn shapes_for_class(&self, class_iri: &str) -> Vec<&ShapeNode> {
103        self.shapes
104            .values()
105            .filter(|s| {
106                s.shape_type == ShapeType::NodeShape && s.target_class.as_deref() == Some(class_iri)
107            })
108            .collect()
109    }
110
111    /// Resolve the property shapes referenced by a node shape.
112    pub fn resolve_property_shapes(&self, node_shape_id: &str) -> Vec<&ShapeNode> {
113        match self.shapes.get(node_shape_id) {
114            None => vec![],
115            Some(node_shape) => node_shape
116                .property_shapes
117                .iter()
118                .filter_map(|ps_id| self.shapes.get(ps_id))
119                .collect(),
120        }
121    }
122
123    /// Return the total number of shapes.
124    pub fn shape_count(&self) -> usize {
125        self.shapes.len()
126    }
127
128    /// Validate internal consistency of the graph and return a list of warnings/errors.
129    pub fn validate_graph(&self) -> Vec<String> {
130        let mut issues = Vec::new();
131        for shape in self.shapes.values() {
132            // Warn if a property shape has no sh:path
133            if shape.shape_type == ShapeType::PropertyShape && shape.path.is_none() {
134                issues.push(format!(
135                    "PropertyShape <{}> has no sh:path defined.",
136                    shape.id
137                ));
138            }
139            // Warn if a node shape references a property shape that does not exist
140            for ps_id in &shape.property_shapes {
141                if !self.shapes.contains_key(ps_id) {
142                    issues.push(format!(
143                        "NodeShape <{}> references missing PropertyShape <{}>.",
144                        shape.id, ps_id
145                    ));
146                }
147            }
148            // Warn about deactivated shapes
149            if shape.deactivated {
150                issues.push(format!("Shape <{}> is deactivated.", shape.id));
151            }
152        }
153        issues
154    }
155
156    /// Remove a shape by IRI. Returns true if it was present.
157    pub fn remove_shape(&mut self, id: &str) -> bool {
158        self.shapes.remove(id).is_some()
159    }
160
161    /// Return all shapes where `sh:deactivated` is true.
162    pub fn deactivated_shapes(&self) -> Vec<&ShapeNode> {
163        self.shapes.values().filter(|s| s.deactivated).collect()
164    }
165}
166
167/// Errors from shape graph operations.
168#[derive(Debug)]
169pub enum ShapeGraphError {
170    /// A shape with this IRI already exists in the graph.
171    DuplicateId(String),
172    /// The shape definition is structurally invalid.
173    InvalidShape(String),
174}
175
176impl std::fmt::Display for ShapeGraphError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            Self::DuplicateId(id) => write!(f, "Duplicate shape id: {id}"),
180            Self::InvalidShape(msg) => write!(f, "Invalid shape: {msg}"),
181        }
182    }
183}
184
185impl std::error::Error for ShapeGraphError {}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn node_shape(id: &str, class: Option<&str>) -> ShapeNode {
192        ShapeNode {
193            id: id.to_string(),
194            shape_type: ShapeType::NodeShape,
195            target_class: class.map(|s| s.to_string()),
196            target_node: None,
197            constraints: vec![],
198            property_shapes: vec![],
199            path: None,
200            deactivated: false,
201        }
202    }
203
204    fn property_shape(id: &str, path: Option<&str>) -> ShapeNode {
205        ShapeNode {
206            id: id.to_string(),
207            shape_type: ShapeType::PropertyShape,
208            target_class: None,
209            target_node: None,
210            constraints: vec![],
211            property_shapes: vec![],
212            path: path.map(|s| s.to_string()),
213            deactivated: false,
214        }
215    }
216
217    // --- add/get ---
218
219    #[test]
220    fn test_add_and_get_shape() {
221        let mut sg = ShapeGraph::new();
222        sg.add_shape(node_shape("http://s1", Some("http://Person")))
223            .expect("should succeed");
224        assert!(sg.get_shape("http://s1").is_some());
225    }
226
227    #[test]
228    fn test_get_missing_shape() {
229        let sg = ShapeGraph::new();
230        assert!(sg.get_shape("http://missing").is_none());
231    }
232
233    #[test]
234    fn test_duplicate_id_error() {
235        let mut sg = ShapeGraph::new();
236        sg.add_shape(node_shape("http://s1", None))
237            .expect("should succeed");
238        let err = sg.add_shape(node_shape("http://s1", None));
239        assert!(matches!(err, Err(ShapeGraphError::DuplicateId(_))));
240    }
241
242    #[test]
243    fn test_shape_count_empty() {
244        let sg = ShapeGraph::new();
245        assert_eq!(sg.shape_count(), 0);
246    }
247
248    #[test]
249    fn test_shape_count_after_adds() {
250        let mut sg = ShapeGraph::new();
251        sg.add_shape(node_shape("http://s1", None))
252            .expect("should succeed");
253        sg.add_shape(property_shape("http://ps1", Some("http://name")))
254            .expect("should succeed");
255        assert_eq!(sg.shape_count(), 2);
256    }
257
258    // --- node_shapes / property_shapes ---
259
260    #[test]
261    fn test_node_shapes_filter() {
262        let mut sg = ShapeGraph::new();
263        sg.add_shape(node_shape("http://ns1", None))
264            .expect("should succeed");
265        sg.add_shape(property_shape("http://ps1", Some("http://p")))
266            .expect("should succeed");
267        let ns = sg.node_shapes();
268        assert_eq!(ns.len(), 1);
269        assert_eq!(ns[0].id, "http://ns1");
270    }
271
272    #[test]
273    fn test_property_shapes_filter() {
274        let mut sg = ShapeGraph::new();
275        sg.add_shape(node_shape("http://ns1", None))
276            .expect("should succeed");
277        sg.add_shape(property_shape("http://ps1", Some("http://p")))
278            .expect("should succeed");
279        sg.add_shape(property_shape("http://ps2", Some("http://q")))
280            .expect("should succeed");
281        let ps = sg.property_shapes();
282        assert_eq!(ps.len(), 2);
283    }
284
285    // --- shapes_for_class ---
286
287    #[test]
288    fn test_shapes_for_class_match() {
289        let mut sg = ShapeGraph::new();
290        sg.add_shape(node_shape("http://s1", Some("http://Person")))
291            .expect("should succeed");
292        sg.add_shape(node_shape("http://s2", Some("http://Organization")))
293            .expect("should succeed");
294        let result = sg.shapes_for_class("http://Person");
295        assert_eq!(result.len(), 1);
296        assert_eq!(result[0].id, "http://s1");
297    }
298
299    #[test]
300    fn test_shapes_for_class_no_match() {
301        let mut sg = ShapeGraph::new();
302        sg.add_shape(node_shape("http://s1", Some("http://Person")))
303            .expect("should succeed");
304        let result = sg.shapes_for_class("http://Animal");
305        assert!(result.is_empty());
306    }
307
308    #[test]
309    fn test_shapes_for_class_multiple() {
310        let mut sg = ShapeGraph::new();
311        sg.add_shape(node_shape("http://s1", Some("http://Person")))
312            .expect("should succeed");
313        sg.add_shape(node_shape("http://s2", Some("http://Person")))
314            .expect("should succeed");
315        let result = sg.shapes_for_class("http://Person");
316        assert_eq!(result.len(), 2);
317    }
318
319    // --- resolve_property_shapes ---
320
321    #[test]
322    fn test_resolve_property_shapes() {
323        let mut sg = ShapeGraph::new();
324        let mut ns = node_shape("http://ns1", None);
325        ns.property_shapes = vec!["http://ps1".to_string()];
326        sg.add_shape(ns).expect("should succeed");
327        sg.add_shape(property_shape("http://ps1", Some("http://name")))
328            .expect("should succeed");
329        let resolved = sg.resolve_property_shapes("http://ns1");
330        assert_eq!(resolved.len(), 1);
331        assert_eq!(resolved[0].id, "http://ps1");
332    }
333
334    #[test]
335    fn test_resolve_property_shapes_missing_ref() {
336        let mut sg = ShapeGraph::new();
337        let mut ns = node_shape("http://ns1", None);
338        ns.property_shapes = vec!["http://missing_ps".to_string()];
339        sg.add_shape(ns).expect("should succeed");
340        let resolved = sg.resolve_property_shapes("http://ns1");
341        assert!(resolved.is_empty());
342    }
343
344    #[test]
345    fn test_resolve_property_shapes_unknown_node() {
346        let sg = ShapeGraph::new();
347        let resolved = sg.resolve_property_shapes("http://unknown");
348        assert!(resolved.is_empty());
349    }
350
351    // --- validate_graph ---
352
353    #[test]
354    fn test_validate_no_warnings() {
355        let mut sg = ShapeGraph::new();
356        let mut ns = node_shape("http://ns1", None);
357        ns.property_shapes = vec!["http://ps1".to_string()];
358        sg.add_shape(ns).expect("should succeed");
359        sg.add_shape(property_shape("http://ps1", Some("http://name")))
360            .expect("should succeed");
361        let warnings: Vec<_> = sg
362            .validate_graph()
363            .into_iter()
364            .filter(|w| w.contains("missing"))
365            .collect();
366        assert!(warnings.is_empty());
367    }
368
369    #[test]
370    fn test_validate_missing_property_shape() {
371        let mut sg = ShapeGraph::new();
372        let mut ns = node_shape("http://ns1", None);
373        ns.property_shapes = vec!["http://missing_ps".to_string()];
374        sg.add_shape(ns).expect("should succeed");
375        let warnings = sg.validate_graph();
376        assert!(warnings.iter().any(|w| w.contains("missing")));
377    }
378
379    #[test]
380    fn test_validate_property_shape_no_path() {
381        let mut sg = ShapeGraph::new();
382        sg.add_shape(property_shape("http://ps1", None))
383            .expect("should succeed");
384        let warnings = sg.validate_graph();
385        assert!(warnings.iter().any(|w| w.contains("no sh:path")));
386    }
387
388    #[test]
389    fn test_validate_deactivated_shape_warning() {
390        let mut sg = ShapeGraph::new();
391        let mut s = node_shape("http://s1", None);
392        s.deactivated = true;
393        sg.add_shape(s).expect("should succeed");
394        let warnings = sg.validate_graph();
395        assert!(warnings.iter().any(|w| w.contains("deactivated")));
396    }
397
398    // --- remove_shape ---
399
400    #[test]
401    fn test_remove_shape_present() {
402        let mut sg = ShapeGraph::new();
403        sg.add_shape(node_shape("http://s1", None))
404            .expect("should succeed");
405        assert!(sg.remove_shape("http://s1"));
406        assert_eq!(sg.shape_count(), 0);
407    }
408
409    #[test]
410    fn test_remove_shape_absent() {
411        let mut sg = ShapeGraph::new();
412        assert!(!sg.remove_shape("http://missing"));
413    }
414
415    // --- deactivated_shapes ---
416
417    #[test]
418    fn test_deactivated_shapes_none() {
419        let mut sg = ShapeGraph::new();
420        sg.add_shape(node_shape("http://s1", None))
421            .expect("should succeed");
422        assert!(sg.deactivated_shapes().is_empty());
423    }
424
425    #[test]
426    fn test_deactivated_shapes_some() {
427        let mut sg = ShapeGraph::new();
428        let mut s = node_shape("http://s1", None);
429        s.deactivated = true;
430        sg.add_shape(s).expect("should succeed");
431        sg.add_shape(node_shape("http://s2", None))
432            .expect("should succeed");
433        assert_eq!(sg.deactivated_shapes().len(), 1);
434    }
435
436    // --- constraints stored correctly ---
437
438    #[test]
439    fn test_constraints_stored() {
440        let mut sg = ShapeGraph::new();
441        let mut s = node_shape("http://s1", None);
442        s.constraints = vec![ShapeConstraint {
443            constraint_type: "sh:minCount".to_string(),
444            value: "1".to_string(),
445        }];
446        sg.add_shape(s).expect("should succeed");
447        let stored = sg.get_shape("http://s1").expect("should succeed");
448        assert_eq!(stored.constraints.len(), 1);
449        assert_eq!(stored.constraints[0].constraint_type, "sh:minCount");
450        assert_eq!(stored.constraints[0].value, "1");
451    }
452
453    #[test]
454    fn test_multiple_constraints() {
455        let mut sg = ShapeGraph::new();
456        let mut s = property_shape("http://ps1", Some("http://name"));
457        s.constraints = vec![
458            ShapeConstraint {
459                constraint_type: "sh:minCount".to_string(),
460                value: "1".to_string(),
461            },
462            ShapeConstraint {
463                constraint_type: "sh:datatype".to_string(),
464                value: "xsd:string".to_string(),
465            },
466        ];
467        sg.add_shape(s).expect("should succeed");
468        let stored = sg.get_shape("http://ps1").expect("should succeed");
469        assert_eq!(stored.constraints.len(), 2);
470    }
471
472    // --- error display ---
473
474    #[test]
475    fn test_duplicate_id_error_display() {
476        let err = ShapeGraphError::DuplicateId("http://s1".to_string());
477        assert!(format!("{err}").contains("http://s1"));
478    }
479
480    #[test]
481    fn test_invalid_shape_error_display() {
482        let err = ShapeGraphError::InvalidShape("bad".to_string());
483        assert!(format!("{err}").contains("bad"));
484    }
485
486    // --- shape_type stored ---
487
488    #[test]
489    fn test_shape_type_node() {
490        let mut sg = ShapeGraph::new();
491        sg.add_shape(node_shape("http://ns1", None))
492            .expect("should succeed");
493        assert_eq!(
494            sg.get_shape("http://ns1")
495                .expect("should succeed")
496                .shape_type,
497            ShapeType::NodeShape
498        );
499    }
500
501    #[test]
502    fn test_shape_type_property() {
503        let mut sg = ShapeGraph::new();
504        sg.add_shape(property_shape("http://ps1", Some("http://p")))
505            .expect("should succeed");
506        assert_eq!(
507            sg.get_shape("http://ps1")
508                .expect("should succeed")
509                .shape_type,
510            ShapeType::PropertyShape
511        );
512    }
513
514    #[test]
515    fn test_target_node_stored() {
516        let mut sg = ShapeGraph::new();
517        let mut s = node_shape("http://ns1", None);
518        s.target_node = Some("http://alice".to_string());
519        sg.add_shape(s).expect("should succeed");
520        assert_eq!(
521            sg.get_shape("http://ns1")
522                .expect("should succeed")
523                .target_node
524                .as_deref(),
525            Some("http://alice")
526        );
527    }
528
529    #[test]
530    fn test_path_stored_in_property_shape() {
531        let mut sg = ShapeGraph::new();
532        sg.add_shape(property_shape("http://ps1", Some("http://schema.org/name")))
533            .expect("should succeed");
534        assert_eq!(
535            sg.get_shape("http://ps1")
536                .expect("should succeed")
537                .path
538                .as_deref(),
539            Some("http://schema.org/name")
540        );
541    }
542
543    #[test]
544    fn test_shape_graph_default() {
545        let sg = ShapeGraph::default();
546        assert_eq!(sg.shape_count(), 0);
547    }
548}