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 algebra path now evaluates components too: "Hallo Welt" should
761        // still be the only violation.
762        let parse_out = shifty_parse::parse_loaded(&loaded);
763        let schema = shifty_opt::normalize(&parse_out.schema);
764        let plan = shifty_opt::plan(&schema);
765        let outcome = validate_plan_graphs(&loaded.graph, &loaded.graph, &plan).unwrap();
766        assert!(!outcome.conforms, "algebra: Hallo Welt must still violate");
767        assert_eq!(
768            outcome.violations.len(),
769            1,
770            "algebra: exactly one violation: {:?}",
771            outcome.violations
772        );
773        assert_eq!(
774            outcome.violations[0].focus.to_string(),
775            "\"Hallo Welt\"",
776            "algebra: wrong focus node"
777        );
778    }
779
780    #[test]
781    fn custom_component_select_node_validator() {
782        // A SELECT node validator with a required parameter: a focus violates
783        // when it lacks an ex:property edge equal to the bound $requiredParam.
784        let ttl = br#"
785            @prefix sh: <http://www.w3.org/ns/shacl#> .
786            @prefix ex: <http://ex/> .
787            ex:C a sh:ConstraintComponent ;
788                sh:parameter [ sh:path ex:requiredParam ] ;
789                sh:nodeValidator [ a sh:SPARQLSelectValidator ;
790                    sh:select """SELECT $this WHERE {
791                        $this ?p ?o .
792                        FILTER NOT EXISTS { $this <http://ex/property> $requiredParam }
793                    }""" ] .
794            ex:S a sh:NodeShape ;
795                ex:requiredParam "Value" ;
796                sh:targetNode ex:Good, ex:Bad .
797            ex:Good <http://ex/property> "Value" .
798            ex:Bad <http://ex/property> "Other" .
799        "#;
800        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
801        let report = validate_report(&loaded, &loaded.graph);
802        assert!(!report.conforms);
803        assert_eq!(report.results.len(), 1);
804        let r = &report.results[0];
805        assert_eq!(r.component.as_str(), "http://ex/C");
806        assert_eq!(r.focus.to_string(), "<http://ex/Bad>");
807        assert_eq!(
808            r.value.as_ref().map(ToString::to_string),
809            Some("<http://ex/Bad>".to_string())
810        );
811    }
812
813    #[test]
814    fn custom_component_not_activated_when_mandatory_param_absent() {
815        // The component is only activated for shapes that supply every mandatory
816        // parameter; a shape missing it produces no results (and no diagnostic).
817        let ttl = br#"
818            @prefix sh: <http://www.w3.org/ns/shacl#> .
819            @prefix ex: <http://ex/> .
820            ex:C a sh:ConstraintComponent ;
821                sh:parameter [ sh:path ex:requiredParam ] ;
822                sh:validator [ a sh:SPARQLAskValidator ; sh:ask "ASK { FILTER (false) }" ] .
823            ex:S a sh:NodeShape ;
824                sh:targetNode ex:x .
825            ex:x ex:other "z" .
826        "#;
827        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
828        let report = validate_report(&loaded, &loaded.graph);
829        assert!(report.conforms, "results: {:?}", report.results);
830
831        let parsed = parse_turtle(ttl, None).unwrap();
832        assert!(
833            !parsed
834                .diagnostics
835                .iter()
836                .any(|d| d.message.contains("custom constraint component")),
837            "an inactive component must not be diagnosed: {:?}",
838            parsed.diagnostics
839        );
840    }
841
842    #[test]
843    fn custom_component_property_validator_complex_path() {
844        // A property shape with a *sequence* path activates a SELECT property
845        // validator: `$PATH` is pre-bound to ex:a/ex:b, so the validator reaches
846        // the value nodes two hops away and flags the forbidden one.
847        let ttl = br#"
848            @prefix sh: <http://www.w3.org/ns/shacl#> .
849            @prefix ex: <http://ex/> .
850            ex:ForbidComponent a sh:ConstraintComponent ;
851                sh:parameter [ sh:path ex:forbidden ] ;
852                sh:propertyValidator [ a sh:SPARQLSelectValidator ;
853                    sh:select """SELECT $this ?value WHERE {
854                        $this $PATH ?value .
855                        FILTER (STR(?value) = STR($forbidden))
856                    }""" ] .
857            ex:S a sh:NodeShape ;
858                sh:targetNode ex:x ;
859                sh:property [ sh:path ( ex:a ex:b ) ; ex:forbidden "bad" ] .
860            ex:x ex:a ex:m .
861            ex:m ex:b "bad", "ok" .
862        "#;
863        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
864        let report = validate_report(&loaded, &loaded.graph);
865        assert!(!report.conforms);
866        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
867        let r = &report.results[0];
868        assert_eq!(r.component.as_str(), "http://ex/ForbidComponent");
869        assert_eq!(r.focus.to_string(), "<http://ex/x>");
870        assert_eq!(
871            r.value.as_ref().map(ToString::to_string),
872            Some("\"bad\"".to_string())
873        );
874    }
875
876    #[test]
877    fn custom_component_property_validator_inverse_path() {
878        // An inverse path `^ex:parent`: the value nodes are the subjects that
879        // point at the focus via ex:parent. The ASK validator runs per value node
880        // with `$PATH` pre-bound, flagging values whose label is not "ok".
881        let ttl = br#"
882            @prefix sh: <http://www.w3.org/ns/shacl#> .
883            @prefix ex: <http://ex/> .
884            ex:OkComponent a sh:ConstraintComponent ;
885                sh:parameter [ sh:path ex:want ] ;
886                sh:validator [ a sh:SPARQLAskValidator ;
887                    sh:ask "ASK { $value <http://ex/label> $want }" ] .
888            ex:S a sh:NodeShape ;
889                sh:targetNode ex:p ;
890                sh:property [ sh:path [ sh:inversePath ex:parent ] ; ex:want "ok" ] .
891            ex:c1 ex:parent ex:p ; ex:label "ok" .
892            ex:c2 ex:parent ex:p ; ex:label "no" .
893        "#;
894        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
895        let report = validate_report(&loaded, &loaded.graph);
896        // Value nodes of ex:p along ^ex:parent are {ex:c1, ex:c2}; only ex:c2
897        // lacks `ex:label "ok"`, so the ASK is false there → one violation.
898        assert!(!report.conforms);
899        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
900        assert_eq!(
901            report.results[0].component.as_str(),
902            "http://ex/OkComponent"
903        );
904        assert_eq!(
905            report.results[0].value.as_ref().map(ToString::to_string),
906            Some("<http://ex/c2>".to_string())
907        );
908    }
909
910    #[test]
911    fn custom_component_ask_validator_subquery_count() {
912        // ASK validator that counts graph-wide instances of $class and checks
913        // the total equals $exactCount (the BuildingMOTIF exactCount pattern).
914        // The correct SPARQL uses a subquery to aggregate, then FILTER in the
915        // outer query — avoiding the invalid `SELECT * … HAVING (aggregate)`
916        // pattern that spargebra rightly rejects.
917        let shapes_ttl = br#"
918            @prefix sh:  <http://www.w3.org/ns/shacl#> .
919            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
920            @prefix ex:  <urn:ex/> .
921
922            ex:countComponent a sh:ConstraintComponent ;
923                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
924                sh:parameter [ sh:path ex:class ] ;
925                sh:validator ex:hasExactCount .
926
927            ex:hasExactCount a sh:SPARQLAskValidator ;
928                sh:message "Wrong count" ;
929                sh:ask """
930                    ASK {
931                        {
932                            SELECT (COUNT(DISTINCT ?i) AS ?count)
933                            WHERE { ?i a $class . }
934                        }
935                        FILTER (?count = $exactCount)
936                    }
937                """ .
938
939            ex:shape a sh:NodeShape ;
940                sh:targetNode ex:sentinel ;
941                ex:class ex:Thing ;
942                ex:exactCount 1 .
943        "#;
944        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
945
946        // Zero instances → violates
947        let data_none =
948            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
949                .unwrap();
950        let report = validate_report_graphs(&shapes, &data_none.graph);
951        assert!(
952            !report.conforms,
953            "zero Things with exactCount=1 must not conform: {:?}",
954            report.results
955        );
956
957        // Exactly one instance → conforms
958        let data_one = shifty_parse::load_turtle(
959            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing .",
960            None,
961        )
962        .unwrap();
963        let report_ok = validate_report_graphs(&shapes, &data_one.graph);
964        assert!(
965            report_ok.conforms,
966            "exactly one Thing with exactCount=1 must conform: {:?}",
967            report_ok.results
968        );
969
970        // Two instances → violates
971        let data_two = shifty_parse::load_turtle(
972            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing . ex:t2 a ex:Thing .",
973            None,
974        )
975        .unwrap();
976        let report_two = validate_report_graphs(&shapes, &data_two.graph);
977        assert!(
978            !report_two.conforms,
979            "two Things with exactCount=1 must not conform: {:?}",
980            report_two.results
981        );
982
983        // Same checks via the algebra path.
984        let parse_out = shifty_parse::parse_loaded(&shapes);
985        let schema = shifty_opt::normalize(&parse_out.schema);
986        let plan = shifty_opt::plan(&schema);
987        let alg_none = validate_plan_graphs(&data_none.graph, &shapes.graph, &plan).unwrap();
988        assert!(!alg_none.conforms, "algebra: zero Things must not conform");
989        let alg_one = validate_plan_graphs(&data_one.graph, &shapes.graph, &plan).unwrap();
990        assert!(alg_one.conforms, "algebra: one Thing must conform");
991        let alg_two = validate_plan_graphs(&data_two.graph, &shapes.graph, &plan).unwrap();
992        assert!(!alg_two.conforms, "algebra: two Things must not conform");
993    }
994
995    #[test]
996    fn custom_component_invalid_sparql_ignored_by_default() {
997        // A validator whose sh:ask is invalid SPARQL (SELECT * with an implicit
998        // GROUP BY from HAVING) is silently skipped under the default Ignore
999        // policy, so the component is not enforced and the shape appears to
1000        // conform even when the data does not.  This documents the known
1001        // silent-skip behaviour; use on_unsupported="error" to surface it.
1002        let shapes_ttl = br#"
1003            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1004            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1005            @prefix ex:  <urn:ex/> .
1006
1007            ex:badComponent a sh:ConstraintComponent ;
1008                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1009                sh:parameter [ sh:path ex:class ] ;
1010                sh:validator [ a sh:SPARQLAskValidator ;
1011                    sh:ask """
1012                        ASK WHERE {
1013                            {
1014                                SELECT *
1015                                WHERE { ?i a $class . }
1016                                HAVING (COUNT(DISTINCT ?i) = $exactCount)
1017                            }
1018                        }
1019                    """ ] .
1020
1021            ex:shape a sh:NodeShape ;
1022                sh:targetNode ex:sentinel ;
1023                ex:class ex:Thing ;
1024                ex:exactCount 1 .
1025        "#;
1026        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1027        let data =
1028            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1029                .unwrap();
1030
1031        // Ignore policy (default): bad query silently skipped → appears to conform
1032        let report = validate_report_graphs(&shapes, &data.graph);
1033        assert!(
1034            report.conforms,
1035            "bad validator query silently skipped under Ignore: {:?}",
1036            report.results
1037        );
1038    }
1039
1040    #[test]
1041    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
1042        let ttl = format!(
1043            "{PREFIXES}
1044            ex:S a sh:NodeShape ;
1045                sh:targetNode ex:valid, ex:extra, ex:missing ;
1046                sh:equals ex:p .
1047            ex:valid ex:p ex:valid .
1048            ex:extra ex:p ex:extra, ex:other .
1049            "
1050        );
1051        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1052        assert!(
1053            parsed.diagnostics.is_empty(),
1054            "diags: {:?}",
1055            parsed.diagnostics
1056        );
1057        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1058
1059        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
1060        assert!(!algebra.conforms);
1061        let mut foci: Vec<_> = algebra
1062            .violations
1063            .iter()
1064            .map(|violation| violation.focus.to_string())
1065            .collect();
1066        foci.sort();
1067        assert_eq!(
1068            foci,
1069            [
1070                "<http://ex/extra>".to_string(),
1071                "<http://ex/missing>".to_string()
1072            ]
1073        );
1074
1075        let normalized = shifty_opt::normalize(&parsed.schema);
1076        let plan = shifty_opt::plan(&normalized);
1077        let planned = validate_plan(&loaded.graph, &plan).unwrap();
1078        assert_eq!(planned.conforms, algebra.conforms);
1079        assert_eq!(planned.violations.len(), algebra.violations.len());
1080
1081        let report = validate_report(&loaded, &loaded.graph);
1082        assert!(!report.conforms);
1083        assert_eq!(report.results.len(), 2);
1084        assert!(report.results.iter().all(|result| result.component.as_str()
1085            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
1086    }
1087
1088    #[test]
1089    fn datatype_violation() {
1090        let ttl = format!(
1091            "{PREFIXES}
1092            ex:S a sh:NodeShape ;
1093                sh:targetNode ex:x ;
1094                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
1095            ex:x ex:p \"hello\" .
1096            "
1097        );
1098        assert!(!run(&ttl).conforms);
1099    }
1100
1101    #[test]
1102    fn nodekind_and_class_target() {
1103        let ttl = format!(
1104            "{PREFIXES}
1105            ex:S a sh:NodeShape ;
1106                sh:targetClass ex:Person ;
1107                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
1108            ex:alice a ex:Person ; ex:knows ex:bob .
1109            ex:carol a ex:Person ; ex:knows \"notaniri\" .
1110            "
1111        );
1112        let outcome = run(&ttl);
1113        assert!(!outcome.conforms);
1114        let bad: Vec<_> = outcome
1115            .violations
1116            .iter()
1117            .map(|r| r.focus.to_string())
1118            .collect();
1119        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
1120    }
1121
1122    #[test]
1123    fn recursion_over_cyclic_data_terminates() {
1124        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
1125        let ttl = format!(
1126            "{PREFIXES}
1127            ex:S a sh:NodeShape ;
1128                sh:targetNode ex:a ;
1129                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
1130            ex:a ex:knows ex:b .
1131            ex:b ex:knows ex:a .
1132            "
1133        );
1134        // Must terminate; with all-IRI neighbours it conforms under the
1135        // provisional cycle-breaking semantics.
1136        assert!(run(&ttl).conforms);
1137    }
1138
1139    #[test]
1140    fn empty_graph_conforms() {
1141        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
1142        assert!(outcome.conforms);
1143    }
1144
1145    #[test]
1146    fn non_stratifiable_schema_is_diagnosed() {
1147        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
1148        let ttl = format!(
1149            "{PREFIXES}
1150            ex:S a sh:NodeShape ;
1151                sh:targetNode ex:x ;
1152                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
1153            ex:x ex:p ex:y .
1154            "
1155        );
1156        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1157        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1158        assert!(validate(&loaded.graph, &out.schema).is_err());
1159    }
1160
1161    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
1162        use oxrdf::NamedNode;
1163        oxrdf::Triple::new(
1164            NamedNode::new(s).unwrap(),
1165            NamedNode::new(p).unwrap(),
1166            NamedNode::new(o).unwrap(),
1167        )
1168    }
1169
1170    #[test]
1171    fn triple_rule_infers_from_path() {
1172        // copy each ex:knows value to ex:knows2
1173        let ttl = format!(
1174            "{PREFIXES}
1175            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1176                sh:rule [ a sh:TripleRule ;
1177                    sh:subject sh:this ; sh:predicate ex:knows2 ;
1178                    sh:object [ sh:path ex:knows ] ] .
1179            ex:a a ex:Person ; ex:knows ex:b .
1180            "
1181        );
1182        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1183        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1184        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1185        assert_eq!(outcome.inferred.len(), 1);
1186        assert!(
1187            outcome
1188                .graph
1189                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
1190        );
1191    }
1192
1193    #[test]
1194    fn inference_reaches_a_fixpoint() {
1195        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
1196        // a→b→c, so a reaches c is derivable only after b reaches c.
1197        let ttl = format!(
1198            "{PREFIXES}
1199            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1200                sh:rule [ a sh:TripleRule ;
1201                    sh:subject sh:this ; sh:predicate ex:reaches ;
1202                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
1203            ex:a a ex:Person ; ex:knows ex:b .
1204            ex:b a ex:Person ; ex:knows ex:c .
1205            ex:c a ex:Person .
1206            "
1207        );
1208        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1209        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1210        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1211        assert!(
1212            outcome
1213                .graph
1214                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
1215        );
1216        assert!(
1217            outcome
1218                .graph
1219                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
1220        );
1221        // the fixpoint result: a reaches c (only via b reaches c)
1222        assert!(
1223            outcome
1224                .graph
1225                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
1226        );
1227    }
1228
1229    #[test]
1230    fn later_order_output_reactivates_an_earlier_rule() {
1231        let ttl = format!(
1232            "{PREFIXES}
1233            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1234                sh:rule [
1235                    a sh:TripleRule ; sh:order 0 ;
1236                    sh:subject sh:this ; sh:predicate ex:done ;
1237                    sh:object [ sh:path ex:ready ]
1238                ] ;
1239                sh:rule [
1240                    a sh:TripleRule ; sh:order 1 ;
1241                    sh:subject sh:this ; sh:predicate ex:ready ;
1242                    sh:object ex:y
1243                ] .
1244            "
1245        );
1246        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1247        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1248        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1249
1250        assert!(
1251            outcome
1252                .graph
1253                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
1254        );
1255        assert!(
1256            outcome
1257                .graph
1258                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
1259        );
1260    }
1261
1262    #[test]
1263    fn inferred_triples_can_create_new_rule_targets() {
1264        let ttl = format!(
1265            "{PREFIXES}
1266            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
1267                sh:rule [
1268                    a sh:TripleRule ;
1269                    sh:subject sh:this ; sh:predicate ex:eligible ;
1270                    sh:object ex:y
1271                ] .
1272            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
1273                sh:rule [
1274                    a sh:TripleRule ;
1275                    sh:subject sh:this ; sh:predicate ex:classified ;
1276                    sh:object ex:yes
1277                ] .
1278            "
1279        );
1280        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1281        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1282        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1283
1284        assert!(outcome.graph.contains(&triple(
1285            "http://ex/x",
1286            "http://ex/classified",
1287            "http://ex/yes",
1288        )));
1289    }
1290
1291    #[test]
1292    fn split_inference_uses_shapes_graph_as_rule_context() {
1293        let shapes_ttl = format!(
1294            "{PREFIXES}
1295            ex:InverseShape a sh:NodeShape ;
1296                sh:targetClass ex:Thing ;
1297                sh:rule [
1298                    a sh:SPARQLRule ;
1299                    sh:construct \"\"\"
1300                        CONSTRUCT {{ ?o ?inverse $this }}
1301                        WHERE {{
1302                            $this ?predicate ?o .
1303                            ?predicate ex:inverseOf ?inverse .
1304                        }}
1305                    \"\"\"
1306                ] .
1307            ex:p ex:inverseOf ex:q .
1308            "
1309        );
1310        let data_ttl = format!(
1311            "{PREFIXES}
1312            ex:a a ex:Thing ; ex:p ex:b .
1313            "
1314        );
1315        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
1316        let parsed = shifty_parse::parse_loaded(&shapes);
1317        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
1318
1319        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
1320
1321        assert!(
1322            outcome
1323                .graph
1324                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
1325        );
1326        assert_eq!(outcome.inferred.len(), 1);
1327    }
1328}