1use crate::focus::FocusNodes;
27use crate::frozen::FrozenIndexedDataset;
28use crate::path::{PathBackend, pred, succ};
29use crate::profile::ShapeCacheSample;
30use crate::sparql::{SparqlDiagnostic, SparqlExecutor, SparqlViolation};
31use crate::value::{compare_terms, value_type_holds};
32use oxrdf::{Graph, NamedNode, Term};
33use regex::Regex;
34use rustc_hash::{FxHashMap, FxHashSet};
35use serde::{Deserialize, Serialize};
36use shifty_algebra::render::{
37 describe_negation_in, describe_shape_in, negated_class_target_shape, path_to_string_in,
38 shape_to_string_in, xone_alternatives,
39};
40use shifty_algebra::{
41 ConstraintKind, NodeExpr, Path, Prefixes, Schema, Selector, Severity, Shape, ShapeArena,
42 ShapeId, SparqlConstraint,
43};
44use shifty_opt::{FocusSource, PhysicalPlan, analyze};
45use std::cmp::Ordering;
46use std::collections::{BTreeSet, HashMap, HashSet};
47use std::fmt;
48use std::sync::OnceLock;
49
50#[derive(Debug, Clone, Copy)]
51struct EvalResult {
52 holds: bool,
53 cacheable: bool,
54}
55
56#[derive(Default)]
69struct EvalState {
70 memo: FxHashMap<ShapeId, FxHashMap<Term, bool>>,
71 active: FxHashMap<ShapeId, FxHashSet<Term>>,
72 telemetry: Option<ShapeCacheSample>,
73}
74
75pub(crate) struct ShapeEvaluator<'a> {
81 g: &'a dyn PathBackend,
82 arena: &'a ShapeArena,
83 prefixes: &'a Prefixes,
86 sparql: &'a SparqlExecutor,
87 state: EvalState,
88}
89
90impl<'a> ShapeEvaluator<'a> {
91 pub(crate) fn new(
92 g: &'a dyn PathBackend,
93 arena: &'a ShapeArena,
94 prefixes: &'a Prefixes,
95 sparql: &'a SparqlExecutor,
96 ) -> Self {
97 Self {
98 g,
99 arena,
100 prefixes,
101 sparql,
102 state: EvalState {
103 telemetry: crate::profile::is_enabled().then(ShapeCacheSample::default),
104 ..EvalState::default()
105 },
106 }
107 }
108
109 pub(crate) fn holds(&mut self, node: &Term, id: ShapeId) -> bool {
110 holds_memoized(self.g, self.arena, node, id, self.sparql, &mut self.state).holds
111 }
112
113 pub(crate) fn sparql(&self) -> &SparqlExecutor {
114 self.sparql
115 }
116
117 pub(crate) fn backend(&self) -> &dyn PathBackend {
119 self.g
120 }
121
122 pub(crate) fn arena(&self) -> &ShapeArena {
124 self.arena
125 }
126}
127
128impl Drop for ShapeEvaluator<'_> {
129 fn drop(&mut self) {
130 let Some(mut sample) = self.state.telemetry else {
131 return;
132 };
133 sample.entries = self.state.memo.values().map(HashMap::len).sum();
134 sample.estimated_bytes = estimated_memo_bytes(&self.state.memo);
135 crate::profile::record_shape_cache(sample);
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct Reason {
142 pub value: Term,
144 pub path: Option<String>,
147 pub shape: ShapeId,
149 #[serde(default = "default_constraint")]
153 pub constraint: Shape,
154 #[serde(default)]
157 pub constraint_kind: ConstraintKind,
158 #[serde(default)]
161 pub constraint_id: ShapeId,
162 #[serde(default)]
164 pub statement_id: usize,
165 #[serde(default)]
167 pub severity: Severity,
168 pub message: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub author_message: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub observed_count: Option<u64>,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub sub_reasons: Vec<Reason>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub sparql_diagnostic: Option<SparqlDiagnostic>,
193}
194
195fn default_constraint() -> Shape {
196 Shape::Pending
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Violation {
202 pub focus: Term,
203 pub statement: usize,
205 pub severity: Severity,
207 pub reasons: Vec<Reason>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct ValidationOutcome {
213 pub conforms: bool,
214 pub violations: Vec<Violation>,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
220pub enum UnsupportedPolicy {
221 #[default]
224 Ignore,
225 Error,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub struct EngineOptions {
235 pub unsupported: UnsupportedPolicy,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ValidationOptions {
244 pub minimum_severity: Severity,
247 pub sort_results: bool,
250 pub entry_shape_names: Vec<String>,
255 pub engine: EngineOptions,
257}
258
259impl Default for ValidationOptions {
260 fn default() -> Self {
261 Self {
262 minimum_severity: Severity::Info,
263 sort_results: true,
264 entry_shape_names: Vec::new(),
265 engine: EngineOptions::default(),
266 }
267 }
268}
269
270fn requested_shape_name_matches(requested: &str, actual: &str) -> bool {
271 let requested = requested.trim();
272 requested == actual
273 || requested
274 .strip_prefix('<')
275 .and_then(|s| s.strip_suffix('>'))
276 .is_some_and(|stripped| stripped == actual)
277}
278
279pub(crate) fn entry_shape_name_selected(
282 entry_shape_names: &[String],
283 actual_name: Option<&str>,
284) -> bool {
285 entry_shape_names.is_empty()
286 || actual_name.is_some_and(|actual| {
287 entry_shape_names
288 .iter()
289 .any(|requested| requested_shape_name_matches(requested, actual))
290 })
291}
292
293pub(crate) fn entry_shape_any_name_selected(
299 entry_shape_names: &[String],
300 actual_names: &[String],
301) -> bool {
302 entry_shape_names.is_empty()
303 || actual_names
304 .iter()
305 .any(|actual| entry_shape_name_selected(entry_shape_names, Some(actual)))
306}
307
308fn most_severe(reasons: &[Reason]) -> Severity {
309 reasons
310 .iter()
311 .max_by_key(|reason| reason.severity.rank())
312 .map(|reason| reason.severity.clone())
313 .unwrap_or(Severity::Violation)
314}
315
316fn conforms_at_threshold(violations: &[Violation], minimum: &Severity) -> bool {
317 !violations
318 .iter()
319 .flat_map(|violation| &violation.reasons)
320 .any(|reason| reason.severity.meets(minimum))
321}
322
323fn sort_violations(violations: &mut [Violation], enabled: bool) {
324 if enabled {
325 violations.sort_by(|left, right| {
326 right
327 .severity
328 .rank()
329 .cmp(&left.severity.rank())
330 .then_with(|| left.focus.to_string().cmp(&right.focus.to_string()))
331 .then_with(|| left.statement.cmp(&right.statement))
332 });
333 }
334}
335
336fn stamp_statement_id(reasons: &mut [Reason], statement_id: usize) {
337 for reason in reasons {
338 reason.statement_id = statement_id;
339 stamp_statement_id(&mut reason.sub_reasons, statement_id);
340 }
341}
342
343#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
345pub enum ValidationGraphMode {
346 Data,
348 #[default]
351 Union,
352 UnionAll,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct NonStratifiable {
361 pub components: Vec<Vec<ShapeId>>,
362}
363
364impl fmt::Display for NonStratifiable {
365 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366 write!(f, "non-stratifiable schema (recursion through negation): ")?;
367 for (i, c) in self.components.iter().enumerate() {
368 if i > 0 {
369 write!(f, "; ")?;
370 }
371 let ids: Vec<String> = c.iter().map(|s| format!("@{}", s.0)).collect();
372 write!(f, "{{{}}}", ids.join(" "))?;
373 }
374 Ok(())
375 }
376}
377
378impl std::error::Error for NonStratifiable {}
379
380impl NonStratifiable {
381 pub fn describe(&self, schema: &Schema) -> String {
390 let mut out = String::from("non-stratifiable schema (recursion through negation): ");
391 for (i, component) in self.components.iter().enumerate() {
392 if i > 0 {
393 out.push_str("; ");
394 }
395 out.push('{');
396 for (j, id) in component.iter().enumerate() {
397 if j > 0 {
398 out.push_str(", ");
401 }
402 out.push_str(&describe_shape_id(schema, *id));
403 }
404 out.push('}');
405 }
406 out
407 }
408}
409
410fn describe_shape_id(schema: &Schema, id: ShapeId) -> String {
411 if let Some(name) = schema.name_of(id) {
412 return schema.prefixes.compact(name);
413 }
414 match schema.sources.get(&id) {
415 Some(Term::BlankNode(node)) => format!("_:{}", node.as_str()),
416 Some(Term::NamedNode(node)) => schema.prefixes.compact(node.as_str()),
417 _ => format!("{} @{}", constraint_kind_label(schema, id), id.0),
423 }
424}
425
426fn constraint_kind_label(schema: &Schema, id: ShapeId) -> &'static str {
427 match ConstraintKind::of(&schema.arena, id) {
428 ConstraintKind::Top => "top",
429 ConstraintKind::Constant => "constant",
430 ConstraintKind::ClassMembership => "class",
431 ConstraintKind::ValueType => "value type",
432 ConstraintKind::NodeKind => "node kind",
433 ConstraintKind::Closed => "closed",
434 ConstraintKind::Equals => "equals",
435 ConstraintKind::Disjoint => "disjoint",
436 ConstraintKind::LessThan => "less than",
437 ConstraintKind::LessThanOrEquals => "less than or equals",
438 ConstraintKind::UniqueLang => "unique lang",
439 ConstraintKind::Negation => "negation",
440 ConstraintKind::Conjunction => "conjunction",
441 ConstraintKind::Disjunction => "disjunction",
442 ConstraintKind::Cardinality => "cardinality",
443 ConstraintKind::Sparql => "sparql",
444 ConstraintKind::Expression => "expression",
445 ConstraintKind::Unknown => "shape",
446 }
447}
448
449pub fn validate(data: &Graph, schema: &Schema) -> Result<ValidationOutcome, NonStratifiable> {
458 validate_with_options(data, schema, &ValidationOptions::default())
459}
460
461pub fn validate_with_options(
463 data: &Graph,
464 schema: &Schema,
465 options: &ValidationOptions,
466) -> Result<ValidationOutcome, NonStratifiable> {
467 validate_with_context_and_options(data, data, schema, options)
468}
469
470pub fn validate_graphs(
472 data: &Graph,
473 shapes: &Graph,
474 schema: &Schema,
475) -> Result<ValidationOutcome, NonStratifiable> {
476 validate_graphs_with_mode_and_options(
477 data,
478 shapes,
479 schema,
480 ValidationGraphMode::default(),
481 &ValidationOptions::default(),
482 )
483}
484
485pub fn validate_graphs_with_mode(
487 data: &Graph,
488 shapes: &Graph,
489 schema: &Schema,
490 mode: ValidationGraphMode,
491) -> Result<ValidationOutcome, NonStratifiable> {
492 validate_graphs_with_mode_and_options(data, shapes, schema, mode, &ValidationOptions::default())
493}
494
495pub fn validate_graphs_with_mode_and_options(
497 data: &Graph,
498 shapes: &Graph,
499 schema: &Schema,
500 mode: ValidationGraphMode,
501 options: &ValidationOptions,
502) -> Result<ValidationOutcome, NonStratifiable> {
503 match mode {
504 ValidationGraphMode::Data => {
505 let uses_shapes = uses_shapes_graph(&schema.arena);
506 let frozen = if uses_shapes {
507 FrozenIndexedDataset::from_graphs(data, shapes)
508 } else {
509 FrozenIndexedDataset::from_graph(data)
510 };
511 validate_with_frozen(data, schema, frozen, uses_shapes, options)
512 }
513 ValidationGraphMode::Union => {
514 let uses_shapes = uses_shapes_graph(&schema.arena);
515 let frozen = if uses_shapes {
516 FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
517 } else {
518 FrozenIndexedDataset::from_graph_union(data, shapes)
519 };
520 validate_with_frozen(data, schema, frozen, uses_shapes, options)
521 }
522 ValidationGraphMode::UnionAll => {
523 let union = graph_union(data, shapes);
524 validate_with_context_and_options(&union, &union, schema, options)
525 }
526 }
527}
528
529pub fn validate_with_context(
533 data: &Graph,
534 context: &Graph,
535 schema: &Schema,
536) -> Result<ValidationOutcome, NonStratifiable> {
537 validate_with_context_and_options(data, context, schema, &ValidationOptions::default())
538}
539
540pub fn validate_with_context_and_options(
542 data: &Graph,
543 context: &Graph,
544 schema: &Schema,
545 options: &ValidationOptions,
546) -> Result<ValidationOutcome, NonStratifiable> {
547 let uses_shapes = uses_shapes_graph(&schema.arena);
548 let frozen = if uses_shapes {
549 FrozenIndexedDataset::from_graphs(context, context)
550 } else {
551 FrozenIndexedDataset::from_graph(context)
552 };
553 validate_with_frozen(data, schema, frozen, uses_shapes, options)
554}
555
556fn validate_with_frozen(
557 data: &Graph,
558 schema: &Schema,
559 frozen: FrozenIndexedDataset,
560 has_shapes_graph: bool,
561 options: &ValidationOptions,
562) -> Result<ValidationOutcome, NonStratifiable> {
563 let strat = analyze(&schema.arena);
564 if !strat.stratifiable {
565 let components = strat
566 .strata
567 .iter()
568 .filter(|s| !s.stratifiable)
569 .map(|s| s.shapes.clone())
570 .collect();
571 return Err(NonStratifiable { components });
572 }
573
574 let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
575 Ok(validate_with_executor(data, schema, &sparql, options))
576}
577
578pub(crate) fn validate_with_executor(
579 data: &dyn FocusNodes,
580 schema: &Schema,
581 sparql: &SparqlExecutor,
582 options: &ValidationOptions,
583) -> ValidationOutcome {
584 validate_with_executor_selected(data, schema, sparql, options, None)
585}
586
587pub(crate) fn validate_with_executor_selected(
588 data: &dyn FocusNodes,
589 schema: &Schema,
590 sparql: &SparqlExecutor,
591 options: &ValidationOptions,
592 selected: Option<&[bool]>,
593) -> ValidationOutcome {
594 let entries = schema.statements.iter().enumerate().filter_map(|(i, st)| {
595 if selected.is_some_and(|selected| !selected[i])
596 || (selected.is_none()
597 && !entry_shape_any_name_selected(
598 &options.entry_shape_names,
599 schema.names_of(st.shape),
600 ))
601 {
602 return None;
603 }
604 let label = schema
605 .name_of(st.shape)
606 .map(str::to_string)
607 .unwrap_or_else(|| format!("@{}", st.shape.0));
608 Some((i, st.shape, &st.selector, label))
609 });
610 validate_entries(
611 data,
612 &schema.arena,
613 &schema.prefixes,
614 sparql,
615 options,
616 entries,
617 focus_nodes_with_evaluator,
618 )
619}
620
621fn validate_entries<'a, S: 'a>(
622 data: &dyn FocusNodes,
623 arena: &ShapeArena,
624 prefixes: &Prefixes,
625 sparql: &SparqlExecutor,
626 options: &ValidationOptions,
627 entries: impl IntoIterator<Item = (usize, ShapeId, &'a S, String)>,
628 focus: impl Fn(&dyn FocusNodes, &S, &mut ShapeEvaluator<'_>) -> Vec<Term>,
629) -> ValidationOutcome {
630 let backend = sparql
631 .frozen()
632 .expect("validation executor always has a frozen dataset");
633 let mut evaluator = ShapeEvaluator::new(backend, arena, prefixes, sparql);
634 let mut violations = Vec::new();
635 for (statement, shape, source, label) in entries {
636 let foci = focus(data, source, &mut evaluator);
637 prefetch_sparql_constraints(arena, shape, &foci, sparql);
638 for focus in foci {
639 let start = web_time::Instant::now();
640 let mut stack = HashSet::new();
641 let mut reasons = explain(
642 &mut evaluator,
643 &focus,
644 shape,
645 None,
646 &Severity::Violation,
647 &mut stack,
648 );
649 crate::profile::record_shape(&label, start.elapsed().as_micros() as u64);
650 dedup_reasons(&mut reasons);
651 stamp_statement_id(&mut reasons, statement);
652 if !reasons.is_empty() {
653 let severity = most_severe(&reasons);
654 violations.push(Violation {
655 focus,
656 statement,
657 severity,
658 reasons,
659 });
660 }
661 }
662 }
663 sort_violations(&mut violations, options.sort_results);
664 ValidationOutcome {
665 conforms: conforms_at_threshold(&violations, &options.minimum_severity),
666 violations,
667 }
668}
669
670pub(crate) fn uses_shapes_graph(arena: &ShapeArena) -> bool {
673 (0..arena.len()).any(|i| {
674 matches!(arena.get(ShapeId(i as u32)), Shape::Sparql(c) if c.query.contains("shapesGraph"))
675 })
676}
677
678pub fn graph_union(left: &Graph, right: &Graph) -> Graph {
689 let (base, extra) = if left.len() >= right.len() {
690 (left, right)
691 } else {
692 (right, left)
693 };
694 let mut union = base.clone();
695 for triple in extra.iter() {
696 union.insert(triple);
697 }
698 crate::profile::record_graph_union(union.len());
699 union
700}
701
702pub fn validate_plan(
708 data: &Graph,
709 plan: &PhysicalPlan,
710) -> Result<ValidationOutcome, NonStratifiable> {
711 validate_plan_with_options(data, plan, &ValidationOptions::default())
712}
713
714pub fn validate_plan_with_options(
716 data: &Graph,
717 plan: &PhysicalPlan,
718 options: &ValidationOptions,
719) -> Result<ValidationOutcome, NonStratifiable> {
720 validate_plan_with_context_and_options(data, data, plan, options)
721}
722
723pub fn validate_plan_graphs(
725 data: &Graph,
726 shapes: &Graph,
727 plan: &PhysicalPlan,
728) -> Result<ValidationOutcome, NonStratifiable> {
729 validate_plan_graphs_with_mode_and_options(
730 data,
731 shapes,
732 plan,
733 ValidationGraphMode::default(),
734 &ValidationOptions::default(),
735 )
736}
737
738pub fn validate_plan_graphs_with_mode(
740 data: &Graph,
741 shapes: &Graph,
742 plan: &PhysicalPlan,
743 mode: ValidationGraphMode,
744) -> Result<ValidationOutcome, NonStratifiable> {
745 validate_plan_graphs_with_mode_and_options(
746 data,
747 shapes,
748 plan,
749 mode,
750 &ValidationOptions::default(),
751 )
752}
753
754pub fn validate_plan_graphs_with_mode_and_options(
756 data: &Graph,
757 shapes: &Graph,
758 plan: &PhysicalPlan,
759 mode: ValidationGraphMode,
760 options: &ValidationOptions,
761) -> Result<ValidationOutcome, NonStratifiable> {
762 match mode {
763 ValidationGraphMode::Data => {
764 let uses_shapes = uses_shapes_graph(&plan.arena);
765 let frozen = if uses_shapes {
766 FrozenIndexedDataset::from_graphs(data, shapes)
767 } else {
768 FrozenIndexedDataset::from_graph(data)
769 };
770 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
771 }
772 ValidationGraphMode::Union => {
773 let uses_shapes = uses_shapes_graph(&plan.arena);
774 let frozen = if uses_shapes {
775 FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
776 } else {
777 FrozenIndexedDataset::from_graph_union(data, shapes)
778 };
779 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
780 }
781 ValidationGraphMode::UnionAll => {
782 let union = graph_union(data, shapes);
783 validate_plan_with_context_and_options(&union, &union, plan, options)
784 }
785 }
786}
787
788pub fn validate_plan_with_context(
790 data: &Graph,
791 context: &Graph,
792 plan: &PhysicalPlan,
793) -> Result<ValidationOutcome, NonStratifiable> {
794 validate_plan_with_context_and_options(data, context, plan, &ValidationOptions::default())
795}
796
797pub fn validate_plan_with_context_and_options(
799 data: &Graph,
800 context: &Graph,
801 plan: &PhysicalPlan,
802 options: &ValidationOptions,
803) -> Result<ValidationOutcome, NonStratifiable> {
804 let uses_shapes = uses_shapes_graph(&plan.arena);
805 let frozen = if uses_shapes {
806 FrozenIndexedDataset::from_graphs(context, context)
807 } else {
808 FrozenIndexedDataset::from_graph(context)
809 };
810 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
811}
812
813fn validate_plan_with_frozen(
814 data: &Graph,
815 plan: &PhysicalPlan,
816 frozen: FrozenIndexedDataset,
817 has_shapes_graph: bool,
818 options: &ValidationOptions,
819) -> Result<ValidationOutcome, NonStratifiable> {
820 let strat = analyze(&plan.arena);
821 if !strat.stratifiable {
822 let components = strat
823 .strata
824 .iter()
825 .filter(|s| !s.stratifiable)
826 .map(|s| s.shapes.clone())
827 .collect();
828 return Err(NonStratifiable { components });
829 }
830
831 let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
832 Ok(validate_plan_with_executor(
833 data, plan, &sparql, options, None,
834 ))
835}
836
837pub(crate) fn validate_plan_with_executor(
838 data: &dyn FocusNodes,
839 plan: &PhysicalPlan,
840 sparql: &SparqlExecutor,
841 options: &ValidationOptions,
842 selected: Option<&[bool]>,
843) -> ValidationOutcome {
844 let entries = plan.statements.iter().enumerate().filter_map(|(i, sp)| {
845 if selected.is_some_and(|selected| !selected[i])
846 || (selected.is_none()
847 && !entry_shape_any_name_selected(
848 &options.entry_shape_names,
849 plan.names_of(sp.shape),
850 ))
851 {
852 return None;
853 }
854 let label = plan
855 .name_of(sp.shape)
856 .map(str::to_string)
857 .unwrap_or_else(|| format!("@{}", sp.shape.0));
858 Some((i, sp.shape, &sp.source, label))
859 });
860 validate_entries(
861 data,
862 &plan.arena,
863 &plan.prefixes,
864 sparql,
865 options,
866 entries,
867 focus_for_source,
868 )
869}
870
871fn focus_for_source(
873 data: &dyn FocusNodes,
874 source: &FocusSource,
875 evaluator: &mut ShapeEvaluator<'_>,
876) -> Vec<Term> {
877 match source {
878 FocusSource::SubjectsOf(p) => data.subjects_of(p),
879 FocusSource::ObjectsOf(p) => data.objects_of(p),
880 FocusSource::Node(c) => vec![c.clone()],
881 FocusSource::PathToConst { path, target } => pred(evaluator.g, target, path)
883 .into_iter()
884 .filter(|node| data.contains_term(node))
885 .collect(),
886 FocusSource::ScanFilter { path, qualifier } => data
887 .all_nodes()
888 .into_iter()
889 .filter(|v| {
890 succ(evaluator.g, v, path)
891 .iter()
892 .any(|u| evaluator.holds(u, *qualifier))
893 })
894 .collect(),
895 FocusSource::Sparql(target) => {
896 let candidates = data.all_nodes();
897 evaluator
898 .sparql
899 .target_nodes(&target.query)
900 .unwrap_or_default()
901 .into_iter()
902 .filter(|node| candidates.contains(node))
903 .collect()
904 }
905 }
906}
907
908pub fn focus_nodes(data: &Graph, sel: &Selector, arena: &ShapeArena) -> Vec<Term> {
910 let sparql =
911 SparqlExecutor::new(data).expect("building an in-memory Oxigraph store should succeed");
912 let mut evaluator = ShapeEvaluator::new(data, arena, Prefixes::empty(), &sparql);
913 focus_nodes_with_evaluator(data, sel, &mut evaluator)
914}
915
916pub(crate) fn focus_nodes_with(
917 data: &dyn FocusNodes,
918 backend: &dyn PathBackend,
919 sel: &Selector,
920 arena: &ShapeArena,
921 sparql: &SparqlExecutor,
922) -> Vec<Term> {
923 let mut evaluator = ShapeEvaluator::new(backend, arena, Prefixes::empty(), sparql);
924 focus_nodes_with_evaluator(data, sel, &mut evaluator)
925}
926
927pub(crate) fn focus_nodes_with_evaluator(
928 data: &dyn FocusNodes,
929 sel: &Selector,
930 evaluator: &mut ShapeEvaluator<'_>,
931) -> Vec<Term> {
932 match sel {
933 Selector::HasOut(q) => data.subjects_of(q),
934 Selector::HasIn(q) => data.objects_of(q),
935 Selector::IsConst(c) => vec![c.clone()],
936 Selector::HasPath(path, qual) => match evaluator.arena.get(*qual) {
937 Shape::TestConst(target) => pred(evaluator.g, target, path)
942 .into_iter()
943 .filter(|node| data.contains_term(node))
944 .collect(),
945 _ => data
946 .all_nodes()
947 .into_iter()
948 .filter(|v| {
949 succ(evaluator.g, v, path)
950 .iter()
951 .any(|u| evaluator.holds(u, *qual))
952 })
953 .collect(),
954 },
955 Selector::Sparql(target) => {
956 let candidates = data.all_nodes();
957 evaluator
958 .sparql
959 .target_nodes(&target.query)
960 .unwrap_or_default()
961 .into_iter()
962 .filter(|node| candidates.contains(node))
963 .collect()
964 }
965 }
966}
967
968fn holds_memoized(
969 g: &dyn PathBackend,
970 arena: &ShapeArena,
971 v: &Term,
972 id: ShapeId,
973 sparql: &SparqlExecutor,
974 state: &mut EvalState,
975) -> EvalResult {
976 if let Some(&holds) = state.memo.get(&id).and_then(|by_term| by_term.get(v)) {
977 if let Some(telemetry) = state.telemetry.as_mut() {
978 telemetry.hits += 1;
979 }
980 return EvalResult {
981 holds,
982 cacheable: true,
983 };
984 }
985 if let Some(telemetry) = state.telemetry.as_mut() {
986 telemetry.misses += 1;
987 }
988 if !state.active.entry(id).or_default().insert(v.clone()) {
989 if let Some(telemetry) = state.telemetry.as_mut() {
990 telemetry.recursion_back_edges += 1;
991 }
992 return EvalResult {
993 holds: true,
994 cacheable: false,
995 }; }
997 let result = match arena.get(id) {
998 Shape::Annotated { shape, .. } => holds_memoized(g, arena, v, *shape, sparql, state),
999 Shape::Top | Shape::Pending => EvalResult {
1000 holds: true,
1001 cacheable: true,
1002 },
1003 Shape::Sparql(constraint) => EvalResult {
1004 holds: sparql
1005 .constraint_violations(constraint, v)
1006 .is_ok_and(|violations| violations.is_empty()),
1007 cacheable: true,
1008 },
1009 Shape::Expression(expr) => {
1010 let mut cacheable = true;
1013 let results = eval_expr(g, arena, v, expr, sparql, state, &mut cacheable);
1014 EvalResult {
1015 holds: results.iter().all(is_boolean_true),
1016 cacheable,
1017 }
1018 }
1019 Shape::TestConst(c) => EvalResult {
1020 holds: v == c,
1021 cacheable: true,
1022 },
1023 Shape::TestType(t) => EvalResult {
1024 holds: value_type_holds(t, v),
1025 cacheable: true,
1026 },
1027 Shape::TestKind(k) => EvalResult {
1028 holds: k.matches(v),
1029 cacheable: true,
1030 },
1031 Shape::Closed(q) => EvalResult {
1032 holds: closed_offenders(g, v, q).is_empty(),
1033 cacheable: true,
1034 },
1035 Shape::Eq(path, p) => EvalResult {
1036 holds: succ(g, v, path) == objects(g, v, p),
1037 cacheable: true,
1038 },
1039 Shape::Disj(path, p) => EvalResult {
1040 holds: succ(g, v, path).is_disjoint(&objects(g, v, p)),
1041 cacheable: true,
1042 },
1043 Shape::Lt(path, p) => EvalResult {
1044 holds: all_pairs_ordered(g, v, path, p, false),
1045 cacheable: true,
1046 },
1047 Shape::Le(path, p) => EvalResult {
1048 holds: all_pairs_ordered(g, v, path, p, true),
1049 cacheable: true,
1050 },
1051 Shape::UniqueLang(path) => EvalResult {
1052 holds: unique_lang(&succ(g, v, path)),
1053 cacheable: true,
1054 },
1055 Shape::Not(c) => {
1056 let child = holds_memoized(g, arena, v, *c, sparql, state);
1057 EvalResult {
1058 holds: !child.holds,
1059 cacheable: child.cacheable,
1060 }
1061 }
1062 Shape::And(cs) => {
1063 let mut result = EvalResult {
1064 holds: true,
1065 cacheable: true,
1066 };
1067 for child in cs {
1068 let child = holds_memoized(g, arena, v, *child, sparql, state);
1069 result.cacheable &= child.cacheable;
1070 if !child.holds {
1071 result.holds = false;
1072 break;
1073 }
1074 }
1075 result
1076 }
1077 Shape::Or(cs) => {
1078 let mut result = EvalResult {
1079 holds: false,
1080 cacheable: true,
1081 };
1082 for child in cs {
1083 let child = holds_memoized(g, arena, v, *child, sparql, state);
1084 result.cacheable &= child.cacheable;
1085 if child.holds {
1086 result.holds = true;
1087 break;
1088 }
1089 }
1090 result
1091 }
1092 Shape::Count {
1093 path,
1094 min,
1095 max,
1096 qualifier,
1097 } => {
1098 let mut n = 0;
1099 let mut cacheable = true;
1100 for value in succ(g, v, path) {
1101 let qualified = holds_memoized(g, arena, &value, *qualifier, sparql, state);
1102 cacheable &= qualified.cacheable;
1103 n += u64::from(qualified.holds);
1104 }
1105 EvalResult {
1106 holds: min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m),
1107 cacheable,
1108 }
1109 }
1110 };
1111 let owned = state.active.get_mut(&id).and_then(|set| set.take(v));
1114 if result.cacheable {
1115 let key = owned.unwrap_or_else(|| v.clone());
1116 state.memo.entry(id).or_default().insert(key, result.holds);
1117 if let Some(telemetry) = state.telemetry.as_mut() {
1118 telemetry.insertions += 1;
1119 }
1120 } else if let Some(telemetry) = state.telemetry.as_mut() {
1121 telemetry.non_cacheable_results += 1;
1122 }
1123 result
1124}
1125
1126fn eval_expr(
1132 g: &dyn PathBackend,
1133 arena: &ShapeArena,
1134 v: &Term,
1135 expr: &NodeExpr,
1136 sparql: &SparqlExecutor,
1137 state: &mut EvalState,
1138 cacheable: &mut bool,
1139) -> HashSet<Term> {
1140 match expr {
1141 NodeExpr::This => {
1142 let mut s = HashSet::with_capacity(1);
1143 s.insert(v.clone());
1144 s
1145 }
1146 NodeExpr::Constant(t) => {
1147 let mut s = HashSet::with_capacity(1);
1148 s.insert(t.clone());
1149 s
1150 }
1151 NodeExpr::Path(p) => succ(g, v, p),
1152 NodeExpr::Filter { input, shape } => {
1153 let inputs = eval_expr(g, arena, v, input, sparql, state, cacheable);
1154 inputs
1155 .into_iter()
1156 .filter(|x| {
1157 let r = holds_memoized(g, arena, x, *shape, sparql, state);
1158 *cacheable &= r.cacheable;
1159 r.holds
1160 })
1161 .collect()
1162 }
1163 NodeExpr::Intersection(es) => {
1164 let mut iter = es.iter();
1165 match iter.next() {
1166 Some(first) => {
1167 let mut acc = eval_expr(g, arena, v, first, sparql, state, cacheable);
1168 for e in iter {
1169 let s = eval_expr(g, arena, v, e, sparql, state, cacheable);
1170 acc.retain(|x| s.contains(x));
1171 }
1172 acc
1173 }
1174 None => HashSet::new(),
1175 }
1176 }
1177 NodeExpr::Union(es) => {
1178 let mut acc = HashSet::new();
1179 for e in es {
1180 acc.extend(eval_expr(g, arena, v, e, sparql, state, cacheable));
1181 }
1182 acc
1183 }
1184 NodeExpr::Function { .. } => HashSet::new(),
1185 }
1186}
1187
1188pub(crate) fn is_boolean_true(t: &Term) -> bool {
1190 matches!(t, Term::Literal(l) if l.datatype() == oxrdf::vocab::xsd::BOOLEAN && l.value() == "true")
1191}
1192
1193fn estimated_memo_bytes(memo: &FxHashMap<ShapeId, FxHashMap<Term, bool>>) -> usize {
1194 const CONTROL_BYTE_ESTIMATE: usize = 1;
1195 const ENTRY_BYTES: usize = std::mem::size_of::<(Term, bool)>() + CONTROL_BYTE_ESTIMATE;
1196 memo.values()
1197 .map(|by_term| {
1198 by_term.capacity() * ENTRY_BYTES
1199 + by_term.keys().map(estimated_term_heap_bytes).sum::<usize>()
1200 })
1201 .sum::<usize>()
1202 + memo.capacity()
1203 * (std::mem::size_of::<(ShapeId, FxHashMap<Term, bool>)>() + CONTROL_BYTE_ESTIMATE)
1204}
1205
1206fn estimated_term_heap_bytes(term: &Term) -> usize {
1207 match term {
1208 Term::NamedNode(node) => node.as_str().len(),
1209 Term::BlankNode(node) => node.as_str().len(),
1210 Term::Literal(literal) => {
1211 literal.value().len()
1212 + literal.language().map_or_else(
1213 || {
1214 let datatype = literal.datatype();
1215 if datatype.as_str() == "http://www.w3.org/2001/XMLSchema#string" {
1216 0
1217 } else {
1218 datatype.as_str().len()
1219 }
1220 },
1221 str::len,
1222 )
1223 }
1224 }
1225}
1226
1227pub(crate) fn prefetch_sparql_constraints(
1236 arena: &ShapeArena,
1237 root: ShapeId,
1238 foci: &[Term],
1239 sparql: &SparqlExecutor,
1240) {
1241 if foci.len() < 2 {
1242 return;
1243 }
1244 let mut constraints = Vec::new();
1245 let mut seen = HashSet::new();
1246 collect_focus_sparql(arena, root, &mut seen, &mut constraints);
1247 for constraint in constraints {
1248 let _ = sparql.prefetch_constraint(constraint, foci);
1249 }
1250}
1251
1252fn collect_focus_sparql<'a>(
1253 arena: &'a ShapeArena,
1254 id: ShapeId,
1255 seen: &mut HashSet<ShapeId>,
1256 out: &mut Vec<&'a SparqlConstraint>,
1257) {
1258 if !seen.insert(id) {
1259 return; }
1261 match arena.get(id) {
1262 Shape::Annotated { shape, .. } => collect_focus_sparql(arena, *shape, seen, out),
1263 Shape::Sparql(constraint) => out.push(constraint),
1264 Shape::Not(inner) => collect_focus_sparql(arena, *inner, seen, out),
1265 Shape::And(ids) | Shape::Or(ids) => {
1266 for &child in ids {
1267 collect_focus_sparql(arena, child, seen, out);
1268 }
1269 }
1270 _ => {}
1272 }
1273}
1274
1275fn explain(
1278 evaluator: &mut ShapeEvaluator<'_>,
1279 node: &Term,
1280 id: ShapeId,
1281 path_ctx: Option<&str>,
1282 severity: &Severity,
1283 stack: &mut HashSet<(ShapeId, Term)>,
1284) -> Vec<Reason> {
1285 let key = (id, node.clone());
1286 if !stack.insert(key.clone()) {
1287 return Vec::new(); }
1289 if evaluator.holds(node, id) {
1290 stack.remove(&key);
1291 return Vec::new();
1292 }
1293 let reasons = match evaluator.arena.get(id).clone() {
1294 Shape::Annotated {
1295 severity: source_severity,
1296 messages,
1297 shape,
1298 } => {
1299 let mut reasons = explain(evaluator, node, shape, path_ctx, &source_severity, stack);
1300 if !messages.is_empty() {
1301 let author = messages
1307 .iter()
1308 .map(|m| apply_message_template(&term_text(m), node, &HashMap::new()))
1309 .collect::<Vec<_>>()
1310 .join("; ");
1311 for r in &mut reasons {
1312 if r.author_message.is_none() {
1313 r.author_message = Some(author.clone());
1314 }
1315 }
1316 }
1317 reasons
1318 }
1319 Shape::Top | Shape::Pending => Vec::new(),
1320 Shape::Sparql(constraint) => {
1321 match evaluator.sparql.constraint_violations(&constraint, node) {
1322 Ok(violations) if violations.is_empty() => Vec::new(),
1323 Ok(violations) => {
1324 let diagnostic = evaluator
1329 .sparql
1330 .constraint_diagnostic(&constraint, node, &violations)
1331 .ok();
1332 violations
1333 .into_iter()
1334 .map(|violation| {
1335 let message = sparql_violation_message(&violation, &constraint, node);
1338 reason(
1339 evaluator.arena,
1340 id,
1341 violation.value.unwrap_or_else(|| node.clone()),
1342 violation
1343 .path
1344 .map(|path| path.to_string())
1345 .or_else(|| path_ctx.map(str::to_string))
1346 .or_else(|| {
1347 constraint
1348 .path
1349 .as_ref()
1350 .map(|p| path_to_string_in(p, evaluator.prefixes))
1351 }),
1352 severity,
1353 message,
1354 None,
1355 Vec::new(),
1356 diagnostic.clone(),
1357 )
1358 })
1359 .collect()
1360 }
1361 Err(error) => vec![reason(
1362 evaluator.arena,
1363 id,
1364 node.clone(),
1365 path_ctx.map(str::to_string),
1366 severity,
1367 format!("SPARQL constraint evaluation failed: {error}"),
1368 None,
1369 Vec::new(),
1370 evaluator
1371 .sparql
1372 .constraint_diagnostic(&constraint, node, &[])
1373 .ok(),
1374 )],
1375 }
1376 }
1377 Shape::TestConst(_)
1378 | Shape::TestType(_)
1379 | Shape::TestKind(_)
1380 | Shape::Eq(..)
1381 | Shape::Disj(..)
1382 | Shape::Lt(..)
1383 | Shape::Le(..)
1384 | Shape::UniqueLang(_) => leaf(
1385 evaluator.arena,
1386 evaluator.holds(node, id),
1387 node,
1388 id,
1389 path_ctx,
1390 severity,
1391 format!(
1392 "{} not satisfied",
1393 shape_to_string_in(evaluator.arena, id, evaluator.prefixes)
1394 ),
1395 ),
1396 Shape::Closed(q) => {
1397 let bad = closed_offenders(evaluator.g, node, &q);
1398 if bad.is_empty() {
1399 Vec::new()
1400 } else {
1401 let preds: Vec<String> = bad
1404 .iter()
1405 .map(|p| evaluator.prefixes.compact(p.as_str()))
1406 .collect();
1407 vec![reason(
1408 evaluator.arena,
1409 id,
1410 node.clone(),
1411 path_ctx.map(str::to_string),
1412 severity,
1413 format!("closed: unexpected predicate(s) {}", preds.join(", ")),
1414 None,
1415 Vec::new(),
1416 None,
1417 )]
1418 }
1419 }
1420 Shape::Not(c) => {
1421 if explain(evaluator, node, c, path_ctx, severity, stack).is_empty() {
1422 vec![reason(
1423 evaluator.arena,
1424 id,
1425 node.clone(),
1426 path_ctx.map(str::to_string),
1427 severity,
1428 format!(
1429 "must satisfy `{}`",
1430 describe_negation_in(evaluator.arena, c, evaluator.prefixes)
1431 ),
1432 None,
1433 Vec::new(),
1434 None,
1435 )]
1436 } else {
1437 Vec::new()
1438 }
1439 }
1440 Shape::And(cs) => cs
1441 .iter()
1442 .flat_map(|c| explain(evaluator, node, *c, path_ctx, severity, stack))
1443 .collect(),
1444 Shape::Or(cs) => {
1445 let mut sub_reasons = Vec::new();
1446 let mut satisfied = false;
1447 for c in &cs {
1448 let sub = explain(evaluator, node, *c, path_ctx, severity, stack);
1449 if sub.is_empty() {
1450 satisfied = true;
1451 break;
1452 }
1453 sub_reasons.extend(sub);
1454 }
1455 if satisfied {
1456 return Vec::new();
1457 }
1458 if let Some(alternatives) = xone_alternatives(id, evaluator.arena) {
1463 let held: Vec<ShapeId> = alternatives
1464 .iter()
1465 .copied()
1466 .filter(|a| evaluator.holds(node, *a))
1467 .collect();
1468 let total = alternatives.len();
1469 let describe_all = |ids: &[ShapeId]| {
1473 ids.iter()
1474 .map(|a| describe_shape_in(evaluator.arena, *a, evaluator.prefixes))
1475 .collect::<Vec<_>>()
1476 .join(", ")
1477 };
1478 let message = if held.is_empty() {
1479 format!("none of the {total} alternatives hold; exactly one must")
1480 } else {
1481 let idle: Vec<ShapeId> = alternatives
1483 .iter()
1484 .copied()
1485 .filter(|a| !held.contains(a))
1486 .collect();
1487 let mut message = format!(
1488 "exactly one alternative may hold; {} of {total} do — holds: {}",
1489 held.len(),
1490 describe_all(&held)
1491 );
1492 if !idle.is_empty() {
1493 message.push_str(&format!("; does not hold: {}", describe_all(&idle)));
1494 }
1495 message
1496 };
1497 let sub_reasons = if held.is_empty() {
1501 sub_reasons
1502 } else {
1503 Vec::new()
1504 };
1505 return vec![reason(
1506 evaluator.arena,
1507 id,
1508 node.clone(),
1509 path_ctx.map(str::to_string),
1510 severity,
1511 message,
1512 None,
1513 sub_reasons,
1514 None,
1515 )];
1516 }
1517 vec![reason(
1518 evaluator.arena,
1519 id,
1520 node.clone(),
1521 path_ctx.map(str::to_string),
1522 severity,
1523 format!("none of {} alternative(s) satisfied", cs.len()),
1524 None,
1525 sub_reasons,
1526 None,
1527 )]
1528 }
1529 Shape::Count {
1530 path,
1531 min,
1532 max,
1533 qualifier,
1534 } => explain_count(
1535 evaluator, node, id, &path, min, max, qualifier, severity, stack,
1536 ),
1537 Shape::Expression(_) => leaf(
1538 evaluator.arena,
1539 false, node,
1541 id,
1542 path_ctx,
1543 severity,
1544 "sh:expression did not evaluate to true".to_string(),
1545 ),
1546 };
1547 stack.remove(&key);
1548 reasons
1549}
1550
1551#[allow(clippy::too_many_arguments)]
1552fn explain_count(
1553 evaluator: &mut ShapeEvaluator<'_>,
1554 node: &Term,
1555 id: ShapeId,
1556 path: &Path,
1557 min: Option<u64>,
1558 max: Option<u64>,
1559 qualifier: ShapeId,
1560 severity: &Severity,
1561 stack: &mut HashSet<(ShapeId, Term)>,
1562) -> Vec<Reason> {
1563 let path_str = path_to_string_in(path, evaluator.prefixes);
1564 let matched: Vec<Term> = succ(evaluator.g, node, path)
1565 .into_iter()
1566 .filter(|u| evaluator.holds(u, qualifier))
1567 .collect();
1568 let n = matched.len() as u64;
1569 let mut reasons = Vec::new();
1570
1571 let qual_clause = match evaluator.arena.get(qualifier) {
1577 Shape::Top => String::new(),
1578 _ => format!(
1579 " matching `{}`",
1580 describe_shape_in(evaluator.arena, qualifier, evaluator.prefixes)
1581 ),
1582 };
1583
1584 if let Some(mx) = max
1585 && n > mx
1586 {
1587 let class_offense = (mx == 0)
1597 .then(|| negated_class_target_shape(qualifier, evaluator.arena))
1598 .flatten();
1599 match evaluator.arena.get(qualifier).clone() {
1600 Shape::Not(inner) if mx == 0 => {
1601 for u in &matched {
1602 reasons.extend(explain(
1603 evaluator,
1604 u,
1605 inner,
1606 Some(&path_str),
1607 severity,
1608 stack,
1609 ));
1610 }
1611 }
1612 _ if class_offense.is_some() => {
1616 let class = class_offense.expect("guard ensured Some");
1617 for u in &matched {
1618 reasons.push(reason(
1619 evaluator.arena,
1620 id,
1621 u.clone(),
1622 Some(path_str.clone()),
1623 severity,
1624 format!(
1625 "must be an instance of {}",
1626 term_display(&class, evaluator.prefixes)
1627 ),
1628 None,
1629 Vec::new(),
1630 None,
1631 ));
1632 }
1633 }
1634 Shape::Top => reasons.push(with_observed(
1636 reason(
1637 evaluator.arena,
1638 id,
1639 node.clone(),
1640 Some(path_str.clone()),
1641 severity,
1642 format!(
1643 "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1644 ),
1645 None,
1646 Vec::new(),
1647 None,
1648 ),
1649 n,
1650 )),
1651 _ if mx == 0 => {
1654 let requirement =
1655 describe_negation_in(evaluator.arena, qualifier, evaluator.prefixes);
1656 for u in &matched {
1657 reasons.push(reason(
1658 evaluator.arena,
1659 id,
1660 u.clone(),
1661 Some(path_str.clone()),
1662 severity,
1663 format!("must satisfy `{requirement}`"),
1664 None,
1665 Vec::new(),
1666 None,
1667 ));
1668 }
1669 }
1670 _ => reasons.push(with_observed(
1672 reason(
1673 evaluator.arena,
1674 id,
1675 node.clone(),
1676 Some(path_str.clone()),
1677 severity,
1678 format!(
1679 "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1680 ),
1681 None,
1682 Vec::new(),
1683 None,
1684 ),
1685 n,
1686 )),
1687 }
1688 }
1689
1690 if let Some(mn) = min
1691 && n < mn
1692 {
1693 reasons.push(with_observed(
1694 reason(
1695 evaluator.arena,
1696 id,
1697 node.clone(),
1698 Some(path_str.clone()),
1699 severity,
1700 format!("at least {mn} value(s){qual_clause} required along {path_str}, found {n}"),
1701 None,
1702 Vec::new(),
1703 None,
1704 ),
1705 n,
1706 ));
1707 }
1708
1709 reasons
1710}
1711
1712fn sparql_violation_message(
1717 violation: &SparqlViolation,
1718 constraint: &SparqlConstraint,
1719 node: &Term,
1720) -> String {
1721 if let Some(message) = &violation.message {
1722 return term_text(message);
1723 }
1724 if !constraint.messages.is_empty() {
1725 return constraint
1726 .messages
1727 .iter()
1728 .map(|m| apply_message_template(&term_text(m), node, &violation.bindings))
1729 .collect::<Vec<_>>()
1730 .join("; ");
1731 }
1732 let mut message = match &constraint.shape {
1733 Some(shape) => format!("SPARQL constraint at {shape} not satisfied"),
1734 None => "SPARQL constraint not satisfied".to_string(),
1735 };
1736 if let Some(value) = &violation.value {
1737 message.push_str(&format!(" (value: {value})"));
1738 }
1739 message
1740}
1741
1742pub(crate) fn apply_message_template(
1747 template: &str,
1748 focus: &Term,
1749 bindings: &HashMap<String, Term>,
1750) -> String {
1751 static RE: OnceLock<Regex> = OnceLock::new();
1752 let re = RE
1753 .get_or_init(|| Regex::new(r"\{(\$[A-Za-z_]\w*|\?[A-Za-z_]\w*)\}").expect("static regex"));
1754 re.replace_all(template, |caps: ®ex::Captures| {
1755 let placeholder = &caps[1];
1756 let name = &placeholder[1..]; let term = if name == "this" {
1758 Some(focus)
1759 } else {
1760 bindings.get(name)
1761 };
1762 term.map(|t| match t {
1763 Term::NamedNode(n) => format!("<{}>", n.as_str()),
1764 Term::BlankNode(b) => format!("_:{}", b.as_str()),
1765 Term::Literal(l) => l.value().to_string(),
1766 })
1767 .unwrap_or_else(|| placeholder.to_string())
1768 })
1769 .to_string()
1770}
1771
1772fn term_text(term: &Term) -> String {
1775 match term {
1776 Term::Literal(literal) => literal.value().to_string(),
1777 other => other.to_string(),
1778 }
1779}
1780
1781fn term_display(term: &Term, px: &Prefixes) -> String {
1785 match term {
1786 Term::NamedNode(iri) => px.compact(iri.as_str()),
1787 other => term_text(other),
1788 }
1789}
1790
1791#[allow(clippy::too_many_arguments)]
1792fn reason(
1793 arena: &ShapeArena,
1794 id: ShapeId,
1795 value: Term,
1796 path: Option<String>,
1797 severity: &Severity,
1798 message: String,
1799 author_message: Option<String>,
1800 sub_reasons: Vec<Reason>,
1801 sparql_diagnostic: Option<SparqlDiagnostic>,
1802) -> Reason {
1803 Reason {
1804 value,
1805 path,
1806 shape: id,
1807 constraint: arena.get(id).clone(),
1808 constraint_kind: ConstraintKind::of(arena, id),
1809 constraint_id: id,
1810 statement_id: usize::MAX,
1811 severity: severity.clone(),
1812 message,
1813 author_message,
1814 observed_count: None,
1815 sub_reasons,
1816 sparql_diagnostic,
1817 }
1818}
1819
1820fn with_observed(mut r: Reason, observed: u64) -> Reason {
1824 r.observed_count = Some(observed);
1825 r
1826}
1827
1828fn leaf(
1829 arena: &ShapeArena,
1830 ok: bool,
1831 node: &Term,
1832 id: ShapeId,
1833 path_ctx: Option<&str>,
1834 severity: &Severity,
1835 message: String,
1836) -> Vec<Reason> {
1837 if ok {
1838 Vec::new()
1839 } else {
1840 vec![reason(
1841 arena,
1842 id,
1843 node.clone(),
1844 path_ctx.map(str::to_string),
1845 severity,
1846 message,
1847 None,
1848 Vec::new(),
1849 None,
1850 )]
1851 }
1852}
1853
1854fn all_pairs_ordered(
1855 g: &dyn PathBackend,
1856 v: &Term,
1857 path: &Path,
1858 p: &NamedNode,
1859 allow_eq: bool,
1860) -> bool {
1861 let lhs = succ(g, v, path);
1862 let rhs = objects(g, v, p);
1863 for a in &lhs {
1864 for b in &rhs {
1865 match compare_terms(a, b) {
1866 Some(Ordering::Less) => {}
1867 Some(Ordering::Equal) if allow_eq => {}
1868 _ => return false,
1869 }
1870 }
1871 }
1872 true
1873}
1874
1875fn objects(g: &dyn PathBackend, v: &Term, p: &NamedNode) -> HashSet<Term> {
1876 succ(g, v, &Path::Pred(p.clone()))
1877}
1878
1879fn closed_offenders(
1881 g: &dyn PathBackend,
1882 node: &Term,
1883 q: &BTreeSet<NamedNode>,
1884) -> BTreeSet<NamedNode> {
1885 g.out_predicates(node)
1886 .into_iter()
1887 .filter(|p| !q.contains(p))
1888 .collect()
1889}
1890
1891fn unique_lang(values: &HashSet<Term>) -> bool {
1892 let mut seen = HashSet::new();
1893 for term in values {
1894 if let Term::Literal(l) = term
1895 && let Some(lang) = l.language()
1896 && !seen.insert(lang.to_ascii_lowercase())
1897 {
1898 return false;
1899 }
1900 }
1901 true
1902}
1903
1904fn dedup_reasons(reasons: &mut Vec<Reason>) {
1905 let mut seen = HashSet::new();
1906 reasons.retain(|r| {
1907 seen.insert((
1908 r.value.to_string(),
1909 r.message.clone(),
1910 r.severity.as_str().to_string(),
1911 ))
1912 });
1913}
1914
1915#[cfg(test)]
1916mod tests {
1917 use super::*;
1918 use oxrdf::{NamedNode, Triple};
1919
1920 fn iri(local: &str) -> NamedNode {
1921 NamedNode::new(format!("http://ex/{local}")).unwrap()
1922 }
1923
1924 fn term(local: &str) -> Term {
1925 Term::NamedNode(iri(local))
1926 }
1927
1928 #[test]
1929 fn memoizes_shared_value_checks_across_focus_nodes() {
1930 let p = iri("p");
1931 let shared = term("shared");
1932 let mut graph = Graph::new();
1933 graph.insert(&Triple::new(iri("a"), p.clone(), shared.clone()));
1934 graph.insert(&Triple::new(iri("b"), p.clone(), shared.clone()));
1935
1936 let mut arena = ShapeArena::new();
1937 let qualifier = arena.insert(Shape::TestConst(shared));
1938 let root = arena.insert(Shape::Count {
1939 path: Path::Pred(p),
1940 min: Some(1),
1941 max: None,
1942 qualifier,
1943 });
1944 let sparql = SparqlExecutor::new(&graph).unwrap();
1945 crate::profile::enable();
1946 {
1947 let mut evaluator = ShapeEvaluator::new(&graph, &arena, Prefixes::empty(), &sparql);
1948 assert!(evaluator.holds(&term("a"), root));
1949 assert!(evaluator.holds(&term("b"), root));
1950 }
1951 let profile = crate::profile::take().unwrap();
1952 let cache = profile.shape_cache();
1953 assert_eq!(cache.evaluators, 1);
1954 assert!(cache.hits >= 1, "shared qualifier should hit the cache");
1955 assert_eq!(cache.peak_entries, 3);
1956 assert!(cache.estimated_peak_bytes > 0);
1957 }
1958
1959 #[test]
1960 fn does_not_cache_cycle_dependent_results() {
1961 let mut arena = ShapeArena::new();
1965 let a = arena.reserve();
1966 let b = arena.reserve();
1967 let bottom = arena.insert(Shape::Or(Vec::new()));
1968 arena.set(a, Shape::And(vec![b, bottom]));
1969 arena.set(b, Shape::And(vec![a]));
1970
1971 let graph = Graph::new();
1972 let sparql = SparqlExecutor::new(&graph).unwrap();
1973 let node = term("x");
1974
1975 crate::profile::enable();
1976 {
1977 let mut evaluator = ShapeEvaluator::new(&graph, &arena, Prefixes::empty(), &sparql);
1978 assert!(!evaluator.holds(&node, a));
1979 assert!(!evaluator.holds(&node, b));
1980 }
1981 let profile = crate::profile::take().unwrap();
1982 let cache = profile.shape_cache();
1983 assert!(cache.recursion_back_edges > 0);
1984 assert!(cache.non_cacheable_results > 0);
1985 }
1986}