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