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() {