Skip to main content

shifty_engine/
lib.rs

1//! Validation + SHACL-AF inference execution (Layers 3, 6, 7).
2//!
3//! Layer 3 lives here: the naive denotational evaluator that is the conformance
4//! oracle — relational path evaluation ([`path`]), value-type checks
5//! ([`value`]), and shape/schema satisfaction ([`validate`]). The rule/fixpoint
6//! inference engine (Layer 6) and compiled executors (Layer 7) come later; every
7//! execution mode must agree with this oracle.
8
9pub mod enumerate;
10pub mod frozen;
11pub mod gate;
12pub mod infer;
13mod native_exec;
14pub mod path;
15mod path_plan;
16pub mod profile;
17pub mod report;
18mod sparql;
19pub mod synthesize;
20pub mod validate;
21pub mod value;
22pub mod witness;
23
24pub use enumerate::{
25    EnumOptions, FixpointResult, RepairSolution, candidates, enumerate_repair, repair_to_fixpoint,
26};
27pub use gate::{RepairOutcome, apply, gate};
28pub use infer::{
29    InferenceOutcome, infer, infer_graphs, infer_with_context, infer_with_context_and_options,
30    infer_with_options,
31};
32pub use report::{
33    ValidationReport, ValidationResult, evaluate_function_expression, report_to_graph,
34    validate_report, validate_report_graphs, validate_report_graphs_with_mode,
35    validate_report_graphs_with_mode_and_options, validate_report_with_options,
36};
37pub use synthesize::{synthesize, synthesize_focus};
38pub use validate::{
39    EngineOptions, NonStratifiable, Reason, UnsupportedPolicy, ValidationGraphMode,
40    ValidationOptions, ValidationOutcome, Violation, focus_nodes, graph_union, validate,
41    validate_graphs, validate_graphs_with_mode, validate_graphs_with_mode_and_options,
42    validate_plan, validate_plan_graphs, validate_plan_graphs_with_mode,
43    validate_plan_graphs_with_mode_and_options, validate_plan_with_context,
44    validate_plan_with_context_and_options, validate_plan_with_options, validate_with_context,
45    validate_with_context_and_options, validate_with_options,
46};
47pub use witness::{
48    BlockReason, FocusSat, FocusWitness, PathSupport, RelKind, SatTrace, Witness, satisfy_shape,
49    shape_id_for_iri, witness_node, witness_shape, witness_violations,
50};
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use oxrdf::Graph;
56    use shifty_parse::parse_turtle;
57
58    fn run(shapes_and_data: &str) -> ValidationOutcome {
59        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
60        // data graph = the same graph (shapes + data coexist), as in the suite.
61        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
62        validate(&loaded.graph, &out.schema).expect("stratifiable schema")
63    }
64
65    const PREFIXES: &str = r#"
66        @prefix sh:  <http://www.w3.org/ns/shacl#> .
67        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
68        @prefix ex:  <http://ex/> .
69        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
70    "#;
71
72    #[test]
73    fn inference_rules_fire_for_implicit_class_targets() {
74        let ttl = br#"
75            @prefix rdf:   <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
76            @prefix rdfs:  <http://www.w3.org/2000/01/rdf-schema#> .
77            @prefix owl:   <http://www.w3.org/2002/07/owl#> .
78            @prefix sh:    <http://www.w3.org/ns/shacl#> .
79            @prefix ex:    <http://ex/> .
80
81            ex:Parent a owl:Class, sh:NodeShape ;
82                sh:rule [
83                    a sh:TripleRule ;
84                    sh:subject sh:this ;
85                    sh:predicate ex:hasTag ;
86                    sh:object ex:Tag
87                ] .
88
89            ex:Child rdfs:subClassOf ex:Parent .
90            ex:item a ex:Child .
91        "#;
92        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
93        let parsed = shifty_parse::parse_loaded(&loaded);
94        let normalized = shifty_opt::normalize(&parsed.schema);
95
96        let outcome = infer(&loaded.graph, &normalized).expect("stratifiable schema");
97
98        assert!(outcome.graph.contains(&oxrdf::Triple::new(
99            oxrdf::NamedNode::new_unchecked("http://ex/item"),
100            oxrdf::NamedNode::new_unchecked("http://ex/hasTag"),
101            oxrdf::NamedNode::new_unchecked("http://ex/Tag"),
102        )));
103    }
104
105    /// Parse + normalize + infer, asserting the parser emitted no diagnostics
106    /// (so the node expressions under test were actually lowered, not skipped).
107    fn infer_ttl(ttl: &[u8]) -> InferenceOutcome {
108        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
109        let parsed = shifty_parse::parse_loaded(&loaded);
110        assert!(
111            parsed.diagnostics.is_empty(),
112            "parse diagnostics: {:?}",
113            parsed.diagnostics
114        );
115        let normalized = shifty_opt::normalize(&parsed.schema);
116        infer(&loaded.graph, &normalized).expect("stratifiable schema")
117    }
118
119    fn triple_term(s: &str, p: &str, o: impl Into<oxrdf::Term>) -> oxrdf::Triple {
120        oxrdf::Triple::new(
121            oxrdf::NamedNode::new_unchecked(s),
122            oxrdf::NamedNode::new_unchecked(p),
123            o,
124        )
125    }
126
127    #[test]
128    fn rule_object_union_node_expression() {
129        // sh:union of two paths: both reachable values are inferred.
130        let outcome = infer_ttl(
131            br#"
132            @prefix sh: <http://www.w3.org/ns/shacl#> .
133            @prefix ex: <http://ex/> .
134            ex:PersonShape a sh:NodeShape ;
135                sh:targetClass ex:Person ;
136                sh:rule [
137                    a sh:TripleRule ;
138                    sh:subject sh:this ;
139                    sh:predicate ex:contact ;
140                    sh:object [ sh:union ( [ sh:path ex:email ] [ sh:path ex:phone ] ) ]
141                ] .
142            ex:alice a ex:Person ; ex:email "a@x.org" ; ex:phone "555-1234" .
143        "#,
144        );
145        let email = oxrdf::Literal::new_simple_literal("a@x.org");
146        let phone = oxrdf::Literal::new_simple_literal("555-1234");
147        assert!(outcome.graph.contains(&triple_term(
148            "http://ex/alice",
149            "http://ex/contact",
150            email
151        )));
152        assert!(outcome.graph.contains(&triple_term(
153            "http://ex/alice",
154            "http://ex/contact",
155            phone
156        )));
157    }
158
159    #[test]
160    fn rule_object_intersection_node_expression() {
161        // sh:intersection of two paths: only the value reachable by both is inferred.
162        let outcome = infer_ttl(
163            br#"
164            @prefix sh: <http://www.w3.org/ns/shacl#> .
165            @prefix ex: <http://ex/> .
166            ex:S a sh:NodeShape ;
167                sh:targetClass ex:T ;
168                sh:rule [
169                    a sh:TripleRule ;
170                    sh:subject sh:this ;
171                    sh:predicate ex:both ;
172                    sh:object [ sh:intersection ( [ sh:path ex:a ] [ sh:path ex:b ] ) ]
173                ] .
174            ex:x a ex:T ; ex:a ex:shared, ex:onlyA ; ex:b ex:shared, ex:onlyB .
175        "#,
176        );
177        let both = "http://ex/both";
178        assert!(outcome.graph.contains(&triple_term(
179            "http://ex/x",
180            both,
181            oxrdf::NamedNode::new_unchecked("http://ex/shared")
182        )));
183        assert!(!outcome.graph.contains(&triple_term(
184            "http://ex/x",
185            both,
186            oxrdf::NamedNode::new_unchecked("http://ex/onlyA")
187        )));
188        assert!(!outcome.graph.contains(&triple_term(
189            "http://ex/x",
190            both,
191            oxrdf::NamedNode::new_unchecked("http://ex/onlyB")
192        )));
193    }
194
195    #[test]
196    fn rule_object_filter_node_expression() {
197        // sh:filterShape + sh:nodes: keep only the values that conform to the shape.
198        let outcome = infer_ttl(
199            br#"
200            @prefix sh:  <http://www.w3.org/ns/shacl#> .
201            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
202            @prefix ex:  <http://ex/> .
203            ex:IntShape a sh:NodeShape ; sh:datatype xsd:integer .
204            ex:S a sh:NodeShape ;
205                sh:targetClass ex:T ;
206                sh:rule [
207                    a sh:TripleRule ;
208                    sh:subject sh:this ;
209                    sh:predicate ex:intValue ;
210                    sh:object [ sh:filterShape ex:IntShape ; sh:nodes [ sh:path ex:value ] ]
211                ] .
212            ex:x a ex:T ; ex:value 42, "hello" .
213        "#,
214        );
215        let int_value = "http://ex/intValue";
216        assert!(outcome.graph.contains(&triple_term(
217            "http://ex/x",
218            int_value,
219            oxrdf::Literal::new_typed_literal(
220                "42",
221                oxrdf::NamedNode::new_unchecked("http://www.w3.org/2001/XMLSchema#integer")
222            )
223        )));
224        assert!(!outcome.graph.contains(&triple_term(
225            "http://ex/x",
226            int_value,
227            oxrdf::Literal::new_simple_literal("hello")
228        )));
229    }
230
231    #[test]
232    fn planned_validation_preserves_severity_and_applies_threshold() {
233        let ttl = format!(
234            "{PREFIXES}
235            ex:S a sh:NodeShape ;
236                sh:targetNode ex:x ;
237                sh:property ex:InfoShape, ex:WarningShape .
238            ex:InfoShape a sh:PropertyShape ;
239                sh:path ex:required ;
240                sh:minCount 1 ;
241                sh:severity sh:Info .
242            ex:WarningShape a sh:PropertyShape ;
243                sh:path ex:required ;
244                sh:minCount 1 ;
245                sh:severity sh:Warning .
246            "
247        );
248        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
249        let parsed = shifty_parse::parse_loaded(&loaded);
250        let normalized = shifty_opt::normalize(&parsed.schema);
251        let plan = shifty_opt::plan(&normalized);
252
253        let info = validate_plan_with_options(
254            &loaded.graph,
255            &plan,
256            &ValidationOptions {
257                minimum_severity: shifty_algebra::Severity::Info,
258                sort_results: true,
259                ..Default::default()
260            },
261        )
262        .unwrap();
263        assert!(!info.conforms);
264        assert_eq!(info.violations.len(), 1);
265        assert_eq!(
266            info.violations[0].severity,
267            shifty_algebra::Severity::Warning
268        );
269        let mut severities: Vec<_> = info.violations[0]
270            .reasons
271            .iter()
272            .map(|reason| reason.severity.clone())
273            .collect();
274        severities.sort_by_key(shifty_algebra::Severity::rank);
275        assert_eq!(
276            severities,
277            vec![
278                shifty_algebra::Severity::Info,
279                shifty_algebra::Severity::Warning
280            ]
281        );
282
283        let warning = validate_plan_with_options(
284            &loaded.graph,
285            &plan,
286            &ValidationOptions {
287                minimum_severity: shifty_algebra::Severity::Warning,
288                sort_results: true,
289                ..Default::default()
290            },
291        )
292        .unwrap();
293        assert!(!warning.conforms);
294
295        let violation = validate_plan_with_options(
296            &loaded.graph,
297            &plan,
298            &ValidationOptions {
299                minimum_severity: shifty_algebra::Severity::Violation,
300                sort_results: true,
301                ..Default::default()
302            },
303        )
304        .unwrap();
305        assert!(violation.conforms);
306        assert_eq!(violation.violations.len(), 1);
307
308        let report = validate_report_with_options(
309            &loaded,
310            &loaded.graph,
311            &ValidationOptions {
312                minimum_severity: shifty_algebra::Severity::Violation,
313                sort_results: true,
314                ..Default::default()
315            },
316        );
317        assert!(report.conforms);
318        assert_eq!(report.results.len(), 2);
319    }
320
321    #[test]
322    fn validation_findings_sort_by_severity_then_focus_node() {
323        let ttl = format!(
324            "{PREFIXES}
325            ex:InfoShape a sh:NodeShape ;
326                sh:targetNode ex:a ;
327                sh:nodeKind sh:Literal ;
328                sh:severity sh:Info .
329            ex:WarningShape a sh:NodeShape ;
330                sh:targetNode ex:z ;
331                sh:nodeKind sh:Literal ;
332                sh:severity sh:Warning .
333            ex:ViolationShape a sh:NodeShape ;
334                sh:targetNode ex:m ;
335                sh:nodeKind sh:Literal .
336            "
337        );
338        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
339        let parsed = shifty_parse::parse_loaded(&loaded);
340        let plan = shifty_opt::plan(&shifty_opt::normalize(&parsed.schema));
341        let outcome = validate_plan(&loaded.graph, &plan).unwrap();
342
343        let ordered: Vec<_> = outcome
344            .violations
345            .iter()
346            .map(|finding| (finding.severity.clone(), finding.focus.to_string()))
347            .collect();
348        assert_eq!(
349            ordered,
350            vec![
351                (
352                    shifty_algebra::Severity::Violation,
353                    "<http://ex/m>".to_string()
354                ),
355                (
356                    shifty_algebra::Severity::Warning,
357                    "<http://ex/z>".to_string()
358                ),
359                (shifty_algebra::Severity::Info, "<http://ex/a>".to_string()),
360            ]
361        );
362    }
363
364    #[test]
365    fn reports_specific_failing_constraints() {
366        let ttl = format!(
367            "{PREFIXES}
368            ex:S a sh:NodeShape ;
369                sh:targetNode ex:x ;
370                sh:closed true ;
371                sh:ignoredProperties ( rdf:type ) ;
372                sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:maxCount 1 ] .
373            ex:x ex:age \"foo\" , 5 ; ex:extra 1 .
374            "
375        );
376        let outcome = run(&ttl);
377        assert!(!outcome.conforms);
378        assert_eq!(outcome.violations.len(), 1);
379        let msgs: Vec<&str> = outcome.violations[0]
380            .reasons
381            .iter()
382            .map(|r| r.message.as_str())
383            .collect();
384        // each distinct constraint is reported, not just "the node failed"
385        assert!(
386            msgs.iter().any(|m| m.contains("datatype(xsd:integer)")),
387            "missing datatype reason: {msgs:?}"
388        );
389        assert!(
390            msgs.iter().any(|m| m.contains("at most 1")),
391            "missing maxCount reason: {msgs:?}"
392        );
393        assert!(
394            msgs.iter()
395                .any(|m| m.contains("closed") && m.contains("extra")),
396            "missing closed reason: {msgs:?}"
397        );
398    }
399
400    #[test]
401    fn cardinality_and_datatype() {
402        let ttl = format!(
403            "{PREFIXES}
404            ex:S a sh:NodeShape ;
405                sh:targetNode ex:alice, ex:bob ;
406                sh:property [ sh:path ex:age ; sh:maxCount 1 ; sh:datatype xsd:integer ] .
407            ex:alice ex:age 30 .
408            ex:bob   ex:age 30 ; ex:age 40 .
409            "
410        );
411        let outcome = run(&ttl);
412        assert!(!outcome.conforms);
413        // only ex:bob violates maxCount 1
414        let bad: Vec<_> = outcome
415            .violations
416            .iter()
417            .map(|r| r.focus.to_string())
418            .collect();
419        assert_eq!(bad, vec!["<http://ex/bob>".to_string()]);
420    }
421
422    #[test]
423    fn qualified_value_shape_disjoint_uses_all_sibling_property_shapes() {
424        let ttl = format!(
425            "{PREFIXES}
426            ex:S a sh:NodeShape ;
427                sh:targetNode ex:x ;
428                sh:property ex:A, ex:B .
429            ex:A a sh:PropertyShape ;
430                sh:path ex:p ;
431                sh:qualifiedValueShape [ sh:class ex:TypeA ] ;
432                sh:qualifiedValueShapesDisjoint true ;
433                sh:qualifiedMinCount 1 .
434            ex:B a sh:PropertyShape ;
435                sh:path ex:q ;
436                sh:qualifiedValueShape [ sh:class ex:TypeB ] ;
437                sh:qualifiedValueShapesDisjoint true ;
438                sh:qualifiedMaxCount 10 .
439            ex:x ex:p ex:value .
440            ex:value a ex:TypeA, ex:TypeB .
441            "
442        );
443        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
444        assert!(
445            parsed.diagnostics.is_empty(),
446            "diags: {:?}",
447            parsed.diagnostics
448        );
449        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
450
451        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
452        assert!(!algebra.conforms);
453
454        let report = validate_report(&loaded, &loaded.graph);
455        assert!(!report.conforms);
456        assert_eq!(report.results.len(), 1);
457        assert_eq!(
458            report.results[0].component.as_str(),
459            "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"
460        );
461    }
462
463    #[test]
464    fn disjoint_on_node_shape_uses_the_focus_node_as_the_value() {
465        let ttl = format!(
466            "{PREFIXES}
467            ex:S a sh:NodeShape ;
468                sh:targetNode ex:valid, ex:invalid ;
469                sh:disjoint ex:p .
470            ex:valid ex:p ex:other .
471            ex:invalid ex:p ex:invalid .
472            "
473        );
474        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
475        assert!(
476            parsed.diagnostics.is_empty(),
477            "diags: {:?}",
478            parsed.diagnostics
479        );
480        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
481
482        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
483        assert!(!algebra.conforms);
484        assert_eq!(algebra.violations.len(), 1);
485        assert_eq!(
486            algebra.violations[0].focus.to_string(),
487            "<http://ex/invalid>"
488        );
489
490        let normalized = shifty_opt::normalize(&parsed.schema);
491        let plan = shifty_opt::plan(&normalized);
492        let planned = validate_plan(&loaded.graph, &plan).unwrap();
493        assert_eq!(planned.conforms, algebra.conforms);
494        assert_eq!(planned.violations.len(), algebra.violations.len());
495
496        let report = validate_report(&loaded, &loaded.graph);
497        assert!(!report.conforms);
498        assert_eq!(report.results.len(), 1);
499        assert_eq!(
500            report.results[0].component.as_str(),
501            "http://www.w3.org/ns/shacl#DisjointConstraintComponent"
502        );
503        assert_eq!(
504            report.results[0].value.as_ref().map(ToString::to_string),
505            Some("<http://ex/invalid>".to_string())
506        );
507    }
508
509    #[test]
510    fn expression_constraint_reports_non_true_values() {
511        // The W3C booleans-001 shape: sh:expression sh:this over boolean foci.
512        let ttl = format!(
513            "{PREFIXES}
514            ex:S a sh:NodeShape ;
515                sh:targetNode true, false ;
516                sh:expression sh:this .
517            "
518        );
519        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
520        assert!(
521            parsed.diagnostics.is_empty(),
522            "diags: {:?}",
523            parsed.diagnostics
524        );
525        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
526
527        // algebra path: only the `false` focus violates.
528        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
529        assert!(!algebra.conforms);
530        assert_eq!(algebra.violations.len(), 1);
531        assert_eq!(
532            algebra.violations[0].focus,
533            oxrdf::Term::Literal(oxrdf::Literal::new_typed_literal(
534                "false",
535                oxrdf::vocab::xsd::BOOLEAN
536            ))
537        );
538
539        // planned path agrees with the algebra oracle.
540        let normalized = shifty_opt::normalize(&parsed.schema);
541        let plan = shifty_opt::plan(&normalized);
542        let planned = validate_plan(&loaded.graph, &plan).unwrap();
543        assert_eq!(planned.conforms, algebra.conforms);
544        assert_eq!(planned.violations.len(), algebra.violations.len());
545
546        // W3C report path: one ExpressionConstraintComponent result, sh:value false.
547        let report = validate_report(&loaded, &loaded.graph);
548        assert!(!report.conforms);
549        assert_eq!(report.results.len(), 1);
550        let result = &report.results[0];
551        assert_eq!(
552            result.component.as_str(),
553            "http://www.w3.org/ns/shacl#ExpressionConstraintComponent"
554        );
555        assert_eq!(result.path, None);
556        assert_eq!(
557            result.value.as_ref().map(ToString::to_string),
558            Some("\"false\"^^<http://www.w3.org/2001/XMLSchema#boolean>".to_string())
559        );
560    }
561
562    #[test]
563    fn expression_constraint_with_path_and_filter() {
564        // The expression traverses a path from the focus and filters the values
565        // by a shape; only nodes passing the filter must (here, fail to) be true,
566        // exercising Path + Filter node expressions on the report path.
567        let ttl = format!(
568            "{PREFIXES}
569            ex:S a sh:NodeShape ;
570                sh:targetNode ex:x ;
571                sh:expression [
572                    sh:filterShape [ sh:datatype xsd:boolean ] ;
573                    sh:nodes [ sh:path ex:flag ] ;
574                ] .
575            ex:x ex:flag true, false, \"not-a-bool\" .
576            "
577        );
578        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
579        assert!(
580            parsed.diagnostics.is_empty(),
581            "diags: {:?}",
582            parsed.diagnostics
583        );
584        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
585
586        // The boolean values that survive the filter are {true, false}; `false`
587        // is the lone non-true value, so exactly one result, sh:value false.
588        let report = validate_report(&loaded, &loaded.graph);
589        assert!(!report.conforms);
590        assert_eq!(report.results.len(), 1);
591        assert_eq!(
592            report.results[0].component.as_str(),
593            "http://www.w3.org/ns/shacl#ExpressionConstraintComponent"
594        );
595        assert_eq!(
596            report.results[0].value.as_ref().map(ToString::to_string),
597            Some("\"false\"^^<http://www.w3.org/2001/XMLSchema#boolean>".to_string())
598        );
599
600        // algebra path agrees on (non-)conformance.
601        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
602        assert!(!algebra.conforms);
603        assert_eq!(algebra.violations.len(), 1);
604    }
605
606    #[test]
607    fn expression_constraint_with_function_is_diagnosed() {
608        // A function application inside sh:expression cannot be evaluated by the
609        // validation paths yet, so it is diagnosed rather than silently dropped.
610        let ttl = format!(
611            "{PREFIXES}
612            ex:S a sh:NodeShape ;
613                sh:targetNode ex:x ;
614                sh:expression [ ex:fn ( sh:this ) ] .
615            "
616        );
617        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
618        assert!(
619            parsed
620                .diagnostics
621                .iter()
622                .any(|d| d.message.contains("sh:expression")),
623            "expected an unsupported-expression diagnostic, got: {:?}",
624            parsed.diagnostics
625        );
626    }
627
628    #[test]
629    fn sparql_function_expression_evaluates() {
630        // The W3C simpleSPARQLFunction shapes: a no-arg ASK function and a
631        // two-argument SELECT function, called via dash:expression strings.
632        let ttl = br#"
633            @prefix sh: <http://www.w3.org/ns/shacl#> .
634            @prefix ex: <http://ex/> .
635            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
636            ex:booleanFunction a sh:SPARQLFunction ;
637                sh:returnType xsd:boolean ;
638                sh:ask "ASK { FILTER (true) }" .
639            ex:withArguments a sh:SPARQLFunction ;
640                sh:parameter [ sh:name "arg1" ; sh:path ex:arg1 ] ,
641                             [ sh:name "arg2" ; sh:path ex:arg2 ] ;
642                sh:returnType xsd:string ;
643                sh:select "SELECT ?result WHERE { BIND (CONCAT($arg1, \"-\", $arg2) AS ?result) }" .
644        "#;
645        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
646
647        let b = evaluate_function_expression(&loaded, "ex:booleanFunction()").unwrap();
648        assert_eq!(b, Some(oxrdf::Term::Literal(oxrdf::Literal::from(true))));
649
650        let s = evaluate_function_expression(&loaded, "ex:withArguments(\"A\", \"B\")").unwrap();
651        assert_eq!(
652            s,
653            Some(oxrdf::Term::Literal(oxrdf::Literal::new_simple_literal(
654                "A-B"
655            )))
656        );
657    }
658
659    #[test]
660    fn sparql_function_called_from_sparql_constraint() {
661        // A SHACL function is callable inside a sh:sparql constraint query: the
662        // constraint flags values for which ex:isOk returns false.
663        let ttl = br#"
664            @prefix sh: <http://www.w3.org/ns/shacl#> .
665            @prefix ex: <http://ex/> .
666            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
667            ex:isOk a sh:SPARQLFunction ;
668                sh:parameter [ sh:path ex:arg ] ;
669                sh:returnType xsd:boolean ;
670                sh:ask "ASK { FILTER (STR($arg) = \"ok\") }" .
671            ex:S a sh:NodeShape ;
672                sh:targetNode ex:x, ex:y ;
673                sh:sparql [ sh:select """SELECT $this ?value WHERE {
674                    $this <http://ex/val> ?value .
675                    FILTER (! <http://ex/isOk>(?value))
676                }""" ] .
677            ex:x <http://ex/val> "ok" .
678            ex:y <http://ex/val> "bad" .
679        "#;
680        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
681        let report = validate_report(&loaded, &loaded.graph);
682        assert!(!report.conforms);
683        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
684        assert_eq!(report.results[0].focus.to_string(), "<http://ex/y>");
685        assert_eq!(
686            report.results[0].value.as_ref().map(ToString::to_string),
687            Some("\"bad\"".to_string())
688        );
689    }
690
691    #[test]
692    fn graph_reading_function_policy_gates_loud_failure() {
693        // `ex:exists` reads the data graph, so from a SPARQL context it can only
694        // be evaluated over an empty dataset. The UnsupportedPolicy decides what
695        // happens when it is called from a sh:sparql constraint.
696        let ttl = br#"
697            @prefix sh: <http://www.w3.org/ns/shacl#> .
698            @prefix ex: <http://ex/> .
699            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
700            ex:exists a sh:SPARQLFunction ;
701                sh:returnType xsd:boolean ;
702                sh:ask "ASK { ?s <http://ex/marker> ?o }" .
703            ex:S a sh:NodeShape ;
704                sh:targetNode ex:x ;
705                sh:sparql [ sh:select
706                    "SELECT $this WHERE { $this a <http://ex/T> . FILTER (<http://ex/exists>()) }" ] .
707            ex:x a <http://ex/T> .
708            ex:y <http://ex/marker> "m" .
709        "#;
710        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
711
712        // Ignore (default): the function runs over an empty dataset (best-effort);
713        // here it returns false, so the constraint silently conforms.
714        let lenient = validate_report(&loaded, &loaded.graph);
715        assert!(lenient.conforms, "results: {:?}", lenient.results);
716
717        // Error: the graph-reading function is not registered, so the SPARQL call
718        // fails and the constraint fails closed (loud) rather than silently wrong.
719        let strict_opts = ValidationOptions {
720            engine: EngineOptions {
721                unsupported: UnsupportedPolicy::Error,
722            },
723            ..Default::default()
724        };
725        let strict = validate_report_with_options(&loaded, &loaded.graph, &strict_opts);
726        assert!(!strict.conforms);
727        assert_eq!(strict.results.len(), 1, "results: {:?}", strict.results);
728    }
729
730    #[test]
731    fn custom_component_ask_validator() {
732        // A two-parameter ASK component (the W3C validator-001 shape): a value
733        // conforms iff it is the concatenation of the two parameters.
734        let ttl = br#"
735            @prefix sh: <http://www.w3.org/ns/shacl#> .
736            @prefix ex: <http://ex/> .
737            ex:TestConstraintComponent a sh:ConstraintComponent ;
738                sh:parameter [ sh:path ex:test1 ] , [ sh:path ex:test2 ] ;
739                sh:validator [ a sh:SPARQLAskValidator ;
740                    sh:ask "ASK { FILTER (?value = CONCAT($test1, $test2)) }" ] .
741            ex:TestShape a sh:NodeShape ;
742                ex:test1 "Hello " ;
743                ex:test2 "World" ;
744                sh:targetNode "Hallo Welt", "Hello World" .
745        "#;
746        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
747        let report = validate_report(&loaded, &loaded.graph);
748        assert!(!report.conforms);
749        assert_eq!(report.results.len(), 1);
750        let r = &report.results[0];
751        assert_eq!(r.component.as_str(), "http://ex/TestConstraintComponent");
752        assert_eq!(r.focus.to_string(), "\"Hallo Welt\"");
753        assert_eq!(
754            r.value.as_ref().map(ToString::to_string),
755            Some("\"Hallo Welt\"".to_string())
756        );
757        assert_eq!(r.source_shape.to_string(), "<http://ex/TestShape>");
758        assert_eq!(r.path, None);
759
760        // The lowered algebra cannot evaluate components, so it diagnoses the
761        // activation rather than silently under-validating.
762        let parsed = parse_turtle(ttl, None).unwrap();
763        assert!(
764            parsed
765                .diagnostics
766                .iter()
767                .any(|d| d.message.contains("custom constraint component")),
768            "expected a custom-component diagnostic, got: {:?}",
769            parsed.diagnostics
770        );
771    }
772
773    #[test]
774    fn custom_component_select_node_validator() {
775        // A SELECT node validator with a required parameter: a focus violates
776        // when it lacks an ex:property edge equal to the bound $requiredParam.
777        let ttl = br#"
778            @prefix sh: <http://www.w3.org/ns/shacl#> .
779            @prefix ex: <http://ex/> .
780            ex:C a sh:ConstraintComponent ;
781                sh:parameter [ sh:path ex:requiredParam ] ;
782                sh:nodeValidator [ a sh:SPARQLSelectValidator ;
783                    sh:select """SELECT $this WHERE {
784                        $this ?p ?o .
785                        FILTER NOT EXISTS { $this <http://ex/property> $requiredParam }
786                    }""" ] .
787            ex:S a sh:NodeShape ;
788                ex:requiredParam "Value" ;
789                sh:targetNode ex:Good, ex:Bad .
790            ex:Good <http://ex/property> "Value" .
791            ex:Bad <http://ex/property> "Other" .
792        "#;
793        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
794        let report = validate_report(&loaded, &loaded.graph);
795        assert!(!report.conforms);
796        assert_eq!(report.results.len(), 1);
797        let r = &report.results[0];
798        assert_eq!(r.component.as_str(), "http://ex/C");
799        assert_eq!(r.focus.to_string(), "<http://ex/Bad>");
800        assert_eq!(
801            r.value.as_ref().map(ToString::to_string),
802            Some("<http://ex/Bad>".to_string())
803        );
804    }
805
806    #[test]
807    fn custom_component_not_activated_when_mandatory_param_absent() {
808        // The component is only activated for shapes that supply every mandatory
809        // parameter; a shape missing it produces no results (and no diagnostic).
810        let ttl = br#"
811            @prefix sh: <http://www.w3.org/ns/shacl#> .
812            @prefix ex: <http://ex/> .
813            ex:C a sh:ConstraintComponent ;
814                sh:parameter [ sh:path ex:requiredParam ] ;
815                sh:validator [ a sh:SPARQLAskValidator ; sh:ask "ASK { FILTER (false) }" ] .
816            ex:S a sh:NodeShape ;
817                sh:targetNode ex:x .
818            ex:x ex:other "z" .
819        "#;
820        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
821        let report = validate_report(&loaded, &loaded.graph);
822        assert!(report.conforms, "results: {:?}", report.results);
823
824        let parsed = parse_turtle(ttl, None).unwrap();
825        assert!(
826            !parsed
827                .diagnostics
828                .iter()
829                .any(|d| d.message.contains("custom constraint component")),
830            "an inactive component must not be diagnosed: {:?}",
831            parsed.diagnostics
832        );
833    }
834
835    #[test]
836    fn custom_component_property_validator_complex_path() {
837        // A property shape with a *sequence* path activates a SELECT property
838        // validator: `$PATH` is pre-bound to ex:a/ex:b, so the validator reaches
839        // the value nodes two hops away and flags the forbidden one.
840        let ttl = br#"
841            @prefix sh: <http://www.w3.org/ns/shacl#> .
842            @prefix ex: <http://ex/> .
843            ex:ForbidComponent a sh:ConstraintComponent ;
844                sh:parameter [ sh:path ex:forbidden ] ;
845                sh:propertyValidator [ a sh:SPARQLSelectValidator ;
846                    sh:select """SELECT $this ?value WHERE {
847                        $this $PATH ?value .
848                        FILTER (STR(?value) = STR($forbidden))
849                    }""" ] .
850            ex:S a sh:NodeShape ;
851                sh:targetNode ex:x ;
852                sh:property [ sh:path ( ex:a ex:b ) ; ex:forbidden "bad" ] .
853            ex:x ex:a ex:m .
854            ex:m ex:b "bad", "ok" .
855        "#;
856        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
857        let report = validate_report(&loaded, &loaded.graph);
858        assert!(!report.conforms);
859        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
860        let r = &report.results[0];
861        assert_eq!(r.component.as_str(), "http://ex/ForbidComponent");
862        assert_eq!(r.focus.to_string(), "<http://ex/x>");
863        assert_eq!(
864            r.value.as_ref().map(ToString::to_string),
865            Some("\"bad\"".to_string())
866        );
867    }
868
869    #[test]
870    fn custom_component_property_validator_inverse_path() {
871        // An inverse path `^ex:parent`: the value nodes are the subjects that
872        // point at the focus via ex:parent. The ASK validator runs per value node
873        // with `$PATH` pre-bound, flagging values whose label is not "ok".
874        let ttl = br#"
875            @prefix sh: <http://www.w3.org/ns/shacl#> .
876            @prefix ex: <http://ex/> .
877            ex:OkComponent a sh:ConstraintComponent ;
878                sh:parameter [ sh:path ex:want ] ;
879                sh:validator [ a sh:SPARQLAskValidator ;
880                    sh:ask "ASK { $value <http://ex/label> $want }" ] .
881            ex:S a sh:NodeShape ;
882                sh:targetNode ex:p ;
883                sh:property [ sh:path [ sh:inversePath ex:parent ] ; ex:want "ok" ] .
884            ex:c1 ex:parent ex:p ; ex:label "ok" .
885            ex:c2 ex:parent ex:p ; ex:label "no" .
886        "#;
887        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
888        let report = validate_report(&loaded, &loaded.graph);
889        // Value nodes of ex:p along ^ex:parent are {ex:c1, ex:c2}; only ex:c2
890        // lacks `ex:label "ok"`, so the ASK is false there → one violation.
891        assert!(!report.conforms);
892        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
893        assert_eq!(
894            report.results[0].component.as_str(),
895            "http://ex/OkComponent"
896        );
897        assert_eq!(
898            report.results[0].value.as_ref().map(ToString::to_string),
899            Some("<http://ex/c2>".to_string())
900        );
901    }
902
903    #[test]
904    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
905        let ttl = format!(
906            "{PREFIXES}
907            ex:S a sh:NodeShape ;
908                sh:targetNode ex:valid, ex:extra, ex:missing ;
909                sh:equals ex:p .
910            ex:valid ex:p ex:valid .
911            ex:extra ex:p ex:extra, ex:other .
912            "
913        );
914        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
915        assert!(
916            parsed.diagnostics.is_empty(),
917            "diags: {:?}",
918            parsed.diagnostics
919        );
920        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
921
922        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
923        assert!(!algebra.conforms);
924        let mut foci: Vec<_> = algebra
925            .violations
926            .iter()
927            .map(|violation| violation.focus.to_string())
928            .collect();
929        foci.sort();
930        assert_eq!(
931            foci,
932            [
933                "<http://ex/extra>".to_string(),
934                "<http://ex/missing>".to_string()
935            ]
936        );
937
938        let normalized = shifty_opt::normalize(&parsed.schema);
939        let plan = shifty_opt::plan(&normalized);
940        let planned = validate_plan(&loaded.graph, &plan).unwrap();
941        assert_eq!(planned.conforms, algebra.conforms);
942        assert_eq!(planned.violations.len(), algebra.violations.len());
943
944        let report = validate_report(&loaded, &loaded.graph);
945        assert!(!report.conforms);
946        assert_eq!(report.results.len(), 2);
947        assert!(report.results.iter().all(|result| result.component.as_str()
948            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
949    }
950
951    #[test]
952    fn datatype_violation() {
953        let ttl = format!(
954            "{PREFIXES}
955            ex:S a sh:NodeShape ;
956                sh:targetNode ex:x ;
957                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
958            ex:x ex:p \"hello\" .
959            "
960        );
961        assert!(!run(&ttl).conforms);
962    }
963
964    #[test]
965    fn nodekind_and_class_target() {
966        let ttl = format!(
967            "{PREFIXES}
968            ex:S a sh:NodeShape ;
969                sh:targetClass ex:Person ;
970                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
971            ex:alice a ex:Person ; ex:knows ex:bob .
972            ex:carol a ex:Person ; ex:knows \"notaniri\" .
973            "
974        );
975        let outcome = run(&ttl);
976        assert!(!outcome.conforms);
977        let bad: Vec<_> = outcome
978            .violations
979            .iter()
980            .map(|r| r.focus.to_string())
981            .collect();
982        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
983    }
984
985    #[test]
986    fn recursion_over_cyclic_data_terminates() {
987        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
988        let ttl = format!(
989            "{PREFIXES}
990            ex:S a sh:NodeShape ;
991                sh:targetNode ex:a ;
992                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
993            ex:a ex:knows ex:b .
994            ex:b ex:knows ex:a .
995            "
996        );
997        // Must terminate; with all-IRI neighbours it conforms under the
998        // provisional cycle-breaking semantics.
999        assert!(run(&ttl).conforms);
1000    }
1001
1002    #[test]
1003    fn empty_graph_conforms() {
1004        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
1005        assert!(outcome.conforms);
1006    }
1007
1008    #[test]
1009    fn non_stratifiable_schema_is_diagnosed() {
1010        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
1011        let ttl = format!(
1012            "{PREFIXES}
1013            ex:S a sh:NodeShape ;
1014                sh:targetNode ex:x ;
1015                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
1016            ex:x ex:p ex:y .
1017            "
1018        );
1019        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1020        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1021        assert!(validate(&loaded.graph, &out.schema).is_err());
1022    }
1023
1024    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
1025        use oxrdf::NamedNode;
1026        oxrdf::Triple::new(
1027            NamedNode::new(s).unwrap(),
1028            NamedNode::new(p).unwrap(),
1029            NamedNode::new(o).unwrap(),
1030        )
1031    }
1032
1033    #[test]
1034    fn triple_rule_infers_from_path() {
1035        // copy each ex:knows value to ex:knows2
1036        let ttl = format!(
1037            "{PREFIXES}
1038            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1039                sh:rule [ a sh:TripleRule ;
1040                    sh:subject sh:this ; sh:predicate ex:knows2 ;
1041                    sh:object [ sh:path ex:knows ] ] .
1042            ex:a a ex:Person ; ex:knows ex:b .
1043            "
1044        );
1045        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1046        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1047        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1048        assert_eq!(outcome.inferred.len(), 1);
1049        assert!(
1050            outcome
1051                .graph
1052                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
1053        );
1054    }
1055
1056    #[test]
1057    fn inference_reaches_a_fixpoint() {
1058        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
1059        // a→b→c, so a reaches c is derivable only after b reaches c.
1060        let ttl = format!(
1061            "{PREFIXES}
1062            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1063                sh:rule [ a sh:TripleRule ;
1064                    sh:subject sh:this ; sh:predicate ex:reaches ;
1065                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
1066            ex:a a ex:Person ; ex:knows ex:b .
1067            ex:b a ex:Person ; ex:knows ex:c .
1068            ex:c a ex:Person .
1069            "
1070        );
1071        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1072        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1073        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1074        assert!(
1075            outcome
1076                .graph
1077                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
1078        );
1079        assert!(
1080            outcome
1081                .graph
1082                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
1083        );
1084        // the fixpoint result: a reaches c (only via b reaches c)
1085        assert!(
1086            outcome
1087                .graph
1088                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
1089        );
1090    }
1091
1092    #[test]
1093    fn later_order_output_reactivates_an_earlier_rule() {
1094        let ttl = format!(
1095            "{PREFIXES}
1096            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1097                sh:rule [
1098                    a sh:TripleRule ; sh:order 0 ;
1099                    sh:subject sh:this ; sh:predicate ex:done ;
1100                    sh:object [ sh:path ex:ready ]
1101                ] ;
1102                sh:rule [
1103                    a sh:TripleRule ; sh:order 1 ;
1104                    sh:subject sh:this ; sh:predicate ex:ready ;
1105                    sh:object ex:y
1106                ] .
1107            "
1108        );
1109        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1110        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1111        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1112
1113        assert!(
1114            outcome
1115                .graph
1116                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
1117        );
1118        assert!(
1119            outcome
1120                .graph
1121                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
1122        );
1123    }
1124
1125    #[test]
1126    fn inferred_triples_can_create_new_rule_targets() {
1127        let ttl = format!(
1128            "{PREFIXES}
1129            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
1130                sh:rule [
1131                    a sh:TripleRule ;
1132                    sh:subject sh:this ; sh:predicate ex:eligible ;
1133                    sh:object ex:y
1134                ] .
1135            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
1136                sh:rule [
1137                    a sh:TripleRule ;
1138                    sh:subject sh:this ; sh:predicate ex:classified ;
1139                    sh:object ex:yes
1140                ] .
1141            "
1142        );
1143        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1144        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1145        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1146
1147        assert!(outcome.graph.contains(&triple(
1148            "http://ex/x",
1149            "http://ex/classified",
1150            "http://ex/yes",
1151        )));
1152    }
1153
1154    #[test]
1155    fn split_inference_uses_shapes_graph_as_rule_context() {
1156        let shapes_ttl = format!(
1157            "{PREFIXES}
1158            ex:InverseShape a sh:NodeShape ;
1159                sh:targetClass ex:Thing ;
1160                sh:rule [
1161                    a sh:SPARQLRule ;
1162                    sh:construct \"\"\"
1163                        CONSTRUCT {{ ?o ?inverse $this }}
1164                        WHERE {{
1165                            $this ?predicate ?o .
1166                            ?predicate ex:inverseOf ?inverse .
1167                        }}
1168                    \"\"\"
1169                ] .
1170            ex:p ex:inverseOf ex:q .
1171            "
1172        );
1173        let data_ttl = format!(
1174            "{PREFIXES}
1175            ex:a a ex:Thing ; ex:p ex:b .
1176            "
1177        );
1178        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
1179        let parsed = shifty_parse::parse_loaded(&shapes);
1180        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
1181
1182        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
1183
1184        assert!(
1185            outcome
1186                .graph
1187                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
1188        );
1189        assert_eq!(outcome.inferred.len(), 1);
1190    }
1191}