From 1449fddc37ba3730d3a1d0026affd131f6d1b27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Fri, 4 Sep 2026 13:42:01 +0200 Subject: [PATCH 1/3] Violation roots survive blank-node instances, and messages come from the constraint's authored label. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf --- .../spinrdf/constraints/SPINConstraints.java | 39 +++-- .../InfOntModelConstraintTest.java | 133 ++++++++++++++++++ 2 files changed, 160 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java b/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java index 2ab7bcb..69fc697 100644 --- a/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java +++ b/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java @@ -393,7 +393,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(), instance)); } } } @@ -408,10 +408,10 @@ protected static List runQueryOnClass(QueryWrapper wrapper, private static List convertToConstraintViolations( Model cm, Model model, - Resource atClass, Resource matchRoot, String label, - Resource source) + Resource source, + Resource instance) { List results = new ArrayList<>(); @@ -419,29 +419,44 @@ 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); + // substitution() inlines a blank-node ?this into the CONSTRUCT template, where instantiation + // relabels it per row like any template bnode, severing its link to the checked instance (URIs + // survive — only bnode instances are affected). A bnode root absent from the checked model can + // only be that relabeling artifact, so restore the instance it stood for. If a template ever + // emits ?this as spin:violationValue, that position needs the same repair. + if (instance != null && root.isAnon() + && !model.contains(root, null, (RDFNode) null) && !model.contains(null, null, root)) + root = instance; } 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..30b1c45 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,132 @@ 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. ?this only round-trips through the query for IRIs; for bnodes the identity has to + // survive by other means, 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 + } + + // 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() { From 7201083105ed2d78724c6c2da1166ec16c320fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Fri, 4 Sep 2026 13:42:01 +0200 Subject: [PATCH 2/3] Snapshot publishing no longer waits for the portal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WK1ssk2A67MYseeH3hWnhf --- pom.xml | 1 - 1 file changed, 1 deletion(-) 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 From f2a836eb21c5df5db22e76ad656497487e41e31e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 20 Sep 2026 10:50:56 +0200 Subject: [PATCH 3/3] A blank-node instance reaches the CONSTRUCT template as a variable value, not as a written blank node. substitution() rewrites the parsed query, so a blank node substituted for ?this becomes a blank node written in the template, which instantiation re-mints per solution; the violation root then denoted nothing in the checked model, and the previous fix repaired that one position after the fact by guessing which dangling blank root stood for the instance. Now the parsed query is rewritten once per constraint: the template's ?this becomes a fresh variable, bound from ?this by a BIND appended to the WHERE clause (and added to GROUP BY where the query groups). The WHERE side still sees the substituted constant everywhere, filters included, and the template copies the instance through unchanged in every position, as the pre-Jena 6 initial binding did. The constraint's SPARQL text is not touched, and the root-repair heuristic and its instance parameter are gone. Co-Authored-By: Claude Fable 5.1 --- .../spinrdf/constraints/SPINConstraints.java | 82 ++++++++++++++++--- .../InfOntModelConstraintTest.java | 58 ++++++++++++- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java b/src/main/java/com/atomgraph/spinrdf/constraints/SPINConstraints.java index 69fc697..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, null, null, wrapper.getSource(), instance)); + cvs.addAll(convertToConstraintViolations(qex.execConstruct(), model, null, null, wrapper.getSource())); } } } @@ -410,8 +477,7 @@ private static List convertToConstraintViolations( Model model, Resource matchRoot, String label, - Resource source, - Resource instance) + Resource source) { List results = new ArrayList<>(); @@ -424,14 +490,6 @@ private static List convertToConstraintViolations( Statement rootS = vio.getProperty(SPIN.violationRoot); if (rootS != null && rootS.getObject().isResource()) { root = rootS.getResource().inModel(model); - // substitution() inlines a blank-node ?this into the CONSTRUCT template, where instantiation - // relabels it per row like any template bnode, severing its link to the checked instance (URIs - // survive — only bnode instances are affected). A bnode root absent from the checked model can - // only be that relabeling artifact, so restore the instance it stood for. If a template ever - // emits ?this as spin:violationValue, that position needs the same repair. - if (instance != null && root.isAnon() - && !model.contains(root, null, (RDFNode) null) && !model.contains(null, null, root)) - root = instance; } if (matchRoot == null || matchRoot.equals(root)) { diff --git a/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java b/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java index 30b1c45..a8afc10 100644 --- a/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java +++ b/src/test/java/com/atomgraph/spinrdf/constraints/InfOntModelConstraintTest.java @@ -204,9 +204,9 @@ public void namedInstanceViolationRoot() } // 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. ?this only round-trips through the query for IRIs; for bnodes the identity has to - // survive by other means, which is what these tests pin down + // 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() { @@ -243,6 +243,58 @@ public void anonInstanceViolationRootRDF() 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