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