use oxrdf::{NamedNode, Triple};
use shifty_engine::{infer, validate, validate_report};
use std::path::{Path, PathBuf};
fn fixture(path: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../testdata")
.join(path)
}
fn load(path: &str) -> (shifty_parse::Loaded, shifty_parse::ParseOutput) {
let path = fixture(path).canonicalize().expect("fixture");
let bytes = std::fs::read(&path).expect("fixture");
let base = format!("file://{}", path.display());
let loaded = shifty_parse::load_turtle(&bytes, Some(&base)).expect("valid Turtle");
let parsed = shifty_parse::parse_turtle(&bytes, Some(&base)).expect("valid shapes");
assert!(
parsed.diagnostics.is_empty(),
"diags: {:?}",
parsed.diagnostics
);
(loaded, parsed)
}
#[test]
fn w3c_node_sparql_constraint() {
let (loaded, parsed) =
load("data-shapes/data-shapes-test-suite/tests/sparql/node/sparql-001.ttl");
let outcome = validate(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(!outcome.conforms);
assert_eq!(outcome.violations.len(), 2);
assert_eq!(
outcome
.violations
.iter()
.map(|violation| violation.reasons.len())
.sum::<usize>(),
3
);
}
#[test]
fn w3c_property_sparql_constraint_prebinds_path() {
let (loaded, parsed) =
load("data-shapes/data-shapes-test-suite/tests/sparql/property/sparql-001.ttl");
let outcome = validate(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(!outcome.conforms);
assert_eq!(outcome.violations.len(), 1);
assert_eq!(outcome.violations[0].reasons.len(), 1);
assert_eq!(
outcome.violations[0].reasons[0].value.to_string(),
"\"Spain\"@en"
);
}
#[test]
fn w3c_sparql_target() {
let (loaded, parsed) = load("test-suite/advanced/target/sparqlTarget-001.test.ttl");
let outcome = validate(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(!outcome.conforms);
assert_eq!(outcome.violations.len(), 1);
assert_eq!(
outcome.violations[0].focus.to_string(),
"<http://datashapes.org/sh/tests/sparql/target/sparqlTarget-001.test#InvalidInstance1>"
);
}
#[test]
fn w3c_sparql_construct_rule() {
let (loaded, parsed) = load("test-suite/advanced/rules/sparql/classify-square.test.ttl");
let outcome = infer(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(
outcome.diagnostics.is_empty(),
"diags: {:?}",
outcome.diagnostics
);
assert!(outcome.graph.contains(&Triple::new(
NamedNode::new(
"http://datashapes.org/shasf/tests/rules/sparql/classify-square.test#SquareRectangle",
)
.unwrap(),
NamedNode::new("http://www.w3.org/1999/02/22-rdf-syntax-ns#type").unwrap(),
NamedNode::new(
"http://datashapes.org/shasf/tests/rules/sparql/classify-square.test#Square",
)
.unwrap(),
)));
}
mod complex_path_prebinding {
use super::*;
fn shapes_data(shapes_ttl: &str) -> shifty_parse::Loaded {
shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).expect("valid Turtle")
}
fn prefix() -> &'static str {
r#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
"#
}
#[test]
fn alternative_path() {
let ttl = format!(
r#"{prefix}
ex:S a sh:PropertyShape ;
sh:path [ sh:alternativePath ( ex:p ex:q ) ] ;
sh:targetClass ex:C ;
sh:sparql [
sh:select """
SELECT $this ?value
WHERE {{ $this $PATH ?value .
FILTER (isLiteral(?value) && str(?value) = "bad") }}
""" ] .
ex:Good a ex:C ; ex:p "ok" .
ex:Bad1 a ex:C ; ex:p "bad" .
ex:Bad2 a ex:C ; ex:q "bad" .
"#,
prefix = prefix()
);
let loaded = shapes_data(&ttl);
let report = validate_report(&loaded, &loaded.graph);
assert!(!report.conforms);
let focuses: Vec<_> = report.results.iter().map(|r| r.focus.to_string()).collect();
assert!(
focuses.contains(&"<http://example.org/Bad1>".to_string()),
"{focuses:?}"
);
assert!(
focuses.contains(&"<http://example.org/Bad2>".to_string()),
"{focuses:?}"
);
assert!(
!focuses.contains(&"<http://example.org/Good>".to_string()),
"{focuses:?}"
);
}
#[test]
fn inverse_path() {
let ttl = format!(
r#"{prefix}
ex:S a sh:PropertyShape ;
sh:path [ sh:inversePath ex:child ] ;
sh:targetClass ex:Person ;
sh:sparql [
sh:select """
SELECT $this ?value
WHERE {{ $this $PATH ?value .
FILTER (!isIRI(?value)) }}
""" ] .
ex:Alice a ex:Person .
ex:Bob a ex:Person ; ex:child ex:Alice .
"#,
prefix = prefix()
);
let loaded = shapes_data(&ttl);
let report = validate_report(&loaded, &loaded.graph);
assert!(report.conforms, "results: {:?}", report.results);
}
#[test]
fn sequence_path() {
let ttl = format!(
r#"{prefix}
ex:S a sh:PropertyShape ;
sh:path ( ex:p ex:q ) ;
sh:targetClass ex:C ;
sh:sparql [
sh:select """
SELECT $this ?value
WHERE {{ $this $PATH ?value .
FILTER (str(?value) = "bad") }}
""" ] .
ex:Good a ex:C ; ex:p [ ex:q "ok" ] .
ex:Bad a ex:C ; ex:p [ ex:q "bad" ] .
"#,
prefix = prefix()
);
let loaded = shapes_data(&ttl);
let report = validate_report(&loaded, &loaded.graph);
assert!(!report.conforms);
let focuses: Vec<_> = report.results.iter().map(|r| r.focus.to_string()).collect();
assert!(
focuses.contains(&"<http://example.org/Bad>".to_string()),
"{focuses:?}"
);
assert!(
!focuses.contains(&"<http://example.org/Good>".to_string()),
"{focuses:?}"
);
}
#[test]
fn zero_or_more_path() {
let ttl = format!(
r#"{prefix}
ex:S a sh:PropertyShape ;
sh:path [ sh:zeroOrMorePath ex:next ] ;
sh:targetClass ex:Node ;
sh:sparql [
sh:select """
SELECT $this ?value
WHERE {{ $this $PATH ?value .
FILTER (?value = ex:Sink) }}
""" ] .
ex:A a ex:Node ; ex:next ex:B .
ex:B a ex:Node ; ex:next ex:Sink .
ex:Sink a ex:Node .
"#,
prefix = prefix()
);
let loaded = shapes_data(&ttl);
let report = validate_report(&loaded, &loaded.graph);
assert!(!report.conforms);
let focuses: Vec<_> = report.results.iter().map(|r| r.focus.to_string()).collect();
assert!(
focuses.contains(&"<http://example.org/A>".to_string()),
"{focuses:?}"
);
assert!(
focuses.contains(&"<http://example.org/B>".to_string()),
"{focuses:?}"
);
}
}
#[test]
fn construct_blank_nodes_are_rejected_to_preserve_termination() {
let ttl = br#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://ex/> .
ex:S a sh:NodeShape ;
sh:targetNode ex:x ;
sh:rule [
a sh:SPARQLRule ;
sh:construct "CONSTRUCT { $this ex:p [] } WHERE {}"
] .
"#;
let loaded = shifty_parse::load_turtle(ttl, None).expect("valid Turtle");
let parsed = shifty_parse::parse_turtle(ttl, None).expect("valid shapes");
let outcome = infer(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(outcome.inferred.is_empty());
assert!(
outcome
.diagnostics
.iter()
.any(|message| message.contains("blank nodes"))
);
}
#[test]
fn aggregate_construct_rule_infers_only_eligible_focus() {
let ttl = br#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://ex/> .
ex:MeterShape a sh:NodeShape ;
sh:targetClass ex:Meter ;
sh:rule [
a sh:SPARQLRule ;
sh:construct """
CONSTRUCT { $this ex:uniqueKind ?k }
WHERE {
{ SELECT $this (COUNT(DISTINCT ?k0) AS ?c)
WHERE { $this ex:reads/ex:kind ?k0 }
GROUP BY $this }
FILTER(?c = 1)
$this ex:reads/ex:kind ?k .
}
"""
] .
# M1 reads two properties of the same kind -> distinct count 1 -> inferred.
ex:M1 a ex:Meter ; ex:reads ex:p1, ex:p2 .
ex:p1 ex:kind ex:Temp .
ex:p2 ex:kind ex:Temp .
# M2 reads two properties of different kinds -> count 2 -> not inferred.
ex:M2 a ex:Meter ; ex:reads ex:q1, ex:q2 .
ex:q1 ex:kind ex:Temp .
ex:q2 ex:kind ex:Flow .
"#;
let loaded = shifty_parse::load_turtle(ttl, None).expect("valid Turtle");
let parsed = shifty_parse::parse_turtle(ttl, None).expect("valid shapes");
let outcome = infer(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(
outcome.diagnostics.is_empty(),
"diags: {:?}",
outcome.diagnostics
);
let unique_kind = NamedNode::new("http://ex/uniqueKind").unwrap();
let m1 = NamedNode::new("http://ex/M1").unwrap();
let m2 = NamedNode::new("http://ex/M2").unwrap();
let temp = NamedNode::new("http://ex/Temp").unwrap();
assert!(
outcome
.graph
.contains(&Triple::new(m1, unique_kind.clone(), temp))
);
assert!(
!outcome
.inferred
.iter()
.any(|t| t.subject == m2.clone().into() && t.predicate == unique_kind)
);
assert_eq!(
outcome.inferred.len(),
1,
"inferred: {:?}",
outcome.inferred
);
}
fn reason_messages(ttl: &[u8]) -> Vec<String> {
let loaded = shifty_parse::load_turtle(ttl, None).expect("valid Turtle");
let parsed = shifty_parse::parse_turtle(ttl, None).expect("valid shapes");
assert!(
parsed.diagnostics.is_empty(),
"diags: {:?}",
parsed.diagnostics
);
let outcome = validate(&loaded.graph, &parsed.schema).expect("stratifiable");
assert!(!outcome.conforms);
outcome
.violations
.iter()
.flat_map(|v| v.reasons.iter().map(|r| r.message.clone()))
.collect()
}
#[test]
fn sparql_violation_uses_message_binding() {
let ttl = br#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://ex/> .
ex:AgeShape a sh:NodeShape ;
sh:targetNode ex:bob ;
sh:sparql [
a sh:SPARQLConstraint ;
sh:select "SELECT $this ?value ?message WHERE { $this <http://ex/age> ?value . FILTER(?value < 0) BIND(CONCAT('age is negative: ', STR(?value)) AS ?message) }"
] .
ex:bob <http://ex/age> -5 .
"#;
assert_eq!(reason_messages(ttl), vec!["age is negative: -5"]);
}
#[test]
fn sparql_violation_falls_back_to_sh_message() {
let ttl = br#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://ex/> .
ex:AgeShape a sh:NodeShape ;
sh:targetNode ex:bob ;
sh:sparql [
a sh:SPARQLConstraint ;
sh:message "Age must not be negative" ;
sh:select "SELECT $this ?value WHERE { $this <http://ex/age> ?value . FILTER(?value < 0) }"
] .
ex:bob <http://ex/age> -5 .
"#;
assert_eq!(reason_messages(ttl), vec!["Age must not be negative"]);
}
#[test]
fn sparql_violation_default_names_shape() {
let ttl = br#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://ex/> .
ex:AgeShape a sh:NodeShape ;
sh:targetNode ex:bob ;
sh:sparql [
a sh:SPARQLConstraint ;
sh:select "SELECT $this ?value WHERE { $this <http://ex/age> ?value . FILTER(?value < 0) }"
] .
ex:bob <http://ex/age> -5 .
"#;
let messages = reason_messages(ttl);
assert_eq!(messages.len(), 1);
assert!(
messages[0].contains("SPARQL constraint at") && messages[0].contains("http://ex/AgeShape"),
"unexpected default message: {}",
messages[0]
);
}