Skip to content

Commit 3a9cdc6

Browse files
committed
Proper evaluation of cycles involving bamm-c:Either.
1 parent 62d4901 commit 3a9cdc6

File tree

1 file changed

+176
-56
lines changed
  • core/sds-aspect-model-validator/src/main/java/io/openmanufacturing/sds/aspectmodel/validation/services

1 file changed

+176
-56
lines changed

core/sds-aspect-model-validator/src/main/java/io/openmanufacturing/sds/aspectmodel/validation/services/ModelCycleDetector.java

Lines changed: 176 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,24 @@
1515

1616
import java.util.ArrayList;
1717
import java.util.HashSet;
18+
import java.util.Iterator;
19+
import java.util.LinkedHashSet;
1820
import java.util.List;
1921
import java.util.Optional;
2022
import java.util.Set;
23+
import java.util.function.BiConsumer;
24+
import java.util.function.Consumer;
2125

22-
import org.antlr.v4.runtime.misc.OrderedHashSet;
2326
import org.apache.jena.query.Query;
2427
import org.apache.jena.query.QueryExecution;
2528
import org.apache.jena.query.QueryExecutionFactory;
2629
import org.apache.jena.query.QueryFactory;
2730
import org.apache.jena.query.QuerySolution;
2831
import org.apache.jena.query.ResultSet;
2932
import org.apache.jena.rdf.model.Model;
30-
import org.apache.jena.rdf.model.Property;
33+
import org.apache.jena.rdf.model.RDFNode;
3134
import org.apache.jena.rdf.model.Resource;
3235
import org.apache.jena.rdf.model.Statement;
33-
import org.apache.jena.rdf.model.StmtIterator;
3436
import org.apache.jena.sparql.core.Var;
3537
import org.apache.jena.sparql.engine.binding.Binding;
3638
import org.apache.jena.vocabulary.RDF;
@@ -43,27 +45,32 @@
4345
import io.openmanufacturing.sds.aspectmodel.vocabulary.BAMM;
4446

4547
/**
46-
* Cycle detector for the models.
48+
* Cycle detector for BAMM models.
4749
*
4850
* Because of the limitations of the property paths in Sparql queries, it is impossible to realize the cycle detection together with
4951
* other validations via Shacl shapes.
5052
*
5153
* According to graph theory:
5254
* A directed graph G is acyclic if and only if a depth-first search of G yields no back edges.
53-
* So a depth-first traversal of the "resolved" property references (via complex types like Entities) is able to deliver us all cycles present in the model.
55+
*
56+
* So a depth-first traversal of the "resolved" (via Characteristics/Entities etc.) property references is able to deliver all cycles present in the model.
5457
*/
5558
public class ModelCycleDetector {
5659

60+
static String ERR_CYCLE_DETECTED =
61+
"The Aspect Model contains a cycle which includes following properties: %s. Please remove any cycles that do not allow a finite json payload.";
62+
5763
private final static String prefixes = "prefix bamm: <urn:bamm:io.openmanufacturing:meta-model:%s#> \r\n" +
5864
"prefix bamm-c: <urn:bamm:io.openmanufacturing:characteristic:%s#> \r\n" +
5965
"prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> \r\n";
6066

61-
final OrderedHashSet<String> discovered = new OrderedHashSet<>();
67+
final Set<String> discovered = new LinkedHashSet<>();
6268
final Set<String> finished = new HashSet<>();
6369

6470
private Query query;
65-
private Property bammOptional;
66-
private Property bammProperty;
71+
72+
private BAMM bamm;
73+
private Model model;
6774

6875
List<ValidationError.Semantic> cycleReports = new ArrayList<>();
6976

@@ -72,91 +79,204 @@ public ValidationReport validateModel( final VersionedModel versionedModel ) {
7279
finished.clear();
7380
cycleReports.clear();
7481

75-
final Model model = versionedModel.getModel();
82+
model = versionedModel.getModel();
7683
final Optional<KnownVersion> metaModelVersion = KnownVersion.fromVersionString( versionedModel.getVersion().toString() );
77-
final BAMM bamm = new BAMM( metaModelVersion.get() );
78-
bammProperty = bamm.property();
79-
bammOptional = bamm.optional();
84+
bamm = new BAMM( metaModelVersion.get() );
8085
initializeQuery( metaModelVersion.get() );
8186

82-
final StmtIterator properties = model.listStatements( null, RDF.type, bamm.Property() );
83-
while ( properties.hasNext() ) {
84-
final Statement property = properties.nextStatement();
85-
final String fullPropertyName = property.getSubject().getURI();
86-
if ( !discovered.contains( fullPropertyName ) && !finished.contains( fullPropertyName ) ) {
87-
depthFirstTraversal( model, property.getSubject() );
87+
// we only want to investigate properties that are directly reachable from an Aspect
88+
final Statement aspect = model.listStatements( null, RDF.type, bamm.Aspect() ).nextStatement();
89+
final Statement properties = aspect.getSubject().getProperty( bamm.properties() );
90+
if ( properties != null ) {
91+
final Iterator<RDFNode> aspectProperties = properties.getList().iterator();
92+
while ( aspectProperties.hasNext() ) {
93+
final RDFNode propRef = aspectProperties.next();
94+
final Statement property = model.listStatements( propRef.asResource(), RDF.type, bamm.Property() ).nextStatement();
95+
final String propertyName = getUniqueName( property.getSubject() );
96+
if ( !discovered.contains( propertyName ) && !finished.contains( propertyName ) ) {
97+
depthFirstTraversal( property.getSubject(), this::reportCycle );
98+
}
8899
}
89100
}
90-
101+
91102
return cycleReports.isEmpty() ?
92103
new ValidationReport.ValidReport() :
93104
new ValidationReportBuilder().withValidationErrors( cycleReports ).buildInvalidReport();
94105
}
95106

96-
private void depthFirstTraversal( final Model model, final Resource currentProperty ) {
97-
final String currentPropertyName = currentProperty.getURI();
107+
private void depthFirstTraversal( final Resource currentProperty, final BiConsumer<String, Set<String>> cycleHandler ) {
108+
final String currentPropertyName = getUniqueName( currentProperty );
98109
discovered.add( currentPropertyName );
99110

100-
// if (either) -> continue with fake cycleReports for both branches and only add the cycle if both branches have cycles
101-
// reachableObject.getObject == bammEither
102-
//
103-
// else normal handling of properties
111+
final List<NextHopProperty> nextHopProperties = getDirectlyReachableProperties( model, currentProperty );
104112

105-
final List<Resource> nextHopProperties = getDirectlyReachableProperties( model, currentProperty );
106-
for ( Resource reachableProperty : nextHopProperties ) {
113+
// bamm-c:Either makes the task somewhat more complicated - we need to know the status of both branches (left/right)
114+
// to be able to decide whether there really is a cycle or not
115+
if ( reachedViaEither( nextHopProperties ) ) {
116+
final EitherCycleDetector leftBranch = new EitherCycleDetector( currentPropertyName, this::reportCycle );
117+
final EitherCycleDetector rightBranch = new EitherCycleDetector( currentPropertyName, this::reportCycle );
118+
nextHopProperties.stream().filter( property -> property.eitherStatus == 1 )
119+
.forEach( property -> investigateProperty( property.propertyNode, leftBranch::collectCycles ) );
120+
nextHopProperties.stream().filter( property -> property.eitherStatus == 2 )
121+
.forEach( property -> investigateProperty( property.propertyNode, rightBranch::collectCycles ) );
122+
if ( leftBranch.hasBreakableCycles() && rightBranch.hasBreakableCycles() ) {
123+
// the cycles found are breakable, but they are present in both branches, resulting in an overall unbreakable cycle
124+
leftBranch.reportCycles( this::reportCycle );
125+
rightBranch.reportCycles( this::reportCycle );
126+
}
127+
} else { // "normal" path
128+
nextHopProperties.forEach( property -> investigateProperty( property.propertyNode, cycleHandler ) );
129+
}
107130

108-
if ( reachableProperty.isAnon() ) { // property usage of the type "[ bamm:property :propName ; bamm:optional true ; ]"
109-
final Statement optional = reachableProperty.getProperty( bammOptional );
110-
if ( (null != optional) && optional.getBoolean() ) {
111-
// presence of bamm:optional = true; no need to continue on this path, the potential cycle would be broken by the optional property anyway
112-
continue;
113-
}
114-
// resolve the property reference
115-
reachableProperty = reachableProperty.getProperty( bammProperty ).getObject().asResource();
131+
discovered.remove( currentPropertyName );
132+
finished.add( currentPropertyName );
133+
}
134+
135+
private boolean reachedViaEither( final List<NextHopProperty> nextHopProperties ) {
136+
return nextHopProperties.stream().anyMatch( property -> property.eitherStatus > 0 );
137+
}
138+
139+
private void investigateProperty( Resource propertyNode, final BiConsumer<String, Set<String>> cycleHandler ) {
140+
// property usage of the type "[ bamm:property :propName ; bamm:optional value ; ]"
141+
if ( propertyNode.isAnon() ) {
142+
final Statement optional = propertyNode.getProperty( bamm.optional() );
143+
if ( (optional != null) && optional.getBoolean() ) {
144+
// presence of bamm:optional = true; no need to continue on this path, the potential cycle would be broken by the optional property anyway
145+
return;
146+
}
147+
148+
// resolve property reference
149+
final Statement prop = propertyNode.getProperty( bamm.property() );
150+
if ( prop != null ) {
151+
propertyNode = prop.getObject().asResource();
116152
}
153+
}
117154

118-
final String reachablePropertyName = reachableProperty.getURI();
155+
final String propertyName = getUniqueName( propertyNode );
119156

120-
if ( discovered.contains( reachablePropertyName ) ) {
121-
// cycle detected
122-
reportCycle();
123-
} else if ( !finished.contains( reachablePropertyName ) ) {
124-
depthFirstTraversal( model, reachableProperty );
157+
if ( discovered.contains( propertyName ) ) {
158+
// found a back edge -> cycle detected
159+
cycleHandler.accept( propertyName, discovered );
160+
} else if ( !finished.contains( propertyName ) ) {
161+
depthFirstTraversal( propertyNode, cycleHandler );
162+
}
163+
}
164+
165+
private String getUniqueName( final Resource property ) {
166+
// Ugly special case: when extending Entities, the property name will always be the same ([ bamm:extends bamm-e:value ; bamm:characteristic :someChara ]),
167+
// so we need a unique name in case more than one extending Entity exists in the model
168+
if ( property.isAnon() ) {
169+
if ( property.getProperty( bamm._extends() ) != null ) {
170+
return findExtendingEntityName( property ) + "|" + model.shortForm( property.getProperty( bamm._extends() ).getObject().asResource().getURI() );
125171
}
172+
// safety net
173+
return property.toString();
126174
}
127175

128-
discovered.remove( discovered.size() - 1 ); // OrderedHashSet does not implement remove( Object )
129-
finished.add( currentPropertyName );
176+
return model.shortForm( property.getURI() );
177+
}
178+
179+
private String findExtendingEntityName( final Resource extendsProperty ) {
180+
return model.listSubjectsWithProperty( bamm._extends() )
181+
.filterKeep( entity -> entity.getProperty( bamm.properties() ).getList().contains( extendsProperty ) )
182+
.mapWith( resource -> model.shortForm( resource.getURI() ) )
183+
.nextOptional().orElse( extendsProperty.toString() );
130184
}
131185

132-
private void reportCycle() {
133-
final String cycledNodes = String.join( " -> ", discovered );
134-
cycleReports.add( new ValidationError.Semantic(
135-
String.format(
136-
"The Aspect Model contains a cycle which includes following properties: %s. Please remove any cycles that do not allow a finite json payload.",
137-
cycledNodes ),
138-
"", "", "ERROR", "" ) );
186+
private void reportCycle( final String backEdgePropertyName, final Set<String> currentPath ) {
187+
reportCycle( formatCurrentCycle( backEdgePropertyName, currentPath ) );
188+
}
189+
190+
private void reportCycle( final String cyclePath ) {
191+
cycleReports.add( new ValidationError.Semantic( String.format( ERR_CYCLE_DETECTED, cyclePath ), "", "", "ERROR", "" ) );
139192
}
140193

141194
private void initializeQuery( final KnownVersion metaModelVersion ) {
142195
final String currentVersionPrefixes = String.format( prefixes, metaModelVersion.toVersionString(), metaModelVersion.toVersionString() );
143196
final String queryString = String.format(
144-
"%s select ?reachableProperty " +
145-
"where { ?currentProperty bamm:characteristic/bamm-c:baseCharacteristic*/bamm-c:left*/bamm-c:right*/bamm:dataType/bamm:properties/rdf:rest*/rdf:first ?reachableProperty }",
197+
"%s select ?reachableProperty ?viaEither"
198+
+ " where {"
199+
+ " {"
200+
+ " ?currentProperty bamm:characteristic/bamm-c:baseCharacteristic*/bamm-c:left/bamm:dataType/bamm:properties/rdf:rest*/rdf:first ?reachableProperty"
201+
+ " bind (1 as ?viaEither)"
202+
+ " }"
203+
+ " union"
204+
+ " {"
205+
+ " ?currentProperty bamm:characteristic/bamm-c:baseCharacteristic*/bamm-c:right/bamm:dataType/bamm:properties/rdf:rest*/rdf:first ?reachableProperty"
206+
+ " bind (2 as ?viaEither)"
207+
+ " }"
208+
+ " union"
209+
+ " {"
210+
+ " ?currentProperty bamm:characteristic/bamm-c:baseCharacteristic*/bamm:dataType/bamm:properties/rdf:rest*/rdf:first ?reachableProperty"
211+
+ " bind (0 as ?viaEither)"
212+
+ " }"
213+
+ "}",
146214
currentVersionPrefixes );
147215
query = QueryFactory.create( queryString );
148216
}
149217

150-
private List<Resource> getDirectlyReachableProperties( final Model model, final Resource currentProperty ) {
151-
final List<Resource> reachableProperties = new ArrayList<>();
218+
private static String formatCurrentCycle( final String backEdgePropertyName, final Set<String> currentPath ) {
219+
return String.join( " -> ", currentPath ) + " -> " + backEdgePropertyName;
220+
}
221+
222+
private List<NextHopProperty> getDirectlyReachableProperties( final Model model, final Resource currentProperty ) {
223+
final List<NextHopProperty> nextHopProperties = new ArrayList<>();
152224
try ( final QueryExecution qexec = QueryExecutionFactory.create( query, model ) ) {
153225
qexec.setInitialBinding( Binding.builder().add( Var.alloc( "currentProperty" ), currentProperty.asNode() ).build() );
154226
final ResultSet results = qexec.execSelect();
155227
while ( results.hasNext() ) {
156228
final QuerySolution solution = results.nextSolution();
157-
reachableProperties.add( solution.getResource( "reachableProperty" ) );
229+
nextHopProperties.add( new NextHopProperty( solution.getResource( "reachableProperty" ), solution.getLiteral( "viaEither" ).getInt() ) );
158230
}
159231
}
160-
return reachableProperties;
232+
return nextHopProperties;
233+
}
234+
235+
private static class NextHopProperty {
236+
public final Resource propertyNode;
237+
public final int eitherStatus;
238+
239+
public NextHopProperty( final Resource propertyNode, final int viaEither ) {
240+
this.propertyNode = propertyNode;
241+
eitherStatus = viaEither;
242+
}
243+
}
244+
245+
private static class EitherCycleDetector {
246+
private final String eitherPropertyName;
247+
private final List<String> breakableCycles = new ArrayList<>();
248+
private final BiConsumer<String, Set<String>> cycleHandler;
249+
250+
EitherCycleDetector( final String eitherPropertyName, final BiConsumer<String, Set<String>> cycleHandler ) {
251+
this.eitherPropertyName = eitherPropertyName;
252+
this.cycleHandler = cycleHandler;
253+
}
254+
255+
private void collectCycles( final String backEdgePropertyName, final Set<String> currentPath ) {
256+
if ( cycleIsBreakable( backEdgePropertyName, currentPath ) ) {
257+
breakableCycles.add( formatCurrentCycle( backEdgePropertyName, currentPath ) );
258+
} else { // unbreakable cycles are simply immediately reported and not retained for later evaluation
259+
cycleHandler.accept( backEdgePropertyName, currentPath );
260+
}
261+
}
262+
263+
// Cycles involving bamm-c:Either can be considered breakable only if they "encompass" the property characterized by the Either construct.
264+
// Consider these two examples: ( E is the Either property )
265+
// a -> E -> b -> c -> a : this cycle can be broken by the other branch of the Either construct
266+
// a -> E -> b -> c -> b : this cycle is unbreakable and can be reported immediately
267+
private boolean cycleIsBreakable( final String backEdgePropertyName, final Set<String> currentPath ) {
268+
final String firstInPath = currentPath.stream()
269+
.filter( propertyName -> propertyName.equals( backEdgePropertyName ) || propertyName.equals( eitherPropertyName ) )
270+
.findFirst().get();
271+
return backEdgePropertyName.equals( firstInPath );
272+
}
273+
274+
boolean hasBreakableCycles() {
275+
return !breakableCycles.isEmpty();
276+
}
277+
278+
void reportCycles( final Consumer<String> reportCycle ) {
279+
breakableCycles.forEach( reportCycle );
280+
}
161281
}
162282
}

0 commit comments

Comments
 (0)