1use crate::frozen::FrozenIndexedDataset;
13use crate::path::{PathBackend, node_of, pred, succ};
14use crate::profile::ShapeCacheSample;
15use crate::sparql::{SparqlExecutor, SparqlViolation};
16use crate::value::{compare_terms, value_type_holds};
17use oxrdf::{Graph, NamedNode, Term};
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20use shifty_algebra::render::{
21 describe_negation, describe_shape, negated_class_target_shape, path_to_string, shape_to_string,
22};
23use shifty_algebra::{
24 NodeExpr, Path, Schema, Selector, Severity, Shape, ShapeArena, ShapeId, SparqlConstraint,
25};
26use shifty_opt::{FocusSource, PhysicalPlan, analyze};
27use std::cmp::Ordering;
28use std::collections::{BTreeSet, HashMap, HashSet};
29use std::fmt;
30use std::sync::OnceLock;
31
32#[derive(Debug, Clone, Copy)]
33struct EvalResult {
34 holds: bool,
35 cacheable: bool,
36}
37
38#[derive(Default)]
39struct EvalState {
40 memo: HashMap<(ShapeId, Term), bool>,
41 active: HashSet<(ShapeId, Term)>,
42 telemetry: Option<ShapeCacheSample>,
43}
44
45pub(crate) struct ShapeEvaluator<'a> {
51 g: &'a dyn PathBackend,
52 arena: &'a ShapeArena,
53 sparql: &'a SparqlExecutor,
54 state: EvalState,
55}
56
57impl<'a> ShapeEvaluator<'a> {
58 pub(crate) fn new(
59 g: &'a dyn PathBackend,
60 arena: &'a ShapeArena,
61 sparql: &'a SparqlExecutor,
62 ) -> Self {
63 Self {
64 g,
65 arena,
66 sparql,
67 state: EvalState {
68 telemetry: crate::profile::is_enabled().then(ShapeCacheSample::default),
69 ..EvalState::default()
70 },
71 }
72 }
73
74 pub(crate) fn holds(&mut self, node: &Term, id: ShapeId) -> bool {
75 holds_memoized(self.g, self.arena, node, id, self.sparql, &mut self.state).holds
76 }
77
78 pub(crate) fn sparql(&self) -> &SparqlExecutor {
79 self.sparql
80 }
81
82 pub(crate) fn backend(&self) -> &dyn PathBackend {
84 self.g
85 }
86
87 pub(crate) fn arena(&self) -> &ShapeArena {
89 self.arena
90 }
91}
92
93impl Drop for ShapeEvaluator<'_> {
94 fn drop(&mut self) {
95 let Some(mut sample) = self.state.telemetry else {
96 return;
97 };
98 sample.entries = self.state.memo.len();
99 sample.estimated_bytes = estimated_memo_bytes(&self.state.memo);
100 crate::profile::record_shape_cache(sample);
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Reason {
107 pub value: Term,
109 pub path: Option<String>,
112 pub shape: ShapeId,
114 #[serde(default)]
116 pub severity: Severity,
117 pub message: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub author_message: Option<String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub sub_reasons: Vec<Reason>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct Violation {
133 pub focus: Term,
134 pub statement: usize,
136 pub severity: Severity,
138 pub reasons: Vec<Reason>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct ValidationOutcome {
144 pub conforms: bool,
145 pub violations: Vec<Violation>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum UnsupportedPolicy {
152 #[default]
155 Ignore,
156 Error,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub struct EngineOptions {
167 pub unsupported: UnsupportedPolicy,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ValidationOptions {
176 pub minimum_severity: Severity,
179 pub sort_results: bool,
182 pub entry_shape_names: Vec<String>,
187 pub engine: EngineOptions,
189}
190
191impl Default for ValidationOptions {
192 fn default() -> Self {
193 Self {
194 minimum_severity: Severity::Info,
195 sort_results: true,
196 entry_shape_names: Vec::new(),
197 engine: EngineOptions::default(),
198 }
199 }
200}
201
202fn requested_shape_name_matches(requested: &str, actual: &str) -> bool {
203 let requested = requested.trim();
204 requested == actual
205 || requested
206 .strip_prefix('<')
207 .and_then(|s| s.strip_suffix('>'))
208 .is_some_and(|stripped| stripped == actual)
209}
210
211pub(crate) fn entry_shape_name_selected(
214 entry_shape_names: &[String],
215 actual_name: Option<&str>,
216) -> bool {
217 entry_shape_names.is_empty()
218 || actual_name.is_some_and(|actual| {
219 entry_shape_names
220 .iter()
221 .any(|requested| requested_shape_name_matches(requested, actual))
222 })
223}
224
225fn most_severe(reasons: &[Reason]) -> Severity {
226 reasons
227 .iter()
228 .max_by_key(|reason| reason.severity.rank())
229 .map(|reason| reason.severity.clone())
230 .unwrap_or(Severity::Violation)
231}
232
233fn conforms_at_threshold(violations: &[Violation], minimum: &Severity) -> bool {
234 !violations
235 .iter()
236 .flat_map(|violation| &violation.reasons)
237 .any(|reason| reason.severity.meets(minimum))
238}
239
240fn sort_violations(violations: &mut [Violation], enabled: bool) {
241 if enabled {
242 violations.sort_by(|left, right| {
243 right
244 .severity
245 .rank()
246 .cmp(&left.severity.rank())
247 .then_with(|| left.focus.to_string().cmp(&right.focus.to_string()))
248 .then_with(|| left.statement.cmp(&right.statement))
249 });
250 }
251}
252
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
255pub enum ValidationGraphMode {
256 Data,
258 #[default]
261 Union,
262 UnionAll,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct NonStratifiable {
271 pub components: Vec<Vec<ShapeId>>,
272}
273
274impl fmt::Display for NonStratifiable {
275 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276 write!(f, "non-stratifiable schema (recursion through negation): ")?;
277 for (i, c) in self.components.iter().enumerate() {
278 if i > 0 {
279 write!(f, "; ")?;
280 }
281 let ids: Vec<String> = c.iter().map(|s| format!("@{}", s.0)).collect();
282 write!(f, "{{{}}}", ids.join(" "))?;
283 }
284 Ok(())
285 }
286}
287
288impl std::error::Error for NonStratifiable {}
289
290pub fn validate(data: &Graph, schema: &Schema) -> Result<ValidationOutcome, NonStratifiable> {
299 validate_with_options(data, schema, &ValidationOptions::default())
300}
301
302pub fn validate_with_options(
304 data: &Graph,
305 schema: &Schema,
306 options: &ValidationOptions,
307) -> Result<ValidationOutcome, NonStratifiable> {
308 validate_with_context_and_options(data, data, schema, options)
309}
310
311pub fn validate_graphs(
313 data: &Graph,
314 shapes: &Graph,
315 schema: &Schema,
316) -> Result<ValidationOutcome, NonStratifiable> {
317 validate_graphs_with_mode_and_options(
318 data,
319 shapes,
320 schema,
321 ValidationGraphMode::default(),
322 &ValidationOptions::default(),
323 )
324}
325
326pub fn validate_graphs_with_mode(
328 data: &Graph,
329 shapes: &Graph,
330 schema: &Schema,
331 mode: ValidationGraphMode,
332) -> Result<ValidationOutcome, NonStratifiable> {
333 validate_graphs_with_mode_and_options(data, shapes, schema, mode, &ValidationOptions::default())
334}
335
336pub fn validate_graphs_with_mode_and_options(
338 data: &Graph,
339 shapes: &Graph,
340 schema: &Schema,
341 mode: ValidationGraphMode,
342 options: &ValidationOptions,
343) -> Result<ValidationOutcome, NonStratifiable> {
344 match mode {
345 ValidationGraphMode::Data => {
346 let uses_shapes = uses_shapes_graph(&schema.arena);
347 let frozen = if uses_shapes {
348 FrozenIndexedDataset::from_graphs(data, shapes)
349 } else {
350 FrozenIndexedDataset::from_graph(data)
351 };
352 validate_with_frozen(data, schema, frozen, uses_shapes, options)
353 }
354 ValidationGraphMode::Union => {
355 let uses_shapes = uses_shapes_graph(&schema.arena);
356 let frozen = if uses_shapes {
357 FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
358 } else {
359 FrozenIndexedDataset::from_graph_union(data, shapes)
360 };
361 validate_with_frozen(data, schema, frozen, uses_shapes, options)
362 }
363 ValidationGraphMode::UnionAll => {
364 let union = graph_union(data, shapes);
365 validate_with_context_and_options(&union, &union, schema, options)
366 }
367 }
368}
369
370pub fn validate_with_context(
374 data: &Graph,
375 context: &Graph,
376 schema: &Schema,
377) -> Result<ValidationOutcome, NonStratifiable> {
378 validate_with_context_and_options(data, context, schema, &ValidationOptions::default())
379}
380
381pub fn validate_with_context_and_options(
383 data: &Graph,
384 context: &Graph,
385 schema: &Schema,
386 options: &ValidationOptions,
387) -> Result<ValidationOutcome, NonStratifiable> {
388 let uses_shapes = uses_shapes_graph(&schema.arena);
389 let frozen = if uses_shapes {
390 FrozenIndexedDataset::from_graphs(context, context)
391 } else {
392 FrozenIndexedDataset::from_graph(context)
393 };
394 validate_with_frozen(data, schema, frozen, uses_shapes, options)
395}
396
397fn validate_with_frozen(
398 data: &Graph,
399 schema: &Schema,
400 frozen: FrozenIndexedDataset,
401 has_shapes_graph: bool,
402 options: &ValidationOptions,
403) -> Result<ValidationOutcome, NonStratifiable> {
404 let strat = analyze(&schema.arena);
405 if !strat.stratifiable {
406 let components = strat
407 .strata
408 .iter()
409 .filter(|s| !s.stratifiable)
410 .map(|s| s.shapes.clone())
411 .collect();
412 return Err(NonStratifiable { components });
413 }
414
415 let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
416 let backend = sparql
417 .frozen()
418 .expect("validation executor always has a frozen dataset");
419 let mut evaluator = ShapeEvaluator::new(backend, &schema.arena, &sparql);
420 let mut violations = Vec::new();
421 for (i, st) in schema.statements.iter().enumerate() {
422 if !entry_shape_name_selected(
423 &options.entry_shape_names,
424 schema.names.get(&st.shape).map(String::as_str),
425 ) {
426 continue;
427 }
428 let label = schema
429 .names
430 .get(&st.shape)
431 .cloned()
432 .unwrap_or_else(|| format!("@{}", st.shape.0));
433 let foci = focus_nodes_with_evaluator(data, &st.selector, &mut evaluator);
434 prefetch_sparql_constraints(&schema.arena, st.shape, &foci, &sparql);
435 for v in foci {
436 let t = web_time::Instant::now();
437 let mut stack = HashSet::new();
438 let mut reasons = explain(
439 &mut evaluator,
440 &v,
441 st.shape,
442 None,
443 &Severity::Violation,
444 &mut stack,
445 );
446 crate::profile::record_shape(&label, t.elapsed().as_micros() as u64);
447 dedup_reasons(&mut reasons);
448 if !reasons.is_empty() {
449 let severity = most_severe(&reasons);
450 violations.push(Violation {
451 focus: v,
452 statement: i,
453 severity,
454 reasons,
455 });
456 }
457 }
458 }
459 sort_violations(&mut violations, options.sort_results);
460 Ok(ValidationOutcome {
461 conforms: conforms_at_threshold(&violations, &options.minimum_severity),
462 violations,
463 })
464}
465
466pub(crate) fn uses_shapes_graph(arena: &ShapeArena) -> bool {
469 (0..arena.len()).any(|i| {
470 matches!(arena.get(ShapeId(i as u32)), Shape::Sparql(c) if c.query.contains("shapesGraph"))
471 })
472}
473
474pub fn graph_union(left: &Graph, right: &Graph) -> Graph {
478 let mut union = left.clone();
479 for triple in right.iter() {
480 union.insert(triple);
481 }
482 union
483}
484
485pub fn validate_plan(
491 data: &Graph,
492 plan: &PhysicalPlan,
493) -> Result<ValidationOutcome, NonStratifiable> {
494 validate_plan_with_options(data, plan, &ValidationOptions::default())
495}
496
497pub fn validate_plan_with_options(
499 data: &Graph,
500 plan: &PhysicalPlan,
501 options: &ValidationOptions,
502) -> Result<ValidationOutcome, NonStratifiable> {
503 validate_plan_with_context_and_options(data, data, plan, options)
504}
505
506pub fn validate_plan_graphs(
508 data: &Graph,
509 shapes: &Graph,
510 plan: &PhysicalPlan,
511) -> Result<ValidationOutcome, NonStratifiable> {
512 validate_plan_graphs_with_mode_and_options(
513 data,
514 shapes,
515 plan,
516 ValidationGraphMode::default(),
517 &ValidationOptions::default(),
518 )
519}
520
521pub fn validate_plan_graphs_with_mode(
523 data: &Graph,
524 shapes: &Graph,
525 plan: &PhysicalPlan,
526 mode: ValidationGraphMode,
527) -> Result<ValidationOutcome, NonStratifiable> {
528 validate_plan_graphs_with_mode_and_options(
529 data,
530 shapes,
531 plan,
532 mode,
533 &ValidationOptions::default(),
534 )
535}
536
537pub fn validate_plan_graphs_with_mode_and_options(
539 data: &Graph,
540 shapes: &Graph,
541 plan: &PhysicalPlan,
542 mode: ValidationGraphMode,
543 options: &ValidationOptions,
544) -> Result<ValidationOutcome, NonStratifiable> {
545 match mode {
546 ValidationGraphMode::Data => {
547 let uses_shapes = uses_shapes_graph(&plan.arena);
548 let frozen = if uses_shapes {
549 FrozenIndexedDataset::from_graphs(data, shapes)
550 } else {
551 FrozenIndexedDataset::from_graph(data)
552 };
553 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
554 }
555 ValidationGraphMode::Union => {
556 let uses_shapes = uses_shapes_graph(&plan.arena);
557 let frozen = if uses_shapes {
558 FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
559 } else {
560 FrozenIndexedDataset::from_graph_union(data, shapes)
561 };
562 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
563 }
564 ValidationGraphMode::UnionAll => {
565 let union = graph_union(data, shapes);
566 validate_plan_with_context_and_options(&union, &union, plan, options)
567 }
568 }
569}
570
571pub fn validate_plan_with_context(
573 data: &Graph,
574 context: &Graph,
575 plan: &PhysicalPlan,
576) -> Result<ValidationOutcome, NonStratifiable> {
577 validate_plan_with_context_and_options(data, context, plan, &ValidationOptions::default())
578}
579
580pub fn validate_plan_with_context_and_options(
582 data: &Graph,
583 context: &Graph,
584 plan: &PhysicalPlan,
585 options: &ValidationOptions,
586) -> Result<ValidationOutcome, NonStratifiable> {
587 let uses_shapes = uses_shapes_graph(&plan.arena);
588 let frozen = if uses_shapes {
589 FrozenIndexedDataset::from_graphs(context, context)
590 } else {
591 FrozenIndexedDataset::from_graph(context)
592 };
593 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
594}
595
596fn validate_plan_with_frozen(
597 data: &Graph,
598 plan: &PhysicalPlan,
599 frozen: FrozenIndexedDataset,
600 has_shapes_graph: bool,
601 options: &ValidationOptions,
602) -> Result<ValidationOutcome, NonStratifiable> {
603 let strat = analyze(&plan.arena);
604 if !strat.stratifiable {
605 let components = strat
606 .strata
607 .iter()
608 .filter(|s| !s.stratifiable)
609 .map(|s| s.shapes.clone())
610 .collect();
611 return Err(NonStratifiable { components });
612 }
613
614 let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
615 let backend = sparql
616 .frozen()
617 .expect("validation executor always has a frozen dataset");
618 let mut evaluator = ShapeEvaluator::new(backend, &plan.arena, &sparql);
619 let mut violations = Vec::new();
620 for (i, sp) in plan.statements.iter().enumerate() {
621 if !entry_shape_name_selected(
622 &options.entry_shape_names,
623 plan.names.get(&sp.shape).map(String::as_str),
624 ) {
625 continue;
626 }
627 let label = plan
628 .names
629 .get(&sp.shape)
630 .cloned()
631 .unwrap_or_else(|| format!("@{}", sp.shape.0));
632 let foci = focus_for_source(data, &sp.source, &mut evaluator);
633 prefetch_sparql_constraints(&plan.arena, sp.shape, &foci, &sparql);
634 for v in foci {
635 let t = web_time::Instant::now();
636 let mut stack = HashSet::new();
637 let mut reasons = explain(
638 &mut evaluator,
639 &v,
640 sp.shape,
641 None,
642 &Severity::Violation,
643 &mut stack,
644 );
645 crate::profile::record_shape(&label, t.elapsed().as_micros() as u64);
646 dedup_reasons(&mut reasons);
647 if !reasons.is_empty() {
648 let severity = most_severe(&reasons);
649 violations.push(Violation {
650 focus: v,
651 statement: i,
652 severity,
653 reasons,
654 });
655 }
656 }
657 }
658 sort_violations(&mut violations, options.sort_results);
659 Ok(ValidationOutcome {
660 conforms: conforms_at_threshold(&violations, &options.minimum_severity),
661 violations,
662 })
663}
664
665fn focus_for_source(
667 data: &Graph,
668 source: &FocusSource,
669 evaluator: &mut ShapeEvaluator<'_>,
670) -> Vec<Term> {
671 match source {
672 FocusSource::SubjectsOf(p) => subjects_of(data, p),
673 FocusSource::ObjectsOf(p) => objects_of(data, p),
674 FocusSource::Node(c) => vec![c.clone()],
675 FocusSource::PathToConst { path, target } => pred(evaluator.g, target, path)
677 .into_iter()
678 .filter(|node| graph_contains_term(data, node))
679 .collect(),
680 FocusSource::ScanFilter { path, qualifier } => all_nodes(data)
681 .into_iter()
682 .filter(|v| {
683 succ(evaluator.g, v, path)
684 .iter()
685 .any(|u| evaluator.holds(u, *qualifier))
686 })
687 .collect(),
688 FocusSource::Sparql(target) => {
689 let candidates = all_nodes(data);
690 evaluator
691 .sparql
692 .target_nodes(&target.query)
693 .unwrap_or_default()
694 .into_iter()
695 .filter(|node| candidates.contains(node))
696 .collect()
697 }
698 }
699}
700
701pub fn focus_nodes(data: &Graph, sel: &Selector, arena: &ShapeArena) -> Vec<Term> {
703 let sparql =
704 SparqlExecutor::new(data).expect("building an in-memory Oxigraph store should succeed");
705 let mut evaluator = ShapeEvaluator::new(data, arena, &sparql);
706 focus_nodes_with_evaluator(data, sel, &mut evaluator)
707}
708
709pub(crate) fn focus_nodes_with(
710 data: &Graph,
711 backend: &dyn PathBackend,
712 sel: &Selector,
713 arena: &ShapeArena,
714 sparql: &SparqlExecutor,
715) -> Vec<Term> {
716 let mut evaluator = ShapeEvaluator::new(backend, arena, sparql);
717 focus_nodes_with_evaluator(data, sel, &mut evaluator)
718}
719
720fn focus_nodes_with_evaluator(
721 data: &Graph,
722 sel: &Selector,
723 evaluator: &mut ShapeEvaluator<'_>,
724) -> Vec<Term> {
725 match sel {
726 Selector::HasOut(q) => subjects_of(data, q),
727 Selector::HasIn(q) => objects_of(data, q),
728 Selector::IsConst(c) => vec![c.clone()],
729 Selector::HasPath(path, qual) => match evaluator.arena.get(*qual) {
730 Shape::TestConst(target) => pred(evaluator.g, target, path)
735 .into_iter()
736 .filter(|node| graph_contains_term(data, node))
737 .collect(),
738 _ => all_nodes(data)
739 .into_iter()
740 .filter(|v| {
741 succ(evaluator.g, v, path)
742 .iter()
743 .any(|u| evaluator.holds(u, *qual))
744 })
745 .collect(),
746 },
747 Selector::Sparql(target) => {
748 let candidates = all_nodes(data);
749 evaluator
750 .sparql
751 .target_nodes(&target.query)
752 .unwrap_or_default()
753 .into_iter()
754 .filter(|node| candidates.contains(node))
755 .collect()
756 }
757 }
758}
759
760fn holds_memoized(
761 g: &dyn PathBackend,
762 arena: &ShapeArena,
763 v: &Term,
764 id: ShapeId,
765 sparql: &SparqlExecutor,
766 state: &mut EvalState,
767) -> EvalResult {
768 let key = (id, v.clone());
769 if let Some(&holds) = state.memo.get(&key) {
770 if let Some(telemetry) = state.telemetry.as_mut() {
771 telemetry.hits += 1;
772 }
773 return EvalResult {
774 holds,
775 cacheable: true,
776 };
777 }
778 if let Some(telemetry) = state.telemetry.as_mut() {
779 telemetry.misses += 1;
780 }
781 if !state.active.insert(key.clone()) {
782 if let Some(telemetry) = state.telemetry.as_mut() {
783 telemetry.recursion_back_edges += 1;
784 }
785 return EvalResult {
786 holds: true,
787 cacheable: false,
788 }; }
790 let result = match arena.get(id) {
791 Shape::Annotated { shape, .. } => holds_memoized(g, arena, v, *shape, sparql, state),
792 Shape::Top | Shape::Pending => EvalResult {
793 holds: true,
794 cacheable: true,
795 },
796 Shape::Sparql(constraint) => EvalResult {
797 holds: sparql
798 .constraint_violations(constraint, v)
799 .is_ok_and(|violations| violations.is_empty()),
800 cacheable: true,
801 },
802 Shape::Expression(expr) => {
803 let mut cacheable = true;
806 let results = eval_expr(g, arena, v, expr, sparql, state, &mut cacheable);
807 EvalResult {
808 holds: results.iter().all(is_boolean_true),
809 cacheable,
810 }
811 }
812 Shape::TestConst(c) => EvalResult {
813 holds: v == c,
814 cacheable: true,
815 },
816 Shape::TestType(t) => EvalResult {
817 holds: value_type_holds(t, v),
818 cacheable: true,
819 },
820 Shape::TestKind(k) => EvalResult {
821 holds: k.matches(v),
822 cacheable: true,
823 },
824 Shape::Closed(q) => EvalResult {
825 holds: closed_offenders(g, v, q).is_empty(),
826 cacheable: true,
827 },
828 Shape::Eq(path, p) => EvalResult {
829 holds: succ(g, v, path) == objects(g, v, p),
830 cacheable: true,
831 },
832 Shape::Disj(path, p) => EvalResult {
833 holds: succ(g, v, path).is_disjoint(&objects(g, v, p)),
834 cacheable: true,
835 },
836 Shape::Lt(path, p) => EvalResult {
837 holds: all_pairs_ordered(g, v, path, p, false),
838 cacheable: true,
839 },
840 Shape::Le(path, p) => EvalResult {
841 holds: all_pairs_ordered(g, v, path, p, true),
842 cacheable: true,
843 },
844 Shape::UniqueLang(path) => EvalResult {
845 holds: unique_lang(&succ(g, v, path)),
846 cacheable: true,
847 },
848 Shape::Not(c) => {
849 let child = holds_memoized(g, arena, v, *c, sparql, state);
850 EvalResult {
851 holds: !child.holds,
852 cacheable: child.cacheable,
853 }
854 }
855 Shape::And(cs) => {
856 let mut result = EvalResult {
857 holds: true,
858 cacheable: true,
859 };
860 for child in cs {
861 let child = holds_memoized(g, arena, v, *child, sparql, state);
862 result.cacheable &= child.cacheable;
863 if !child.holds {
864 result.holds = false;
865 break;
866 }
867 }
868 result
869 }
870 Shape::Or(cs) => {
871 let mut result = EvalResult {
872 holds: false,
873 cacheable: true,
874 };
875 for child in cs {
876 let child = holds_memoized(g, arena, v, *child, sparql, state);
877 result.cacheable &= child.cacheable;
878 if child.holds {
879 result.holds = true;
880 break;
881 }
882 }
883 result
884 }
885 Shape::Count {
886 path,
887 min,
888 max,
889 qualifier,
890 } => {
891 let mut n = 0;
892 let mut cacheable = true;
893 for value in succ(g, v, path) {
894 let qualified = holds_memoized(g, arena, &value, *qualifier, sparql, state);
895 cacheable &= qualified.cacheable;
896 n += u64::from(qualified.holds);
897 }
898 EvalResult {
899 holds: min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m),
900 cacheable,
901 }
902 }
903 };
904 state.active.remove(&key);
905 if result.cacheable {
906 state.memo.insert(key, result.holds);
907 if let Some(telemetry) = state.telemetry.as_mut() {
908 telemetry.insertions += 1;
909 }
910 } else if let Some(telemetry) = state.telemetry.as_mut() {
911 telemetry.non_cacheable_results += 1;
912 }
913 result
914}
915
916fn eval_expr(
922 g: &dyn PathBackend,
923 arena: &ShapeArena,
924 v: &Term,
925 expr: &NodeExpr,
926 sparql: &SparqlExecutor,
927 state: &mut EvalState,
928 cacheable: &mut bool,
929) -> HashSet<Term> {
930 match expr {
931 NodeExpr::This => {
932 let mut s = HashSet::with_capacity(1);
933 s.insert(v.clone());
934 s
935 }
936 NodeExpr::Constant(t) => {
937 let mut s = HashSet::with_capacity(1);
938 s.insert(t.clone());
939 s
940 }
941 NodeExpr::Path(p) => succ(g, v, p),
942 NodeExpr::Filter { input, shape } => {
943 let inputs = eval_expr(g, arena, v, input, sparql, state, cacheable);
944 inputs
945 .into_iter()
946 .filter(|x| {
947 let r = holds_memoized(g, arena, x, *shape, sparql, state);
948 *cacheable &= r.cacheable;
949 r.holds
950 })
951 .collect()
952 }
953 NodeExpr::Intersection(es) => {
954 let mut iter = es.iter();
955 match iter.next() {
956 Some(first) => {
957 let mut acc = eval_expr(g, arena, v, first, sparql, state, cacheable);
958 for e in iter {
959 let s = eval_expr(g, arena, v, e, sparql, state, cacheable);
960 acc.retain(|x| s.contains(x));
961 }
962 acc
963 }
964 None => HashSet::new(),
965 }
966 }
967 NodeExpr::Union(es) => {
968 let mut acc = HashSet::new();
969 for e in es {
970 acc.extend(eval_expr(g, arena, v, e, sparql, state, cacheable));
971 }
972 acc
973 }
974 NodeExpr::Function { .. } => HashSet::new(),
975 }
976}
977
978pub(crate) fn is_boolean_true(t: &Term) -> bool {
980 matches!(t, Term::Literal(l) if l.datatype() == oxrdf::vocab::xsd::BOOLEAN && l.value() == "true")
981}
982
983fn estimated_memo_bytes(memo: &HashMap<(ShapeId, Term), bool>) -> usize {
984 const CONTROL_BYTE_ESTIMATE: usize = 1;
985 let bucket_bytes =
986 memo.capacity() * (std::mem::size_of::<((ShapeId, Term), bool)>() + CONTROL_BYTE_ESTIMATE);
987 bucket_bytes
988 + memo
989 .keys()
990 .map(|(_, term)| estimated_term_heap_bytes(term))
991 .sum::<usize>()
992}
993
994fn estimated_term_heap_bytes(term: &Term) -> usize {
995 match term {
996 Term::NamedNode(node) => node.as_str().len(),
997 Term::BlankNode(node) => node.as_str().len(),
998 Term::Literal(literal) => {
999 literal.value().len()
1000 + literal.language().map_or_else(
1001 || {
1002 let datatype = literal.datatype();
1003 if datatype.as_str() == "http://www.w3.org/2001/XMLSchema#string" {
1004 0
1005 } else {
1006 datatype.as_str().len()
1007 }
1008 },
1009 str::len,
1010 )
1011 }
1012 }
1013}
1014
1015fn prefetch_sparql_constraints(
1024 arena: &ShapeArena,
1025 root: ShapeId,
1026 foci: &[Term],
1027 sparql: &SparqlExecutor,
1028) {
1029 if foci.len() < 2 {
1030 return;
1031 }
1032 let mut constraints = Vec::new();
1033 let mut seen = HashSet::new();
1034 collect_focus_sparql(arena, root, &mut seen, &mut constraints);
1035 for constraint in constraints {
1036 let _ = sparql.prefetch_constraint(constraint, foci);
1037 }
1038}
1039
1040fn collect_focus_sparql<'a>(
1041 arena: &'a ShapeArena,
1042 id: ShapeId,
1043 seen: &mut HashSet<ShapeId>,
1044 out: &mut Vec<&'a SparqlConstraint>,
1045) {
1046 if !seen.insert(id) {
1047 return; }
1049 match arena.get(id) {
1050 Shape::Annotated { shape, .. } => collect_focus_sparql(arena, *shape, seen, out),
1051 Shape::Sparql(constraint) => out.push(constraint),
1052 Shape::Not(inner) => collect_focus_sparql(arena, *inner, seen, out),
1053 Shape::And(ids) | Shape::Or(ids) => {
1054 for &child in ids {
1055 collect_focus_sparql(arena, child, seen, out);
1056 }
1057 }
1058 _ => {}
1060 }
1061}
1062
1063fn explain(
1066 evaluator: &mut ShapeEvaluator<'_>,
1067 node: &Term,
1068 id: ShapeId,
1069 path_ctx: Option<&str>,
1070 severity: &Severity,
1071 stack: &mut HashSet<(ShapeId, Term)>,
1072) -> Vec<Reason> {
1073 let key = (id, node.clone());
1074 if !stack.insert(key.clone()) {
1075 return Vec::new(); }
1077 if evaluator.holds(node, id) {
1078 stack.remove(&key);
1079 return Vec::new();
1080 }
1081 let reasons = match evaluator.arena.get(id).clone() {
1082 Shape::Annotated {
1083 severity: source_severity,
1084 messages,
1085 shape,
1086 } => {
1087 let mut reasons = explain(evaluator, node, shape, path_ctx, &source_severity, stack);
1088 if !messages.is_empty() {
1089 let author = messages
1095 .iter()
1096 .map(|m| apply_message_template(&term_text(m), node, &HashMap::new()))
1097 .collect::<Vec<_>>()
1098 .join("; ");
1099 for r in &mut reasons {
1100 if r.author_message.is_none() {
1101 r.author_message = Some(author.clone());
1102 }
1103 }
1104 }
1105 reasons
1106 }
1107 Shape::Top | Shape::Pending => Vec::new(),
1108 Shape::Sparql(constraint) => {
1109 match evaluator.sparql.constraint_violations(&constraint, node) {
1110 Ok(violations) => violations
1111 .into_iter()
1112 .map(|violation| {
1113 let message = sparql_violation_message(&violation, &constraint, node);
1116 Reason {
1117 value: violation.value.unwrap_or_else(|| node.clone()),
1118 path: violation
1119 .path
1120 .map(|path| path.to_string())
1121 .or_else(|| path_ctx.map(str::to_string))
1122 .or_else(|| constraint.path.as_ref().map(path_to_string)),
1123 message,
1124 shape: id,
1125 severity: severity.clone(),
1126 author_message: None,
1127 sub_reasons: Vec::new(),
1128 }
1129 })
1130 .collect(),
1131 Err(error) => vec![Reason {
1132 value: node.clone(),
1133 path: path_ctx.map(str::to_string),
1134 shape: id,
1135 severity: severity.clone(),
1136 message: format!("SPARQL constraint evaluation failed: {error}"),
1137 author_message: None,
1138 sub_reasons: Vec::new(),
1139 }],
1140 }
1141 }
1142 Shape::TestConst(_)
1143 | Shape::TestType(_)
1144 | Shape::TestKind(_)
1145 | Shape::Eq(..)
1146 | Shape::Disj(..)
1147 | Shape::Lt(..)
1148 | Shape::Le(..)
1149 | Shape::UniqueLang(_) => leaf(
1150 evaluator.holds(node, id),
1151 node,
1152 id,
1153 path_ctx,
1154 severity,
1155 format!("{} not satisfied", shape_to_string(evaluator.arena, id)),
1156 ),
1157 Shape::Closed(q) => {
1158 let bad = closed_offenders(evaluator.g, node, &q);
1159 if bad.is_empty() {
1160 Vec::new()
1161 } else {
1162 let preds: Vec<String> = bad.iter().map(|p| p.to_string()).collect();
1163 vec![Reason {
1164 value: node.clone(),
1165 path: path_ctx.map(str::to_string),
1166 shape: id,
1167 severity: severity.clone(),
1168 message: format!("closed: unexpected predicate(s) {}", preds.join(", ")),
1169 author_message: None,
1170 sub_reasons: Vec::new(),
1171 }]
1172 }
1173 }
1174 Shape::Not(c) => {
1175 if explain(evaluator, node, c, path_ctx, severity, stack).is_empty() {
1176 vec![Reason {
1177 value: node.clone(),
1178 path: path_ctx.map(str::to_string),
1179 shape: id,
1180 severity: severity.clone(),
1181 message: "negated shape unexpectedly held".to_string(),
1182 author_message: None,
1183 sub_reasons: Vec::new(),
1184 }]
1185 } else {
1186 Vec::new()
1187 }
1188 }
1189 Shape::And(cs) => cs
1190 .iter()
1191 .flat_map(|c| explain(evaluator, node, *c, path_ctx, severity, stack))
1192 .collect(),
1193 Shape::Or(cs) => {
1194 let mut sub_reasons = Vec::new();
1195 let mut satisfied = false;
1196 for c in &cs {
1197 let sub = explain(evaluator, node, *c, path_ctx, severity, stack);
1198 if sub.is_empty() {
1199 satisfied = true;
1200 break;
1201 }
1202 sub_reasons.extend(sub);
1203 }
1204 if satisfied {
1205 Vec::new()
1206 } else {
1207 vec![Reason {
1208 value: node.clone(),
1209 path: path_ctx.map(str::to_string),
1210 shape: id,
1211 severity: severity.clone(),
1212 message: format!("none of {} alternative(s) satisfied", cs.len()),
1213 author_message: None,
1214 sub_reasons,
1215 }]
1216 }
1217 }
1218 Shape::Count {
1219 path,
1220 min,
1221 max,
1222 qualifier,
1223 } => explain_count(
1224 evaluator, node, id, &path, min, max, qualifier, severity, stack,
1225 ),
1226 Shape::Expression(_) => leaf(
1227 false, node,
1229 id,
1230 path_ctx,
1231 severity,
1232 "sh:expression did not evaluate to true".to_string(),
1233 ),
1234 };
1235 stack.remove(&key);
1236 reasons
1237}
1238
1239#[allow(clippy::too_many_arguments)]
1240fn explain_count(
1241 evaluator: &mut ShapeEvaluator<'_>,
1242 node: &Term,
1243 id: ShapeId,
1244 path: &Path,
1245 min: Option<u64>,
1246 max: Option<u64>,
1247 qualifier: ShapeId,
1248 severity: &Severity,
1249 stack: &mut HashSet<(ShapeId, Term)>,
1250) -> Vec<Reason> {
1251 let path_str = path_to_string(path);
1252 let matched: Vec<Term> = succ(evaluator.g, node, path)
1253 .into_iter()
1254 .filter(|u| evaluator.holds(u, qualifier))
1255 .collect();
1256 let n = matched.len() as u64;
1257 let mut reasons = Vec::new();
1258
1259 let qual_clause = match evaluator.arena.get(qualifier) {
1265 Shape::Top => String::new(),
1266 _ => format!(" matching `{}`", describe_shape(evaluator.arena, qualifier)),
1267 };
1268
1269 if let Some(mx) = max
1270 && n > mx
1271 {
1272 let class_offense = (mx == 0)
1282 .then(|| negated_class_target_shape(qualifier, evaluator.arena))
1283 .flatten();
1284 match evaluator.arena.get(qualifier).clone() {
1285 Shape::Not(inner) if mx == 0 => {
1286 for u in &matched {
1287 reasons.extend(explain(
1288 evaluator,
1289 u,
1290 inner,
1291 Some(&path_str),
1292 severity,
1293 stack,
1294 ));
1295 }
1296 }
1297 _ if class_offense.is_some() => {
1301 let class = class_offense.expect("guard ensured Some");
1302 for u in &matched {
1303 reasons.push(Reason {
1304 value: u.clone(),
1305 path: Some(path_str.clone()),
1306 shape: id,
1307 severity: severity.clone(),
1308 message: format!("must be an instance of {}", term_text(&class)),
1309 author_message: None,
1310 sub_reasons: Vec::new(),
1311 });
1312 }
1313 }
1314 Shape::Top => reasons.push(Reason {
1316 value: node.clone(),
1317 path: Some(path_str.clone()),
1318 shape: id,
1319 severity: severity.clone(),
1320 message: format!(
1321 "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1322 ),
1323 author_message: None,
1324 sub_reasons: Vec::new(),
1325 }),
1326 _ if mx == 0 => {
1329 let requirement = describe_negation(evaluator.arena, qualifier);
1330 for u in &matched {
1331 reasons.push(Reason {
1332 value: u.clone(),
1333 path: Some(path_str.clone()),
1334 shape: id,
1335 severity: severity.clone(),
1336 message: format!("must satisfy `{requirement}`"),
1337 author_message: None,
1338 sub_reasons: Vec::new(),
1339 });
1340 }
1341 }
1342 _ => reasons.push(Reason {
1344 value: node.clone(),
1345 path: Some(path_str.clone()),
1346 shape: id,
1347 severity: severity.clone(),
1348 message: format!(
1349 "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1350 ),
1351 author_message: None,
1352 sub_reasons: Vec::new(),
1353 }),
1354 }
1355 }
1356
1357 if let Some(mn) = min
1358 && n < mn
1359 {
1360 reasons.push(Reason {
1361 value: node.clone(),
1362 path: Some(path_str.clone()),
1363 shape: id,
1364 severity: severity.clone(),
1365 message: format!(
1366 "at least {mn} value(s){qual_clause} required along {path_str}, found {n}"
1367 ),
1368 author_message: None,
1369 sub_reasons: Vec::new(),
1370 });
1371 }
1372
1373 reasons
1374}
1375
1376fn sparql_violation_message(
1381 violation: &SparqlViolation,
1382 constraint: &SparqlConstraint,
1383 node: &Term,
1384) -> String {
1385 if let Some(message) = &violation.message {
1386 return term_text(message);
1387 }
1388 if !constraint.messages.is_empty() {
1389 return constraint
1390 .messages
1391 .iter()
1392 .map(|m| apply_message_template(&term_text(m), node, &violation.bindings))
1393 .collect::<Vec<_>>()
1394 .join("; ");
1395 }
1396 let mut message = match &constraint.shape {
1397 Some(shape) => format!("SPARQL constraint at {shape} not satisfied"),
1398 None => "SPARQL constraint not satisfied".to_string(),
1399 };
1400 if let Some(value) = &violation.value {
1401 message.push_str(&format!(" (value: {value})"));
1402 }
1403 message
1404}
1405
1406pub(crate) fn apply_message_template(
1411 template: &str,
1412 focus: &Term,
1413 bindings: &HashMap<String, Term>,
1414) -> String {
1415 static RE: OnceLock<Regex> = OnceLock::new();
1416 let re = RE
1417 .get_or_init(|| Regex::new(r"\{(\$[A-Za-z_]\w*|\?[A-Za-z_]\w*)\}").expect("static regex"));
1418 re.replace_all(template, |caps: ®ex::Captures| {
1419 let placeholder = &caps[1];
1420 let name = &placeholder[1..]; let term = if name == "this" {
1422 Some(focus)
1423 } else {
1424 bindings.get(name)
1425 };
1426 term.map(|t| match t {
1427 Term::NamedNode(n) => format!("<{}>", n.as_str()),
1428 Term::BlankNode(b) => format!("_:{}", b.as_str()),
1429 Term::Literal(l) => l.value().to_string(),
1430 })
1431 .unwrap_or_else(|| placeholder.to_string())
1432 })
1433 .to_string()
1434}
1435
1436fn term_text(term: &Term) -> String {
1439 match term {
1440 Term::Literal(literal) => literal.value().to_string(),
1441 other => other.to_string(),
1442 }
1443}
1444
1445fn leaf(
1446 ok: bool,
1447 node: &Term,
1448 id: ShapeId,
1449 path_ctx: Option<&str>,
1450 severity: &Severity,
1451 message: String,
1452) -> Vec<Reason> {
1453 if ok {
1454 Vec::new()
1455 } else {
1456 vec![Reason {
1457 value: node.clone(),
1458 path: path_ctx.map(str::to_string),
1459 shape: id,
1460 severity: severity.clone(),
1461 message,
1462 author_message: None,
1463 sub_reasons: Vec::new(),
1464 }]
1465 }
1466}
1467
1468fn all_pairs_ordered(
1469 g: &dyn PathBackend,
1470 v: &Term,
1471 path: &Path,
1472 p: &NamedNode,
1473 allow_eq: bool,
1474) -> bool {
1475 let lhs = succ(g, v, path);
1476 let rhs = objects(g, v, p);
1477 for a in &lhs {
1478 for b in &rhs {
1479 match compare_terms(a, b) {
1480 Some(Ordering::Less) => {}
1481 Some(Ordering::Equal) if allow_eq => {}
1482 _ => return false,
1483 }
1484 }
1485 }
1486 true
1487}
1488
1489fn objects(g: &dyn PathBackend, v: &Term, p: &NamedNode) -> HashSet<Term> {
1490 succ(g, v, &Path::Pred(p.clone()))
1491}
1492
1493fn closed_offenders(
1495 g: &dyn PathBackend,
1496 node: &Term,
1497 q: &BTreeSet<NamedNode>,
1498) -> BTreeSet<NamedNode> {
1499 g.out_predicates(node)
1500 .into_iter()
1501 .filter(|p| !q.contains(p))
1502 .collect()
1503}
1504
1505fn unique_lang(values: &HashSet<Term>) -> bool {
1506 let mut seen = HashSet::new();
1507 for term in values {
1508 if let Term::Literal(l) = term
1509 && let Some(lang) = l.language()
1510 && !seen.insert(lang.to_ascii_lowercase())
1511 {
1512 return false;
1513 }
1514 }
1515 true
1516}
1517
1518fn dedup_reasons(reasons: &mut Vec<Reason>) {
1519 let mut seen = HashSet::new();
1520 reasons.retain(|r| {
1521 seen.insert((
1522 r.value.to_string(),
1523 r.message.clone(),
1524 r.severity.as_str().to_string(),
1525 ))
1526 });
1527}
1528
1529fn subject_term(s: oxrdf::NamedOrBlankNodeRef) -> Term {
1530 crate::path::term_of(s.into_owned())
1531}
1532
1533fn subjects_of(data: &Graph, p: &NamedNode) -> Vec<Term> {
1535 let mut seen = HashSet::new();
1536 data.triples_for_predicate(p.as_ref())
1537 .filter_map(|t| {
1538 let term = subject_term(t.subject);
1539 seen.insert(term.clone()).then_some(term)
1540 })
1541 .collect()
1542}
1543
1544fn objects_of(data: &Graph, p: &NamedNode) -> Vec<Term> {
1546 let mut seen = HashSet::new();
1547 data.triples_for_predicate(p.as_ref())
1548 .filter_map(|t| {
1549 let term = t.object.into_owned();
1550 seen.insert(term.clone()).then_some(term)
1551 })
1552 .collect()
1553}
1554
1555fn all_nodes(g: &Graph) -> HashSet<Term> {
1557 let mut nodes = HashSet::new();
1558 for t in g.iter() {
1559 nodes.insert(subject_term(t.subject));
1560 nodes.insert(t.object.into_owned());
1561 }
1562 nodes
1563}
1564
1565fn graph_contains_term(g: &Graph, term: &Term) -> bool {
1567 node_of(term).is_some_and(|node| g.triples_for_subject(&node).next().is_some())
1568 || g.triples_for_object(term).next().is_some()
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573 use super::*;
1574 use oxrdf::{NamedNode, Triple};
1575
1576 fn iri(local: &str) -> NamedNode {
1577 NamedNode::new(format!("http://ex/{local}")).unwrap()
1578 }
1579
1580 fn term(local: &str) -> Term {
1581 Term::NamedNode(iri(local))
1582 }
1583
1584 #[test]
1585 fn memoizes_shared_value_checks_across_focus_nodes() {
1586 let p = iri("p");
1587 let shared = term("shared");
1588 let mut graph = Graph::new();
1589 graph.insert(&Triple::new(iri("a"), p.clone(), shared.clone()));
1590 graph.insert(&Triple::new(iri("b"), p.clone(), shared.clone()));
1591
1592 let mut arena = ShapeArena::new();
1593 let qualifier = arena.insert(Shape::TestConst(shared));
1594 let root = arena.insert(Shape::Count {
1595 path: Path::Pred(p),
1596 min: Some(1),
1597 max: None,
1598 qualifier,
1599 });
1600 let sparql = SparqlExecutor::new(&graph).unwrap();
1601 crate::profile::enable();
1602 {
1603 let mut evaluator = ShapeEvaluator::new(&graph, &arena, &sparql);
1604 assert!(evaluator.holds(&term("a"), root));
1605 assert!(evaluator.holds(&term("b"), root));
1606 }
1607 let profile = crate::profile::take().unwrap();
1608 let cache = profile.shape_cache();
1609 assert_eq!(cache.evaluators, 1);
1610 assert!(cache.hits >= 1, "shared qualifier should hit the cache");
1611 assert_eq!(cache.peak_entries, 3);
1612 assert!(cache.estimated_peak_bytes > 0);
1613 }
1614
1615 #[test]
1616 fn does_not_cache_cycle_dependent_results() {
1617 let mut arena = ShapeArena::new();
1621 let a = arena.reserve();
1622 let b = arena.reserve();
1623 let bottom = arena.insert(Shape::Or(Vec::new()));
1624 arena.set(a, Shape::And(vec![b, bottom]));
1625 arena.set(b, Shape::And(vec![a]));
1626
1627 let graph = Graph::new();
1628 let sparql = SparqlExecutor::new(&graph).unwrap();
1629 let node = term("x");
1630
1631 crate::profile::enable();
1632 {
1633 let mut evaluator = ShapeEvaluator::new(&graph, &arena, &sparql);
1634 assert!(!evaluator.holds(&node, a));
1635 assert!(!evaluator.holds(&node, b));
1636 }
1637 let profile = crate::profile::take().unwrap();
1638 let cache = profile.shape_cache();
1639 assert!(cache.recursion_back_edges > 0);
1640 assert!(cache.non_cacheable_results > 0);
1641 }
1642}