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