1pub 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 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 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 #[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 assert!(
219 !messages.iter().any(|m| m.contains("at most 0")),
220 "raw ∃≤0 message leaked: {messages:?}"
221 );
222
223 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 assert_eq!(reason.path.as_deref(), Some("ex:hasKind"));
234 }
235
236 #[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 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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let lenient = validate_report(&loaded, &loaded.graph);
1055 assert!(lenient.conforms, "results: {:?}", lenient.results);
1056
1057 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}