Skip to content

Commit 1b9db88

Browse files
committed
fix(DataMapper): Support circular xs:include
Fixes: #3206 While it is invalid from XML schema perspective, there is(are) well known XML schema such as FPML having it. Support it anyway.
1 parent 9914e63 commit 1b9db88

7 files changed

Lines changed: 155 additions & 15 deletions

File tree

packages/ui/src/services/document/xml-schema/xml-schema-analysis.service.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,18 @@ ${elements}
7575
expect(result.errors[0].filePath).toBe('A.xsd');
7676
});
7777

78-
it('should detect circular includes', () => {
78+
it('should warn on circular includes instead of erroring', () => {
7979
const files = {
8080
'A.xsd': makeSchema({ includes: ['B.xsd'] }),
8181
'B.xsd': makeSchema({ includes: ['A.xsd'] }),
8282
};
8383
const result = XmlSchemaAnalysisService.analyze(files);
8484
const circularErrors = result.errors.filter((e) => e.message.includes('Circular'));
85-
expect(circularErrors.length).toBeGreaterThan(0);
86-
expect(circularErrors[0].message).toContain('A.xsd');
87-
expect(circularErrors[0].message).toContain('B.xsd');
88-
expect(circularErrors[0].filePath).toBeUndefined();
85+
expect(circularErrors).toHaveLength(0);
86+
const circularWarnings = result.warnings.filter((w) => w.message.includes('Circular xs:include'));
87+
expect(circularWarnings.length).toBeGreaterThan(0);
88+
expect(circularWarnings[0].message).toContain('A.xsd');
89+
expect(circularWarnings[0].message).toContain('B.xsd');
8990
});
9091

9192
it('should allow circular imports (different namespaces)', () => {

packages/ui/src/services/document/xml-schema/xml-schema-analysis.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ export class XmlSchemaAnalysisService {
7373
}
7474
}
7575

76-
const circularErrors = XmlSchemaAnalysisService.detectCircularIncludes(fileInfos, edges);
77-
errors.push(...circularErrors);
76+
const circularIncludeWarnings = XmlSchemaAnalysisService.detectCircularIncludes(fileInfos, edges);
7877

7978
const warnings = XmlSchemaAnalysisService.detectCircularImports(fileInfos, edges);
79+
warnings.push(...circularIncludeWarnings);
8080

8181
const includeNsErrors = XmlSchemaAnalysisService.validateIncludeNamespaces(fileInfos, edges);
8282
errors.push(...includeNsErrors);

packages/ui/src/services/document/xml-schema/xml-schema-document.service.schema-files.test.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
} from '../../../models/datamapper/document';
77
import { IFieldSubstitution } from '../../../models/datamapper/metadata';
88
import { NS_XML_SCHEMA } from '../../../models/datamapper/standard-namespaces';
9-
import { FieldOverrideVariant } from '../../../models/datamapper/types';
9+
import { FieldOverrideVariant, Types } from '../../../models/datamapper/types';
1010
import {
11+
getCircularIncludeAXsd,
12+
getCircularIncludeBXsd,
1113
getCommonTypesXsd,
1214
getElementRefXsd,
1315
getFieldSubstitutionXsd,
@@ -273,7 +275,7 @@ describe('XmlSchemaDocumentService / schema file management', () => {
273275
expect(result.errors!.length).toBeGreaterThan(0);
274276
});
275277

276-
it('should return error for circular includes', () => {
278+
it('should warn but succeed for circular includes', () => {
277279
const schemaA = `<?xml version="1.0" encoding="UTF-8"?>
278280
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
279281
<xs:include schemaLocation="B.xsd"/>
@@ -293,10 +295,45 @@ describe('XmlSchemaDocumentService / schema file management', () => {
293295
{ 'A.xsd': schemaA, 'B.xsd': schemaB },
294296
);
295297
const result = XmlSchemaDocumentService.createXmlSchemaDocument(definition);
296-
expect(result.validationStatus).toBe('error');
297-
expect(result.errors![0].message).toContain('Circular xs:include');
298-
expect(result.errors).toBeDefined();
299-
expect(result.errors!.length).toBeGreaterThan(0);
298+
expect(result.validationStatus).toBe('warning');
299+
expect(result.warnings).toBeDefined();
300+
expect(result.warnings!.some((w) => w.message.includes('Circular xs:include'))).toBe(true);
301+
expect(result.document).toBeDefined();
302+
});
303+
304+
it('should resolve cross-referenced types in circular includes', () => {
305+
const definition = new DocumentDefinition(
306+
DocumentType.SOURCE_BODY,
307+
DocumentDefinitionType.XML_SCHEMA,
308+
BODY_DOCUMENT_ID,
309+
{
310+
'CircularIncludeA.xsd': getCircularIncludeAXsd(),
311+
'CircularIncludeB.xsd': getCircularIncludeBXsd(),
312+
},
313+
);
314+
const result = XmlSchemaDocumentService.createXmlSchemaDocument(definition);
315+
expect(result.validationStatus).toBe('warning');
316+
expect(result.warnings!.some((w) => w.message.includes('Circular xs:include'))).toBe(true);
317+
const document = result.document as XmlSchemaDocument;
318+
expect(document).toBeDefined();
319+
expect(document.fields[0].name).toBe('Root');
320+
321+
const orderField = document.fields[0].fields.find((f) => f.name === 'order');
322+
expect(orderField).toBeDefined();
323+
expect(orderField!.type).toBe(Types.Container);
324+
325+
const NS = 'http://example.com/circular';
326+
const orderTypeQName = new QName(NS, 'OrderType');
327+
expect(document.xmlSchemaCollection.getTypeByQName(orderTypeQName)).toBeDefined();
328+
const productTypeQName = new QName(NS, 'ProductType');
329+
expect(document.xmlSchemaCollection.getTypeByQName(productTypeQName)).toBeDefined();
330+
331+
const orderFragment = document.namedTypeFragments[orderTypeQName.toString()];
332+
expect(orderFragment).toBeDefined();
333+
expect(orderFragment.fields.some((f) => f.name === 'product')).toBe(true);
334+
const productFragment = document.namedTypeFragments[productTypeQName.toString()];
335+
expect(productFragment).toBeDefined();
336+
expect(productFragment.fields.some((f) => f.name === 'productName')).toBe(true);
300337
});
301338

302339
it('should warn with circular imports (different namespaces)', () => {

packages/ui/src/services/document/xml-schema/xml-schema-document.service.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ export class XmlSchemaDocumentService {
143143
*/
144144
private static loadSchemaFiles(
145145
collection: XmlSchemaCollection,
146-
analysis: { loadOrder: string[]; edges: Array<{ directive: { type: string }; to: string }> },
146+
analysis: { loadOrder: string[]; edges: Array<{ directive: { type: string }; to: string; from: string }> },
147147
definitionFiles: Record<string, string>,
148148
): { error?: { message: string; filePath?: string } } {
149-
const includeTargets = new Set(analysis.edges.filter((e) => e.directive.type === 'include').map((e) => e.to));
149+
const includeTargets = XmlSchemaDocumentService.computeIncludeTargetsToSkip(analysis.loadOrder, analysis.edges);
150150

151151
try {
152152
for (const path of analysis.loadOrder) {
@@ -162,6 +162,62 @@ export class XmlSchemaDocumentService {
162162
}
163163
}
164164

165+
/**
166+
* Determines which include-target files can safely be skipped during direct loading.
167+
* Normally, files that are xs:include targets are loaded indirectly when their parent
168+
* schema processes the include directive. However, with circular includes (A includes B,
169+
* B includes A), all files in the cycle become include targets — skipping them all means
170+
* nothing loads. This method ensures at least one file per isolated cycle is promoted to
171+
* direct loading so the SchemaBuilder can process the cycle via its built-in
172+
* `collection.check(key)` guard.
173+
*/
174+
private static computeIncludeTargetsToSkip(
175+
loadOrder: string[],
176+
edges: Array<{ directive: { type: string }; to: string; from: string }>,
177+
): Set<string> {
178+
const includeEdges = edges.filter((e) => e.directive.type === 'include');
179+
const includeTargets = new Set(includeEdges.map((e) => e.to));
180+
const adjacencyMap = XmlSchemaDocumentService.buildIncludeAdjacency(includeEdges);
181+
182+
const reachable = new Set<string>();
183+
const seeds = loadOrder.filter((p) => !includeTargets.has(p));
184+
XmlSchemaDocumentService.bfsReachable(seeds, adjacencyMap, reachable);
185+
186+
for (const path of loadOrder) {
187+
if (reachable.has(path)) continue;
188+
includeTargets.delete(path);
189+
XmlSchemaDocumentService.bfsReachable([path], adjacencyMap, reachable);
190+
}
191+
192+
return includeTargets;
193+
}
194+
195+
private static buildIncludeAdjacency(includeEdges: Array<{ from: string; to: string }>): Map<string, string[]> {
196+
const adjacencyMap = new Map<string, string[]>();
197+
for (const edge of includeEdges) {
198+
const list = adjacencyMap.get(edge.from) ?? [];
199+
list.push(edge.to);
200+
adjacencyMap.set(edge.from, list);
201+
}
202+
return adjacencyMap;
203+
}
204+
205+
private static bfsReachable(seeds: string[], adj: Map<string, string[]>, reachable: Set<string>): void {
206+
const queue = [...seeds];
207+
for (const seed of seeds) {
208+
reachable.add(seed);
209+
}
210+
while (queue.length > 0) {
211+
const file = queue.shift()!;
212+
for (const target of adj.get(file) ?? []) {
213+
if (!reachable.has(target)) {
214+
reachable.add(target);
215+
queue.push(target);
216+
}
217+
}
218+
}
219+
}
220+
165221
/**
166222
* Helper method to validate the schema collection.
167223
*/

packages/ui/src/stubs/datamapper/data-mapper.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ export function getMultiIncludeComponentAXsd(): string {
265265
export function getMultiIncludeComponentBXsd(): string {
266266
return readStubFile('./xml/MultiIncludeComponentB.xsd');
267267
}
268+
export function getCircularIncludeAXsd(): string {
269+
return readStubFile('./xml/CircularIncludeA.xsd');
270+
}
271+
export function getCircularIncludeBXsd(): string {
272+
return readStubFile('./xml/CircularIncludeB.xsd');
273+
}
268274
export function getInlineAttrSimpleTypeXsd(): string {
269275
return readStubFile('./xml/InlineAttrSimpleType.xsd');
270276
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3+
targetNamespace="http://example.com/circular"
4+
xmlns:tns="http://example.com/circular"
5+
elementFormDefault="qualified">
6+
7+
<xs:include schemaLocation="CircularIncludeB.xsd"/>
8+
9+
<xs:element name="Root">
10+
<xs:complexType>
11+
<xs:sequence>
12+
<xs:element name="order" type="tns:OrderType"/>
13+
</xs:sequence>
14+
</xs:complexType>
15+
</xs:element>
16+
17+
<xs:complexType name="OrderType">
18+
<xs:sequence>
19+
<xs:element name="orderId" type="xs:string"/>
20+
<xs:element name="product" type="tns:ProductType"/>
21+
</xs:sequence>
22+
</xs:complexType>
23+
24+
</xs:schema>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3+
targetNamespace="http://example.com/circular"
4+
xmlns:tns="http://example.com/circular"
5+
elementFormDefault="qualified">
6+
7+
<xs:include schemaLocation="CircularIncludeA.xsd"/>
8+
9+
<xs:complexType name="ProductType">
10+
<xs:sequence>
11+
<xs:element name="productName" type="xs:string"/>
12+
<xs:element name="price" type="xs:decimal"/>
13+
</xs:sequence>
14+
</xs:complexType>
15+
16+
</xs:schema>

0 commit comments

Comments
 (0)