use crate::focus::FocusNodes;
use crate::frozen::FrozenIndexedDataset;
use crate::path::{PathBackend, pred, succ};
use crate::profile::ShapeCacheSample;
use crate::sparql::{SparqlDiagnostic, SparqlExecutor, SparqlViolation};
use crate::value::{compare_terms, value_type_holds};
use oxrdf::{Graph, NamedNode, Term};
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use shifty_algebra::render::{
describe_negation_in, describe_shape_in, negated_class_target_shape, path_to_string_in,
shape_to_string_in, xone_alternatives,
};
use shifty_algebra::{
ConstraintKind, NodeExpr, Path, Prefixes, Schema, Selector, Severity, Shape, ShapeArena,
ShapeId, SparqlConstraint,
};
use shifty_opt::{FocusSource, PhysicalPlan, analyze};
use std::cmp::Ordering;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy)]
struct EvalResult {
holds: bool,
cacheable: bool,
}
#[derive(Default)]
struct EvalState {
memo: FxHashMap<ShapeId, FxHashMap<Term, bool>>,
active: FxHashMap<ShapeId, FxHashSet<Term>>,
telemetry: Option<ShapeCacheSample>,
}
pub(crate) struct ShapeEvaluator<'a> {
g: &'a dyn PathBackend,
arena: &'a ShapeArena,
prefixes: &'a Prefixes,
sparql: &'a SparqlExecutor,
state: EvalState,
}
impl<'a> ShapeEvaluator<'a> {
pub(crate) fn new(
g: &'a dyn PathBackend,
arena: &'a ShapeArena,
prefixes: &'a Prefixes,
sparql: &'a SparqlExecutor,
) -> Self {
Self {
g,
arena,
prefixes,
sparql,
state: EvalState {
telemetry: crate::profile::is_enabled().then(ShapeCacheSample::default),
..EvalState::default()
},
}
}
pub(crate) fn holds(&mut self, node: &Term, id: ShapeId) -> bool {
holds_memoized(self.g, self.arena, node, id, self.sparql, &mut self.state).holds
}
pub(crate) fn sparql(&self) -> &SparqlExecutor {
self.sparql
}
pub(crate) fn backend(&self) -> &dyn PathBackend {
self.g
}
pub(crate) fn arena(&self) -> &ShapeArena {
self.arena
}
}
impl Drop for ShapeEvaluator<'_> {
fn drop(&mut self) {
let Some(mut sample) = self.state.telemetry else {
return;
};
sample.entries = self.state.memo.values().map(HashMap::len).sum();
sample.estimated_bytes = estimated_memo_bytes(&self.state.memo);
crate::profile::record_shape_cache(sample);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Reason {
pub value: Term,
pub path: Option<String>,
pub shape: ShapeId,
#[serde(default = "default_constraint")]
pub constraint: Shape,
#[serde(default)]
pub constraint_kind: ConstraintKind,
#[serde(default)]
pub constraint_id: ShapeId,
#[serde(default)]
pub statement_id: usize,
#[serde(default)]
pub severity: Severity,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_count: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sub_reasons: Vec<Reason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sparql_diagnostic: Option<SparqlDiagnostic>,
}
fn default_constraint() -> Shape {
Shape::Pending
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Violation {
pub focus: Term,
pub statement: usize,
pub severity: Severity,
pub reasons: Vec<Reason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationOutcome {
pub conforms: bool,
pub violations: Vec<Violation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UnsupportedPolicy {
#[default]
Ignore,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EngineOptions {
pub unsupported: UnsupportedPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationOptions {
pub minimum_severity: Severity,
pub sort_results: bool,
pub entry_shape_names: Vec<String>,
pub engine: EngineOptions,
}
impl Default for ValidationOptions {
fn default() -> Self {
Self {
minimum_severity: Severity::Info,
sort_results: true,
entry_shape_names: Vec::new(),
engine: EngineOptions::default(),
}
}
}
fn requested_shape_name_matches(requested: &str, actual: &str) -> bool {
let requested = requested.trim();
requested == actual
|| requested
.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.is_some_and(|stripped| stripped == actual)
}
pub(crate) fn entry_shape_name_selected(
entry_shape_names: &[String],
actual_name: Option<&str>,
) -> bool {
entry_shape_names.is_empty()
|| actual_name.is_some_and(|actual| {
entry_shape_names
.iter()
.any(|requested| requested_shape_name_matches(requested, actual))
})
}
pub(crate) fn entry_shape_any_name_selected(
entry_shape_names: &[String],
actual_names: &[String],
) -> bool {
entry_shape_names.is_empty()
|| actual_names
.iter()
.any(|actual| entry_shape_name_selected(entry_shape_names, Some(actual)))
}
fn most_severe(reasons: &[Reason]) -> Severity {
reasons
.iter()
.max_by_key(|reason| reason.severity.rank())
.map(|reason| reason.severity.clone())
.unwrap_or(Severity::Violation)
}
fn conforms_at_threshold(violations: &[Violation], minimum: &Severity) -> bool {
!violations
.iter()
.flat_map(|violation| &violation.reasons)
.any(|reason| reason.severity.meets(minimum))
}
fn sort_violations(violations: &mut [Violation], enabled: bool) {
if enabled {
violations.sort_by(|left, right| {
right
.severity
.rank()
.cmp(&left.severity.rank())
.then_with(|| left.focus.to_string().cmp(&right.focus.to_string()))
.then_with(|| left.statement.cmp(&right.statement))
});
}
}
fn stamp_statement_id(reasons: &mut [Reason], statement_id: usize) {
for reason in reasons {
reason.statement_id = statement_id;
stamp_statement_id(&mut reason.sub_reasons, statement_id);
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ValidationGraphMode {
Data,
#[default]
Union,
UnionAll,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonStratifiable {
pub components: Vec<Vec<ShapeId>>,
}
impl fmt::Display for NonStratifiable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "non-stratifiable schema (recursion through negation): ")?;
for (i, c) in self.components.iter().enumerate() {
if i > 0 {
write!(f, "; ")?;
}
let ids: Vec<String> = c.iter().map(|s| format!("@{}", s.0)).collect();
write!(f, "{{{}}}", ids.join(" "))?;
}
Ok(())
}
}
impl std::error::Error for NonStratifiable {}
impl NonStratifiable {
pub fn describe(&self, schema: &Schema) -> String {
let mut out = String::from("non-stratifiable schema (recursion through negation): ");
for (i, component) in self.components.iter().enumerate() {
if i > 0 {
out.push_str("; ");
}
out.push('{');
for (j, id) in component.iter().enumerate() {
if j > 0 {
out.push_str(", ");
}
out.push_str(&describe_shape_id(schema, *id));
}
out.push('}');
}
out
}
}
fn describe_shape_id(schema: &Schema, id: ShapeId) -> String {
if let Some(name) = schema.name_of(id) {
return schema.prefixes.compact(name);
}
match schema.sources.get(&id) {
Some(Term::BlankNode(node)) => format!("_:{}", node.as_str()),
Some(Term::NamedNode(node)) => schema.prefixes.compact(node.as_str()),
_ => format!("{} @{}", constraint_kind_label(schema, id), id.0),
}
}
fn constraint_kind_label(schema: &Schema, id: ShapeId) -> &'static str {
match ConstraintKind::of(&schema.arena, id) {
ConstraintKind::Top => "top",
ConstraintKind::Constant => "constant",
ConstraintKind::ClassMembership => "class",
ConstraintKind::ValueType => "value type",
ConstraintKind::NodeKind => "node kind",
ConstraintKind::Closed => "closed",
ConstraintKind::Equals => "equals",
ConstraintKind::Disjoint => "disjoint",
ConstraintKind::LessThan => "less than",
ConstraintKind::LessThanOrEquals => "less than or equals",
ConstraintKind::UniqueLang => "unique lang",
ConstraintKind::Negation => "negation",
ConstraintKind::Conjunction => "conjunction",
ConstraintKind::Disjunction => "disjunction",
ConstraintKind::Cardinality => "cardinality",
ConstraintKind::Sparql => "sparql",
ConstraintKind::Expression => "expression",
ConstraintKind::Unknown => "shape",
}
}
pub fn validate(data: &Graph, schema: &Schema) -> Result<ValidationOutcome, NonStratifiable> {
validate_with_options(data, schema, &ValidationOptions::default())
}
pub fn validate_with_options(
data: &Graph,
schema: &Schema,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_with_context_and_options(data, data, schema, options)
}
pub fn validate_graphs(
data: &Graph,
shapes: &Graph,
schema: &Schema,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_graphs_with_mode_and_options(
data,
shapes,
schema,
ValidationGraphMode::default(),
&ValidationOptions::default(),
)
}
pub fn validate_graphs_with_mode(
data: &Graph,
shapes: &Graph,
schema: &Schema,
mode: ValidationGraphMode,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_graphs_with_mode_and_options(data, shapes, schema, mode, &ValidationOptions::default())
}
pub fn validate_graphs_with_mode_and_options(
data: &Graph,
shapes: &Graph,
schema: &Schema,
mode: ValidationGraphMode,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
match mode {
ValidationGraphMode::Data => {
let uses_shapes = uses_shapes_graph(&schema.arena);
let frozen = if uses_shapes {
FrozenIndexedDataset::from_graphs(data, shapes)
} else {
FrozenIndexedDataset::from_graph(data)
};
validate_with_frozen(data, schema, frozen, uses_shapes, options)
}
ValidationGraphMode::Union => {
let uses_shapes = uses_shapes_graph(&schema.arena);
let frozen = if uses_shapes {
FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
} else {
FrozenIndexedDataset::from_graph_union(data, shapes)
};
validate_with_frozen(data, schema, frozen, uses_shapes, options)
}
ValidationGraphMode::UnionAll => {
let union = graph_union(data, shapes);
validate_with_context_and_options(&union, &union, schema, options)
}
}
}
pub fn validate_with_context(
data: &Graph,
context: &Graph,
schema: &Schema,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_with_context_and_options(data, context, schema, &ValidationOptions::default())
}
pub fn validate_with_context_and_options(
data: &Graph,
context: &Graph,
schema: &Schema,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
let uses_shapes = uses_shapes_graph(&schema.arena);
let frozen = if uses_shapes {
FrozenIndexedDataset::from_graphs(context, context)
} else {
FrozenIndexedDataset::from_graph(context)
};
validate_with_frozen(data, schema, frozen, uses_shapes, options)
}
fn validate_with_frozen(
data: &Graph,
schema: &Schema,
frozen: FrozenIndexedDataset,
has_shapes_graph: bool,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
let strat = analyze(&schema.arena);
if !strat.stratifiable {
let components = strat
.strata
.iter()
.filter(|s| !s.stratifiable)
.map(|s| s.shapes.clone())
.collect();
return Err(NonStratifiable { components });
}
let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
Ok(validate_with_executor(data, schema, &sparql, options))
}
pub(crate) fn validate_with_executor(
data: &dyn FocusNodes,
schema: &Schema,
sparql: &SparqlExecutor,
options: &ValidationOptions,
) -> ValidationOutcome {
validate_with_executor_selected(data, schema, sparql, options, None)
}
pub(crate) fn validate_with_executor_selected(
data: &dyn FocusNodes,
schema: &Schema,
sparql: &SparqlExecutor,
options: &ValidationOptions,
selected: Option<&[bool]>,
) -> ValidationOutcome {
let entries = schema.statements.iter().enumerate().filter_map(|(i, st)| {
if selected.is_some_and(|selected| !selected[i])
|| (selected.is_none()
&& !entry_shape_any_name_selected(
&options.entry_shape_names,
schema.names_of(st.shape),
))
{
return None;
}
let label = schema
.name_of(st.shape)
.map(str::to_string)
.unwrap_or_else(|| format!("@{}", st.shape.0));
Some((i, st.shape, &st.selector, label))
});
validate_entries(
data,
&schema.arena,
&schema.prefixes,
sparql,
options,
entries,
focus_nodes_with_evaluator,
)
}
fn validate_entries<'a, S: 'a>(
data: &dyn FocusNodes,
arena: &ShapeArena,
prefixes: &Prefixes,
sparql: &SparqlExecutor,
options: &ValidationOptions,
entries: impl IntoIterator<Item = (usize, ShapeId, &'a S, String)>,
focus: impl Fn(&dyn FocusNodes, &S, &mut ShapeEvaluator<'_>) -> Vec<Term>,
) -> ValidationOutcome {
let backend = sparql
.frozen()
.expect("validation executor always has a frozen dataset");
let mut evaluator = ShapeEvaluator::new(backend, arena, prefixes, sparql);
let mut violations = Vec::new();
for (statement, shape, source, label) in entries {
let foci = focus(data, source, &mut evaluator);
prefetch_sparql_constraints(arena, shape, &foci, sparql);
for focus in foci {
let start = web_time::Instant::now();
let mut stack = HashSet::new();
let mut reasons = explain(
&mut evaluator,
&focus,
shape,
None,
&Severity::Violation,
&mut stack,
);
crate::profile::record_shape(&label, start.elapsed().as_micros() as u64);
dedup_reasons(&mut reasons);
stamp_statement_id(&mut reasons, statement);
if !reasons.is_empty() {
let severity = most_severe(&reasons);
violations.push(Violation {
focus,
statement,
severity,
reasons,
});
}
}
}
sort_violations(&mut violations, options.sort_results);
ValidationOutcome {
conforms: conforms_at_threshold(&violations, &options.minimum_severity),
violations,
}
}
pub(crate) fn uses_shapes_graph(arena: &ShapeArena) -> bool {
(0..arena.len()).any(|i| {
matches!(arena.get(ShapeId(i as u32)), Shape::Sparql(c) if c.query.contains("shapesGraph"))
})
}
pub fn graph_union(left: &Graph, right: &Graph) -> Graph {
let (base, extra) = if left.len() >= right.len() {
(left, right)
} else {
(right, left)
};
let mut union = base.clone();
for triple in extra.iter() {
union.insert(triple);
}
crate::profile::record_graph_union(union.len());
union
}
pub fn validate_plan(
data: &Graph,
plan: &PhysicalPlan,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_plan_with_options(data, plan, &ValidationOptions::default())
}
pub fn validate_plan_with_options(
data: &Graph,
plan: &PhysicalPlan,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_plan_with_context_and_options(data, data, plan, options)
}
pub fn validate_plan_graphs(
data: &Graph,
shapes: &Graph,
plan: &PhysicalPlan,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_plan_graphs_with_mode_and_options(
data,
shapes,
plan,
ValidationGraphMode::default(),
&ValidationOptions::default(),
)
}
pub fn validate_plan_graphs_with_mode(
data: &Graph,
shapes: &Graph,
plan: &PhysicalPlan,
mode: ValidationGraphMode,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_plan_graphs_with_mode_and_options(
data,
shapes,
plan,
mode,
&ValidationOptions::default(),
)
}
pub fn validate_plan_graphs_with_mode_and_options(
data: &Graph,
shapes: &Graph,
plan: &PhysicalPlan,
mode: ValidationGraphMode,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
match mode {
ValidationGraphMode::Data => {
let uses_shapes = uses_shapes_graph(&plan.arena);
let frozen = if uses_shapes {
FrozenIndexedDataset::from_graphs(data, shapes)
} else {
FrozenIndexedDataset::from_graph(data)
};
validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
}
ValidationGraphMode::Union => {
let uses_shapes = uses_shapes_graph(&plan.arena);
let frozen = if uses_shapes {
FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
} else {
FrozenIndexedDataset::from_graph_union(data, shapes)
};
validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
}
ValidationGraphMode::UnionAll => {
let union = graph_union(data, shapes);
validate_plan_with_context_and_options(&union, &union, plan, options)
}
}
}
pub fn validate_plan_with_context(
data: &Graph,
context: &Graph,
plan: &PhysicalPlan,
) -> Result<ValidationOutcome, NonStratifiable> {
validate_plan_with_context_and_options(data, context, plan, &ValidationOptions::default())
}
pub fn validate_plan_with_context_and_options(
data: &Graph,
context: &Graph,
plan: &PhysicalPlan,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
let uses_shapes = uses_shapes_graph(&plan.arena);
let frozen = if uses_shapes {
FrozenIndexedDataset::from_graphs(context, context)
} else {
FrozenIndexedDataset::from_graph(context)
};
validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
}
fn validate_plan_with_frozen(
data: &Graph,
plan: &PhysicalPlan,
frozen: FrozenIndexedDataset,
has_shapes_graph: bool,
options: &ValidationOptions,
) -> Result<ValidationOutcome, NonStratifiable> {
let strat = analyze(&plan.arena);
if !strat.stratifiable {
let components = strat
.strata
.iter()
.filter(|s| !s.stratifiable)
.map(|s| s.shapes.clone())
.collect();
return Err(NonStratifiable { components });
}
let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
Ok(validate_plan_with_executor(
data, plan, &sparql, options, None,
))
}
pub(crate) fn validate_plan_with_executor(
data: &dyn FocusNodes,
plan: &PhysicalPlan,
sparql: &SparqlExecutor,
options: &ValidationOptions,
selected: Option<&[bool]>,
) -> ValidationOutcome {
let entries = plan.statements.iter().enumerate().filter_map(|(i, sp)| {
if selected.is_some_and(|selected| !selected[i])
|| (selected.is_none()
&& !entry_shape_any_name_selected(
&options.entry_shape_names,
plan.names_of(sp.shape),
))
{
return None;
}
let label = plan
.name_of(sp.shape)
.map(str::to_string)
.unwrap_or_else(|| format!("@{}", sp.shape.0));
Some((i, sp.shape, &sp.source, label))
});
validate_entries(
data,
&plan.arena,
&plan.prefixes,
sparql,
options,
entries,
focus_for_source,
)
}
fn focus_for_source(
data: &dyn FocusNodes,
source: &FocusSource,
evaluator: &mut ShapeEvaluator<'_>,
) -> Vec<Term> {
match source {
FocusSource::SubjectsOf(p) => data.subjects_of(p),
FocusSource::ObjectsOf(p) => data.objects_of(p),
FocusSource::Node(c) => vec![c.clone()],
FocusSource::PathToConst { path, target } => pred(evaluator.g, target, path)
.into_iter()
.filter(|node| data.contains_term(node))
.collect(),
FocusSource::ScanFilter { path, qualifier } => data
.all_nodes()
.into_iter()
.filter(|v| {
succ(evaluator.g, v, path)
.iter()
.any(|u| evaluator.holds(u, *qualifier))
})
.collect(),
FocusSource::Sparql(target) => {
let candidates = data.all_nodes();
evaluator
.sparql
.target_nodes(&target.query)
.unwrap_or_default()
.into_iter()
.filter(|node| candidates.contains(node))
.collect()
}
}
}
pub fn focus_nodes(data: &Graph, sel: &Selector, arena: &ShapeArena) -> Vec<Term> {
let sparql =
SparqlExecutor::new(data).expect("building an in-memory Oxigraph store should succeed");
let mut evaluator = ShapeEvaluator::new(data, arena, Prefixes::empty(), &sparql);
focus_nodes_with_evaluator(data, sel, &mut evaluator)
}
pub(crate) fn focus_nodes_with(
data: &dyn FocusNodes,
backend: &dyn PathBackend,
sel: &Selector,
arena: &ShapeArena,
sparql: &SparqlExecutor,
) -> Vec<Term> {
let mut evaluator = ShapeEvaluator::new(backend, arena, Prefixes::empty(), sparql);
focus_nodes_with_evaluator(data, sel, &mut evaluator)
}
pub(crate) fn focus_nodes_with_evaluator(
data: &dyn FocusNodes,
sel: &Selector,
evaluator: &mut ShapeEvaluator<'_>,
) -> Vec<Term> {
match sel {
Selector::HasOut(q) => data.subjects_of(q),
Selector::HasIn(q) => data.objects_of(q),
Selector::IsConst(c) => vec![c.clone()],
Selector::HasPath(path, qual) => match evaluator.arena.get(*qual) {
Shape::TestConst(target) => pred(evaluator.g, target, path)
.into_iter()
.filter(|node| data.contains_term(node))
.collect(),
_ => data
.all_nodes()
.into_iter()
.filter(|v| {
succ(evaluator.g, v, path)
.iter()
.any(|u| evaluator.holds(u, *qual))
})
.collect(),
},
Selector::Sparql(target) => {
let candidates = data.all_nodes();
evaluator
.sparql
.target_nodes(&target.query)
.unwrap_or_default()
.into_iter()
.filter(|node| candidates.contains(node))
.collect()
}
}
}
fn holds_memoized(
g: &dyn PathBackend,
arena: &ShapeArena,
v: &Term,
id: ShapeId,
sparql: &SparqlExecutor,
state: &mut EvalState,
) -> EvalResult {
if let Some(&holds) = state.memo.get(&id).and_then(|by_term| by_term.get(v)) {
if let Some(telemetry) = state.telemetry.as_mut() {
telemetry.hits += 1;
}
return EvalResult {
holds,
cacheable: true,
};
}
if let Some(telemetry) = state.telemetry.as_mut() {
telemetry.misses += 1;
}
if !state.active.entry(id).or_default().insert(v.clone()) {
if let Some(telemetry) = state.telemetry.as_mut() {
telemetry.recursion_back_edges += 1;
}
return EvalResult {
holds: true,
cacheable: false,
}; }
let result = match arena.get(id) {
Shape::Annotated { shape, .. } => holds_memoized(g, arena, v, *shape, sparql, state),
Shape::Top | Shape::Pending => EvalResult {
holds: true,
cacheable: true,
},
Shape::Sparql(constraint) => EvalResult {
holds: sparql
.constraint_violations(constraint, v)
.is_ok_and(|violations| violations.is_empty()),
cacheable: true,
},
Shape::Expression(expr) => {
let mut cacheable = true;
let results = eval_expr(g, arena, v, expr, sparql, state, &mut cacheable);
EvalResult {
holds: results.iter().all(is_boolean_true),
cacheable,
}
}
Shape::TestConst(c) => EvalResult {
holds: v == c,
cacheable: true,
},
Shape::TestType(t) => EvalResult {
holds: value_type_holds(t, v),
cacheable: true,
},
Shape::TestKind(k) => EvalResult {
holds: k.matches(v),
cacheable: true,
},
Shape::Closed(q) => EvalResult {
holds: closed_offenders(g, v, q).is_empty(),
cacheable: true,
},
Shape::Eq(path, p) => EvalResult {
holds: succ(g, v, path) == objects(g, v, p),
cacheable: true,
},
Shape::Disj(path, p) => EvalResult {
holds: succ(g, v, path).is_disjoint(&objects(g, v, p)),
cacheable: true,
},
Shape::Lt(path, p) => EvalResult {
holds: all_pairs_ordered(g, v, path, p, false),
cacheable: true,
},
Shape::Le(path, p) => EvalResult {
holds: all_pairs_ordered(g, v, path, p, true),
cacheable: true,
},
Shape::UniqueLang(path) => EvalResult {
holds: unique_lang(&succ(g, v, path)),
cacheable: true,
},
Shape::Not(c) => {
let child = holds_memoized(g, arena, v, *c, sparql, state);
EvalResult {
holds: !child.holds,
cacheable: child.cacheable,
}
}
Shape::And(cs) => {
let mut result = EvalResult {
holds: true,
cacheable: true,
};
for child in cs {
let child = holds_memoized(g, arena, v, *child, sparql, state);
result.cacheable &= child.cacheable;
if !child.holds {
result.holds = false;
break;
}
}
result
}
Shape::Or(cs) => {
let mut result = EvalResult {
holds: false,
cacheable: true,
};
for child in cs {
let child = holds_memoized(g, arena, v, *child, sparql, state);
result.cacheable &= child.cacheable;
if child.holds {
result.holds = true;
break;
}
}
result
}
Shape::Count {
path,
min,
max,
qualifier,
} => {
let mut n = 0;
let mut cacheable = true;
for value in succ(g, v, path) {
let qualified = holds_memoized(g, arena, &value, *qualifier, sparql, state);
cacheable &= qualified.cacheable;
n += u64::from(qualified.holds);
}
EvalResult {
holds: min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m),
cacheable,
}
}
};
let owned = state.active.get_mut(&id).and_then(|set| set.take(v));
if result.cacheable {
let key = owned.unwrap_or_else(|| v.clone());
state.memo.entry(id).or_default().insert(key, result.holds);
if let Some(telemetry) = state.telemetry.as_mut() {
telemetry.insertions += 1;
}
} else if let Some(telemetry) = state.telemetry.as_mut() {
telemetry.non_cacheable_results += 1;
}
result
}
fn eval_expr(
g: &dyn PathBackend,
arena: &ShapeArena,
v: &Term,
expr: &NodeExpr,
sparql: &SparqlExecutor,
state: &mut EvalState,
cacheable: &mut bool,
) -> HashSet<Term> {
match expr {
NodeExpr::This => {
let mut s = HashSet::with_capacity(1);
s.insert(v.clone());
s
}
NodeExpr::Constant(t) => {
let mut s = HashSet::with_capacity(1);
s.insert(t.clone());
s
}
NodeExpr::Path(p) => succ(g, v, p),
NodeExpr::Filter { input, shape } => {
let inputs = eval_expr(g, arena, v, input, sparql, state, cacheable);
inputs
.into_iter()
.filter(|x| {
let r = holds_memoized(g, arena, x, *shape, sparql, state);
*cacheable &= r.cacheable;
r.holds
})
.collect()
}
NodeExpr::Intersection(es) => {
let mut iter = es.iter();
match iter.next() {
Some(first) => {
let mut acc = eval_expr(g, arena, v, first, sparql, state, cacheable);
for e in iter {
let s = eval_expr(g, arena, v, e, sparql, state, cacheable);
acc.retain(|x| s.contains(x));
}
acc
}
None => HashSet::new(),
}
}
NodeExpr::Union(es) => {
let mut acc = HashSet::new();
for e in es {
acc.extend(eval_expr(g, arena, v, e, sparql, state, cacheable));
}
acc
}
NodeExpr::Function { .. } => HashSet::new(),
}
}
pub(crate) fn is_boolean_true(t: &Term) -> bool {
matches!(t, Term::Literal(l) if l.datatype() == oxrdf::vocab::xsd::BOOLEAN && l.value() == "true")
}
fn estimated_memo_bytes(memo: &FxHashMap<ShapeId, FxHashMap<Term, bool>>) -> usize {
const CONTROL_BYTE_ESTIMATE: usize = 1;
const ENTRY_BYTES: usize = std::mem::size_of::<(Term, bool)>() + CONTROL_BYTE_ESTIMATE;
memo.values()
.map(|by_term| {
by_term.capacity() * ENTRY_BYTES
+ by_term.keys().map(estimated_term_heap_bytes).sum::<usize>()
})
.sum::<usize>()
+ memo.capacity()
* (std::mem::size_of::<(ShapeId, FxHashMap<Term, bool>)>() + CONTROL_BYTE_ESTIMATE)
}
fn estimated_term_heap_bytes(term: &Term) -> usize {
match term {
Term::NamedNode(node) => node.as_str().len(),
Term::BlankNode(node) => node.as_str().len(),
Term::Literal(literal) => {
literal.value().len()
+ literal.language().map_or_else(
|| {
let datatype = literal.datatype();
if datatype.as_str() == "http://www.w3.org/2001/XMLSchema#string" {
0
} else {
datatype.as_str().len()
}
},
str::len,
)
}
}
}
pub(crate) fn prefetch_sparql_constraints(
arena: &ShapeArena,
root: ShapeId,
foci: &[Term],
sparql: &SparqlExecutor,
) {
if foci.len() < 2 {
return;
}
let mut constraints = Vec::new();
let mut seen = HashSet::new();
collect_focus_sparql(arena, root, &mut seen, &mut constraints);
for constraint in constraints {
let _ = sparql.prefetch_constraint(constraint, foci);
}
}
fn collect_focus_sparql<'a>(
arena: &'a ShapeArena,
id: ShapeId,
seen: &mut HashSet<ShapeId>,
out: &mut Vec<&'a SparqlConstraint>,
) {
if !seen.insert(id) {
return; }
match arena.get(id) {
Shape::Annotated { shape, .. } => collect_focus_sparql(arena, *shape, seen, out),
Shape::Sparql(constraint) => out.push(constraint),
Shape::Not(inner) => collect_focus_sparql(arena, *inner, seen, out),
Shape::And(ids) | Shape::Or(ids) => {
for &child in ids {
collect_focus_sparql(arena, child, seen, out);
}
}
_ => {}
}
}
fn explain(
evaluator: &mut ShapeEvaluator<'_>,
node: &Term,
id: ShapeId,
path_ctx: Option<&str>,
severity: &Severity,
stack: &mut HashSet<(ShapeId, Term)>,
) -> Vec<Reason> {
let key = (id, node.clone());
if !stack.insert(key.clone()) {
return Vec::new(); }
if evaluator.holds(node, id) {
stack.remove(&key);
return Vec::new();
}
let reasons = match evaluator.arena.get(id).clone() {
Shape::Annotated {
severity: source_severity,
messages,
shape,
} => {
let mut reasons = explain(evaluator, node, shape, path_ctx, &source_severity, stack);
if !messages.is_empty() {
let author = messages
.iter()
.map(|m| apply_message_template(&term_text(m), node, &HashMap::new()))
.collect::<Vec<_>>()
.join("; ");
for r in &mut reasons {
if r.author_message.is_none() {
r.author_message = Some(author.clone());
}
}
}
reasons
}
Shape::Top | Shape::Pending => Vec::new(),
Shape::Sparql(constraint) => {
match evaluator.sparql.constraint_violations(&constraint, node) {
Ok(violations) if violations.is_empty() => Vec::new(),
Ok(violations) => {
let diagnostic = evaluator
.sparql
.constraint_diagnostic(&constraint, node, &violations)
.ok();
violations
.into_iter()
.map(|violation| {
let message = sparql_violation_message(&violation, &constraint, node);
reason(
evaluator.arena,
id,
violation.value.unwrap_or_else(|| node.clone()),
violation
.path
.map(|path| path.to_string())
.or_else(|| path_ctx.map(str::to_string))
.or_else(|| {
constraint
.path
.as_ref()
.map(|p| path_to_string_in(p, evaluator.prefixes))
}),
severity,
message,
None,
Vec::new(),
diagnostic.clone(),
)
})
.collect()
}
Err(error) => vec![reason(
evaluator.arena,
id,
node.clone(),
path_ctx.map(str::to_string),
severity,
format!("SPARQL constraint evaluation failed: {error}"),
None,
Vec::new(),
evaluator
.sparql
.constraint_diagnostic(&constraint, node, &[])
.ok(),
)],
}
}
Shape::TestConst(_)
| Shape::TestType(_)
| Shape::TestKind(_)
| Shape::Eq(..)
| Shape::Disj(..)
| Shape::Lt(..)
| Shape::Le(..)
| Shape::UniqueLang(_) => leaf(
evaluator.arena,
evaluator.holds(node, id),
node,
id,
path_ctx,
severity,
format!(
"{} not satisfied",
shape_to_string_in(evaluator.arena, id, evaluator.prefixes)
),
),
Shape::Closed(q) => {
let bad = closed_offenders(evaluator.g, node, &q);
if bad.is_empty() {
Vec::new()
} else {
let preds: Vec<String> = bad
.iter()
.map(|p| evaluator.prefixes.compact(p.as_str()))
.collect();
vec![reason(
evaluator.arena,
id,
node.clone(),
path_ctx.map(str::to_string),
severity,
format!("closed: unexpected predicate(s) {}", preds.join(", ")),
None,
Vec::new(),
None,
)]
}
}
Shape::Not(c) => {
if explain(evaluator, node, c, path_ctx, severity, stack).is_empty() {
vec![reason(
evaluator.arena,
id,
node.clone(),
path_ctx.map(str::to_string),
severity,
format!(
"must satisfy `{}`",
describe_negation_in(evaluator.arena, c, evaluator.prefixes)
),
None,
Vec::new(),
None,
)]
} else {
Vec::new()
}
}
Shape::And(cs) => cs
.iter()
.flat_map(|c| explain(evaluator, node, *c, path_ctx, severity, stack))
.collect(),
Shape::Or(cs) => {
let mut sub_reasons = Vec::new();
let mut satisfied = false;
for c in &cs {
let sub = explain(evaluator, node, *c, path_ctx, severity, stack);
if sub.is_empty() {
satisfied = true;
break;
}
sub_reasons.extend(sub);
}
if satisfied {
return Vec::new();
}
if let Some(alternatives) = xone_alternatives(id, evaluator.arena) {
let held: Vec<ShapeId> = alternatives
.iter()
.copied()
.filter(|a| evaluator.holds(node, *a))
.collect();
let total = alternatives.len();
let describe_all = |ids: &[ShapeId]| {
ids.iter()
.map(|a| describe_shape_in(evaluator.arena, *a, evaluator.prefixes))
.collect::<Vec<_>>()
.join(", ")
};
let message = if held.is_empty() {
format!("none of the {total} alternatives hold; exactly one must")
} else {
let idle: Vec<ShapeId> = alternatives
.iter()
.copied()
.filter(|a| !held.contains(a))
.collect();
let mut message = format!(
"exactly one alternative may hold; {} of {total} do — holds: {}",
held.len(),
describe_all(&held)
);
if !idle.is_empty() {
message.push_str(&format!("; does not hold: {}", describe_all(&idle)));
}
message
};
let sub_reasons = if held.is_empty() {
sub_reasons
} else {
Vec::new()
};
return vec![reason(
evaluator.arena,
id,
node.clone(),
path_ctx.map(str::to_string),
severity,
message,
None,
sub_reasons,
None,
)];
}
vec![reason(
evaluator.arena,
id,
node.clone(),
path_ctx.map(str::to_string),
severity,
format!("none of {} alternative(s) satisfied", cs.len()),
None,
sub_reasons,
None,
)]
}
Shape::Count {
path,
min,
max,
qualifier,
} => explain_count(
evaluator, node, id, &path, min, max, qualifier, severity, stack,
),
Shape::Expression(_) => leaf(
evaluator.arena,
false, node,
id,
path_ctx,
severity,
"sh:expression did not evaluate to true".to_string(),
),
};
stack.remove(&key);
reasons
}
#[allow(clippy::too_many_arguments)]
fn explain_count(
evaluator: &mut ShapeEvaluator<'_>,
node: &Term,
id: ShapeId,
path: &Path,
min: Option<u64>,
max: Option<u64>,
qualifier: ShapeId,
severity: &Severity,
stack: &mut HashSet<(ShapeId, Term)>,
) -> Vec<Reason> {
let path_str = path_to_string_in(path, evaluator.prefixes);
let matched: Vec<Term> = succ(evaluator.g, node, path)
.into_iter()
.filter(|u| evaluator.holds(u, qualifier))
.collect();
let n = matched.len() as u64;
let mut reasons = Vec::new();
let qual_clause = match evaluator.arena.get(qualifier) {
Shape::Top => String::new(),
_ => format!(
" matching `{}`",
describe_shape_in(evaluator.arena, qualifier, evaluator.prefixes)
),
};
if let Some(mx) = max
&& n > mx
{
let class_offense = (mx == 0)
.then(|| negated_class_target_shape(qualifier, evaluator.arena))
.flatten();
match evaluator.arena.get(qualifier).clone() {
Shape::Not(inner) if mx == 0 => {
for u in &matched {
reasons.extend(explain(
evaluator,
u,
inner,
Some(&path_str),
severity,
stack,
));
}
}
_ if class_offense.is_some() => {
let class = class_offense.expect("guard ensured Some");
for u in &matched {
reasons.push(reason(
evaluator.arena,
id,
u.clone(),
Some(path_str.clone()),
severity,
format!(
"must be an instance of {}",
term_display(&class, evaluator.prefixes)
),
None,
Vec::new(),
None,
));
}
}
Shape::Top => reasons.push(with_observed(
reason(
evaluator.arena,
id,
node.clone(),
Some(path_str.clone()),
severity,
format!(
"at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
),
None,
Vec::new(),
None,
),
n,
)),
_ if mx == 0 => {
let requirement =
describe_negation_in(evaluator.arena, qualifier, evaluator.prefixes);
for u in &matched {
reasons.push(reason(
evaluator.arena,
id,
u.clone(),
Some(path_str.clone()),
severity,
format!("must satisfy `{requirement}`"),
None,
Vec::new(),
None,
));
}
}
_ => reasons.push(with_observed(
reason(
evaluator.arena,
id,
node.clone(),
Some(path_str.clone()),
severity,
format!(
"at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
),
None,
Vec::new(),
None,
),
n,
)),
}
}
if let Some(mn) = min
&& n < mn
{
reasons.push(with_observed(
reason(
evaluator.arena,
id,
node.clone(),
Some(path_str.clone()),
severity,
format!("at least {mn} value(s){qual_clause} required along {path_str}, found {n}"),
None,
Vec::new(),
None,
),
n,
));
}
reasons
}
fn sparql_violation_message(
violation: &SparqlViolation,
constraint: &SparqlConstraint,
node: &Term,
) -> String {
if let Some(message) = &violation.message {
return term_text(message);
}
if !constraint.messages.is_empty() {
return constraint
.messages
.iter()
.map(|m| apply_message_template(&term_text(m), node, &violation.bindings))
.collect::<Vec<_>>()
.join("; ");
}
let mut message = match &constraint.shape {
Some(shape) => format!("SPARQL constraint at {shape} not satisfied"),
None => "SPARQL constraint not satisfied".to_string(),
};
if let Some(value) = &violation.value {
message.push_str(&format!(" (value: {value})"));
}
message
}
pub(crate) fn apply_message_template(
template: &str,
focus: &Term,
bindings: &HashMap<String, Term>,
) -> String {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE
.get_or_init(|| Regex::new(r"\{(\$[A-Za-z_]\w*|\?[A-Za-z_]\w*)\}").expect("static regex"));
re.replace_all(template, |caps: ®ex::Captures| {
let placeholder = &caps[1];
let name = &placeholder[1..]; let term = if name == "this" {
Some(focus)
} else {
bindings.get(name)
};
term.map(|t| match t {
Term::NamedNode(n) => format!("<{}>", n.as_str()),
Term::BlankNode(b) => format!("_:{}", b.as_str()),
Term::Literal(l) => l.value().to_string(),
})
.unwrap_or_else(|| placeholder.to_string())
})
.to_string()
}
fn term_text(term: &Term) -> String {
match term {
Term::Literal(literal) => literal.value().to_string(),
other => other.to_string(),
}
}
fn term_display(term: &Term, px: &Prefixes) -> String {
match term {
Term::NamedNode(iri) => px.compact(iri.as_str()),
other => term_text(other),
}
}
#[allow(clippy::too_many_arguments)]
fn reason(
arena: &ShapeArena,
id: ShapeId,
value: Term,
path: Option<String>,
severity: &Severity,
message: String,
author_message: Option<String>,
sub_reasons: Vec<Reason>,
sparql_diagnostic: Option<SparqlDiagnostic>,
) -> Reason {
Reason {
value,
path,
shape: id,
constraint: arena.get(id).clone(),
constraint_kind: ConstraintKind::of(arena, id),
constraint_id: id,
statement_id: usize::MAX,
severity: severity.clone(),
message,
author_message,
observed_count: None,
sub_reasons,
sparql_diagnostic,
}
}
fn with_observed(mut r: Reason, observed: u64) -> Reason {
r.observed_count = Some(observed);
r
}
fn leaf(
arena: &ShapeArena,
ok: bool,
node: &Term,
id: ShapeId,
path_ctx: Option<&str>,
severity: &Severity,
message: String,
) -> Vec<Reason> {
if ok {
Vec::new()
} else {
vec![reason(
arena,
id,
node.clone(),
path_ctx.map(str::to_string),
severity,
message,
None,
Vec::new(),
None,
)]
}
}
fn all_pairs_ordered(
g: &dyn PathBackend,
v: &Term,
path: &Path,
p: &NamedNode,
allow_eq: bool,
) -> bool {
let lhs = succ(g, v, path);
let rhs = objects(g, v, p);
for a in &lhs {
for b in &rhs {
match compare_terms(a, b) {
Some(Ordering::Less) => {}
Some(Ordering::Equal) if allow_eq => {}
_ => return false,
}
}
}
true
}
fn objects(g: &dyn PathBackend, v: &Term, p: &NamedNode) -> HashSet<Term> {
succ(g, v, &Path::Pred(p.clone()))
}
fn closed_offenders(
g: &dyn PathBackend,
node: &Term,
q: &BTreeSet<NamedNode>,
) -> BTreeSet<NamedNode> {
g.out_predicates(node)
.into_iter()
.filter(|p| !q.contains(p))
.collect()
}
fn unique_lang(values: &HashSet<Term>) -> bool {
let mut seen = HashSet::new();
for term in values {
if let Term::Literal(l) = term
&& let Some(lang) = l.language()
&& !seen.insert(lang.to_ascii_lowercase())
{
return false;
}
}
true
}
fn dedup_reasons(reasons: &mut Vec<Reason>) {
let mut seen = HashSet::new();
reasons.retain(|r| {
seen.insert((
r.value.to_string(),
r.message.clone(),
r.severity.as_str().to_string(),
))
});
}
#[cfg(test)]
mod tests {
use super::*;
use oxrdf::{NamedNode, Triple};
fn iri(local: &str) -> NamedNode {
NamedNode::new(format!("http://ex/{local}")).unwrap()
}
fn term(local: &str) -> Term {
Term::NamedNode(iri(local))
}
#[test]
fn memoizes_shared_value_checks_across_focus_nodes() {
let p = iri("p");
let shared = term("shared");
let mut graph = Graph::new();
graph.insert(&Triple::new(iri("a"), p.clone(), shared.clone()));
graph.insert(&Triple::new(iri("b"), p.clone(), shared.clone()));
let mut arena = ShapeArena::new();
let qualifier = arena.insert(Shape::TestConst(shared));
let root = arena.insert(Shape::Count {
path: Path::Pred(p),
min: Some(1),
max: None,
qualifier,
});
let sparql = SparqlExecutor::new(&graph).unwrap();
crate::profile::enable();
{
let mut evaluator = ShapeEvaluator::new(&graph, &arena, Prefixes::empty(), &sparql);
assert!(evaluator.holds(&term("a"), root));
assert!(evaluator.holds(&term("b"), root));
}
let profile = crate::profile::take().unwrap();
let cache = profile.shape_cache();
assert_eq!(cache.evaluators, 1);
assert!(cache.hits >= 1, "shared qualifier should hit the cache");
assert_eq!(cache.peak_entries, 3);
assert!(cache.estimated_peak_bytes > 0);
}
#[test]
fn does_not_cache_cycle_dependent_results() {
let mut arena = ShapeArena::new();
let a = arena.reserve();
let b = arena.reserve();
let bottom = arena.insert(Shape::Or(Vec::new()));
arena.set(a, Shape::And(vec![b, bottom]));
arena.set(b, Shape::And(vec![a]));
let graph = Graph::new();
let sparql = SparqlExecutor::new(&graph).unwrap();
let node = term("x");
crate::profile::enable();
{
let mut evaluator = ShapeEvaluator::new(&graph, &arena, Prefixes::empty(), &sparql);
assert!(!evaluator.holds(&node, a));
assert!(!evaluator.holds(&node, b));
}
let profile = crate::profile::take().unwrap();
let cache = profile.shape_cache();
assert!(cache.recursion_back_edges > 0);
assert!(cache.non_cacheable_results > 0);
}
}