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