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