Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@
<configuration>
<publishingServerId>central-portal-snapshots</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
</configuration>
</plugin>
</plugins>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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<Var> 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<Var> 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.
Expand Down Expand Up @@ -393,7 +460,7 @@ protected static List<ConstraintViolation> 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()));
}
}
}
Expand All @@ -408,7 +475,6 @@ protected static List<ConstraintViolation> runQueryOnClass(QueryWrapper wrapper,
private static List<ConstraintViolation> convertToConstraintViolations(
Model cm,
Model model,
Resource atClass,
Resource matchRoot,
String label,
Resource source)
Expand All @@ -419,29 +485,36 @@ private static List<ConstraintViolation> 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<SimplePropertyPath> paths = getViolationPaths(model, vio, root);
List<TemplateCall> 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));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<ConstraintViolation> 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<ConstraintViolation> 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<ConstraintViolation> 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: <http://spinrdf.org/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<ConstraintViolation> 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: <http://spinrdf.org/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<ConstraintViolation> 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<ConstraintViolation> 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<ConstraintViolation> 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: <http://spinrdf.org/spin#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
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<ConstraintViolation> 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()
{
Expand Down