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