pub mod constraint;
mod parser;
pub mod path;
pub mod report;
pub mod shape;
mod target;
use std::collections::{HashMap, HashSet};
use crate::graph::rdf::{RdfStore, Term};
pub use constraint::evaluate_constraint;
pub use parser::parse_shapes;
pub use path::evaluate_path;
pub use report::{ValidationReport, ValidationResult};
pub use shape::{
Constraint, NodeKindValue, NodeShape, PropertyPath, PropertyShape, SH, Severity, ShaclError,
Shape, Target,
};
pub use target::resolve_targets;
pub trait SparqlExecutor {
fn execute(
&self,
query: &str,
this_binding: &Term,
) -> Result<Vec<HashMap<String, Term>>, ShaclError>;
}
pub fn validate(
data_graph: &RdfStore,
shapes_graph: &RdfStore,
sparql_executor: Option<&dyn SparqlExecutor>,
) -> Result<ValidationReport, ShaclError> {
let shapes = parse_shapes(shapes_graph)?;
let mut all_results = Vec::new();
for shape in &shapes {
if shape.is_deactivated() {
continue;
}
let focus_nodes = resolve_targets(shape, data_graph);
for focus_node in &focus_nodes {
let results = validate_shape(shape, focus_node, data_graph, &shapes, sparql_executor)?;
all_results.extend(results);
}
}
Ok(ValidationReport::from_results(all_results))
}
fn validate_shape(
shape: &Shape,
focus_node: &Term,
data_graph: &RdfStore,
all_shapes: &[Shape],
sparql_executor: Option<&dyn SparqlExecutor>,
) -> Result<Vec<ValidationResult>, ShaclError> {
let mut visited = HashSet::new();
let mut results = Vec::new();
match shape {
Shape::Node(ns) => {
let mut ctx = constraint::EvalContext {
focus_node,
shape,
path: None,
data_graph,
all_shapes,
visited: &mut visited,
};
for c in &ns.constraints {
let value_nodes = vec![focus_node.clone()];
results.extend(evaluate_constraint(c, &value_nodes, &mut ctx));
}
for c in &ns.constraints {
if let Constraint::Sparql(sc) = c {
results.extend(evaluate_sparql_constraint(
sc,
focus_node,
shape,
None,
sparql_executor,
)?);
}
}
for ps in &ns.property_shapes {
if ps.deactivated {
continue;
}
let path_values = evaluate_path(&ps.path, focus_node, data_graph);
let ps_shape = Shape::Property(ps.clone());
let mut ps_ctx = constraint::EvalContext {
focus_node,
shape: &ps_shape,
path: Some(&ps.path),
data_graph,
all_shapes,
visited: &mut visited,
};
for c in &ps.constraints {
if let Constraint::Sparql(sc) = c {
results.extend(evaluate_sparql_constraint(
sc,
focus_node,
&ps_shape,
Some(&ps.path),
sparql_executor,
)?);
} else {
results.extend(evaluate_constraint(c, &path_values, &mut ps_ctx));
}
}
}
}
Shape::Property(ps) => {
let path_values = evaluate_path(&ps.path, focus_node, data_graph);
let mut ctx = constraint::EvalContext {
focus_node,
shape,
path: Some(&ps.path),
data_graph,
all_shapes,
visited: &mut visited,
};
for c in &ps.constraints {
if let Constraint::Sparql(sc) = c {
results.extend(evaluate_sparql_constraint(
sc,
focus_node,
shape,
Some(&ps.path),
sparql_executor,
)?);
} else {
results.extend(evaluate_constraint(c, &path_values, &mut ctx));
}
}
}
}
Ok(results)
}
fn evaluate_sparql_constraint(
sc: &shape::SparqlConstraint,
focus_node: &Term,
shape: &Shape,
result_path: Option<&PropertyPath>,
sparql_executor: Option<&dyn SparqlExecutor>,
) -> Result<Vec<ValidationResult>, ShaclError> {
if sc.deactivated {
return Ok(Vec::new());
}
let Some(executor) = sparql_executor else {
return Ok(Vec::new());
};
let mut query = String::new();
for decl in &sc.prefixes {
use std::fmt::Write;
let _ = writeln!(query, "PREFIX {}: <{}>", decl.prefix, decl.namespace);
}
query.push_str(&sc.select);
let rows = executor.execute(&query, focus_node)?;
let mut results = Vec::new();
for row in &rows {
let value = row.get("value").cloned();
let message = row
.get("message")
.and_then(|t| match t {
Term::Literal(lit) => Some(lit.value().to_string()),
_ => None,
})
.or_else(|| sc.message.clone());
results.push(ValidationResult {
focus_node: focus_node.clone(),
source_constraint_component: format!("{}SPARQLConstraintComponent", SH::NS),
source_shape: shape.id().clone(),
value,
result_path: result_path.cloned(),
severity: shape.severity(),
message,
});
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::rdf::{RdfStore, Triple};
#[test]
fn validate_conforming_data() {
let data = RdfStore::new();
let rdf_type = Term::iri(shape::RDF::TYPE);
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
rdf_type,
Term::iri("http://ex.org/Person"),
));
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://ex.org/name"),
Term::literal("Alix"),
));
let shapes = RdfStore::new();
let shape_id = Term::iri("http://ex.org/PersonShape");
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let prop = Term::blank("p");
shapes.insert(Triple::new(shape_id, Term::iri(SH::PROPERTY), prop.clone()));
shapes.insert(Triple::new(
prop.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/name"),
));
shapes.insert(Triple::new(
prop,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(report.conforms, "Data should conform: {report}");
}
#[test]
fn validate_with_violations() {
let data = RdfStore::new();
let rdf_type = Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
rdf_type,
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let shape_id = Term::iri("http://ex.org/PersonShape");
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let prop = Term::blank("p");
shapes.insert(Triple::new(shape_id, Term::iri(SH::PROPERTY), prop.clone()));
shapes.insert(Triple::new(
prop.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/name"),
));
shapes.insert(Triple::new(
prop,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(!report.conforms);
assert_eq!(report.results.len(), 1);
}
#[test]
fn empty_shapes_conforms() {
let data = RdfStore::new();
let shapes = RdfStore::new();
let report = validate(&data, &shapes, None).unwrap();
assert!(report.conforms);
assert!(report.results.is_empty());
}
#[test]
fn deactivated_shape_skipped() {
let data = RdfStore::new();
let rdf_type = Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
rdf_type,
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let shape_id = Term::iri("http://ex.org/PersonShape");
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri(SH::DEACTIVATED),
Term::typed_literal("true", "http://www.w3.org/2001/XMLSchema#boolean"),
));
let prop = Term::blank("p");
shapes.insert(Triple::new(shape_id, Term::iri(SH::PROPERTY), prop.clone()));
shapes.insert(Triple::new(
prop.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/name"),
));
shapes.insert(Triple::new(
prop,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(report.conforms, "Deactivated shape should be skipped");
}
#[test]
fn report_to_triples() {
let data = RdfStore::new();
let rdf_type = Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
rdf_type,
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let shape_id = Term::iri("http://ex.org/S");
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
shape_id.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let prop = Term::blank("p");
shapes.insert(Triple::new(shape_id, Term::iri(SH::PROPERTY), prop.clone()));
shapes.insert(Triple::new(
prop.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/name"),
));
shapes.insert(Triple::new(
prop,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
let triples = report.to_triples();
assert!(
triples.len() >= 5,
"Expected at least 5 triples, got {}",
triples.len()
);
}
#[test]
fn display_format() {
let report = ValidationReport::from_results(vec![ValidationResult {
focus_node: Term::iri("http://ex.org/alix"),
source_constraint_component: format!("{}MinCountConstraintComponent", SH::NS),
source_shape: Term::iri("http://ex.org/S"),
value: None,
result_path: Some(PropertyPath::Predicate(Term::iri("http://ex.org/name"))),
severity: Severity::Violation,
message: Some("Expected at least 1 value(s), got 0".to_string()),
}]);
let text = format!("{report}");
assert!(text.contains("FAILED"));
assert!(text.contains("Violation"));
}
#[test]
fn validate_property_shape_with_datatype() {
let data = RdfStore::new();
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri("http://ex.org/Person"),
));
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://ex.org/age"),
Term::literal("not-a-number"), ));
let shapes = RdfStore::new();
let sid = Term::iri("http://ex.org/S");
shapes.insert(Triple::new(
sid.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let prop = Term::blank("p");
shapes.insert(Triple::new(sid, Term::iri(SH::PROPERTY), prop.clone()));
shapes.insert(Triple::new(
prop.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/age"),
));
shapes.insert(Triple::new(
prop,
Term::iri(SH::DATATYPE),
Term::iri("http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(!report.conforms);
assert_eq!(report.results.len(), 1);
assert!(
report.results[0]
.source_constraint_component
.contains("Datatype")
);
assert!(
report.results[0].result_path.is_some(),
"Property shape results should include path"
);
}
#[test]
fn validate_multiple_property_shapes() {
let data = RdfStore::new();
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let sid = Term::iri("http://ex.org/S");
shapes.insert(Triple::new(
sid.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let p1 = Term::blank("p1");
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::PROPERTY),
p1.clone(),
));
shapes.insert(Triple::new(
p1.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/name"),
));
shapes.insert(Triple::new(
p1,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let p2 = Term::blank("p2");
shapes.insert(Triple::new(sid, Term::iri(SH::PROPERTY), p2.clone()));
shapes.insert(Triple::new(
p2.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/email"),
));
shapes.insert(Triple::new(
p2,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(!report.conforms);
assert_eq!(
report.results.len(),
2,
"Should have violations for both name and email"
);
}
#[test]
fn validate_standalone_property_shape() {
let data = RdfStore::new();
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let sid = Term::iri("http://ex.org/PS");
shapes.insert(Triple::new(
sid.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::PROPERTY_SHAPE),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::PATH),
Term::iri("http://ex.org/name"),
));
shapes.insert(Triple::new(
sid,
Term::iri(SH::MIN_COUNT),
Term::typed_literal("1", "http://www.w3.org/2001/XMLSchema#integer"),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(
!report.conforms,
"Missing name should violate standalone property shape"
);
assert_eq!(report.results.len(), 1);
}
#[test]
fn validate_node_level_constraint() {
let data = RdfStore::new();
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let sid = Term::iri("http://ex.org/S");
shapes.insert(Triple::new(
sid.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
shapes.insert(Triple::new(
sid,
Term::iri(SH::NODE_KIND),
Term::iri(SH::IRI),
));
let report = validate(&data, &shapes, None).unwrap();
assert!(report.conforms, "alix is an IRI: {report}");
}
#[test]
fn validate_with_mock_sparql_executor() {
use std::collections::HashMap;
struct MockExecutor;
impl SparqlExecutor for MockExecutor {
fn execute(
&self,
_query: &str,
_this_binding: &Term,
) -> Result<Vec<HashMap<String, Term>>, ShaclError> {
let mut row = HashMap::new();
row.insert("value".to_string(), Term::literal("bad"));
row.insert("message".to_string(), Term::literal("Mock violation"));
Ok(vec![row])
}
}
let data = RdfStore::new();
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let sid = Term::iri("http://ex.org/S");
shapes.insert(Triple::new(
sid.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let sq = Term::blank("sq");
shapes.insert(Triple::new(sid, Term::iri(SH::SPARQL), sq.clone()));
shapes.insert(Triple::new(
sq,
Term::iri(SH::SELECT),
Term::literal("SELECT $this ?value WHERE { $this ?p ?value }"),
));
let executor = MockExecutor;
let report = validate(&data, &shapes, Some(&executor)).unwrap();
assert!(!report.conforms);
assert_eq!(report.results.len(), 1);
assert_eq!(report.results[0].message.as_deref(), Some("Mock violation"));
assert_eq!(report.results[0].value, Some(Term::literal("bad")));
}
#[test]
fn validate_sparql_error_propagates() {
use std::collections::HashMap;
struct FailingExecutor;
impl SparqlExecutor for FailingExecutor {
fn execute(
&self,
_query: &str,
_this_binding: &Term,
) -> Result<Vec<HashMap<String, Term>>, ShaclError> {
Err(ShaclError::SparqlError("query failed".to_string()))
}
}
let data = RdfStore::new();
data.insert(Triple::new(
Term::iri("http://ex.org/alix"),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri("http://ex.org/Person"),
));
let shapes = RdfStore::new();
let sid = Term::iri("http://ex.org/S");
shapes.insert(Triple::new(
sid.clone(),
Term::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
Term::iri(SH::NODE_SHAPE),
));
shapes.insert(Triple::new(
sid.clone(),
Term::iri(SH::TARGET_CLASS),
Term::iri("http://ex.org/Person"),
));
let sq = Term::blank("sq");
shapes.insert(Triple::new(sid, Term::iri(SH::SPARQL), sq.clone()));
shapes.insert(Triple::new(
sq,
Term::iri(SH::SELECT),
Term::literal("SELECT $this WHERE { $this ?p ?o }"),
));
let executor = FailingExecutor;
let result = validate(&data, &shapes, Some(&executor));
assert!(
result.is_err(),
"SPARQL errors should propagate as ShaclError"
);
}
}