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