15
15
16
16
import java .util .ArrayList ;
17
17
import java .util .HashSet ;
18
+ import java .util .Iterator ;
19
+ import java .util .LinkedHashSet ;
18
20
import java .util .List ;
19
21
import java .util .Optional ;
20
22
import java .util .Set ;
23
+ import java .util .function .BiConsumer ;
24
+ import java .util .function .Consumer ;
21
25
22
- import org .antlr .v4 .runtime .misc .OrderedHashSet ;
23
26
import org .apache .jena .query .Query ;
24
27
import org .apache .jena .query .QueryExecution ;
25
28
import org .apache .jena .query .QueryExecutionFactory ;
26
29
import org .apache .jena .query .QueryFactory ;
27
30
import org .apache .jena .query .QuerySolution ;
28
31
import org .apache .jena .query .ResultSet ;
29
32
import org .apache .jena .rdf .model .Model ;
30
- import org .apache .jena .rdf .model .Property ;
33
+ import org .apache .jena .rdf .model .RDFNode ;
31
34
import org .apache .jena .rdf .model .Resource ;
32
35
import org .apache .jena .rdf .model .Statement ;
33
- import org .apache .jena .rdf .model .StmtIterator ;
34
36
import org .apache .jena .sparql .core .Var ;
35
37
import org .apache .jena .sparql .engine .binding .Binding ;
36
38
import org .apache .jena .vocabulary .RDF ;
43
45
import io .openmanufacturing .sds .aspectmodel .vocabulary .BAMM ;
44
46
45
47
/**
46
- * Cycle detector for the models.
48
+ * Cycle detector for BAMM models.
47
49
*
48
50
* Because of the limitations of the property paths in Sparql queries, it is impossible to realize the cycle detection together with
49
51
* other validations via Shacl shapes.
50
52
*
51
53
* According to graph theory:
52
54
* 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.
54
57
*/
55
58
public class ModelCycleDetector {
56
59
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
+
57
63
private final static String prefixes = "prefix bamm: <urn:bamm:io.openmanufacturing:meta-model:%s#> \r \n " +
58
64
"prefix bamm-c: <urn:bamm:io.openmanufacturing:characteristic:%s#> \r \n " +
59
65
"prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> \r \n " ;
60
66
61
- final OrderedHashSet <String > discovered = new OrderedHashSet <>();
67
+ final Set <String > discovered = new LinkedHashSet <>();
62
68
final Set <String > finished = new HashSet <>();
63
69
64
70
private Query query ;
65
- private Property bammOptional ;
66
- private Property bammProperty ;
71
+
72
+ private BAMM bamm ;
73
+ private Model model ;
67
74
68
75
List <ValidationError .Semantic > cycleReports = new ArrayList <>();
69
76
@@ -72,91 +79,204 @@ public ValidationReport validateModel( final VersionedModel versionedModel ) {
72
79
finished .clear ();
73
80
cycleReports .clear ();
74
81
75
- final Model model = versionedModel .getModel ();
82
+ model = versionedModel .getModel ();
76
83
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 () );
80
85
initializeQuery ( metaModelVersion .get () );
81
86
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
+ }
88
99
}
89
100
}
90
-
101
+
91
102
return cycleReports .isEmpty () ?
92
103
new ValidationReport .ValidReport () :
93
104
new ValidationReportBuilder ().withValidationErrors ( cycleReports ).buildInvalidReport ();
94
105
}
95
106
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 );
98
109
discovered .add ( currentPropertyName );
99
110
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 );
104
112
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
+ }
107
130
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 ();
116
152
}
153
+ }
117
154
118
- final String reachablePropertyName = reachableProperty . getURI ( );
155
+ final String propertyName = getUniqueName ( propertyNode );
119
156
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 () );
125
171
}
172
+ // safety net
173
+ return property .toString ();
126
174
}
127
175
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 () );
130
184
}
131
185
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" , "" ) );
139
192
}
140
193
141
194
private void initializeQuery ( final KnownVersion metaModelVersion ) {
142
195
final String currentVersionPrefixes = String .format ( prefixes , metaModelVersion .toVersionString (), metaModelVersion .toVersionString () );
143
196
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
+ + "}" ,
146
214
currentVersionPrefixes );
147
215
query = QueryFactory .create ( queryString );
148
216
}
149
217
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 <>();
152
224
try ( final QueryExecution qexec = QueryExecutionFactory .create ( query , model ) ) {
153
225
qexec .setInitialBinding ( Binding .builder ().add ( Var .alloc ( "currentProperty" ), currentProperty .asNode () ).build () );
154
226
final ResultSet results = qexec .execSelect ();
155
227
while ( results .hasNext () ) {
156
228
final QuerySolution solution = results .nextSolution ();
157
- reachableProperties .add ( solution .getResource ( "reachableProperty" ) );
229
+ nextHopProperties .add ( new NextHopProperty ( solution .getResource ( "reachableProperty" ), solution . getLiteral ( "viaEither" ). getInt () ) );
158
230
}
159
231
}
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
+ }
161
281
}
162
282
}
0 commit comments