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