Skip to content

Commit 56c8160

Browse files
committed
library-api: add validation tests for situation input types in DMN models
- local tSituation should be a subset of BDT.tSituation - leaf field base types should match - situation input should be present in all checks/benefits
1 parent 00f3f05 commit 56c8160

File tree

3 files changed

+358
-2
lines changed

3 files changed

+358
-2
lines changed

library-api/src/main/java/org/codeforphilly/bdt/api/DMNSchemaResolver.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@ public Map<String, Object> generateExample(String schemaRef) {
137137
return generateExampleFromSchema(schema);
138138
}
139139

140-
private Map<String, Object> generateExampleFromSchema(JsonNode schema) {
140+
/**
141+
* Generate an example Map from a JSON schema node.
142+
* Package-private to allow test access.
143+
*/
144+
Map<String, Object> generateExampleFromSchema(JsonNode schema) {
141145
Map<String, Object> example = new LinkedHashMap<>();
142146

143147
if (!schema.has("properties")) {

library-api/src/main/resources/checks/age/PersonMinAge.dmn

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
<dmn:typeRef>number</dmn:typeRef>
1616
</dmn:itemComponent>
1717
</dmn:itemDefinition>
18+
<dmn:itemDefinition id="_7D92AF2A-4AE7-4B47-B814-12BA855002E0" name="tSituation" isCollection="false">
19+
<dmn:itemComponent id="_CC2F4895-42C3-45AB-BBB2-84486375A3C6" name="people" isCollection="false">
20+
<dmn:typeRef>tPersonList</dmn:typeRef>
21+
</dmn:itemComponent>
22+
</dmn:itemDefinition>
23+
<dmn:itemDefinition id="_EF9C2662-3FEC-48BF-A9FB-938764E5F64C" name="tPerson" isCollection="false">
24+
<dmn:itemComponent id="_DBBDAE72-DFB5-44AC-A779-63FC799B9182" name="id" isCollection="false">
25+
<dmn:typeRef>string</dmn:typeRef>
26+
</dmn:itemComponent>
27+
<dmn:itemComponent id="_5A9A2DC3-2354-42F9-ABA6-094AF982A273" name="dateOfBirth" isCollection="false">
28+
<dmn:typeRef>date</dmn:typeRef>
29+
</dmn:itemComponent>
30+
</dmn:itemDefinition>
31+
<dmn:itemDefinition id="_AE32BE46-9EE4-4165-ABB7-5B5E22ED8ED2" name="tPersonList" isCollection="true">
32+
<dmn:typeRef>tPerson</dmn:typeRef>
33+
</dmn:itemDefinition>
1834
<dmn:decisionService id="_6E428234-5215-4D38-AB4E-9083E80882D4" name="PersonMinAgeService">
1935
<dmn:extensionElements/>
2036
<dmn:variable id="_DAF63FA4-85ED-405B-859E-7AFF792F499E" name="PersonMinAgeService" typeRef="BDT.tCheckResponse"/>
@@ -62,7 +78,7 @@
6278
</dmn:decision>
6379
<dmn:inputData id="_7E4E0F8D-FA5D-49EF-8A65-0FEB4B7C0951" name="situation">
6480
<dmn:extensionElements/>
65-
<dmn:variable id="_9EA245F9-EA24-4E5D-AA6F-02662B21B792" name="situation" typeRef="BDT.tSituation"/>
81+
<dmn:variable id="_9EA245F9-EA24-4E5D-AA6F-02662B21B792" name="situation" typeRef="tSituation"/>
6682
</dmn:inputData>
6783
<dmn:inputData id="_F4F5DB0A-F298-4519-9554-44344C6179FE" name="parameters">
6884
<dmn:extensionElements/>
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
package org.codeforphilly.bdt.api;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import io.quarkus.test.junit.QuarkusTest;
5+
import org.junit.jupiter.api.Test;
6+
import org.w3c.dom.Document;
7+
import org.w3c.dom.Element;
8+
import org.w3c.dom.NodeList;
9+
10+
import javax.inject.Inject;
11+
import javax.xml.parsers.DocumentBuilder;
12+
import javax.xml.parsers.DocumentBuilderFactory;
13+
import java.io.InputStream;
14+
import java.util.*;
15+
import java.util.stream.Collectors;
16+
17+
import static org.junit.jupiter.api.Assertions.*;
18+
19+
/**
20+
* Validates that all "situation" inputs in DMN models are proper subtypes of BDT.tSituation.
21+
*
22+
* This test ensures:
23+
* 1. BDT.dmn exists and defines tSituation with all expected fields (recursively)
24+
* 2. All DMN models with decision services that accept a "situation" parameter either:
25+
* a) Reference BDT.tSituation directly (typeRef="BDT.tSituation"), OR
26+
* b) Define a local tSituation type that is a valid subset of BDT.tSituation
27+
* (all fields/nested fields exist in BDT.tSituation)
28+
*
29+
* Uses JSON schema comparison via DMNSchemaResolver to recursively validate nested types.
30+
*/
31+
@QuarkusTest
32+
public class SituationTypeValidationTest {
33+
34+
@Inject
35+
ModelRegistry modelRegistry;
36+
37+
@Inject
38+
DMNSchemaResolver schemaResolver;
39+
40+
private static final String DMN_NAMESPACE = "http://www.omg.org/spec/DMN/20180521/MODEL/";
41+
private static final String BDT_NAMESPACE = "https://kie.apache.org/dmn/_1B91A885-130A-4E0B-A762-E12AA6DD5C79";
42+
43+
@Test
44+
public void testBdtDefinesTSituation() {
45+
// Find BDT tSituation schema in dmnDefinitions.json
46+
String bdtSchemaKey = findBdtTSituationSchemaKey();
47+
assertNotNull(bdtSchemaKey, "BDT.tSituation schema should exist in dmnDefinitions.json");
48+
49+
JsonNode bdtSchema = schemaResolver.getSchema(bdtSchemaKey);
50+
assertNotNull(bdtSchema, "BDT.tSituation schema should be loadable");
51+
52+
// Generate example to verify structure
53+
Map<String, Object> bdtExample = schemaResolver.generateExampleFromSchema(bdtSchema);
54+
assertFalse(bdtExample.isEmpty(), "BDT.tSituation should have fields");
55+
56+
// Verify expected core fields exist
57+
assertTrue(bdtExample.containsKey("people"),
58+
"BDT.tSituation should define 'people' field");
59+
assertTrue(bdtExample.containsKey("primaryPersonId"),
60+
"BDT.tSituation should define 'primaryPersonId' field");
61+
62+
System.out.println("BDT.tSituation schema key: " + bdtSchemaKey);
63+
System.out.println("BDT.tSituation example fields: " + bdtExample.keySet());
64+
}
65+
66+
/**
67+
* Find the schema key for BDT.tSituation in dmnDefinitions.json.
68+
* Pattern: looks for a schema with x-dmn-type containing BDT namespace and tSituation.
69+
*/
70+
private String findBdtTSituationSchemaKey() {
71+
Set<String> allKeys = schemaResolver.getAllSchemaKeys();
72+
for (String key : allKeys) {
73+
JsonNode schema = schemaResolver.getSchema(key);
74+
if (schema != null && schema.has("x-dmn-type")) {
75+
String dmnType = schema.get("x-dmn-type").asText();
76+
if (dmnType.contains(BDT_NAMESPACE) && dmnType.contains("tSituation")) {
77+
return key;
78+
}
79+
}
80+
}
81+
return null;
82+
}
83+
84+
@Test
85+
public void testAllSituationInputsAreValidSubtypes() throws Exception {
86+
Map<String, ModelInfo> allModels = modelRegistry.getAllModels();
87+
88+
// Get all models with decision services (both benefits and checks)
89+
List<ModelInfo> modelsToValidate = allModels.values().stream()
90+
.filter(model -> model.getPath().startsWith("checks/") || model.getPath().startsWith("benefits/"))
91+
.filter(model -> !model.getDecisionServices().isEmpty())
92+
.collect(Collectors.toList());
93+
94+
assertTrue(modelsToValidate.size() > 0,
95+
"Should have at least one model to validate");
96+
97+
for (ModelInfo model : modelsToValidate) {
98+
validateSituationInput(model);
99+
}
100+
}
101+
102+
private void validateSituationInput(ModelInfo model) throws Exception {
103+
// Load and parse the DMN file (add .dmn extension to path)
104+
String dmnPath = model.getPath() + ".dmn";
105+
InputStream dmnStream = getClass().getClassLoader()
106+
.getResourceAsStream(dmnPath);
107+
assertNotNull(dmnStream,
108+
"DMN file should exist at " + dmnPath);
109+
110+
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
111+
factory.setNamespaceAware(true);
112+
DocumentBuilder builder = factory.newDocumentBuilder();
113+
Document doc = builder.parse(dmnStream);
114+
115+
// Find inputData elements with name="situation"
116+
NodeList inputDataElements = doc.getElementsByTagNameNS(DMN_NAMESPACE, "inputData");
117+
Element situationInput = null;
118+
119+
for (int i = 0; i < inputDataElements.getLength(); i++) {
120+
Element inputData = (Element) inputDataElements.item(i);
121+
if ("situation".equals(inputData.getAttribute("name"))) {
122+
situationInput = inputData;
123+
break;
124+
}
125+
}
126+
127+
// Not all models have a situation input (e.g., utility models like Age.dmn)
128+
if (situationInput == null) {
129+
System.out.println("Skipping " + model.getPath() + " - no 'situation' input found");
130+
return;
131+
}
132+
133+
// Find the variable element within inputData
134+
NodeList variables = situationInput.getElementsByTagNameNS(DMN_NAMESPACE, "variable");
135+
assertTrue(variables.getLength() > 0,
136+
model.getPath() + ": situation input must have a variable element");
137+
138+
Element variable = (Element) variables.item(0);
139+
String typeRef = variable.getAttribute("typeRef");
140+
141+
assertNotNull(typeRef,
142+
model.getPath() + ": situation variable must have a typeRef");
143+
assertFalse(typeRef.isEmpty(),
144+
model.getPath() + ": situation variable typeRef must not be empty");
145+
146+
// Case 1: Direct reference to BDT.tSituation - this is always valid
147+
if ("BDT.tSituation".equals(typeRef)) {
148+
System.out.println("✓ " + model.getPath() + " uses BDT.tSituation directly");
149+
return;
150+
}
151+
152+
// Case 2: Local tSituation reference - must validate it's a proper subset
153+
if ("tSituation".equals(typeRef)) {
154+
validateLocalTSituationType(model, doc);
155+
return;
156+
}
157+
158+
// Any other typeRef is invalid
159+
fail(model.getPath() + ": situation variable has unexpected typeRef '" + typeRef +
160+
"'. Expected 'BDT.tSituation' or 'tSituation'");
161+
}
162+
163+
private void validateLocalTSituationType(ModelInfo model, Document doc) {
164+
// Find BDT tSituation schema
165+
String bdtSchemaKey = findBdtTSituationSchemaKey();
166+
assertNotNull(bdtSchemaKey, "BDT.tSituation schema must exist");
167+
168+
// Find local tSituation schema from dmnDefinitions.json
169+
String localSchemaKey = findLocalTSituationSchemaKey(model);
170+
if (localSchemaKey == null) {
171+
fail(model.getPath() + ": uses typeRef='tSituation' but schema not found in dmnDefinitions.json");
172+
}
173+
174+
// Get schemas
175+
JsonNode bdtSchema = schemaResolver.getSchema(bdtSchemaKey);
176+
JsonNode localSchema = schemaResolver.getSchema(localSchemaKey);
177+
178+
assertNotNull(bdtSchema, "BDT schema should exist");
179+
assertNotNull(localSchema, "Local schema should exist for " + model.getPath());
180+
181+
// Check that local schema has properties (not empty/Any-only)
182+
if (!localSchema.has("properties") || !localSchema.get("properties").fields().hasNext()) {
183+
fail(model.getPath() + ": local tSituation must define at least one field from BDT.tSituation. " +
184+
"It currently has no fields (may just be typeRef='Any').");
185+
}
186+
187+
// Recursively validate that local schema is subset of BDT schema
188+
List<String> invalidPaths = new ArrayList<>();
189+
validateSchemaSubset(bdtSchema, localSchema, "", invalidPaths);
190+
191+
if (!invalidPaths.isEmpty()) {
192+
// Generate examples for error message
193+
Map<String, Object> bdtExample = schemaResolver.generateExampleFromSchema(bdtSchema);
194+
Map<String, Object> localExample = schemaResolver.generateExampleFromSchema(localSchema);
195+
196+
fail(model.getPath() + ": local tSituation defines structure not compatible with BDT.tSituation.\n" +
197+
"Invalid paths: " + invalidPaths + "\n" +
198+
"Local example: " + localExample + "\n" +
199+
"BDT example: " + bdtExample);
200+
}
201+
202+
// Generate example for success message
203+
Map<String, Object> localExample = schemaResolver.generateExampleFromSchema(localSchema);
204+
System.out.println("✓ " + model.getPath() + " uses valid local tSituation subset with fields: " +
205+
localExample.keySet());
206+
}
207+
208+
/**
209+
* Find the schema key for a model's local tSituation type.
210+
* Pattern: {nsN}tSituation where N is the namespace number for the model.
211+
*/
212+
private String findLocalTSituationSchemaKey(ModelInfo model) {
213+
String modelNamespace = model.getNamespace();
214+
Set<String> allKeys = schemaResolver.getAllSchemaKeys();
215+
216+
for (String key : allKeys) {
217+
JsonNode schema = schemaResolver.getSchema(key);
218+
if (schema != null && schema.has("x-dmn-type")) {
219+
String dmnType = schema.get("x-dmn-type").asText();
220+
// Check if this is tSituation from the model's namespace
221+
if (dmnType.contains(modelNamespace) && dmnType.contains("tSituation")) {
222+
return key;
223+
}
224+
}
225+
}
226+
return null;
227+
}
228+
229+
/**
230+
* Recursively validate that local schema is a structural and type-compatible subset of BDT schema.
231+
* All fields/paths in local schema must exist in BDT schema with compatible types and formats.
232+
*
233+
* @param bdtSchema The canonical BDT schema node
234+
* @param localSchema The local schema node to validate
235+
* @param currentPath The current path in dot notation (for error reporting)
236+
* @param invalidPaths List to accumulate invalid paths found
237+
*/
238+
private void validateSchemaSubset(JsonNode bdtSchema, JsonNode localSchema, String currentPath, List<String> invalidPaths) {
239+
// Resolve any $ref references
240+
JsonNode resolvedBdt = resolveSchemaRef(bdtSchema);
241+
JsonNode resolvedLocal = resolveSchemaRef(localSchema);
242+
243+
// If local schema is null or missing, it's valid (no constraint)
244+
if (resolvedLocal == null || resolvedLocal.isNull()) {
245+
return;
246+
}
247+
248+
// Handle object types (have "properties")
249+
if (resolvedLocal.has("properties")) {
250+
if (!resolvedBdt.has("properties")) {
251+
invalidPaths.add(currentPath + " [BDT is not an object but local is]");
252+
return;
253+
}
254+
255+
JsonNode localProps = resolvedLocal.get("properties");
256+
JsonNode bdtProps = resolvedBdt.get("properties");
257+
258+
// Check each local property
259+
localProps.fields().forEachRemaining(entry -> {
260+
String fieldName = entry.getKey();
261+
String newPath = currentPath.isEmpty() ? fieldName : currentPath + "." + fieldName;
262+
263+
// Check if field exists in BDT
264+
if (!bdtProps.has(fieldName)) {
265+
invalidPaths.add(newPath + " [field does not exist in BDT]");
266+
return;
267+
}
268+
269+
// Recursively validate the field's schema
270+
validateSchemaSubset(bdtProps.get(fieldName), entry.getValue(), newPath, invalidPaths);
271+
});
272+
return;
273+
}
274+
275+
// Handle array types (have "items")
276+
if (resolvedLocal.has("items")) {
277+
if (!resolvedBdt.has("items")) {
278+
invalidPaths.add(currentPath + " [BDT is not an array but local is]");
279+
return;
280+
}
281+
282+
String newPath = currentPath + "[]";
283+
validateSchemaSubset(resolvedBdt.get("items"), resolvedLocal.get("items"), newPath, invalidPaths);
284+
return;
285+
}
286+
287+
// Handle primitive types - check type and format compatibility
288+
String localType = resolvedLocal.has("type") ? resolvedLocal.get("type").asText() : null;
289+
String bdtType = resolvedBdt.has("type") ? resolvedBdt.get("type").asText() : null;
290+
291+
if (localType != null && bdtType != null && !localType.equals(bdtType)) {
292+
invalidPaths.add(currentPath + " [type mismatch: BDT has " + bdtType + ", local has " + localType + "]");
293+
return;
294+
}
295+
296+
// Check format (important for date vs string distinction)
297+
String localFormat = resolvedLocal.has("format") ? resolvedLocal.get("format").asText() : null;
298+
String bdtFormat = resolvedBdt.has("format") ? resolvedBdt.get("format").asText() : null;
299+
300+
// Both should have same format, or both should have no format
301+
if (!Objects.equals(localFormat, bdtFormat)) {
302+
String localDesc = localFormat != null ? localFormat : "no format";
303+
String bdtDesc = bdtFormat != null ? bdtFormat : "no format";
304+
invalidPaths.add(currentPath + " [format mismatch: BDT has " + bdtDesc + ", local has " + localDesc + "]");
305+
}
306+
}
307+
308+
/**
309+
* Resolve a JSON Schema $ref to get the actual schema definition.
310+
* Uses DMNSchemaResolver's schema cache.
311+
*/
312+
private JsonNode resolveSchemaRef(JsonNode schema) {
313+
if (schema == null || !schema.has("$ref")) {
314+
return schema;
315+
}
316+
317+
String ref = schema.get("$ref").asText();
318+
String refKey = null;
319+
320+
if (ref.startsWith("#/components/schemas/")) {
321+
refKey = ref.substring("#/components/schemas/".length());
322+
} else if (ref.startsWith("#/definitions/")) {
323+
refKey = ref.substring("#/definitions/".length());
324+
}
325+
326+
if (refKey != null) {
327+
JsonNode refSchema = schemaResolver.getSchema(refKey);
328+
if (refSchema != null) {
329+
// Recursively resolve in case the referenced schema is also a reference
330+
return resolveSchemaRef(refSchema);
331+
}
332+
}
333+
334+
return schema;
335+
}
336+
}

0 commit comments

Comments
 (0)