Skip to content

Commit c149095

Browse files
authored
GH-4689 SHACL SPARQLConstraint message fix (#5613)
1 parent df377a4 commit c149095

8 files changed

Lines changed: 1240 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,7 @@ rdf4j: root project
862862
863863
* Don’t commit or push unless explicitly asked.
864864
* Don’t add new dependencies without explicit approval.
865+
* Never revert unrelated working tree changes
865866
866867
### Version Control Conventions
867868

core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/ShaclSailValidationReportHelper.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.eclipse.rdf4j.rio.Rio;
2323
import org.eclipse.rdf4j.rio.WriterConfig;
2424
import org.eclipse.rdf4j.rio.helpers.BasicWriterSettings;
25+
import org.eclipse.rdf4j.sail.shacl.results.ValidationReport;
2526

2627
/**
2728
* @author Florian Kleedorfer
@@ -54,6 +55,16 @@ public static Optional<String> getValidationReportAsString(Throwable t) {
5455
return Optional.of(reportAsString);
5556
}
5657

58+
public static Optional<String> getValidationReportAsString(ValidationReport t) {
59+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
60+
printValidationReport(t, baos);
61+
String reportAsString = baos.toString();
62+
if (reportAsString == null || reportAsString.isBlank()) {
63+
return Optional.empty();
64+
}
65+
return Optional.of(reportAsString);
66+
}
67+
5768
/**
5869
* Finds a validation report using {@link #getValidationReport(Throwable)} and pretty-prints it to the specified
5970
* output stream.
@@ -68,6 +79,12 @@ public static void printValidationReport(Throwable t, OutputStream out) {
6879
}
6980
}
7081

82+
public static void printValidationReport(ValidationReport t, OutputStream out) {
83+
Model model = t.asModel();
84+
Rio.write(model, out, RDFFormat.TURTLE, WRITER_CONFIG);
85+
86+
}
87+
7188
/**
7289
* Looks for a {@link ValidationException} starting with the specified throwable and working back through the cause
7390
* references, and returns the validation report as a {@link Model} if one is found.

core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/ast/Shape.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,13 @@ public final List<Literal> getMessage() {
513513
return message;
514514
}
515515

516+
/**
517+
* @return any explicit sh:message values defined on this shape, without falling back to constraint defaults.
518+
*/
519+
public final List<Literal> getExplicitMessages() {
520+
return message;
521+
}
522+
516523
@Override
517524
public List<Literal> getDefaultMessage() {
518525
return constraintComponents

core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/ast/constraintcomponents/SparqlConstraintComponent.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public class SparqlConstraintComponent extends AbstractConstraintComponent imple
5454
private Boolean deactivated;
5555
private final Set<Namespace> namespaces;
5656
private final Model prefixes;
57+
private final boolean hasMessageTemplateVariables;
5758

5859
public SparqlConstraintComponent(Resource id, ShapeSource shapeSource, Shape shape) {
5960
super(id);
@@ -96,6 +97,8 @@ public SparqlConstraintComponent(Resource id, ShapeSource shapeSource, Shape sha
9697
});
9798
}
9899

100+
hasMessageTemplateVariables = containsMessageTemplateVariables(message);
101+
99102
var shaclNamespaces = ShaclPrefixParser.extractNamespaces(id, shapeSource);
100103
prefixes = shaclNamespaces.getModel();
101104
namespaces = shaclNamespaces.getNamespaces();
@@ -115,6 +118,21 @@ public SparqlConstraintComponent(Resource id, Shape shape, boolean produceValida
115118
this.deactivated = deactivated;
116119
this.prefixes = prefixes;
117120
this.namespaces = namespaces;
121+
this.hasMessageTemplateVariables = containsMessageTemplateVariables(message);
122+
}
123+
124+
public boolean hasMessageTemplateVariables() {
125+
return hasMessageTemplateVariables;
126+
}
127+
128+
private static boolean containsMessageTemplateVariables(List<Literal> messages) {
129+
for (Literal literal : messages) {
130+
String label = literal.getLabel();
131+
if (label.contains("{?") || label.contains("{$")) {
132+
return true;
133+
}
134+
}
135+
return false;
118136
}
119137

120138
@Override
@@ -155,6 +173,10 @@ public PlanNode generateTransactionalValidationPlan(ConnectionsGroup connections
155173
Path path = getTargetChain().getPath().get();
156174
String s = path.toSparqlPathString();
157175
select = select.replace(" $PATH ", " " + s + " ");
176+
if (select.contains("$PATH")) {
177+
throw new IllegalStateException(
178+
"Illegal use of $PATH in SPARQL constraint for property shape " + getId());
179+
}
158180
}
159181

160182
PlanNode allTargets;

core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/ast/planNodes/SparqlConstraintSelect.java

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,23 @@
1212
package org.eclipse.rdf4j.sail.shacl.ast.planNodes;
1313

1414
import java.util.Arrays;
15+
import java.util.List;
1516
import java.util.Objects;
17+
import java.util.regex.Matcher;
18+
import java.util.regex.Pattern;
19+
import java.util.stream.Collectors;
1620

1721
import org.eclipse.rdf4j.common.iteration.CloseableIteration;
22+
import org.eclipse.rdf4j.model.Literal;
1823
import org.eclipse.rdf4j.model.Resource;
1924
import org.eclipse.rdf4j.model.Value;
2025
import org.eclipse.rdf4j.model.impl.BooleanLiteral;
26+
import org.eclipse.rdf4j.model.util.Values;
2127
import org.eclipse.rdf4j.query.BindingSet;
2228
import org.eclipse.rdf4j.query.Dataset;
2329
import org.eclipse.rdf4j.query.MalformedQueryException;
2430
import org.eclipse.rdf4j.query.QueryLanguage;
31+
import org.eclipse.rdf4j.query.impl.MapBindingSet;
2532
import org.eclipse.rdf4j.query.parser.ParsedQuery;
2633
import org.eclipse.rdf4j.query.parser.QueryParserFactory;
2734
import org.eclipse.rdf4j.query.parser.QueryParserRegistry;
@@ -41,6 +48,7 @@
4148
public class SparqlConstraintSelect implements PlanNode {
4249

4350
private static final Logger logger = LoggerFactory.getLogger(SparqlConstraintSelect.class);
51+
private static final Pattern MESSAGE_TEMPLATE_PATTERN = Pattern.compile("\\{[?$]([A-Za-z_][A-Za-z0-9_]*)\\}");
4452

4553
private final SailConnection connection;
4654

@@ -55,6 +63,9 @@ public class SparqlConstraintSelect implements PlanNode {
5563
private final Dataset dataset;
5664
private final ParsedQuery parsedQuery;
5765
private final boolean printed = false;
66+
private final Value shapesGraphBinding;
67+
private final Value currentShapeBinding;
68+
private final Value pathForMessageBinding;
5869
private ValidationExecutionLogger validationExecutionLogger;
5970

6071
public SparqlConstraintSelect(SailConnection connection, PlanNode targets, String query,
@@ -72,6 +83,9 @@ public SparqlConstraintSelect(SailConnection connection, PlanNode targets, Strin
7283
this.variables = new String[] { "$this" };
7384
this.scope = scope;
7485
this.dataset = PlanNodeHelper.asDefaultGraphDataset(dataGraph);
86+
this.currentShapeBinding = shape != null ? shape.getId() : null;
87+
this.shapesGraphBinding = determineShapesGraphBinding(shape);
88+
this.pathForMessageBinding = determinePathForMessageBinding(constraintComponent, scope);
7589

7690
QueryParserFactory queryParserFactory = QueryParserRegistry.getInstance()
7791
.get(QueryLanguage.SPARQL)
@@ -86,6 +100,33 @@ public SparqlConstraintSelect(SailConnection connection, PlanNode targets, Strin
86100

87101
}
88102

103+
private static Value determineShapesGraphBinding(Shape shape) {
104+
if (shape == null) {
105+
return null;
106+
}
107+
Resource[] contexts = shape.getContexts();
108+
if (contexts == null) {
109+
return null;
110+
}
111+
for (Resource context : contexts) {
112+
if (context != null && context.isIRI()) {
113+
return context;
114+
}
115+
}
116+
return null;
117+
}
118+
119+
private static Value determinePathForMessageBinding(SparqlConstraintComponent constraintComponent,
120+
ConstraintComponent.Scope scope) {
121+
if (constraintComponent == null || scope != ConstraintComponent.Scope.propertyShape) {
122+
return null;
123+
}
124+
return constraintComponent.getTargetChain()
125+
.getPath()
126+
.map(p -> Values.literal(p.toSparqlPathString()))
127+
.orElse(null);
128+
}
129+
89130
@Override
90131
public CloseableIteration<? extends ValidationTuple> iterator() {
91132
return new LoggingCloseableIteration(this, validationExecutionLogger) {
@@ -108,7 +149,14 @@ private void calculateNext() {
108149

109150
if (results == null && targetIterator.hasNext()) {
110151
nextTarget = targetIterator.next();
111-
SingletonBindingSet bindings = new SingletonBindingSet("this", nextTarget.getActiveTarget());
152+
MapBindingSet bindings = new MapBindingSet(3);
153+
bindings.setBinding("this", nextTarget.getActiveTarget());
154+
if (currentShapeBinding != null) {
155+
bindings.setBinding("currentShape", currentShapeBinding);
156+
}
157+
if (shapesGraphBinding != null) {
158+
bindings.setBinding("shapesGraph", shapesGraphBinding);
159+
}
112160
results = connection.evaluate(parsedQuery.getTupleExpr(), dataset, bindings, true);
113161
}
114162

@@ -128,6 +176,32 @@ private void calculateNext() {
128176
Value currentValue = value1;
129177

130178
Value path = bindingSet.getValue("path");
179+
List<Literal> resultMessages = null;
180+
181+
Value messageValue = bindingSet.getValue("message");
182+
if (messageValue != null) {
183+
if (messageValue.isLiteral()) {
184+
resultMessages = List.of((Literal) messageValue);
185+
} else {
186+
resultMessages = List.of(Values.literal(messageValue.stringValue()));
187+
}
188+
} else if (produceValidationReports) {
189+
List<Literal> templates = constraintComponent.getDefaultMessage();
190+
if (!templates.isEmpty()) {
191+
if (constraintComponent.hasMessageTemplateVariables()) {
192+
Value focusNode = nextTarget.getActiveTarget();
193+
resultMessages = templates.stream()
194+
.map(t -> substituteMessageTemplate(t, bindingSet, focusNode,
195+
shapesGraphBinding, currentShapeBinding,
196+
pathForMessageBinding))
197+
.collect(Collectors.toList());
198+
} else {
199+
resultMessages = List.copyOf(templates);
200+
}
201+
}
202+
}
203+
204+
final List<Literal> finalResultMessages = resultMessages;
131205

132206
if (scope == ConstraintComponent.Scope.nodeShape) {
133207
next = nextTarget.addValidationResult(t -> {
@@ -137,7 +211,10 @@ private void calculateNext() {
137211
constraintComponent, shape.getSeverity(),
138212
ConstraintComponent.Scope.nodeShape, t.getContexts(),
139213
shape.getContexts());
140-
if (path != null) {
214+
if (finalResultMessages != null) {
215+
validationResult.setMessagesOverride(finalResultMessages);
216+
}
217+
if (path != null && path.isIRI()) {
141218
validationResult.setPathIri(path);
142219
}
143220
return validationResult;
@@ -154,7 +231,10 @@ private void calculateNext() {
154231
constraintComponent, shape.getSeverity(),
155232
ConstraintComponent.Scope.propertyShape, t.getContexts(),
156233
shape.getContexts());
157-
if (path != null) {
234+
if (finalResultMessages != null) {
235+
validationResult.setMessagesOverride(finalResultMessages);
236+
}
237+
if (path != null && path.isIRI()) {
158238
validationResult.setPathIri(path);
159239
}
160240
return validationResult;
@@ -225,6 +305,41 @@ public void getPlanAsGraphvizDot(StringBuilder stringBuilder) {
225305

226306
}
227307

308+
private static Literal substituteMessageTemplate(Literal template, BindingSet bindingSet, Value focusNode,
309+
Value shapesGraphBinding, Value currentShapeBinding, Value pathForMessageBinding) {
310+
String label = template.getLabel();
311+
Matcher matcher = MESSAGE_TEMPLATE_PATTERN.matcher(label);
312+
if (!matcher.find()) {
313+
return template;
314+
}
315+
316+
matcher.reset();
317+
StringBuffer sb = new StringBuffer();
318+
while (matcher.find()) {
319+
String varName = matcher.group(1);
320+
Value value = bindingSet.getValue(varName);
321+
if (value == null) {
322+
if ("this".equals(varName)) {
323+
value = focusNode;
324+
} else if ("shapesGraph".equals(varName)) {
325+
value = shapesGraphBinding;
326+
} else if ("currentShape".equals(varName)) {
327+
value = currentShapeBinding;
328+
} else if ("PATH".equals(varName)) {
329+
value = pathForMessageBinding;
330+
}
331+
}
332+
String replacement = value != null ? value.stringValue() : matcher.group(0);
333+
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
334+
}
335+
matcher.appendTail(sb);
336+
337+
if (template.getLanguage().isPresent()) {
338+
return Values.literal(sb.toString(), template.getLanguage().get());
339+
}
340+
return Values.literal(sb.toString(), template.getDatatype());
341+
}
342+
228343
@Override
229344
public String getId() {
230345
return System.identityHashCode(this) + "";

core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/results/ValidationReport.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.eclipse.rdf4j.model.impl.DynamicModelFactory;
2626
import org.eclipse.rdf4j.model.vocabulary.RDF;
2727
import org.eclipse.rdf4j.model.vocabulary.RDF4J;
28+
import org.eclipse.rdf4j.model.vocabulary.RDFS;
2829
import org.eclipse.rdf4j.model.vocabulary.SHACL;
2930

3031
/**
@@ -74,7 +75,11 @@ public Model asModel(Model model) {
7475
}
7576

7677
public Model asModel() {
77-
return asModel(DYNAMIC_MODEL_FACTORY.createEmptyModel());
78+
Model model = asModel(DYNAMIC_MODEL_FACTORY.createEmptyModel());
79+
model.setNamespace(SHACL.PREFIX, SHACL.NAMESPACE);
80+
model.setNamespace(RDF.PREFIX, RDF.NAMESPACE);
81+
model.setNamespace(RDFS.PREFIX, RDFS.NAMESPACE);
82+
return model;
7883
}
7984

8085
public final Resource getId() {

core/sail/shacl/src/main/java/org/eclipse/rdf4j/sail/shacl/results/ValidationResult.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public class ValidationResult {
6666
private Path rsxPairwisePath;
6767
private ValidationResult detail;
6868
private Value pathIri;
69+
private List<Literal> messagesOverride;
6970

7071
public ValidationResult(Value focusNode, Value value, Shape shape,
7172
ConstraintComponent sourceConstraint, Severity severity, ConstraintComponent.Scope scope,
@@ -119,6 +120,13 @@ public void setDetail(ValidationResult detail) {
119120
this.detail = detail;
120121
}
121122

123+
/**
124+
* Allows per-result overriding of sh:resultMessage values (used by SPARQL-based constraints).
125+
*/
126+
public void setMessagesOverride(List<Literal> messagesOverride) {
127+
this.messagesOverride = messagesOverride;
128+
}
129+
122130
/**
123131
* @return all ValidationResult(s) with more information as to what failed. Usually for nested Shapes in eg. sh:or.
124132
*/
@@ -175,7 +183,21 @@ public Model asModel(Model model, Set<Resource> rdfListDedupe) {
175183
model.add(getId(), SHACL.SOURCE_CONSTRAINT_COMPONENT, getSourceConstraintComponent().getIri());
176184
model.add(getId(), SHACL.RESULT_SEVERITY, severity.getIri());
177185

178-
for (Literal message : shape.getMessage()) {
186+
List<Literal> messagesToAdd;
187+
if (messagesOverride != null) {
188+
messagesToAdd = messagesOverride;
189+
} else if (sourceConstraint instanceof SparqlConstraintComponent) {
190+
messagesToAdd = sourceConstraint.getDefaultMessage();
191+
} else {
192+
List<Literal> explicitMessages = shape.getExplicitMessages();
193+
if (!explicitMessages.isEmpty()) {
194+
messagesToAdd = explicitMessages;
195+
} else {
196+
messagesToAdd = sourceConstraint.getDefaultMessage();
197+
}
198+
}
199+
200+
for (Literal message : messagesToAdd) {
179201
model.add(getId(), SHACL.RESULT_MESSAGE, message);
180202
}
181203

0 commit comments

Comments
 (0)