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 pre-bound to ex:a/ex:b, so the validator 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_property_validator_inverse_path() {
1103        // An inverse path `^ex:parent`: the value nodes are the subjects that
1104        // point at the focus via ex:parent. The ASK validator runs per value node
1105        // with `$PATH` pre-bound, flagging values whose label is not "ok".
1106        let ttl = br#"
1107            @prefix sh: <http://www.w3.org/ns/shacl#> .
1108            @prefix ex: <http://ex/> .
1109            ex:OkComponent a sh:ConstraintComponent ;
1110                sh:parameter [ sh:path ex:want ] ;
1111                sh:validator [ a sh:SPARQLAskValidator ;
1112                    sh:ask "ASK { $value <http://ex/label> $want }" ] .
1113            ex:S a sh:NodeShape ;
1114                sh:targetNode ex:p ;
1115                sh:property [ sh:path [ sh:inversePath ex:parent ] ; ex:want "ok" ] .
1116            ex:c1 ex:parent ex:p ; ex:label "ok" .
1117            ex:c2 ex:parent ex:p ; ex:label "no" .
1118        "#;
1119        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
1120        let report = validate_report(&loaded, &loaded.graph);
1121        // Value nodes of ex:p along ^ex:parent are {ex:c1, ex:c2}; only ex:c2
1122        // lacks `ex:label "ok"`, so the ASK is false there → one violation.
1123        assert!(!report.conforms);
1124        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
1125        assert_eq!(
1126            report.results[0].component.as_str(),
1127            "http://ex/OkComponent"
1128        );
1129        assert_eq!(
1130            report.results[0].value.as_ref().map(ToString::to_string),
1131            Some("<http://ex/c2>".to_string())
1132        );
1133    }
1134
1135    #[test]
1136    fn custom_component_ask_validator_subquery_count() {
1137        // ASK validator that counts graph-wide instances of $class and checks
1138        // the total equals $exactCount (the BuildingMOTIF exactCount pattern).
1139        // The correct SPARQL uses a subquery to aggregate, then FILTER in the
1140        // outer query — avoiding the invalid `SELECT * … HAVING (aggregate)`
1141        // pattern that spargebra rightly rejects.
1142        let shapes_ttl = br#"
1143            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1144            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1145            @prefix ex:  <urn:ex/> .
1146
1147            ex:countComponent a sh:ConstraintComponent ;
1148                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1149                sh:parameter [ sh:path ex:class ] ;
1150                sh:validator ex:hasExactCount .
1151
1152            ex:hasExactCount a sh:SPARQLAskValidator ;
1153                sh:message "Wrong count" ;
1154                sh:ask """
1155                    ASK {
1156                        {
1157                            SELECT (COUNT(DISTINCT ?i) AS ?count)
1158                            WHERE { ?i a $class . }
1159                        }
1160                        FILTER (?count = $exactCount)
1161                    }
1162                """ .
1163
1164            ex:shape a sh:NodeShape ;
1165                sh:targetNode ex:sentinel ;
1166                ex:class ex:Thing ;
1167                ex:exactCount 1 .
1168        "#;
1169        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1170
1171        // Zero instances → violates
1172        let data_none =
1173            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1174                .unwrap();
1175        let report = validate_report_graphs(&shapes, &data_none.graph);
1176        assert!(
1177            !report.conforms,
1178            "zero Things with exactCount=1 must not conform: {:?}",
1179            report.results
1180        );
1181
1182        // Exactly one instance → conforms
1183        let data_one = shifty_parse::load_turtle(
1184            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing .",
1185            None,
1186        )
1187        .unwrap();
1188        let report_ok = validate_report_graphs(&shapes, &data_one.graph);
1189        assert!(
1190            report_ok.conforms,
1191            "exactly one Thing with exactCount=1 must conform: {:?}",
1192            report_ok.results
1193        );
1194
1195        // Two instances → violates
1196        let data_two = shifty_parse::load_turtle(
1197            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing . ex:t2 a ex:Thing .",
1198            None,
1199        )
1200        .unwrap();
1201        let report_two = validate_report_graphs(&shapes, &data_two.graph);
1202        assert!(
1203            !report_two.conforms,
1204            "two Things with exactCount=1 must not conform: {:?}",
1205            report_two.results
1206        );
1207
1208        // Same checks via the algebra path.
1209        let parse_out = shifty_parse::parse_loaded(&shapes);
1210        let schema = shifty_opt::normalize(&parse_out.schema);
1211        let plan = shifty_opt::plan(&schema);
1212        let alg_none = validate_plan_graphs(&data_none.graph, &shapes.graph, &plan).unwrap();
1213        assert!(!alg_none.conforms, "algebra: zero Things must not conform");
1214        let alg_one = validate_plan_graphs(&data_one.graph, &shapes.graph, &plan).unwrap();
1215        assert!(alg_one.conforms, "algebra: one Thing must conform");
1216        let alg_two = validate_plan_graphs(&data_two.graph, &shapes.graph, &plan).unwrap();
1217        assert!(!alg_two.conforms, "algebra: two Things must not conform");
1218    }
1219
1220    #[test]
1221    fn custom_component_invalid_sparql_ignored_by_default() {
1222        // A validator whose sh:ask is invalid SPARQL (SELECT * with an implicit
1223        // GROUP BY from HAVING) is silently skipped under the default Ignore
1224        // policy, so the component is not enforced and the shape appears to
1225        // conform even when the data does not.  This documents the known
1226        // silent-skip behaviour; use on_unsupported="error" to surface it.
1227        let shapes_ttl = br#"
1228            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1229            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1230            @prefix ex:  <urn:ex/> .
1231
1232            ex:badComponent a sh:ConstraintComponent ;
1233                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1234                sh:parameter [ sh:path ex:class ] ;
1235                sh:validator [ a sh:SPARQLAskValidator ;
1236                    sh:ask """
1237                        ASK WHERE {
1238                            {
1239                                SELECT *
1240                                WHERE { ?i a $class . }
1241                                HAVING (COUNT(DISTINCT ?i) = $exactCount)
1242                            }
1243                        }
1244                    """ ] .
1245
1246            ex:shape a sh:NodeShape ;
1247                sh:targetNode ex:sentinel ;
1248                ex:class ex:Thing ;
1249                ex:exactCount 1 .
1250        "#;
1251        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1252        let data =
1253            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1254                .unwrap();
1255
1256        // Ignore policy (default): bad query silently skipped → appears to conform
1257        let report = validate_report_graphs(&shapes, &data.graph);
1258        assert!(
1259            report.conforms,
1260            "bad validator query silently skipped under Ignore: {:?}",
1261            report.results
1262        );
1263    }
1264
1265    #[test]
1266    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
1267        let ttl = format!(
1268            "{PREFIXES}
1269            ex:S a sh:NodeShape ;
1270                sh:targetNode ex:valid, ex:extra, ex:missing ;
1271                sh:equals ex:p .
1272            ex:valid ex:p ex:valid .
1273            ex:extra ex:p ex:extra, ex:other .
1274            "
1275        );
1276        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1277        assert!(
1278            parsed.diagnostics.is_empty(),
1279            "diags: {:?}",
1280            parsed.diagnostics
1281        );
1282        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1283
1284        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
1285        assert!(!algebra.conforms);
1286        let mut foci: Vec<_> = algebra
1287            .violations
1288            .iter()
1289            .map(|violation| violation.focus.to_string())
1290            .collect();
1291        foci.sort();
1292        assert_eq!(
1293            foci,
1294            [
1295                "<http://ex/extra>".to_string(),
1296                "<http://ex/missing>".to_string()
1297            ]
1298        );
1299
1300        let normalized = shifty_opt::normalize(&parsed.schema);
1301        let plan = shifty_opt::plan(&normalized);
1302        let planned = validate_plan(&loaded.graph, &plan).unwrap();
1303        assert_eq!(planned.conforms, algebra.conforms);
1304        assert_eq!(planned.violations.len(), algebra.violations.len());
1305
1306        let report = validate_report(&loaded, &loaded.graph);
1307        assert!(!report.conforms);
1308        assert_eq!(report.results.len(), 2);
1309        assert!(report.results.iter().all(|result| result.component.as_str()
1310            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
1311    }
1312
1313    #[test]
1314    fn datatype_violation() {
1315        let ttl = format!(
1316            "{PREFIXES}
1317            ex:S a sh:NodeShape ;
1318                sh:targetNode ex:x ;
1319                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
1320            ex:x ex:p \"hello\" .
1321            "
1322        );
1323        assert!(!run(&ttl).conforms);
1324    }
1325
1326    #[test]
1327    fn nodekind_and_class_target() {
1328        let ttl = format!(
1329            "{PREFIXES}
1330            ex:S a sh:NodeShape ;
1331                sh:targetClass ex:Person ;
1332                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
1333            ex:alice a ex:Person ; ex:knows ex:bob .
1334            ex:carol a ex:Person ; ex:knows \"notaniri\" .
1335            "
1336        );
1337        let outcome = run(&ttl);
1338        assert!(!outcome.conforms);
1339        let bad: Vec<_> = outcome
1340            .violations
1341            .iter()
1342            .map(|r| r.focus.to_string())
1343            .collect();
1344        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
1345    }
1346
1347    #[test]
1348    fn recursion_over_cyclic_data_terminates() {
1349        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
1350        let ttl = format!(
1351            "{PREFIXES}
1352            ex:S a sh:NodeShape ;
1353                sh:targetNode ex:a ;
1354                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
1355            ex:a ex:knows ex:b .
1356            ex:b ex:knows ex:a .
1357            "
1358        );
1359        // Must terminate; with all-IRI neighbours it conforms under the
1360        // provisional cycle-breaking semantics.
1361        assert!(run(&ttl).conforms);
1362    }
1363
1364    #[test]
1365    fn empty_graph_conforms() {
1366        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
1367        assert!(outcome.conforms);
1368    }
1369
1370    #[test]
1371    fn non_stratifiable_schema_is_diagnosed() {
1372        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
1373        let ttl = format!(
1374            "{PREFIXES}
1375            ex:S a sh:NodeShape ;
1376                sh:targetNode ex:x ;
1377                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
1378            ex:x ex:p ex:y .
1379            "
1380        );
1381        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1382        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1383        assert!(validate(&loaded.graph, &out.schema).is_err());
1384    }
1385
1386    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
1387        use oxrdf::NamedNode;
1388        oxrdf::Triple::new(
1389            NamedNode::new(s).unwrap(),
1390            NamedNode::new(p).unwrap(),
1391            NamedNode::new(o).unwrap(),
1392        )
1393    }
1394
1395    #[test]
1396    fn triple_rule_infers_from_path() {
1397        // copy each ex:knows value to ex:knows2
1398        let ttl = format!(
1399            "{PREFIXES}
1400            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1401                sh:rule [ a sh:TripleRule ;
1402                    sh:subject sh:this ; sh:predicate ex:knows2 ;
1403                    sh:object [ sh:path ex:knows ] ] .
1404            ex:a a ex:Person ; ex:knows ex:b .
1405            "
1406        );
1407        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1408        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1409        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1410        assert_eq!(outcome.inferred.len(), 1);
1411        assert!(
1412            outcome
1413                .graph
1414                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
1415        );
1416    }
1417
1418    #[test]
1419    fn inference_reaches_a_fixpoint() {
1420        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
1421        // a→b→c, so a reaches c is derivable only after b reaches c.
1422        let ttl = format!(
1423            "{PREFIXES}
1424            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1425                sh:rule [ a sh:TripleRule ;
1426                    sh:subject sh:this ; sh:predicate ex:reaches ;
1427                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
1428            ex:a a ex:Person ; ex:knows ex:b .
1429            ex:b a ex:Person ; ex:knows ex:c .
1430            ex:c a ex:Person .
1431            "
1432        );
1433        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1434        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1435        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1436        assert!(
1437            outcome
1438                .graph
1439                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
1440        );
1441        assert!(
1442            outcome
1443                .graph
1444                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
1445        );
1446        // the fixpoint result: a reaches c (only via b reaches c)
1447        assert!(
1448            outcome
1449                .graph
1450                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
1451        );
1452    }
1453
1454    #[test]
1455    fn later_order_output_reactivates_an_earlier_rule() {
1456        let ttl = format!(
1457            "{PREFIXES}
1458            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1459                sh:rule [
1460                    a sh:TripleRule ; sh:order 0 ;
1461                    sh:subject sh:this ; sh:predicate ex:done ;
1462                    sh:object [ sh:path ex:ready ]
1463                ] ;
1464                sh:rule [
1465                    a sh:TripleRule ; sh:order 1 ;
1466                    sh:subject sh:this ; sh:predicate ex:ready ;
1467                    sh:object ex:y
1468                ] .
1469            "
1470        );
1471        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1472        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1473        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1474
1475        assert!(
1476            outcome
1477                .graph
1478                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
1479        );
1480        assert!(
1481            outcome
1482                .graph
1483                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
1484        );
1485    }
1486
1487    #[test]
1488    fn inferred_triples_can_create_new_rule_targets() {
1489        let ttl = format!(
1490            "{PREFIXES}
1491            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
1492                sh:rule [
1493                    a sh:TripleRule ;
1494                    sh:subject sh:this ; sh:predicate ex:eligible ;
1495                    sh:object ex:y
1496                ] .
1497            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
1498                sh:rule [
1499                    a sh:TripleRule ;
1500                    sh:subject sh:this ; sh:predicate ex:classified ;
1501                    sh:object ex:yes
1502                ] .
1503            "
1504        );
1505        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1506        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1507        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1508
1509        assert!(outcome.graph.contains(&triple(
1510            "http://ex/x",
1511            "http://ex/classified",
1512            "http://ex/yes",
1513        )));
1514    }
1515
1516    #[test]
1517    fn split_inference_uses_shapes_graph_as_rule_context() {
1518        let shapes_ttl = format!(
1519            "{PREFIXES}
1520            ex:InverseShape a sh:NodeShape ;
1521                sh:targetClass ex:Thing ;
1522                sh:rule [
1523                    a sh:SPARQLRule ;
1524                    sh:construct \"\"\"
1525                        CONSTRUCT {{ ?o ?inverse $this }}
1526                        WHERE {{
1527                            $this ?predicate ?o .
1528                            ?predicate ex:inverseOf ?inverse .
1529                        }}
1530                    \"\"\"
1531                ] .
1532            ex:p ex:inverseOf ex:q .
1533            "
1534        );
1535        let data_ttl = format!(
1536            "{PREFIXES}
1537            ex:a a ex:Thing ; ex:p ex:b .
1538            "
1539        );
1540        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
1541        let parsed = shifty_parse::parse_loaded(&shapes);
1542        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
1543
1544        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
1545
1546        assert!(
1547            outcome
1548                .graph
1549                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
1550        );
1551        assert_eq!(outcome.inferred.len(), 1);
1552    }
1553}