diff --git a/pom.xml b/pom.xml
index 3b18cc0..01f08f3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -136,7 +136,6 @@
central-portal-snapshots
true
- published
diff --git a/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java b/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java
index 2ab7bcb..fbd73ca 100644
--- a/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java
+++ b/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java
@@ -24,15 +24,26 @@
import java.util.List;
import java.util.Map;
import org.apache.jena.graph.Graph;
+import org.apache.jena.graph.Node;
import org.apache.jena.graph.compose.MultiUnion;
import org.apache.jena.query.Query;
import org.apache.jena.query.QueryExecution;
import org.apache.jena.query.QueryFactory;
import org.apache.jena.query.QuerySolutionMap;
+import org.apache.jena.sparql.core.Quad;
import org.apache.jena.sparql.core.Var;
import org.apache.jena.sparql.engine.binding.Binding;
import org.apache.jena.sparql.engine.binding.BindingBuilder;
import org.apache.jena.sparql.engine.binding.BindingFactory;
+import org.apache.jena.sparql.expr.ExprVar;
+import org.apache.jena.sparql.graph.NodeTransform;
+import org.apache.jena.sparql.graph.NodeTransformLib;
+import org.apache.jena.sparql.modify.request.QuadAcc;
+import org.apache.jena.sparql.syntax.Element;
+import org.apache.jena.sparql.syntax.ElementBind;
+import org.apache.jena.sparql.syntax.ElementGroup;
+import org.apache.jena.sparql.syntax.PatternVars;
+import org.apache.jena.sparql.syntax.Template;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.rdf.model.impl.ModelCom;
@@ -339,7 +350,7 @@ protected static QueryWrapper createWrapper(Resource constraint)
try
{
com.atomgraph.spinrdf.model.Query query = constraint.as(com.atomgraph.spinrdf.model.Query.class);
- constraintQuery = QueryFactory.create(query.getText());
+ constraintQuery = bindThisInTemplate(QueryFactory.create(query.getText()));
}
catch (PropertyNotFoundException ex)
{
@@ -356,6 +367,62 @@ protected static QueryWrapper createWrapper(Resource constraint)
return new QueryWrapper(constraint, constraintQuery, qsm);
}
+ /**
+ * Routes {@code ?this} into the CONSTRUCT template through the WHERE clause. {@code QueryExecution.substitution()}
+ * rewrites the parsed query syntactically, so a blank-node instance substituted for {@code ?this} becomes a
+ * blank node written in the template, and template instantiation mints a fresh one per solution (SPARQL 1.1
+ * Query, "Templates with Blank Nodes"): the violation root then denotes nothing in the checked model. Named
+ * instances are unaffected, because an IRI written in a template is a constant. Renaming the template's
+ * {@code ?this} to a fresh variable and binding that variable from {@code ?this} at the end of the WHERE clause
+ * keeps the substituted term out of the template: the instance reaches it as a variable value, which
+ * instantiation copies through unchanged, as the pre-Jena 6 initial binding did. The WHERE clause still sees the
+ * substituted constant everywhere, filters included, and the appended BIND adds no rows. A query that groups
+ * has the fresh variable added to its GROUP BY, or grouping would hide it from the template.
+ * The SPARQL text of the constraint is not touched; only the parsed query is.
+ * @param query the parsed constraint query, modified in place
+ * @return the same query
+ */
+ protected static Query bindThisInTemplate(Query query)
+ {
+ if (!query.isConstructType()) return query;
+
+ Var thisVar = Var.alloc(SPIN.THIS_VAR_NAME);
+ Template template = query.getConstructTemplate();
+
+ Set templateVars = new HashSet<>();
+ for (Quad quad : template.getQuads())
+ for (Node node : List.of(quad.getGraph(), quad.getSubject(), quad.getPredicate(), quad.getObject()))
+ if (Var.isVar(node)) templateVars.add(Var.alloc(node));
+ if (!templateVars.contains(thisVar)) return query;
+
+ // a variable the query does not use anywhere - pattern, sub-selects, template, GROUP BY
+ Set usedVars = new HashSet<>(templateVars);
+ usedVars.addAll(PatternVars.vars(query.getQueryPattern()));
+ usedVars.addAll(query.getGroupBy().getVars());
+ Var freshVar = Var.alloc(SPIN.THIS_VAR_NAME + "_");
+ while (usedVars.contains(freshVar)) freshVar = Var.alloc(freshVar.getVarName() + "_");
+ final Var boundVar = freshVar;
+
+ NodeTransform rename = node -> thisVar.equals(node) ? boundVar : node;
+ if (template.containsRealQuad()) query.setConstructTemplate(new Template(new QuadAcc(NodeTransformLib.transformQuads(rename, template.getQuads()))));
+ else query.setConstructTemplate(new Template(NodeTransformLib.transform(rename, template.getBGP())));
+
+ Element pattern = query.getQueryPattern();
+ final ElementGroup group;
+ if (pattern instanceof ElementGroup elementGroup) group = elementGroup;
+ else
+ {
+ group = new ElementGroup();
+ if (pattern != null) group.addElement(pattern);
+ }
+ group.addElement(new ElementBind(boundVar, new ExprVar(thisVar)));
+ query.setQueryPattern(group);
+
+ if (query.hasGroupBy()) query.addGroupBy(boundVar);
+
+ return query;
+ }
+
/**
* Executes a constraint query against every instance of a class and collects the resulting violations. The
* query is run once per instance, with {@code ?this} bound to the instance.
@@ -393,7 +460,7 @@ protected static List runQueryOnClass(QueryWrapper wrapper,
{
//ResultSetFormatter.out(System.out, qex.execSelect());
- cvs.addAll(convertToConstraintViolations(qex.execConstruct(), model, cls, null, null, wrapper.getSource()));
+ cvs.addAll(convertToConstraintViolations(qex.execConstruct(), model, null, null, wrapper.getSource()));
}
}
}
@@ -408,7 +475,6 @@ protected static List runQueryOnClass(QueryWrapper wrapper,
private static List convertToConstraintViolations(
Model cm,
Model model,
- Resource atClass,
Resource matchRoot,
String label,
Resource source)
@@ -419,29 +485,36 @@ private static List convertToConstraintViolations(
while(it.hasNext()) {
Statement s = it.nextStatement();
Resource vio = s.getSubject();
-
+
Resource root = null;
Statement rootS = vio.getProperty(SPIN.violationRoot);
if (rootS != null && rootS.getObject().isResource()) {
root = rootS.getResource().inModel(model);
}
if (matchRoot == null || matchRoot.equals(root)) {
-
+
+ // per-violation message: the CONSTRUCT-emitted rdfs:label wins, then the caller-supplied
+ // label, then the constraint resource's own rdfs:label. No authored label means no message -
+ // a violation must not grow boilerplate text that consumers could mistake for an authored
+ // constraint message, and one violation's label must not leak into the next (the label
+ // parameter used to double as the loop accumulator)
+ String message = label;
Statement labelS = vio.getProperty(RDFS.label);
if (labelS != null && labelS.getObject().isLiteral()) {
- label = labelS.getString();
+ message = labelS.getString();
}
- else if (label == null) {
- label = "SPIN constraint at " + getLabel(atClass);
+ else if (message == null && source != null) {
+ Statement sourceLabelS = source.getProperty(RDFS.label);
+ if (sourceLabelS != null && sourceLabelS.getObject().isLiteral()) message = sourceLabelS.getString();
}
-
+
List paths = getViolationPaths(model, vio, root);
List fixes = getFixes(cm, model, vio);
-
+
RDFNode value = vio.hasProperty(SPIN.violationValue) ? vio.getRequiredProperty(SPIN.violationValue).getObject() : null;
Resource level = vio.hasProperty(SPIN.violationLevel) ? vio.getPropertyResourceValue(SPIN.violationLevel) : null;
-
- results.add(createConstraintViolation(paths, value, fixes, root, label, source, level));
+
+ results.add(createConstraintViolation(paths, value, fixes, root, message, source, level));
}
}
diff --git a/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java b/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java
index 98b3104..a8afc10 100644
--- a/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java
+++ b/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java
@@ -33,8 +33,15 @@
import org.apache.jena.vocabulary.RDF;
import org.apache.jena.vocabulary.RDFS;
import org.apache.jena.vocabulary.XSD;
+import java.util.List;
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.RDFNode;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -180,6 +187,184 @@ public void invalidMinCount()
assertEquals(1, SPINConstraints.check(getOntModel()).size());
}
+ @Test
+ public void namedInstanceViolationRoot()
+ {
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, SPL.Attribute).
+ addProperty(SPL.predicate, FOAF.name).
+ addLiteral(SPL.minCount, ResourceFactory.createTypedLiteral("1", XSDDatatype.XSDinteger));
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ Resource instance = getOntModel().createResource("http://data/instance").addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+ assertEquals(instance, cvs.get(0).getRoot()); // spin:violationRoot ?this must resolve to the checked instance
+ }
+
+ // the checked instance is a blank node (e.g. an unsaved resource in a POSTed request body), and the
+ // violation root must still be that very node - not a bnode freshly minted by CONSTRUCT template
+ // instantiation. A substituted ?this only round-trips through the template for IRIs; for bnodes the
+ // identity has to reach the template as a variable value, which is what these tests pin down
+ @Test
+ public void anonInstanceViolationRoot()
+ {
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, SPL.Attribute).
+ addProperty(SPL.predicate, FOAF.name).
+ addLiteral(SPL.minCount, ResourceFactory.createTypedLiteral("1", XSDDatatype.XSDinteger));
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ Resource instance = getOntModel().createResource().addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+ assertEquals(instance, cvs.get(0).getRoot());
+ }
+
+ @Test
+ public void anonInstanceViolationRootRDF()
+ {
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, SPL.Attribute).
+ addProperty(SPL.predicate, FOAF.name).
+ addLiteral(SPL.minCount, ResourceFactory.createTypedLiteral("1", XSDDatatype.XSDinteger));
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ Resource instance = getOntModel().createResource().addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+
+ // add the violations into the model that holds the instance, the way a validating server merges
+ // them into the request model for the error response
+ SPINConstraints.addConstraintViolationsRDF(cvs, getOntModel(), true);
+ assertTrue(getOntModel().contains(null, SPIN.violationRoot, instance)); // root must not dangle
+ }
+
+ // ?this in any template position, not only spin:violationRoot, must denote the checked blank node
+ @Test
+ public void anonInstanceViolationValue()
+ {
+ Resource template = getOntModel().createResource("http://ontology/template").addProperty(RDF.type, SPIN.Template).
+ addProperty(SPIN.body, getOntModel().createResource().addProperty(RDF.type, SP.Construct).
+ addProperty(SP.text, """
+ PREFIX spin:
+ CONSTRUCT {
+ _:a a spin:ConstraintViolation .
+ _:a spin:violationRoot ?this .
+ _:a spin:violationValue ?this .
+ }
+ WHERE {}"""));
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, template);
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ Resource instance = getOntModel().createResource().addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+ assertEquals(instance, cvs.get(0).getRoot());
+ assertEquals(instance, cvs.get(0).getValue());
+ }
+
+ // the variable the template is rerouted through must not collide with one the constraint body already uses
+ @Test
+ public void anonInstanceViolationRootWithThisUnderscoreInBody()
+ {
+ Resource template = getOntModel().createResource("http://ontology/template").addProperty(RDF.type, SPIN.Template).
+ addProperty(SPIN.body, getOntModel().createResource().addProperty(RDF.type, SP.Construct).
+ addProperty(SP.text, """
+ PREFIX spin:
+ CONSTRUCT {
+ _:a a spin:ConstraintViolation .
+ _:a spin:violationRoot ?this .
+ _:a spin:violationValue ?this_ .
+ }
+ WHERE { BIND ("taken" AS ?this_) }"""));
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, template);
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ Resource instance = getOntModel().createResource().addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+ assertEquals(instance, cvs.get(0).getRoot());
+ assertEquals("taken", cvs.get(0).getValue().asLiteral().getString());
+ }
+
+ // the violation message is the constraint's own authored rdfs:label - the channel the SPINConstraints
+ // rewrite severed when it stopped consulting the constraint resource. No authored label means no
+ // message at all: boilerplate fallbacks ("SPIN constraint at ...") must not masquerade as authored text
+ @Test
+ public void violationMessageFromConstraintLabel()
+ {
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, SPL.Attribute).
+ addProperty(SPL.predicate, FOAF.name).
+ addLiteral(SPL.minCount, ResourceFactory.createTypedLiteral("1", XSDDatatype.XSDinteger)).
+ addProperty(RDFS.label, "Missing foaf:name");
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ getOntModel().createResource("http://data/instance").addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+ assertEquals("Missing foaf:name", cvs.get(0).getMessage());
+ }
+
+ @Test
+ public void violationMessageAbsentWithoutLabel()
+ {
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, SPL.Attribute).
+ addProperty(SPL.predicate, FOAF.name).
+ addLiteral(SPL.minCount, ResourceFactory.createTypedLiteral("1", XSDDatatype.XSDinteger));
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ getOntModel().createResource("http://data/instance").addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(1, cvs.size());
+ assertNull(cvs.get(0).getMessage());
+
+ Model result = ModelFactory.createDefaultModel();
+ SPINConstraints.addConstraintViolationsRDF(cvs, result, true);
+ assertFalse(result.contains(null, RDFS.label, (RDFNode) null));
+ }
+
+ @Test
+ public void violationMessagesIndependentPerViolation()
+ {
+ Resource template = getOntModel().createResource("http://ontology/template").addProperty(RDF.type, SPIN.Template).
+ addProperty(SPIN.body, getOntModel().createResource().addProperty(RDF.type, SP.Construct).
+ addProperty(SP.text, """
+ PREFIX spin:
+ PREFIX rdfs:
+ CONSTRUCT {
+ _:a a spin:ConstraintViolation .
+ _:a spin:violationRoot ?this .
+ _:a rdfs:label "labelled violation" .
+ _:b a spin:ConstraintViolation .
+ _:b spin:violationRoot ?this .
+ }
+ WHERE {}"""));
+ Resource constraint = getOntModel().createResource("http://ontology/constraint").addProperty(RDF.type, template);
+ Resource cls = getOntModel().createResource("http://ontology/class").addProperty(RDF.type, RDFS.Class).
+ addProperty(SPIN.constraint, constraint);
+
+ getOntModel().createResource("http://data/instance").addProperty(RDF.type, cls);
+
+ List cvs = SPINConstraints.check(getOntModel());
+ assertEquals(2, cvs.size());
+ // one violation carries its CONSTRUCT-emitted label, the other has none - it must not inherit
+ // the first one's label (the loop used its label variable as an accumulator) nor grow a fallback
+ assertEquals(1, cvs.stream().filter(cv -> "labelled violation".equals(cv.getMessage())).count());
+ assertEquals(1, cvs.stream().filter(cv -> cv.getMessage() == null).count());
+ }
+
@Test
public void invalidMaxCount()
{