Skip to content

Commit 24bbf06

Browse files
committed
Merge branch 'fix/333-335-shacl-terms' into land/oo-2026-10-28
# Conflicts: # CHANGELOG.md
2 parents a3a9dd8 + a818fde commit 24bbf06

4 files changed

Lines changed: 250 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ All notable changes to Open Ontologies are documented here.
150150
and it no longer claims that the ceiling "cannot" fire, since that was
151151
arithmetic about the tableaux and not about the run.
152152

153+
- **A datatype the store cannot tell apart is refused inside `sh:or`,
154+
`sh:not` and `sh:qualifiedValueShape`, as it already was on its own.** The
155+
store widens xsd:int, xsd:nonNegativeInteger and the other integer-derived
156+
types (and xsd:dateTimeStamp) to the type they derive from, and the direct
157+
`sh:datatype` constraint refuses to decide them. A member naming one did
158+
not: `sh:or` and a qualified minimum reported every conforming value, and
159+
`sh:not [ sh:datatype xsd:nonNegativeInteger ]` returned `conforms: true`
160+
over values of exactly that type. Each now records the constraint in
161+
`skipped_constraints` with the direct path's reason and suppresses the
162+
verdict. W3C columns unchanged (61, 53).
163+
- **`sh:node` to a shape whose own `sh:datatype` the store cannot tell apart
164+
is refused rather than answered.** The shape compiler refused such a
165+
datatype on a property inside the referenced shape and not on the shape
166+
itself, so every value the store had widened was reported. The `sh:node` is
167+
now recorded as not evaluated. W3C columns unchanged.
168+
- **A node-level `sh:or` result carries the node shape's `sh:severity`.** It
169+
was written `"Violation"` whatever the shape declared, and the RDF report
170+
followed it with `sh:resultSeverity sh:Violation`; it now uses the declared
171+
term, through the same `severity_name` as every other path (#334). W3C
172+
columns unchanged.
153173
- **A custom `sh:severity` keeps its namespace in the JSON report** (#334).
154174
Every path set `severity` to the text after the last `#` of the declared
155175
IRI, and every reader takes a bare name as `sh:`, so W3C

‎src/shacl.rs‎

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,10 @@ impl ShaclValidator {
796796
// and the run came back CLEAN. Same defect in sh:in and sh:not.
797797
let mut or_alternatives: HashMap<String, Vec<String>> = HashMap::new();
798798
let mut or_unsupported: HashSet<String> = HashSet::new();
799+
// Property shape -> a member datatype the store does not preserve.
800+
// The direct sh:datatype path refuses that question; a member
801+
// answering it reported every widened value as matching nothing.
802+
let mut or_unpreserved: HashMap<String, String> = HashMap::new();
799803
let or_rows = query_solutions_bound(
800804
&shapes_store,
801805
&format!(
@@ -835,7 +839,12 @@ impl ShaclValidator {
835839
continue;
836840
}
837841
let clause = if let Some(dt) = row.get("datatype") {
838-
datatype_conforms(&strip_angle_brackets(dt), "val")
842+
let dt = strip_angle_brackets(dt);
843+
if datatype_is_indistinguishable_in_store(&dt) {
844+
or_unpreserved.insert(p, dt);
845+
continue;
846+
}
847+
datatype_conforms(&dt, "val")
839848
} else if let Some(c) = row.get("class") {
840849
format!(
841850
"EXISTS {{ ?val <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <{}> }}",
@@ -849,6 +858,13 @@ impl ShaclValidator {
849858
};
850859
or_alternatives.entry(p).or_default().push(clause);
851860
}
861+
for (p, dt) in &or_unpreserved {
862+
or_alternatives.remove(p);
863+
if !or_unsupported.contains(p) {
864+
let path = or_paths.get(p).cloned().unwrap_or_default();
865+
skipped.push(unpreserved_datatype_skip(&shape_iri, "sh:or", &path, dt));
866+
}
867+
}
852868
for p in &or_unsupported {
853869
or_alternatives.remove(p);
854870
skipped.push(serde_json::json!({
@@ -876,6 +892,10 @@ impl ShaclValidator {
876892
// and 198 real violations were reported as a clean run.
877893
let mut not_clauses: HashMap<String, Vec<String>> = HashMap::new();
878894
let mut not_unsupported: HashSet<String> = HashSet::new();
895+
// As `or_unpreserved`. Here answering anyway was a false clean: a
896+
// value of the forbidden datatype read as xsd:integer, did not
897+
// match, and satisfied the negation.
898+
let mut not_unpreserved: HashMap<String, String> = HashMap::new();
879899
let not_rows = query_solutions_bound(
880900
&shapes_store,
881901
&format!(
@@ -918,7 +938,12 @@ impl ShaclValidator {
918938
continue;
919939
}
920940
let clause = if let Some(dt) = row.get("datatype") {
921-
datatype_conforms(&strip_angle_brackets(dt), "val")
941+
let dt = strip_angle_brackets(dt);
942+
if datatype_is_indistinguishable_in_store(&dt) {
943+
not_unpreserved.insert(p, dt);
944+
continue;
945+
}
946+
datatype_conforms(&dt, "val")
922947
} else if let Some(c) = row.get("class") {
923948
format!(
924949
"EXISTS {{ ?val <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <{}> }}",
@@ -939,6 +964,13 @@ impl ShaclValidator {
939964
};
940965
not_clauses.entry(p).or_default().push(clause);
941966
}
967+
for (p, dt) in &not_unpreserved {
968+
not_clauses.remove(p);
969+
if !not_unsupported.contains(p) {
970+
let path = not_paths.get(p).cloned().unwrap_or_default();
971+
skipped.push(unpreserved_datatype_skip(&shape_iri, "sh:not", &path, dt));
972+
}
973+
}
942974
for p in &not_unsupported {
943975
not_clauses.remove(p);
944976
skipped.push(serde_json::json!({
@@ -1087,20 +1119,30 @@ impl ShaclValidator {
10871119
.collect();
10881120
members.sort();
10891121
let disjunction = members.join(" || ");
1090-
let node_message_term = query_solutions(
1122+
let node_terms = query_solutions(
10911123
&shapes_store,
10921124
&format!(
10931125
r#"PREFIX sh: <http://www.w3.org/ns/shacl#>
1094-
SELECT ?m WHERE {{ {} sh:message ?m }}"#,
1095-
shape_term
1126+
SELECT ?m ?sev WHERE {{
1127+
OPTIONAL {{ {shape_term} sh:message ?m }}
1128+
OPTIONAL {{ {shape_term} sh:severity ?sev }}
1129+
}}"#
10961130
),
1097-
)?
1098-
.first()
1099-
.and_then(|r| r.get("m").cloned());
1131+
)?;
1132+
let node_term = |k: &str| node_terms.first().and_then(|r| r.get(k).cloned());
1133+
let node_message_term = node_term("m");
11001134
let node_message =
11011135
node_message_term.as_deref().map(strip_quotes).unwrap_or_default();
1136+
// The node shape's sh:severity, as on every other path.
1137+
// This one wrote "Violation" whatever was declared, and
1138+
// the RDF report followed it with sh:Violation.
1139+
let node_severity_term = node_term("sev");
1140+
let node_severity = node_severity_term
1141+
.as_deref()
1142+
.map(severity_name)
1143+
.unwrap_or_else(|| "Violation".to_string());
11021144
let node_or_rdf = RdfTerms::new(
1103-
None,
1145+
node_severity_term,
11041146
node_message_term.filter(|_| !node_message.is_empty()),
11051147
);
11061148
let query = format!(
@@ -1117,7 +1159,7 @@ impl ShaclValidator {
11171159
node_message.clone()
11181160
};
11191161
violations.push(attribute(&shape_iri, &shape_iri, &node_or_rdf, serde_json::json!({
1120-
"severity": "Violation",
1162+
"severity": node_severity,
11211163
"focus_node": strip_angle_brackets(focus),
11221164
"value": strip_angle_brackets(focus),
11231165
"constraint": "or",
@@ -1184,6 +1226,17 @@ impl ShaclValidator {
11841226
row.get("message").cloned().filter(|_| !q_message.is_empty()),
11851227
);
11861228

1229+
if let Some(dt) = row.get("datatype").map(|d| strip_angle_brackets(d))
1230+
&& datatype_is_indistinguishable_in_store(&dt)
1231+
{
1232+
skipped.push(unpreserved_datatype_skip(
1233+
&shape_iri,
1234+
"sh:qualifiedValueShape",
1235+
&q_path,
1236+
&dt,
1237+
));
1238+
continue;
1239+
}
11871240
let clause = if row.get("other").is_some() {
11881241
None
11891242
} else if let Some(c) = row.get("class") {
@@ -1460,15 +1513,7 @@ impl ShaclValidator {
14601513
// for every value that satisfied the shape: nine of them in
14611514
// jsonld-escaping-conformance, which is how this was found.
14621515
if datatype_is_indistinguishable_in_store(&dt) {
1463-
skipped.push(serde_json::json!({
1464-
"shape": strip_angle_brackets(&shape_iri),
1465-
"constraint": "sh:datatype",
1466-
"path": path,
1467-
"datatype": dt,
1468-
"reason": "the store does not preserve this datatype IRI, so a value \
1469-
carrying it is indistinguishable from one carrying the type \
1470-
it derives from; the constraint was not evaluated",
1471-
}));
1516+
skipped.push(unpreserved_datatype_skip(&shape_iri, "sh:datatype", &path, &dt));
14721517
continue;
14731518
}
14741519
// One evaluator gave two answers to one constraint. SHACL
@@ -2796,7 +2841,18 @@ fn compile_node_shape(
27962841
"EXISTS {{ ?{var} <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <{}> }}",
27972842
strip_angle_brackets(obj)
27982843
)),
2799-
"datatype" => clauses.push(datatype_conforms(&strip_angle_brackets(obj), var)),
2844+
"datatype" => {
2845+
// Refused for the same reason as on a property inside the
2846+
// shape, below: the store cannot tell the datatype from the
2847+
// one it derives from. Only the property form was guarded, so
2848+
// `sh:node` to a shape declaring `sh:datatype xsd:int` itself
2849+
// reported every value the store had widened to xsd:integer.
2850+
let dt = strip_angle_brackets(obj);
2851+
if datatype_is_indistinguishable_in_store(&dt) {
2852+
return None;
2853+
}
2854+
clauses.push(datatype_conforms(&dt, var))
2855+
}
28002856
"nodeKind" => clauses.push(node_kind_test(&strip_angle_brackets(obj), var)?),
28012857
_ => return None,
28022858
}
@@ -2978,6 +3034,24 @@ fn datatype_is_indistinguishable_in_store(datatype: &str) -> bool {
29783034
}
29793035

29803036

3037+
/// The `skipped_constraints` entry for a constraint that names a datatype
3038+
/// [`datatype_is_indistinguishable_in_store`] says cannot be decided. One
3039+
/// record, so the direct `sh:datatype` constraint and the `sh:or`, `sh:not`
3040+
/// and `sh:qualifiedValueShape` members that name such a datatype refuse in
3041+
/// the same words: the member paths used to answer the question the direct
3042+
/// path refuses, and answered it wrongly.
3043+
fn unpreserved_datatype_skip(shape: &str, constraint: &str, path: &str, datatype: &str) -> serde_json::Value {
3044+
serde_json::json!({
3045+
"shape": strip_angle_brackets(shape),
3046+
"constraint": constraint,
3047+
"path": path,
3048+
"datatype": datatype,
3049+
"reason": "the store does not preserve this datatype IRI, so a value \
3050+
carrying it is indistinguishable from one carrying the type \
3051+
it derives from; the constraint was not evaluated",
3052+
})
3053+
}
3054+
29813055
/// The SPARQL pattern selecting the focus nodes of one target, bound to `var`.
29823056
///
29833057
/// Note for anyone counting over the result: the class selector binds a focus

‎tests/shacl_datatype_indistinguishable_test.rs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,105 @@ fn plain_integer_is_still_evaluated() {
109109
let r = report(&store(), shapes);
110110
assert!(!r["conforms"].is_null(), "must give a verdict: {r}");
111111
}
112+
113+
// ── The same guard on every path that tests a datatype ───────────────────────
114+
//
115+
// The direct `sh:datatype` constraint (on a property shape and on a node shape)
116+
// consults `datatype_is_indistinguishable_in_store` and refuses to answer. The
117+
// paths that test a datatype as a MEMBER of something else did not, so the
118+
// question the direct path refuses was answered anyway, wrongly, by them: every
119+
// value the store had widened to `xsd:integer` failed a member of
120+
// `xsd:nonNegativeInteger`. Each test below names its path, asserts the refusal
121+
// the direct path gives, and asserts that no violation is reported.
122+
123+
const PREFIXES: &str = r#"
124+
@prefix sh: <http://www.w3.org/ns/shacl#> .
125+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
126+
@prefix ex: <http://example.org/> .
127+
"#;
128+
129+
fn refused(shapes_body: &str, constraint: &str) {
130+
let r = report(&store(), &format!("{PREFIXES}{shapes_body}"));
131+
assert_eq!(
132+
r["violation_count"],
133+
serde_json::json!(0),
134+
"the data satisfies this shape; reporting a violation is the bug: {r}"
135+
);
136+
assert!(r["conforms"].is_null(), "the datatype cannot be decided, so there is no verdict: {r}");
137+
let skipped = r["skipped_constraints"].as_array().cloned().unwrap_or_default();
138+
assert!(
139+
skipped.iter().any(|s| s["constraint"] == constraint),
140+
"{constraint} must be recorded as not evaluated: {r}"
141+
);
142+
}
143+
144+
/// `sh:or` over a property shape's values, one member naming the datatype.
145+
#[test]
146+
fn an_or_member_naming_an_unpreserved_datatype_is_not_answered() {
147+
refused(
148+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
149+
sh:property [ sh:path ex:passes ;
150+
sh:or ( [ sh:datatype xsd:nonNegativeInteger ] [ sh:datatype xsd:string ] ) ] .",
151+
"sh:or",
152+
);
153+
}
154+
155+
/// `sh:not [ sh:datatype D ]`. Here the wrong answer was a FALSE CLEAN: every
156+
/// value is an xsd:nonNegativeInteger and so violates the negation, but the
157+
/// store said xsd:integer and the run came back `conforms: true`.
158+
#[test]
159+
fn a_not_member_naming_an_unpreserved_datatype_is_not_answered() {
160+
refused(
161+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
162+
sh:property [ sh:path ex:passes ; sh:not [ sh:datatype xsd:nonNegativeInteger ] ] .",
163+
"sh:not",
164+
);
165+
}
166+
167+
/// `sh:qualifiedValueShape [ sh:datatype D ]`: no value counted as conforming.
168+
#[test]
169+
fn a_qualified_shape_naming_an_unpreserved_datatype_is_not_answered() {
170+
refused(
171+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
172+
sh:property [ sh:path ex:passes ;
173+
sh:qualifiedValueShape [ sh:datatype xsd:nonNegativeInteger ] ; sh:qualifiedMinCount 1 ] .",
174+
"sh:qualifiedValueShape",
175+
);
176+
}
177+
178+
/// `sh:node` to a shape whose OWN constraint is the datatype. The compiler
179+
/// already refused the same datatype on a property inside the referenced shape
180+
/// and not on the shape itself.
181+
#[test]
182+
fn a_node_shape_reference_naming_an_unpreserved_datatype_is_not_answered() {
183+
refused(
184+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
185+
sh:property [ sh:path ex:passes ; sh:node ex:Count ] .
186+
ex:Count a sh:NodeShape ; sh:datatype xsd:nonNegativeInteger .",
187+
"sh:node",
188+
);
189+
}
190+
191+
/// The guard must not swallow the decidable members: the same four paths over
192+
/// `xsd:integer` and `xsd:string` still give a verdict and still catch a value
193+
/// that matches none of them.
194+
#[test]
195+
fn members_naming_a_preserved_datatype_are_still_evaluated() {
196+
let cases = [
197+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
198+
sh:property [ sh:path ex:name ; sh:or ( [ sh:datatype xsd:integer ] [ sh:datatype xsd:date ] ) ] .",
199+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
200+
sh:property [ sh:path ex:name ; sh:not [ sh:datatype xsd:string ] ] .",
201+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
202+
sh:property [ sh:path ex:name ;
203+
sh:qualifiedValueShape [ sh:datatype xsd:integer ] ; sh:qualifiedMinCount 1 ] .",
204+
"ex:S a sh:NodeShape ; sh:targetClass ex:Profile ;
205+
sh:property [ sh:path ex:name ; sh:node ex:Int ] .
206+
ex:Int a sh:NodeShape ; sh:datatype xsd:integer .",
207+
];
208+
for body in cases {
209+
let r = report(&store(), &format!("{PREFIXES}{body}"));
210+
assert_eq!(r["conforms"], serde_json::json!(false), "{body}\n{r}");
211+
assert_eq!(r["violation_count"], serde_json::json!(2), "{body}\n{r}");
212+
}
213+
}

‎tests/shacl_validation_report_test.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,40 @@ ex:SparqlLevel a sh:NodeShape ; sh:targetClass ex:Person ;
285285
assert_eq!(got, want, "{j}");
286286
}
287287

288+
/// `sh:or` asserted on a node shape over member shapes wrote every result as
289+
/// `"Violation"` whatever the shape declared, and the RDF report followed it
290+
/// with `sh:resultSeverity sh:Violation`. The node shape's `sh:severity` is
291+
/// the result's severity (SHACL 3.6.2) as on every other path.
292+
#[test]
293+
fn a_node_level_or_result_carries_the_shapes_declared_severity() {
294+
for (declared, json, rdf) in [
295+
("sh:Warning", "Warning", "sh:Warning"),
296+
("sev:MySeverity", "http://ex.org/sev#MySeverity", "<http://ex.org/sev#MySeverity>"),
297+
("", "Violation", "sh:Violation"),
298+
] {
299+
let severity = if declared.is_empty() { String::new() } else { format!("sh:severity {declared} ;") };
300+
let shapes = format!(
301+
r#"
302+
@prefix sh: <http://www.w3.org/ns/shacl#> .
303+
@prefix ex: <http://ex.org/> .
304+
@prefix sev: <http://ex.org/sev#> .
305+
ex:S a sh:NodeShape ; sh:targetClass ex:Person ; {severity}
306+
sh:or ( [ sh:property [ sh:path ex:email ; sh:minCount 1 ] ]
307+
[ sh:property [ sh:path ex:phone ; sh:minCount 1 ] ] ) .
308+
"#
309+
);
310+
let j = json_report(DATA, &shapes);
311+
assert_eq!(j["violation_count"], 1, "{declared}: {j}");
312+
assert_eq!(j["violations"][0]["constraint"], "or", "{declared}: {j}");
313+
assert_eq!(j["violations"][0]["severity"], json, "{declared}: {j}");
314+
let s = parsed(&rdf_report(DATA, &shapes, ReportFormat::Turtle), RdfFormat::Turtle);
315+
assert!(
316+
ask(&s, &format!("?x sh:sourceConstraintComponent sh:OrConstraintComponent ; sh:resultSeverity {rdf}")),
317+
"{declared}: the RDF report must carry {rdf}"
318+
);
319+
}
320+
}
321+
288322
/// #335, MEASURED AND NOT FIXED IN THIS REPOSITORY. This test pins the loss
289323
/// so that it cannot be mistaken for a report-writer bug, and so that it fails,
290324
/// by name, on the day the store starts keeping lexical forms.

0 commit comments

Comments
 (0)