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    PropertyWitness, ValidationReport, ValidationResult, evaluate_function_expression,
34    property_witnesses_graphs_with_mode, property_witnesses_graphs_with_mode_and_options,
35    report_to_graph, validate_report, validate_report_graphs, validate_report_graphs_with_mode,
36    validate_report_graphs_with_mode_and_options, validate_report_with_options,
37};
38pub use synthesize::{synthesize, synthesize_focus};
39pub use validate::{
40    EngineOptions, NonStratifiable, Reason, UnsupportedPolicy, ValidationGraphMode,
41    ValidationOptions, ValidationOutcome, Violation, focus_nodes, graph_union, validate,
42    validate_graphs, validate_graphs_with_mode, validate_graphs_with_mode_and_options,
43    validate_plan, validate_plan_graphs, validate_plan_graphs_with_mode,
44    validate_plan_graphs_with_mode_and_options, validate_plan_with_context,
45    validate_plan_with_context_and_options, validate_plan_with_options, validate_with_context,
46    validate_with_context_and_options, validate_with_options,
47};
48pub use witness::{
49    BlockReason, FocusSat, FocusWitness, PathSupport, RelKind, SatTrace, Witness, satisfy_shape,
50    shape_id_for_iri, witness_node, witness_shape, witness_violations,
51};
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use oxrdf::Graph;
57    use shifty_parse::parse_turtle;
58
59    fn run(shapes_and_data: &str) -> ValidationOutcome {
60        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
61        // data graph = the same graph (shapes + data coexist), as in the suite.
62        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
63        validate(&loaded.graph, &out.schema).expect("stratifiable schema")
64    }
65
66    /// Like [`run`], but validates the *normalized* schema — the schema the CLI
67    /// and wasm front-ends actually run, where NNF rewriting can reshape the IR
68    /// that reporting inspects.
69    fn run_normalized(shapes_and_data: &str) -> ValidationOutcome {
70        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
71        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
72        let normalized = shifty_opt::normalize(&out.schema);
73        validate(&loaded.graph, &normalized).expect("stratifiable schema")
74    }
75
76    const PREFIXES: &str = r#"
77        @prefix sh:  <http://www.w3.org/ns/shacl#> .
78        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
79        @prefix ex:  <http://ex/> .
80        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
81    "#;
82
83    /// A `sh:class` value constraint on a property is the universal
84    /// `∀path.(instance of C)`, lowered to `∃≤0 path.¬(instance of C)`. After
85    /// NNF normalization the inner `¬(instance of C)` becomes an `∃≤0` class
86    /// count rather than a bare `Not`, which used to defeat the drill-in and
87    /// leave the raw "at most 0 value(s) … found 1" message. The offender should
88    /// instead be told which class the value must be an instance of.
89    #[test]
90    fn normalized_class_universal_names_the_required_class() {
91        let ttl = format!(
92            "{PREFIXES}
93            ex:SensorShape a sh:NodeShape ;
94                sh:targetClass ex:Sensor ;
95                sh:property [ sh:path ex:hasKind ; sh:class ex:QuantityKind ] .
96
97            ex:s1 a ex:Sensor ; ex:hasKind ex:Temperature .
98            # ex:Temperature is deliberately untyped (as if its ontology import
99            # were missing), so it fails the class check.
100            "
101        );
102
103        let outcome = run_normalized(&ttl);
104        let messages: Vec<&str> = outcome
105            .violations
106            .iter()
107            .flat_map(|v| v.reasons.iter())
108            .map(|r| r.message.as_str())
109            .collect();
110
111        assert!(
112            messages.contains(&"must be an instance of <http://ex/QuantityKind>"),
113            "expected an intuitive class message, got: {messages:?}"
114        );
115        // The old double-negated structural phrasing must not surface.
116        assert!(
117            !messages.iter().any(|m| m.contains("at most 0")),
118            "raw ∃≤0 message leaked: {messages:?}"
119        );
120
121        // The offending value node and path are carried on the reason.
122        let reason = outcome
123            .violations
124            .iter()
125            .flat_map(|v| &v.reasons)
126            .find(|r| r.message.starts_with("must be an instance of"))
127            .expect("class reason present");
128        assert_eq!(reason.value.to_string(), "<http://ex/Temperature>");
129        assert_eq!(reason.path.as_deref(), Some("<http://ex/hasKind>"));
130    }
131
132    /// A universal whose `¬φ` normalizes to a non-`Not`, non-class form — here a
133    /// complemented `sh:nodeKind` — still names the positive requirement (the
134    /// general `describe_negation` path) instead of the raw `∃≤0` count message.
135    #[test]
136    fn normalized_nodekind_universal_names_the_requirement() {
137        let ttl = format!(
138            "{PREFIXES}
139            ex:S a sh:NodeShape ;
140                sh:targetClass ex:T ;
141                sh:property [ sh:path ex:p ; sh:nodeKind sh:IRI ] .
142
143            ex:a a ex:T ; ex:p \"not-an-iri\" .
144            "
145        );
146
147        let outcome = run_normalized(&ttl);
148        let messages: Vec<&str> = outcome
149            .violations
150            .iter()
151            .flat_map(|v| v.reasons.iter())
152            .map(|r| r.message.as_str())
153            .collect();
154
155        assert!(
156            messages.contains(&"must satisfy `nodeKind(IRI)`"),
157            "expected a nodeKind requirement message, got: {messages:?}"
158        );
159        assert!(
160            !messages.iter().any(|m| m.contains("at most 0")),
161            "raw ∃≤0 message leaked: {messages:?}"
162        );
163    }
164
165    /// A source shape's `sh:message` rides through lowering + normalization on
166    /// `Shape::Annotated` and is stamped onto each reason (with `{$this}`
167    /// resolved to the focus node), while the generated `message` stays as a
168    /// fallback. Shapes without one leave `author_message` unset.
169    #[test]
170    fn author_sh_message_is_carried_onto_reasons() {
171        let ttl = format!(
172            "{PREFIXES}
173            ex:S a sh:NodeShape ;
174                sh:targetClass ex:T ;
175                sh:property [ sh:path ex:hasKind ; sh:class ex:QuantityKind ;
176                    sh:message \"{{$this}} needs a known QuantityKind\" ] .
177
178            ex:a a ex:T ; ex:hasKind ex:Temperature .
179            "
180        );
181
182        let outcome = run_normalized(&ttl);
183        let reason = outcome
184            .violations
185            .iter()
186            .flat_map(|v| &v.reasons)
187            .find(|r| r.author_message.is_some())
188            .expect("author message present");
189
190        // `{$this}` resolved to the focus node, generated message kept as fallback.
191        assert_eq!(
192            reason.author_message.as_deref(),
193            Some("<http://ex/a> needs a known QuantityKind")
194        );
195        assert_eq!(
196            reason.message,
197            "must be an instance of <http://ex/QuantityKind>"
198        );
199    }
200
201    /// Without `sh:message`, `author_message` stays `None` (generated only).
202    #[test]
203    fn absent_sh_message_leaves_author_message_unset() {
204        let ttl = format!(
205            "{PREFIXES}
206            ex:S a sh:NodeShape ;
207                sh:targetClass ex:T ;
208                sh:property [ sh:path ex:hasKind ; sh:class ex:QuantityKind ] .
209
210            ex:a a ex:T ; ex:hasKind ex:Temperature .
211            "
212        );
213
214        let outcome = run_normalized(&ttl);
215        assert!(
216            outcome
217                .violations
218                .iter()
219                .flat_map(|v| &v.reasons)
220                .all(|r| r.author_message.is_none()),
221            "no author message expected"
222        );
223    }
224
225    #[test]
226    fn inference_rules_fire_for_implicit_class_targets() {
227        let ttl = br#"
228            @prefix rdf:   <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
229            @prefix rdfs:  <http://www.w3.org/2000/01/rdf-schema#> .
230            @prefix owl:   <http://www.w3.org/2002/07/owl#> .
231            @prefix sh:    <http://www.w3.org/ns/shacl#> .
232            @prefix ex:    <http://ex/> .
233
234            ex:Parent a owl:Class, sh:NodeShape ;
235                sh:rule [
236                    a sh:TripleRule ;
237                    sh:subject sh:this ;
238                    sh:predicate ex:hasTag ;
239                    sh:object ex:Tag
240                ] .
241
242            ex:Child rdfs:subClassOf ex:Parent .
243            ex:item a ex:Child .
244        "#;
245        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
246        let parsed = shifty_parse::parse_loaded(&loaded);
247        let normalized = shifty_opt::normalize(&parsed.schema);
248
249        let outcome = infer(&loaded.graph, &normalized).expect("stratifiable schema");
250
251        assert!(outcome.graph.contains(&oxrdf::Triple::new(
252            oxrdf::NamedNode::new_unchecked("http://ex/item"),
253            oxrdf::NamedNode::new_unchecked("http://ex/hasTag"),
254            oxrdf::NamedNode::new_unchecked("http://ex/Tag"),
255        )));
256    }
257
258    /// Parse + normalize + infer, asserting the parser emitted no diagnostics
259    /// (so the node expressions under test were actually lowered, not skipped).
260    fn infer_ttl(ttl: &[u8]) -> InferenceOutcome {
261        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
262        let parsed = shifty_parse::parse_loaded(&loaded);
263        assert!(
264            parsed.diagnostics.is_empty(),
265            "parse diagnostics: {:?}",
266            parsed.diagnostics
267        );
268        let normalized = shifty_opt::normalize(&parsed.schema);
269        infer(&loaded.graph, &normalized).expect("stratifiable schema")
270    }
271
272    fn triple_term(s: &str, p: &str, o: impl Into<oxrdf::Term>) -> oxrdf::Triple {
273        oxrdf::Triple::new(
274            oxrdf::NamedNode::new_unchecked(s),
275            oxrdf::NamedNode::new_unchecked(p),
276            o,
277        )
278    }
279
280    #[test]
281    fn rule_object_union_node_expression() {
282        // sh:union of two paths: both reachable values are inferred.
283        let outcome = infer_ttl(
284            br#"
285            @prefix sh: <http://www.w3.org/ns/shacl#> .
286            @prefix ex: <http://ex/> .
287            ex:PersonShape a sh:NodeShape ;
288                sh:targetClass ex:Person ;
289                sh:rule [
290                    a sh:TripleRule ;
291                    sh:subject sh:this ;
292                    sh:predicate ex:contact ;
293                    sh:object [ sh:union ( [ sh:path ex:email ] [ sh:path ex:phone ] ) ]
294                ] .
295            ex:alice a ex:Person ; ex:email "a@x.org" ; ex:phone "555-1234" .
296        "#,
297        );
298        let email = oxrdf::Literal::new_simple_literal("a@x.org");
299        let phone = oxrdf::Literal::new_simple_literal("555-1234");
300        assert!(outcome.graph.contains(&triple_term(
301            "http://ex/alice",
302            "http://ex/contact",
303            email
304        )));
305        assert!(outcome.graph.contains(&triple_term(
306            "http://ex/alice",
307            "http://ex/contact",
308            phone
309        )));
310    }
311
312    #[test]
313    fn rule_object_intersection_node_expression() {
314        // sh:intersection of two paths: only the value reachable by both is inferred.
315        let outcome = infer_ttl(
316            br#"
317            @prefix sh: <http://www.w3.org/ns/shacl#> .
318            @prefix ex: <http://ex/> .
319            ex:S a sh:NodeShape ;
320                sh:targetClass ex:T ;
321                sh:rule [
322                    a sh:TripleRule ;
323                    sh:subject sh:this ;
324                    sh:predicate ex:both ;
325                    sh:object [ sh:intersection ( [ sh:path ex:a ] [ sh:path ex:b ] ) ]
326                ] .
327            ex:x a ex:T ; ex:a ex:shared, ex:onlyA ; ex:b ex:shared, ex:onlyB .
328        "#,
329        );
330        let both = "http://ex/both";
331        assert!(outcome.graph.contains(&triple_term(
332            "http://ex/x",
333            both,
334            oxrdf::NamedNode::new_unchecked("http://ex/shared")
335        )));
336        assert!(!outcome.graph.contains(&triple_term(
337            "http://ex/x",
338            both,
339            oxrdf::NamedNode::new_unchecked("http://ex/onlyA")
340        )));
341        assert!(!outcome.graph.contains(&triple_term(
342            "http://ex/x",
343            both,
344            oxrdf::NamedNode::new_unchecked("http://ex/onlyB")
345        )));
346    }
347
348    #[test]
349    fn rule_object_filter_node_expression() {
350        // sh:filterShape + sh:nodes: keep only the values that conform to the shape.
351        let outcome = infer_ttl(
352            br#"
353            @prefix sh:  <http://www.w3.org/ns/shacl#> .
354            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
355            @prefix ex:  <http://ex/> .
356            ex:IntShape a sh:NodeShape ; sh:datatype xsd:integer .
357            ex:S a sh:NodeShape ;
358                sh:targetClass ex:T ;
359                sh:rule [
360                    a sh:TripleRule ;
361                    sh:subject sh:this ;
362                    sh:predicate ex:intValue ;
363                    sh:object [ sh:filterShape ex:IntShape ; sh:nodes [ sh:path ex:value ] ]
364                ] .
365            ex:x a ex:T ; ex:value 42, "hello" .
366        "#,
367        );
368        let int_value = "http://ex/intValue";
369        assert!(outcome.graph.contains(&triple_term(
370            "http://ex/x",
371            int_value,
372            oxrdf::Literal::new_typed_literal(
373                "42",
374                oxrdf::NamedNode::new_unchecked("http://www.w3.org/2001/XMLSchema#integer")
375            )
376        )));
377        assert!(!outcome.graph.contains(&triple_term(
378            "http://ex/x",
379            int_value,
380            oxrdf::Literal::new_simple_literal("hello")
381        )));
382    }
383
384    #[test]
385    fn planned_validation_preserves_severity_and_applies_threshold() {
386        let ttl = format!(
387            "{PREFIXES}
388            ex:S a sh:NodeShape ;
389                sh:targetNode ex:x ;
390                sh:property ex:InfoShape, ex:WarningShape .
391            ex:InfoShape a sh:PropertyShape ;
392                sh:path ex:required ;
393                sh:minCount 1 ;
394                sh:severity sh:Info .
395            ex:WarningShape a sh:PropertyShape ;
396                sh:path ex:required ;
397                sh:minCount 1 ;
398                sh:severity sh:Warning .
399            "
400        );
401        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
402        let parsed = shifty_parse::parse_loaded(&loaded);
403        let normalized = shifty_opt::normalize(&parsed.schema);
404        let plan = shifty_opt::plan(&normalized);
405
406        let info = validate_plan_with_options(
407            &loaded.graph,
408            &plan,
409            &ValidationOptions {
410                minimum_severity: shifty_algebra::Severity::Info,
411                sort_results: true,
412                ..Default::default()
413            },
414        )
415        .unwrap();
416        assert!(!info.conforms);
417        assert_eq!(info.violations.len(), 1);
418        assert_eq!(
419            info.violations[0].severity,
420            shifty_algebra::Severity::Warning
421        );
422        let mut severities: Vec<_> = info.violations[0]
423            .reasons
424            .iter()
425            .map(|reason| reason.severity.clone())
426            .collect();
427        severities.sort_by_key(shifty_algebra::Severity::rank);
428        assert_eq!(
429            severities,
430            vec![
431                shifty_algebra::Severity::Info,
432                shifty_algebra::Severity::Warning
433            ]
434        );
435
436        let warning = validate_plan_with_options(
437            &loaded.graph,
438            &plan,
439            &ValidationOptions {
440                minimum_severity: shifty_algebra::Severity::Warning,
441                sort_results: true,
442                ..Default::default()
443            },
444        )
445        .unwrap();
446        assert!(!warning.conforms);
447
448        let violation = validate_plan_with_options(
449            &loaded.graph,
450            &plan,
451            &ValidationOptions {
452                minimum_severity: shifty_algebra::Severity::Violation,
453                sort_results: true,
454                ..Default::default()
455            },
456        )
457        .unwrap();
458        assert!(violation.conforms);
459        assert_eq!(violation.violations.len(), 1);
460
461        let report = validate_report_with_options(
462            &loaded,
463            &loaded.graph,
464            &ValidationOptions {
465                minimum_severity: shifty_algebra::Severity::Violation,
466                sort_results: true,
467                ..Default::default()
468            },
469        );
470        assert!(report.conforms);
471        assert_eq!(report.results.len(), 2);
472    }
473
474    #[test]
475    fn validation_findings_sort_by_severity_then_focus_node() {
476        let ttl = format!(
477            "{PREFIXES}
478            ex:InfoShape a sh:NodeShape ;
479                sh:targetNode ex:a ;
480                sh:nodeKind sh:Literal ;
481                sh:severity sh:Info .
482            ex:WarningShape a sh:NodeShape ;
483                sh:targetNode ex:z ;
484                sh:nodeKind sh:Literal ;
485                sh:severity sh:Warning .
486            ex:ViolationShape a sh:NodeShape ;
487                sh:targetNode ex:m ;
488                sh:nodeKind sh:Literal .
489            "
490        );
491        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
492        let parsed = shifty_parse::parse_loaded(&loaded);
493        let plan = shifty_opt::plan(&shifty_opt::normalize(&parsed.schema));
494        let outcome = validate_plan(&loaded.graph, &plan).unwrap();
495
496        let ordered: Vec<_> = outcome
497            .violations
498            .iter()
499            .map(|finding| (finding.severity.clone(), finding.focus.to_string()))
500            .collect();
501        assert_eq!(
502            ordered,
503            vec![
504                (
505                    shifty_algebra::Severity::Violation,
506                    "<http://ex/m>".to_string()
507                ),
508                (
509                    shifty_algebra::Severity::Warning,
510                    "<http://ex/z>".to_string()
511                ),
512                (shifty_algebra::Severity::Info, "<http://ex/a>".to_string()),
513            ]
514        );
515    }
516
517    #[test]
518    fn reports_specific_failing_constraints() {
519        let ttl = format!(
520            "{PREFIXES}
521            ex:S a sh:NodeShape ;
522                sh:targetNode ex:x ;
523                sh:closed true ;
524                sh:ignoredProperties ( rdf:type ) ;
525                sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:maxCount 1 ] .
526            ex:x ex:age \"foo\" , 5 ; ex:extra 1 .
527            "
528        );
529        let outcome = run(&ttl);
530        assert!(!outcome.conforms);
531        assert_eq!(outcome.violations.len(), 1);
532        let msgs: Vec<&str> = outcome.violations[0]
533            .reasons
534            .iter()
535            .map(|r| r.message.as_str())
536            .collect();
537        // each distinct constraint is reported, not just "the node failed"
538        assert!(
539            msgs.iter().any(|m| m.contains("datatype(xsd:integer)")),
540            "missing datatype reason: {msgs:?}"
541        );
542        assert!(
543            msgs.iter().any(|m| m.contains("at most 1")),
544            "missing maxCount reason: {msgs:?}"
545        );
546        assert!(
547            msgs.iter()
548                .any(|m| m.contains("closed") && m.contains("extra")),
549            "missing closed reason: {msgs:?}"
550        );
551    }
552
553    #[test]
554    fn cardinality_and_datatype() {
555        let ttl = format!(
556            "{PREFIXES}
557            ex:S a sh:NodeShape ;
558                sh:targetNode ex:alice, ex:bob ;
559                sh:property [ sh:path ex:age ; sh:maxCount 1 ; sh:datatype xsd:integer ] .
560            ex:alice ex:age 30 .
561            ex:bob   ex:age 30 ; ex:age 40 .
562            "
563        );
564        let outcome = run(&ttl);
565        assert!(!outcome.conforms);
566        // only ex:bob violates maxCount 1
567        let bad: Vec<_> = outcome
568            .violations
569            .iter()
570            .map(|r| r.focus.to_string())
571            .collect();
572        assert_eq!(bad, vec!["<http://ex/bob>".to_string()]);
573    }
574
575    #[test]
576    fn qualified_value_shape_disjoint_uses_all_sibling_property_shapes() {
577        let ttl = format!(
578            "{PREFIXES}
579            ex:S a sh:NodeShape ;
580                sh:targetNode ex:x ;
581                sh:property ex:A, ex:B .
582            ex:A a sh:PropertyShape ;
583                sh:path ex:p ;
584                sh:qualifiedValueShape [ sh:class ex:TypeA ] ;
585                sh:qualifiedValueShapesDisjoint true ;
586                sh:qualifiedMinCount 1 .
587            ex:B a sh:PropertyShape ;
588                sh:path ex:q ;
589                sh:qualifiedValueShape [ sh:class ex:TypeB ] ;
590                sh:qualifiedValueShapesDisjoint true ;
591                sh:qualifiedMaxCount 10 .
592            ex:x ex:p ex:value .
593            ex:value a ex:TypeA, ex:TypeB .
594            "
595        );
596        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
597        assert!(
598            parsed.diagnostics.is_empty(),
599            "diags: {:?}",
600            parsed.diagnostics
601        );
602        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
603
604        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
605        assert!(!algebra.conforms);
606
607        let report = validate_report(&loaded, &loaded.graph);
608        assert!(!report.conforms);
609        assert_eq!(report.results.len(), 1);
610        assert_eq!(
611            report.results[0].component.as_str(),
612            "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"
613        );
614    }
615
616    #[test]
617    fn disjoint_on_node_shape_uses_the_focus_node_as_the_value() {
618        let ttl = format!(
619            "{PREFIXES}
620            ex:S a sh:NodeShape ;
621                sh:targetNode ex:valid, ex:invalid ;
622                sh:disjoint ex:p .
623            ex:valid ex:p ex:other .
624            ex:invalid ex:p ex:invalid .
625            "
626        );
627        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
628        assert!(
629            parsed.diagnostics.is_empty(),
630            "diags: {:?}",
631            parsed.diagnostics
632        );
633        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
634
635        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
636        assert!(!algebra.conforms);
637        assert_eq!(algebra.violations.len(), 1);
638        assert_eq!(
639            algebra.violations[0].focus.to_string(),
640            "<http://ex/invalid>"
641        );
642
643        let normalized = shifty_opt::normalize(&parsed.schema);
644        let plan = shifty_opt::plan(&normalized);
645        let planned = validate_plan(&loaded.graph, &plan).unwrap();
646        assert_eq!(planned.conforms, algebra.conforms);
647        assert_eq!(planned.violations.len(), algebra.violations.len());
648
649        let report = validate_report(&loaded, &loaded.graph);
650        assert!(!report.conforms);
651        assert_eq!(report.results.len(), 1);
652        assert_eq!(
653            report.results[0].component.as_str(),
654            "http://www.w3.org/ns/shacl#DisjointConstraintComponent"
655        );
656        assert_eq!(
657            report.results[0].value.as_ref().map(ToString::to_string),
658            Some("<http://ex/invalid>".to_string())
659        );
660    }
661
662    #[test]
663    fn expression_constraint_reports_non_true_values() {
664        // The W3C booleans-001 shape: sh:expression sh:this over boolean foci.
665        let ttl = format!(
666            "{PREFIXES}
667            ex:S a sh:NodeShape ;
668                sh:targetNode true, false ;
669                sh:expression sh:this .
670            "
671        );
672        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
673        assert!(
674            parsed.diagnostics.is_empty(),
675            "diags: {:?}",
676            parsed.diagnostics
677        );
678        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
679
680        // algebra path: only the `false` focus violates.
681        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
682        assert!(!algebra.conforms);
683        assert_eq!(algebra.violations.len(), 1);
684        assert_eq!(
685            algebra.violations[0].focus,
686            oxrdf::Term::Literal(oxrdf::Literal::new_typed_literal(
687                "false",
688                oxrdf::vocab::xsd::BOOLEAN
689            ))
690        );
691
692        // planned path agrees with the algebra oracle.
693        let normalized = shifty_opt::normalize(&parsed.schema);
694        let plan = shifty_opt::plan(&normalized);
695        let planned = validate_plan(&loaded.graph, &plan).unwrap();
696        assert_eq!(planned.conforms, algebra.conforms);
697        assert_eq!(planned.violations.len(), algebra.violations.len());
698
699        // W3C report path: one ExpressionConstraintComponent result, sh:value false.
700        let report = validate_report(&loaded, &loaded.graph);
701        assert!(!report.conforms);
702        assert_eq!(report.results.len(), 1);
703        let result = &report.results[0];
704        assert_eq!(
705            result.component.as_str(),
706            "http://www.w3.org/ns/shacl#ExpressionConstraintComponent"
707        );
708        assert_eq!(result.path, None);
709        assert_eq!(
710            result.value.as_ref().map(ToString::to_string),
711            Some("\"false\"^^<http://www.w3.org/2001/XMLSchema#boolean>".to_string())
712        );
713    }
714
715    #[test]
716    fn expression_constraint_with_path_and_filter() {
717        // The expression traverses a path from the focus and filters the values
718        // by a shape; only nodes passing the filter must (here, fail to) be true,
719        // exercising Path + Filter node expressions on the report path.
720        let ttl = format!(
721            "{PREFIXES}
722            ex:S a sh:NodeShape ;
723                sh:targetNode ex:x ;
724                sh:expression [
725                    sh:filterShape [ sh:datatype xsd:boolean ] ;
726                    sh:nodes [ sh:path ex:flag ] ;
727                ] .
728            ex:x ex:flag true, false, \"not-a-bool\" .
729            "
730        );
731        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
732        assert!(
733            parsed.diagnostics.is_empty(),
734            "diags: {:?}",
735            parsed.diagnostics
736        );
737        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
738
739        // The boolean values that survive the filter are {true, false}; `false`
740        // is the lone non-true value, so exactly one result, sh:value false.
741        let report = validate_report(&loaded, &loaded.graph);
742        assert!(!report.conforms);
743        assert_eq!(report.results.len(), 1);
744        assert_eq!(
745            report.results[0].component.as_str(),
746            "http://www.w3.org/ns/shacl#ExpressionConstraintComponent"
747        );
748        assert_eq!(
749            report.results[0].value.as_ref().map(ToString::to_string),
750            Some("\"false\"^^<http://www.w3.org/2001/XMLSchema#boolean>".to_string())
751        );
752
753        // algebra path agrees on (non-)conformance.
754        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
755        assert!(!algebra.conforms);
756        assert_eq!(algebra.violations.len(), 1);
757    }
758
759    #[test]
760    fn expression_constraint_with_function_is_diagnosed() {
761        // A function application inside sh:expression cannot be evaluated by the
762        // validation paths yet, so it is diagnosed rather than silently dropped.
763        let ttl = format!(
764            "{PREFIXES}
765            ex:S a sh:NodeShape ;
766                sh:targetNode ex:x ;
767                sh:expression [ ex:fn ( sh:this ) ] .
768            "
769        );
770        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
771        assert!(
772            parsed
773                .diagnostics
774                .iter()
775                .any(|d| d.message.contains("sh:expression")),
776            "expected an unsupported-expression diagnostic, got: {:?}",
777            parsed.diagnostics
778        );
779    }
780
781    #[test]
782    fn sparql_function_expression_evaluates() {
783        // The W3C simpleSPARQLFunction shapes: a no-arg ASK function and a
784        // two-argument SELECT function, called via dash:expression strings.
785        let ttl = br#"
786            @prefix sh: <http://www.w3.org/ns/shacl#> .
787            @prefix ex: <http://ex/> .
788            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
789            ex:booleanFunction a sh:SPARQLFunction ;
790                sh:returnType xsd:boolean ;
791                sh:ask "ASK { FILTER (true) }" .
792            ex:withArguments a sh:SPARQLFunction ;
793                sh:parameter [ sh:name "arg1" ; sh:path ex:arg1 ] ,
794                             [ sh:name "arg2" ; sh:path ex:arg2 ] ;
795                sh:returnType xsd:string ;
796                sh:select "SELECT ?result WHERE { BIND (CONCAT($arg1, \"-\", $arg2) AS ?result) }" .
797        "#;
798        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
799
800        let b = evaluate_function_expression(&loaded, "ex:booleanFunction()").unwrap();
801        assert_eq!(b, Some(oxrdf::Term::Literal(oxrdf::Literal::from(true))));
802
803        let s = evaluate_function_expression(&loaded, "ex:withArguments(\"A\", \"B\")").unwrap();
804        assert_eq!(
805            s,
806            Some(oxrdf::Term::Literal(oxrdf::Literal::new_simple_literal(
807                "A-B"
808            )))
809        );
810    }
811
812    #[test]
813    fn sparql_function_called_from_sparql_constraint() {
814        // A SHACL function is callable inside a sh:sparql constraint query: the
815        // constraint flags values for which ex:isOk returns false.
816        let ttl = br#"
817            @prefix sh: <http://www.w3.org/ns/shacl#> .
818            @prefix ex: <http://ex/> .
819            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
820            ex:isOk a sh:SPARQLFunction ;
821                sh:parameter [ sh:path ex:arg ] ;
822                sh:returnType xsd:boolean ;
823                sh:ask "ASK { FILTER (STR($arg) = \"ok\") }" .
824            ex:S a sh:NodeShape ;
825                sh:targetNode ex:x, ex:y ;
826                sh:sparql [ sh:select """SELECT $this ?value WHERE {
827                    $this <http://ex/val> ?value .
828                    FILTER (! <http://ex/isOk>(?value))
829                }""" ] .
830            ex:x <http://ex/val> "ok" .
831            ex:y <http://ex/val> "bad" .
832        "#;
833        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
834        let report = validate_report(&loaded, &loaded.graph);
835        assert!(!report.conforms);
836        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
837        assert_eq!(report.results[0].focus.to_string(), "<http://ex/y>");
838        assert_eq!(
839            report.results[0].value.as_ref().map(ToString::to_string),
840            Some("\"bad\"".to_string())
841        );
842    }
843
844    #[test]
845    fn graph_reading_function_policy_gates_loud_failure() {
846        // `ex:exists` reads the data graph, so from a SPARQL context it can only
847        // be evaluated over an empty dataset. The UnsupportedPolicy decides what
848        // happens when it is called from a sh:sparql constraint.
849        let ttl = br#"
850            @prefix sh: <http://www.w3.org/ns/shacl#> .
851            @prefix ex: <http://ex/> .
852            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
853            ex:exists a sh:SPARQLFunction ;
854                sh:returnType xsd:boolean ;
855                sh:ask "ASK { ?s <http://ex/marker> ?o }" .
856            ex:S a sh:NodeShape ;
857                sh:targetNode ex:x ;
858                sh:sparql [ sh:select
859                    "SELECT $this WHERE { $this a <http://ex/T> . FILTER (<http://ex/exists>()) }" ] .
860            ex:x a <http://ex/T> .
861            ex:y <http://ex/marker> "m" .
862        "#;
863        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
864
865        // Ignore (default): the function runs over an empty dataset (best-effort);
866        // here it returns false, so the constraint silently conforms.
867        let lenient = validate_report(&loaded, &loaded.graph);
868        assert!(lenient.conforms, "results: {:?}", lenient.results);
869
870        // Error: the graph-reading function is not registered, so the SPARQL call
871        // fails and the constraint fails closed (loud) rather than silently wrong.
872        let strict_opts = ValidationOptions {
873            engine: EngineOptions {
874                unsupported: UnsupportedPolicy::Error,
875            },
876            ..Default::default()
877        };
878        let strict = validate_report_with_options(&loaded, &loaded.graph, &strict_opts);
879        assert!(!strict.conforms);
880        assert_eq!(strict.results.len(), 1, "results: {:?}", strict.results);
881    }
882
883    #[test]
884    fn custom_component_ask_validator() {
885        // A two-parameter ASK component (the W3C validator-001 shape): a value
886        // conforms iff it is the concatenation of the two parameters.
887        let ttl = br#"
888            @prefix sh: <http://www.w3.org/ns/shacl#> .
889            @prefix ex: <http://ex/> .
890            ex:TestConstraintComponent a sh:ConstraintComponent ;
891                sh:parameter [ sh:path ex:test1 ] , [ sh:path ex:test2 ] ;
892                sh:validator [ a sh:SPARQLAskValidator ;
893                    sh:ask "ASK { FILTER (?value = CONCAT($test1, $test2)) }" ] .
894            ex:TestShape a sh:NodeShape ;
895                ex:test1 "Hello " ;
896                ex:test2 "World" ;
897                sh:targetNode "Hallo Welt", "Hello World" .
898        "#;
899        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
900        let report = validate_report(&loaded, &loaded.graph);
901        assert!(!report.conforms);
902        assert_eq!(report.results.len(), 1);
903        let r = &report.results[0];
904        assert_eq!(r.component.as_str(), "http://ex/TestConstraintComponent");
905        assert_eq!(r.focus.to_string(), "\"Hallo Welt\"");
906        assert_eq!(
907            r.value.as_ref().map(ToString::to_string),
908            Some("\"Hallo Welt\"".to_string())
909        );
910        assert_eq!(r.source_shape.to_string(), "<http://ex/TestShape>");
911        assert_eq!(r.path, None);
912
913        // The algebra path now evaluates components too: "Hallo Welt" should
914        // still be the only violation.
915        let parse_out = shifty_parse::parse_loaded(&loaded);
916        let schema = shifty_opt::normalize(&parse_out.schema);
917        let plan = shifty_opt::plan(&schema);
918        let outcome = validate_plan_graphs(&loaded.graph, &loaded.graph, &plan).unwrap();
919        assert!(!outcome.conforms, "algebra: Hallo Welt must still violate");
920        assert_eq!(
921            outcome.violations.len(),
922            1,
923            "algebra: exactly one violation: {:?}",
924            outcome.violations
925        );
926        assert_eq!(
927            outcome.violations[0].focus.to_string(),
928            "\"Hallo Welt\"",
929            "algebra: wrong focus node"
930        );
931    }
932
933    #[test]
934    fn custom_component_select_node_validator() {
935        // A SELECT node validator with a required parameter: a focus violates
936        // when it lacks an ex:property edge equal to the bound $requiredParam.
937        let ttl = br#"
938            @prefix sh: <http://www.w3.org/ns/shacl#> .
939            @prefix ex: <http://ex/> .
940            ex:C a sh:ConstraintComponent ;
941                sh:parameter [ sh:path ex:requiredParam ] ;
942                sh:nodeValidator [ a sh:SPARQLSelectValidator ;
943                    sh:select """SELECT $this WHERE {
944                        $this ?p ?o .
945                        FILTER NOT EXISTS { $this <http://ex/property> $requiredParam }
946                    }""" ] .
947            ex:S a sh:NodeShape ;
948                ex:requiredParam "Value" ;
949                sh:targetNode ex:Good, ex:Bad .
950            ex:Good <http://ex/property> "Value" .
951            ex:Bad <http://ex/property> "Other" .
952        "#;
953        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
954        let report = validate_report(&loaded, &loaded.graph);
955        assert!(!report.conforms);
956        assert_eq!(report.results.len(), 1);
957        let r = &report.results[0];
958        assert_eq!(r.component.as_str(), "http://ex/C");
959        assert_eq!(r.focus.to_string(), "<http://ex/Bad>");
960        assert_eq!(
961            r.value.as_ref().map(ToString::to_string),
962            Some("<http://ex/Bad>".to_string())
963        );
964    }
965
966    #[test]
967    fn custom_component_not_activated_when_mandatory_param_absent() {
968        // The component is only activated for shapes that supply every mandatory
969        // parameter; a shape missing it produces no results (and no diagnostic).
970        let ttl = br#"
971            @prefix sh: <http://www.w3.org/ns/shacl#> .
972            @prefix ex: <http://ex/> .
973            ex:C a sh:ConstraintComponent ;
974                sh:parameter [ sh:path ex:requiredParam ] ;
975                sh:validator [ a sh:SPARQLAskValidator ; sh:ask "ASK { FILTER (false) }" ] .
976            ex:S a sh:NodeShape ;
977                sh:targetNode ex:x .
978            ex:x ex:other "z" .
979        "#;
980        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
981        let report = validate_report(&loaded, &loaded.graph);
982        assert!(report.conforms, "results: {:?}", report.results);
983
984        let parsed = parse_turtle(ttl, None).unwrap();
985        assert!(
986            !parsed
987                .diagnostics
988                .iter()
989                .any(|d| d.message.contains("custom constraint component")),
990            "an inactive component must not be diagnosed: {:?}",
991            parsed.diagnostics
992        );
993    }
994
995    #[test]
996    fn custom_component_property_validator_complex_path() {
997        // A property shape with a *sequence* path activates a SELECT property
998        // validator: `$PATH` is pre-bound to ex:a/ex:b, so the validator reaches
999        // the value nodes two hops away and flags the forbidden one.
1000        let ttl = br#"
1001            @prefix sh: <http://www.w3.org/ns/shacl#> .
1002            @prefix ex: <http://ex/> .
1003            ex:ForbidComponent a sh:ConstraintComponent ;
1004                sh:parameter [ sh:path ex:forbidden ] ;
1005                sh:propertyValidator [ a sh:SPARQLSelectValidator ;
1006                    sh:select """SELECT $this ?value WHERE {
1007                        $this $PATH ?value .
1008                        FILTER (STR(?value) = STR($forbidden))
1009                    }""" ] .
1010            ex:S a sh:NodeShape ;
1011                sh:targetNode ex:x ;
1012                sh:property [ sh:path ( ex:a ex:b ) ; ex:forbidden "bad" ] .
1013            ex:x ex:a ex:m .
1014            ex:m ex:b "bad", "ok" .
1015        "#;
1016        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
1017        let report = validate_report(&loaded, &loaded.graph);
1018        assert!(!report.conforms);
1019        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
1020        let r = &report.results[0];
1021        assert_eq!(r.component.as_str(), "http://ex/ForbidComponent");
1022        assert_eq!(r.focus.to_string(), "<http://ex/x>");
1023        assert_eq!(
1024            r.value.as_ref().map(ToString::to_string),
1025            Some("\"bad\"".to_string())
1026        );
1027    }
1028
1029    #[test]
1030    fn custom_component_property_validator_inverse_path() {
1031        // An inverse path `^ex:parent`: the value nodes are the subjects that
1032        // point at the focus via ex:parent. The ASK validator runs per value node
1033        // with `$PATH` pre-bound, flagging values whose label is not "ok".
1034        let ttl = br#"
1035            @prefix sh: <http://www.w3.org/ns/shacl#> .
1036            @prefix ex: <http://ex/> .
1037            ex:OkComponent a sh:ConstraintComponent ;
1038                sh:parameter [ sh:path ex:want ] ;
1039                sh:validator [ a sh:SPARQLAskValidator ;
1040                    sh:ask "ASK { $value <http://ex/label> $want }" ] .
1041            ex:S a sh:NodeShape ;
1042                sh:targetNode ex:p ;
1043                sh:property [ sh:path [ sh:inversePath ex:parent ] ; ex:want "ok" ] .
1044            ex:c1 ex:parent ex:p ; ex:label "ok" .
1045            ex:c2 ex:parent ex:p ; ex:label "no" .
1046        "#;
1047        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
1048        let report = validate_report(&loaded, &loaded.graph);
1049        // Value nodes of ex:p along ^ex:parent are {ex:c1, ex:c2}; only ex:c2
1050        // lacks `ex:label "ok"`, so the ASK is false there → one violation.
1051        assert!(!report.conforms);
1052        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
1053        assert_eq!(
1054            report.results[0].component.as_str(),
1055            "http://ex/OkComponent"
1056        );
1057        assert_eq!(
1058            report.results[0].value.as_ref().map(ToString::to_string),
1059            Some("<http://ex/c2>".to_string())
1060        );
1061    }
1062
1063    #[test]
1064    fn custom_component_ask_validator_subquery_count() {
1065        // ASK validator that counts graph-wide instances of $class and checks
1066        // the total equals $exactCount (the BuildingMOTIF exactCount pattern).
1067        // The correct SPARQL uses a subquery to aggregate, then FILTER in the
1068        // outer query — avoiding the invalid `SELECT * … HAVING (aggregate)`
1069        // pattern that spargebra rightly rejects.
1070        let shapes_ttl = br#"
1071            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1072            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1073            @prefix ex:  <urn:ex/> .
1074
1075            ex:countComponent a sh:ConstraintComponent ;
1076                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1077                sh:parameter [ sh:path ex:class ] ;
1078                sh:validator ex:hasExactCount .
1079
1080            ex:hasExactCount a sh:SPARQLAskValidator ;
1081                sh:message "Wrong count" ;
1082                sh:ask """
1083                    ASK {
1084                        {
1085                            SELECT (COUNT(DISTINCT ?i) AS ?count)
1086                            WHERE { ?i a $class . }
1087                        }
1088                        FILTER (?count = $exactCount)
1089                    }
1090                """ .
1091
1092            ex:shape a sh:NodeShape ;
1093                sh:targetNode ex:sentinel ;
1094                ex:class ex:Thing ;
1095                ex:exactCount 1 .
1096        "#;
1097        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1098
1099        // Zero instances → violates
1100        let data_none =
1101            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1102                .unwrap();
1103        let report = validate_report_graphs(&shapes, &data_none.graph);
1104        assert!(
1105            !report.conforms,
1106            "zero Things with exactCount=1 must not conform: {:?}",
1107            report.results
1108        );
1109
1110        // Exactly one instance → conforms
1111        let data_one = shifty_parse::load_turtle(
1112            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing .",
1113            None,
1114        )
1115        .unwrap();
1116        let report_ok = validate_report_graphs(&shapes, &data_one.graph);
1117        assert!(
1118            report_ok.conforms,
1119            "exactly one Thing with exactCount=1 must conform: {:?}",
1120            report_ok.results
1121        );
1122
1123        // Two instances → violates
1124        let data_two = shifty_parse::load_turtle(
1125            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing . ex:t2 a ex:Thing .",
1126            None,
1127        )
1128        .unwrap();
1129        let report_two = validate_report_graphs(&shapes, &data_two.graph);
1130        assert!(
1131            !report_two.conforms,
1132            "two Things with exactCount=1 must not conform: {:?}",
1133            report_two.results
1134        );
1135
1136        // Same checks via the algebra path.
1137        let parse_out = shifty_parse::parse_loaded(&shapes);
1138        let schema = shifty_opt::normalize(&parse_out.schema);
1139        let plan = shifty_opt::plan(&schema);
1140        let alg_none = validate_plan_graphs(&data_none.graph, &shapes.graph, &plan).unwrap();
1141        assert!(!alg_none.conforms, "algebra: zero Things must not conform");
1142        let alg_one = validate_plan_graphs(&data_one.graph, &shapes.graph, &plan).unwrap();
1143        assert!(alg_one.conforms, "algebra: one Thing must conform");
1144        let alg_two = validate_plan_graphs(&data_two.graph, &shapes.graph, &plan).unwrap();
1145        assert!(!alg_two.conforms, "algebra: two Things must not conform");
1146    }
1147
1148    #[test]
1149    fn custom_component_invalid_sparql_ignored_by_default() {
1150        // A validator whose sh:ask is invalid SPARQL (SELECT * with an implicit
1151        // GROUP BY from HAVING) is silently skipped under the default Ignore
1152        // policy, so the component is not enforced and the shape appears to
1153        // conform even when the data does not.  This documents the known
1154        // silent-skip behaviour; use on_unsupported="error" to surface it.
1155        let shapes_ttl = br#"
1156            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1157            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1158            @prefix ex:  <urn:ex/> .
1159
1160            ex:badComponent a sh:ConstraintComponent ;
1161                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1162                sh:parameter [ sh:path ex:class ] ;
1163                sh:validator [ a sh:SPARQLAskValidator ;
1164                    sh:ask """
1165                        ASK WHERE {
1166                            {
1167                                SELECT *
1168                                WHERE { ?i a $class . }
1169                                HAVING (COUNT(DISTINCT ?i) = $exactCount)
1170                            }
1171                        }
1172                    """ ] .
1173
1174            ex:shape a sh:NodeShape ;
1175                sh:targetNode ex:sentinel ;
1176                ex:class ex:Thing ;
1177                ex:exactCount 1 .
1178        "#;
1179        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1180        let data =
1181            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1182                .unwrap();
1183
1184        // Ignore policy (default): bad query silently skipped → appears to conform
1185        let report = validate_report_graphs(&shapes, &data.graph);
1186        assert!(
1187            report.conforms,
1188            "bad validator query silently skipped under Ignore: {:?}",
1189            report.results
1190        );
1191    }
1192
1193    #[test]
1194    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
1195        let ttl = format!(
1196            "{PREFIXES}
1197            ex:S a sh:NodeShape ;
1198                sh:targetNode ex:valid, ex:extra, ex:missing ;
1199                sh:equals ex:p .
1200            ex:valid ex:p ex:valid .
1201            ex:extra ex:p ex:extra, ex:other .
1202            "
1203        );
1204        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1205        assert!(
1206            parsed.diagnostics.is_empty(),
1207            "diags: {:?}",
1208            parsed.diagnostics
1209        );
1210        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1211
1212        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
1213        assert!(!algebra.conforms);
1214        let mut foci: Vec<_> = algebra
1215            .violations
1216            .iter()
1217            .map(|violation| violation.focus.to_string())
1218            .collect();
1219        foci.sort();
1220        assert_eq!(
1221            foci,
1222            [
1223                "<http://ex/extra>".to_string(),
1224                "<http://ex/missing>".to_string()
1225            ]
1226        );
1227
1228        let normalized = shifty_opt::normalize(&parsed.schema);
1229        let plan = shifty_opt::plan(&normalized);
1230        let planned = validate_plan(&loaded.graph, &plan).unwrap();
1231        assert_eq!(planned.conforms, algebra.conforms);
1232        assert_eq!(planned.violations.len(), algebra.violations.len());
1233
1234        let report = validate_report(&loaded, &loaded.graph);
1235        assert!(!report.conforms);
1236        assert_eq!(report.results.len(), 2);
1237        assert!(report.results.iter().all(|result| result.component.as_str()
1238            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
1239    }
1240
1241    #[test]
1242    fn datatype_violation() {
1243        let ttl = format!(
1244            "{PREFIXES}
1245            ex:S a sh:NodeShape ;
1246                sh:targetNode ex:x ;
1247                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
1248            ex:x ex:p \"hello\" .
1249            "
1250        );
1251        assert!(!run(&ttl).conforms);
1252    }
1253
1254    #[test]
1255    fn nodekind_and_class_target() {
1256        let ttl = format!(
1257            "{PREFIXES}
1258            ex:S a sh:NodeShape ;
1259                sh:targetClass ex:Person ;
1260                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
1261            ex:alice a ex:Person ; ex:knows ex:bob .
1262            ex:carol a ex:Person ; ex:knows \"notaniri\" .
1263            "
1264        );
1265        let outcome = run(&ttl);
1266        assert!(!outcome.conforms);
1267        let bad: Vec<_> = outcome
1268            .violations
1269            .iter()
1270            .map(|r| r.focus.to_string())
1271            .collect();
1272        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
1273    }
1274
1275    #[test]
1276    fn recursion_over_cyclic_data_terminates() {
1277        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
1278        let ttl = format!(
1279            "{PREFIXES}
1280            ex:S a sh:NodeShape ;
1281                sh:targetNode ex:a ;
1282                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
1283            ex:a ex:knows ex:b .
1284            ex:b ex:knows ex:a .
1285            "
1286        );
1287        // Must terminate; with all-IRI neighbours it conforms under the
1288        // provisional cycle-breaking semantics.
1289        assert!(run(&ttl).conforms);
1290    }
1291
1292    #[test]
1293    fn empty_graph_conforms() {
1294        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
1295        assert!(outcome.conforms);
1296    }
1297
1298    #[test]
1299    fn non_stratifiable_schema_is_diagnosed() {
1300        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
1301        let ttl = format!(
1302            "{PREFIXES}
1303            ex:S a sh:NodeShape ;
1304                sh:targetNode ex:x ;
1305                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
1306            ex:x ex:p ex:y .
1307            "
1308        );
1309        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1310        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1311        assert!(validate(&loaded.graph, &out.schema).is_err());
1312    }
1313
1314    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
1315        use oxrdf::NamedNode;
1316        oxrdf::Triple::new(
1317            NamedNode::new(s).unwrap(),
1318            NamedNode::new(p).unwrap(),
1319            NamedNode::new(o).unwrap(),
1320        )
1321    }
1322
1323    #[test]
1324    fn triple_rule_infers_from_path() {
1325        // copy each ex:knows value to ex:knows2
1326        let ttl = format!(
1327            "{PREFIXES}
1328            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1329                sh:rule [ a sh:TripleRule ;
1330                    sh:subject sh:this ; sh:predicate ex:knows2 ;
1331                    sh:object [ sh:path ex:knows ] ] .
1332            ex:a a ex:Person ; ex:knows ex:b .
1333            "
1334        );
1335        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1336        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1337        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1338        assert_eq!(outcome.inferred.len(), 1);
1339        assert!(
1340            outcome
1341                .graph
1342                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
1343        );
1344    }
1345
1346    #[test]
1347    fn inference_reaches_a_fixpoint() {
1348        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
1349        // a→b→c, so a reaches c is derivable only after b reaches c.
1350        let ttl = format!(
1351            "{PREFIXES}
1352            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1353                sh:rule [ a sh:TripleRule ;
1354                    sh:subject sh:this ; sh:predicate ex:reaches ;
1355                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
1356            ex:a a ex:Person ; ex:knows ex:b .
1357            ex:b a ex:Person ; ex:knows ex:c .
1358            ex:c a ex:Person .
1359            "
1360        );
1361        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1362        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1363        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1364        assert!(
1365            outcome
1366                .graph
1367                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
1368        );
1369        assert!(
1370            outcome
1371                .graph
1372                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
1373        );
1374        // the fixpoint result: a reaches c (only via b reaches c)
1375        assert!(
1376            outcome
1377                .graph
1378                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
1379        );
1380    }
1381
1382    #[test]
1383    fn later_order_output_reactivates_an_earlier_rule() {
1384        let ttl = format!(
1385            "{PREFIXES}
1386            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1387                sh:rule [
1388                    a sh:TripleRule ; sh:order 0 ;
1389                    sh:subject sh:this ; sh:predicate ex:done ;
1390                    sh:object [ sh:path ex:ready ]
1391                ] ;
1392                sh:rule [
1393                    a sh:TripleRule ; sh:order 1 ;
1394                    sh:subject sh:this ; sh:predicate ex:ready ;
1395                    sh:object ex:y
1396                ] .
1397            "
1398        );
1399        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1400        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1401        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1402
1403        assert!(
1404            outcome
1405                .graph
1406                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
1407        );
1408        assert!(
1409            outcome
1410                .graph
1411                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
1412        );
1413    }
1414
1415    #[test]
1416    fn inferred_triples_can_create_new_rule_targets() {
1417        let ttl = format!(
1418            "{PREFIXES}
1419            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
1420                sh:rule [
1421                    a sh:TripleRule ;
1422                    sh:subject sh:this ; sh:predicate ex:eligible ;
1423                    sh:object ex:y
1424                ] .
1425            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
1426                sh:rule [
1427                    a sh:TripleRule ;
1428                    sh:subject sh:this ; sh:predicate ex:classified ;
1429                    sh:object ex:yes
1430                ] .
1431            "
1432        );
1433        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1434        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1435        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1436
1437        assert!(outcome.graph.contains(&triple(
1438            "http://ex/x",
1439            "http://ex/classified",
1440            "http://ex/yes",
1441        )));
1442    }
1443
1444    #[test]
1445    fn split_inference_uses_shapes_graph_as_rule_context() {
1446        let shapes_ttl = format!(
1447            "{PREFIXES}
1448            ex:InverseShape a sh:NodeShape ;
1449                sh:targetClass ex:Thing ;
1450                sh:rule [
1451                    a sh:SPARQLRule ;
1452                    sh:construct \"\"\"
1453                        CONSTRUCT {{ ?o ?inverse $this }}
1454                        WHERE {{
1455                            $this ?predicate ?o .
1456                            ?predicate ex:inverseOf ?inverse .
1457                        }}
1458                    \"\"\"
1459                ] .
1460            ex:p ex:inverseOf ex:q .
1461            "
1462        );
1463        let data_ttl = format!(
1464            "{PREFIXES}
1465            ex:a a ex:Thing ; ex:p ex:b .
1466            "
1467        );
1468        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
1469        let parsed = shifty_parse::parse_loaded(&shapes);
1470        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
1471
1472        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
1473
1474        assert!(
1475            outcome
1476                .graph
1477                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
1478        );
1479        assert_eq!(outcome.inferred.len(), 1);
1480    }
1481}