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