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