Skip to content

Commit 5601713

Browse files
authored
Merge pull request #367 from bci-oss/365-fix-recursive-sh-node-shapes
Fix crash of ShaclValidor for recursive sh:node
2 parents f7ce7f7 + 3d15840 commit 5601713

File tree

78 files changed

+739
-303
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+739
-303
lines changed

core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/ShaclValidator.java

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
import java.util.function.Function;
2323
import java.util.stream.Collectors;
2424

25+
import org.eclipse.esmf.aspectmodel.resolver.services.VersionedModel;
26+
import org.eclipse.esmf.aspectmodel.shacl.constraint.Constraint;
27+
import org.eclipse.esmf.aspectmodel.shacl.constraint.MinCountConstraint;
28+
import org.eclipse.esmf.aspectmodel.shacl.constraint.SparqlConstraint;
29+
import org.eclipse.esmf.aspectmodel.shacl.path.PathNodeRetriever;
30+
import org.eclipse.esmf.aspectmodel.shacl.path.PredicatePath;
31+
import org.eclipse.esmf.aspectmodel.shacl.violation.EvaluationContext;
32+
import org.eclipse.esmf.aspectmodel.shacl.violation.Violation;
33+
34+
import com.google.common.collect.Streams;
2535
import org.apache.jena.query.Query;
2636
import org.apache.jena.query.QueryExecution;
2737
import org.apache.jena.query.QueryExecutionFactory;
@@ -33,20 +43,10 @@
3343
import org.apache.jena.rdf.model.Resource;
3444
import org.apache.jena.rdf.model.Statement;
3545
import org.apache.jena.vocabulary.RDF;
36-
import org.eclipse.esmf.aspectmodel.resolver.services.VersionedModel;
37-
import org.eclipse.esmf.aspectmodel.shacl.constraint.Constraint;
38-
import org.eclipse.esmf.aspectmodel.shacl.constraint.MinCountConstraint;
39-
import org.eclipse.esmf.aspectmodel.shacl.constraint.SparqlConstraint;
40-
import org.eclipse.esmf.aspectmodel.shacl.path.PathNodeRetriever;
41-
import org.eclipse.esmf.aspectmodel.shacl.path.PredicatePath;
42-
import org.eclipse.esmf.aspectmodel.shacl.violation.EvaluationContext;
43-
import org.eclipse.esmf.aspectmodel.shacl.violation.Violation;
44-
45-
import com.google.common.collect.Streams;
4646

4747
/**
48-
* Implementation of a SHACL engine that allows validation on a per-element basis: {@link #validateElement(Resource)} can be used to retrieve validation
49-
* results only for this specific resource.
48+
* Implementation of a SHACL engine that allows validation on a per-element basis: {@link #validateElement(Resource)} can be used to
49+
* retrieve validation results only for this specific resource.
5050
*/
5151
public class ShaclValidator {
5252
private final List<Shape.Node> shapes;
@@ -56,6 +56,7 @@ public class ShaclValidator {
5656

5757
/**
5858
* Constructor to provide a custom RDF model containing SHACL shapes
59+
*
5960
* @param shapesModel the shapes model
6061
*/
6162
public ShaclValidator( final Model shapesModel ) {
@@ -77,7 +78,9 @@ public ShaclValidator( final Model shapesModel ) {
7778
* If you have more than one element to validate, prefer the method {@link #validateElements(List)} to calling this method in a loop
7879
* for better performance.
7980
* {@link Resource#getModel()} on the element must not return null, i.e., the resource may not be created using
80-
* {@link org.apache.jena.rdf.model.ResourceFactory#createProperty(String)}, but instead must be created via {@link Model#createResource(String)}.
81+
* {@link org.apache.jena.rdf.model.ResourceFactory#createProperty(String)}, but instead must be created via
82+
* {@link Model#createResource(String)}.
83+
*
8184
* @param element the element to be validated
8285
* @return the list of {@link Violation}s if there are violations
8386
*/
@@ -86,7 +89,8 @@ public List<Violation> validateElement( final Resource element ) {
8689
return validateElement( element, sparqlTargets, element.getModel() );
8790
}
8891

89-
private List<Violation> validateElement( final Resource element, final Map<Resource, List<Shape.Node>> sparqlTargets, final Model resolvedModel ) {
92+
private List<Violation> validateElement( final Resource element, final Map<Resource, List<Shape.Node>> sparqlTargets,
93+
final Model resolvedModel ) {
9094
final List<Violation> violations = new ArrayList<>();
9195
for ( final Shape.Node shape : targetClassShapesThatApplyToElement( element, resolvedModel ) ) {
9296
violations.addAll( validateShapeForElement( element, shape, resolvedModel ) );
@@ -110,9 +114,11 @@ private List<Violation> validateElement( final Resource element, final Map<Resou
110114

111115
/**
112116
* Validates a model using the SHACL shapes the validator was initialized with.
117+
*
113118
* @param model the model to be validated
114119
* @return the list of {@link Violation}s if there are violations
115120
*/
121+
@SuppressWarnings( "UnstableApiUsage" ) // Usage of Streams.stream is deemed ok
116122
public List<Violation> validateModel( final VersionedModel model ) {
117123
final Map<Resource, List<Shape.Node>> sparqlTargetsWithShapes = findSparqlTargets( model.getRawModel() );
118124
return Streams.stream( model.getRawModel().listStatements( null, RDF.type, (RDFNode) null ) )
@@ -158,41 +164,46 @@ private List<Resource> querySparqlTargets( final Model model, final Query query
158164
}
159165

160166
public List<Violation> validateElements( final List<Resource> elements ) {
161-
final Map<Resource, List<Shape.Node>> sparqlTargets = !elements.isEmpty() ? findSparqlTargets( elements.get( 0 ).getModel() ) : Map.of();
167+
final Map<Resource, List<Shape.Node>> sparqlTargets = !elements.isEmpty() ?
168+
findSparqlTargets( elements.get( 0 ).getModel() ) : Map.of();
162169
return elements.stream().flatMap( element -> validateElement( element, sparqlTargets, element.getModel() ).stream() ).toList();
163170
}
164171

165-
public List<Violation> validateShapeForElement( final Resource element, final Shape.Node shape, final Model resolvedModel ) {
172+
public List<Violation> validateShapeForElement( final Resource element, final Shape.Node nodeShape, final Model resolvedModel ) {
166173
final List<Violation> violations = new ArrayList<>();
167-
for ( final Shape.Property property : shape.properties() ) {
168-
for ( final Constraint constraint : property.attributes().constraints() ) {
169-
final List<Statement> reachableNodes = property.path().accept( element, retriever );
174+
for ( final Shape.Property propertyShape : nodeShape.properties() ) {
175+
for ( final Constraint constraint : propertyShape.attributes().constraints() ) {
176+
final List<Statement> reachableNodes = propertyShape.path().accept( element, retriever );
170177
// For all values that are present on the target node, check the applicable shapes and collect violations
171178
for ( final Statement assertion : reachableNodes ) {
172-
final EvaluationContext context = new EvaluationContext( element, shape, Optional.of( assertion.getPredicate() ), reachableNodes, this,
173-
resolvedModel );
179+
final EvaluationContext context = new EvaluationContext( element, nodeShape, Optional.of( propertyShape ),
180+
Optional.of( assertion.getPredicate() ), List.of( assertion ), this, resolvedModel );
174181
violations.addAll( constraint.apply( assertion.getObject(), context ) );
175182
}
176183

177184
// important detail: Sparql constraints must run independent of whether there are any matches via the sh:path property or not
178185
// ( the check could be the verification whether the property exists )
179186
if ( reachableNodes.isEmpty() && constraint instanceof SparqlConstraint ) {
180-
final EvaluationContext context = new EvaluationContext( element, shape, Optional.empty(), List.of(), this, resolvedModel );
187+
final EvaluationContext context = new EvaluationContext( element, nodeShape, Optional.of( propertyShape ), Optional.empty(),
188+
List.of(), this, resolvedModel );
181189
violations.addAll( constraint.apply( null, context ) );
182190
}
183191

184-
// MinCount needs to be handled separately: If the property is not used at all on the target node, but a MinCount constraints >= 1
185-
// exists, a violation must be emitted even though no value for the property exists
186-
if ( reachableNodes.isEmpty() && constraint instanceof MinCountConstraint && property.path() instanceof final PredicatePath predicatePath ) {
192+
// MinCount needs to be handled separately: If the property is not used at all on the target node, but a MinCount constraints
193+
// >= 1 exists, a violation must be emitted even though no value for the property exists
194+
if ( reachableNodes.isEmpty() && constraint instanceof MinCountConstraint
195+
&& propertyShape.path() instanceof final PredicatePath predicatePath ) {
187196
final Property rdfProperty = resolvedModel.createProperty( predicatePath.predicate().getURI() );
188-
final EvaluationContext context = new EvaluationContext( element, shape, Optional.of( rdfProperty ), List.of(), this, resolvedModel );
197+
final EvaluationContext context = new EvaluationContext( element, nodeShape, Optional.of( propertyShape ),
198+
Optional.of( rdfProperty ), List.of(), this, resolvedModel );
189199
violations.addAll( constraint.apply( null, context ) );
190200
}
191201
}
192202
}
193203

194-
final EvaluationContext context = new EvaluationContext( element, shape, Optional.empty(), List.of(), this, resolvedModel );
195-
for ( final Constraint constraint : shape.attributes().constraints() ) {
204+
final EvaluationContext context = new EvaluationContext( element, nodeShape, Optional.empty(), Optional.empty(), List.of(), this,
205+
resolvedModel );
206+
for ( final Constraint constraint : nodeShape.attributes().constraints() ) {
196207
if ( !constraint.canBeUsedOnNodeShapes() ) {
197208
continue;
198209
}
@@ -205,6 +216,7 @@ public List<Violation> validateShapeForElement( final Resource element, final Sh
205216
/**
206217
* Returns the shapes that apply to the element because the element has a type (or the type has a transitive supertype) that
207218
* is given as sh:targetClass
219+
*
208220
* @param element a model element
209221
* @return the stream of shapes
210222
*/
@@ -217,6 +229,7 @@ private Set<Shape.Node> targetClassShapesThatApplyToElement( final Resource elem
217229

218230
/**
219231
* Returns the shapes that apply to the element because the element uses a property which is given as sh:targetSubjectsOf
232+
*
220233
* @param element a model element
221234
* @return the stream of shapes
222235
*/
@@ -254,8 +267,4 @@ public List<Shape.Node> getShapes() {
254267
public Model getShapesModel() {
255268
return shapesModel;
256269
}
257-
258-
public PathNodeRetriever getPathNodeRetriever() {
259-
return retriever;
260-
}
261270
}

core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/ShapeLoader.java

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
package org.eclipse.esmf.aspectmodel.shacl;
1515

16+
import java.util.Collection;
1617
import java.util.HashMap;
18+
import java.util.HashSet;
1719
import java.util.List;
1820
import java.util.Map;
1921
import java.util.Optional;
@@ -80,8 +82,13 @@
8082
public class ShapeLoader implements Function<Model, List<Shape.Node>> {
8183
private static final SHACL SHACL = new SHACL();
8284

85+
private final Map<Resource, JsLibrary> jsLibraries = new HashMap<>();
86+
private final Map<Resource, Shape.Node> nodeShapes = new HashMap<>();
87+
private final Set<Resource> seenNodeShapes = new HashSet<>();
88+
8389
/**
8490
* When constraints are instantiated for a shape, the context is passed as input
91+
*
8592
* @param path If the parent shape of the constraint is a property shape, the path determines what the property shape refers to
8693
*/
8794
private record ShapeContext(Statement statement, Optional<Path> path) {
@@ -121,17 +128,28 @@ private record ShapeContext(Statement statement, Optional<Path> path) {
121128
.put( SHACL.and(), context -> new AndConstraint( nestedConstraintList( context.statement(), context.path() ) ) )
122129
.put( SHACL.or(), context -> new OrConstraint( nestedConstraintList( context.statement(), context.path() ) ) )
123130
.put( SHACL.xone(), context -> new XoneConstraint( nestedConstraintList( context.statement(), context.path() ) ) )
124-
.put( SHACL.node(), context -> new NodeConstraint( nodeShape( context.statement().getObject().asResource() ), context.path() ) )
131+
.put( SHACL.node(), context -> {
132+
// Since sh:node can recursively refer to the same NodeShape is used in when shapes define recursive structures,
133+
// the NodeConstraint is built using a Supplier for the actual NodeShape. Only if the NodeShape has not yet been
134+
// seen (i.e., it could be in the process of being built right now), create it now.
135+
final Resource resource = context.statement().getObject().asResource();
136+
if ( !seenNodeShapes.contains( resource ) ) {
137+
nodeShape( resource );
138+
}
139+
return new NodeConstraint( () -> nodeShapes.get( resource ), context.path() );
140+
} )
125141
.put( SHACL.in(), context -> new AllowedValuesConstraint( context.statement().getResource().as( RDFList.class ).asJavaList() ) )
126142
.put( SHACL.closed(), context -> {
127143
boolean closed = context.statement().getBoolean();
128144
if ( !closed ) {
129145
throw new RuntimeException();
130146
}
131-
Set<Property> ignoredProperties = context.statement().getSubject().getProperty( SHACL.ignoredProperties() ).getResource()
132-
.as( RDFList.class )
133-
.asJavaList()
147+
Set<Property> ignoredProperties = Optional.ofNullable( context.statement().getSubject().getProperty( SHACL.ignoredProperties() ) )
148+
.map( Statement::getResource )
149+
.map( resource -> resource.as( RDFList.class ) )
150+
.map( RDFList::asJavaList )
134151
.stream()
152+
.flatMap( Collection::stream )
135153
.map( RDFNode::asResource )
136154
.map( Resource::getURI )
137155
.map( uri -> context.statement().getModel().createProperty( uri ) )
@@ -145,29 +163,31 @@ private record ShapeContext(Statement statement, Optional<Path> path) {
145163
return new SparqlConstraint( message, sparqlQuery( constraintNode ) );
146164
} )
147165
.put( SHACL.js(), context -> {
148-
Resource constraintNode = context.statement().getResource();
149-
JsLibrary library = jsLibrary( constraintNode.getProperty( SHACL.jsLibrary() ).getResource() );
150-
String functionName = constraintNode.getProperty( SHACL.jsFunctionName() ).getString();
166+
final Resource constraintNode = context.statement().getResource();
167+
final JsLibrary library = jsLibrary( constraintNode.getProperty( SHACL.jsLibrary() ).getResource() );
168+
final String functionName = constraintNode.getProperty( SHACL.jsFunctionName() ).getString();
151169
final String message = Optional.ofNullable( constraintNode.getProperty( SHACL.message() ) ).map( Statement::getString ).orElse( "" );
152170
return new JsConstraint( message, library, functionName );
153171
} )
154172
.build();
155173

156-
private final Map<Resource, JsLibrary> jsLibraries = new HashMap<>();
157-
158174
private List<Constraint> nestedConstraintList( final Statement statement, final Optional<Path> path ) {
159175
return statement.getObject().as( RDFList.class ).asJavaList().stream()
160176
.filter( RDFNode::isResource )
161177
.map( RDFNode::asResource )
162-
.flatMap( resource -> resource.isURIResource()
163-
? nodeShape( resource ).attributes().constraints().stream()
164-
: shapeAttributes( resource, path ).constraints().stream() )
178+
.flatMap( resource -> {
179+
if ( resource.isURIResource() ) {
180+
return nodeShape( resource ).attributes().constraints().stream();
181+
}
182+
return shapeAttributes( resource, path ).constraints().stream();
183+
} )
165184
.toList();
166185
}
167186

168187
/**
169188
* Builds a {@link Pattern} from a pattern string and a flags string as specified in
170189
* <a href="https://www.w3.org/TR/xpath-functions/#func-matches">xpath functions</a>.
190+
*
171191
* @param patternString the pattern string
172192
* @param flagsString the flags string
173193
* @return the pattern
@@ -190,6 +210,7 @@ private Pattern buildPattern( final String patternString, final String flagsStri
190210
}
191211

192212
@Override
213+
@SuppressWarnings( "UnstableApiUsage" ) // Usage of Streams.stream is deemed ok
193214
public List<Shape.Node> apply( final Model model ) {
194215
return Streams.stream( model.listStatements( null, RDF.type, SHACL.NodeShape() ) )
195216
.map( Statement::getSubject )
@@ -225,12 +246,16 @@ private Shape.Attributes shapeAttributes( final Resource shapeNode, final Option
225246
defaultValue, deactivated, message, severity, constraints );
226247
}
227248

249+
@SuppressWarnings( "UnstableApiUsage" ) // Usage of Streams.stream is deemed ok
228250
private Shape.Node nodeShape( final Resource shapeNode ) {
251+
seenNodeShapes.add( shapeNode );
229252
final Shape.Attributes attributes = shapeAttributes( shapeNode, Optional.empty() );
230253
final List<Shape.Property> properties = Streams.stream( shapeNode.listProperties( SHACL.property() ) )
231254
.map( Statement::getResource )
232255
.map( this::propertyShape ).toList();
233-
return new Shape.Node( attributes, properties );
256+
final Shape.Node node = new Shape.Node( attributes, properties );
257+
nodeShapes.put( shapeNode, node );
258+
return node;
234259
}
235260

236261
private Shape.Property propertyShape( final Resource shapeNode ) {
@@ -291,6 +316,7 @@ private boolean isRdfList( final Resource resource ) {
291316
|| resource.hasProperty( RDF.rest ) || resource.hasProperty( RDF.first );
292317
}
293318

319+
@SuppressWarnings( "UnstableApiUsage" ) // Usage of Streams.stream is deemed ok
294320
private List<Constraint> constraints( final Resource valueNode, final Optional<Path> path ) {
295321
return constraintLoaders.entrySet().stream()
296322
.flatMap( entry -> {

core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/constraint/AllowedLanguagesConstraint.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@
1616
import java.util.List;
1717

1818
import org.apache.jena.rdf.model.RDFNode;
19+
1920
import org.eclipse.esmf.aspectmodel.shacl.Shape;
2021
import org.eclipse.esmf.aspectmodel.shacl.violation.EvaluationContext;
2122
import org.eclipse.esmf.aspectmodel.shacl.violation.LanguageFromListViolation;
2223
import org.eclipse.esmf.aspectmodel.shacl.violation.Violation;
2324

2425
/**
2526
* Implements <a href="https://www.w3.org/TR/shacl/#LanguageInConstraintComponent">sh:languageIn</a>
27+
*
2628
* @param allowedLanguages the list of allowed language tags
2729
*/
28-
public record AllowedLanguagesConstraint(List<String> allowedLanguages) implements Constraint {
30+
public record AllowedLanguagesConstraint( List<String> allowedLanguages ) implements Constraint {
2931
@Override
3032
public List<Violation> apply( final RDFNode rdfNode, final EvaluationContext context ) {
3133
final List<Violation> nodeKindViolations = new NodeKindConstraint( Shape.NodeKind.Literal ).apply( rdfNode, context );

core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/constraint/AllowedValuesConstraint.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616
import java.util.List;
1717

1818
import org.apache.jena.rdf.model.RDFNode;
19+
1920
import org.eclipse.esmf.aspectmodel.shacl.violation.EvaluationContext;
2021
import org.eclipse.esmf.aspectmodel.shacl.violation.ValueFromListViolation;
2122
import org.eclipse.esmf.aspectmodel.shacl.violation.Violation;
2223

2324
/**
2425
* Implements <a href="https://www.w3.org/TR/shacl/#InConstraintComponent">sh:in</a> *
26+
*
2527
* @param allowedValues the list of allowed values
2628
*/
27-
public record AllowedValuesConstraint(List<RDFNode> allowedValues) implements Constraint {
29+
public record AllowedValuesConstraint( List<RDFNode> allowedValues ) implements Constraint {
2830
@Override
2931
public List<Violation> apply( final RDFNode rdfNode, final EvaluationContext context ) {
3032
return allowedValues.contains( rdfNode ) ?

core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/constraint/AndConstraint.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717
import java.util.stream.Collectors;
1818

1919
import org.apache.jena.rdf.model.RDFNode;
20+
2021
import org.eclipse.esmf.aspectmodel.shacl.violation.EvaluationContext;
2122
import org.eclipse.esmf.aspectmodel.shacl.violation.Violation;
2223

2324
/**
2425
* Implements <a href="https://www.w3.org/TR/shacl/#AndConstraintComponent">sh:and</a>
2526
*/
26-
public record AndConstraint(List<Constraint> constraints) implements Constraint {
27+
public record AndConstraint( List<Constraint> constraints ) implements Constraint {
2728
@Override
2829
public List<Violation> apply( final RDFNode rdfNode, final EvaluationContext context ) {
2930
return constraints().stream().flatMap( constraint -> constraint.apply( rdfNode, context ).stream() ).collect( Collectors.toList() );

0 commit comments

Comments
 (0)