From fcc7b70314669291e7e0e175db8b2a5fde64945a Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Tue, 22 Sep 2026 15:18:59 +0200 Subject: [PATCH] fix(compiler): honor minOccurs/maxOccurs on wrapping compositors collectParticles and collectWildcards in src/compiler/schemaCompiler.ts recursed into nested xs:sequence/xs:all/xs:choice compositor nodes without ever reading the compositor's own minOccurs/maxOccurs, so bounds declared on a wrapping compositor (e.g. ) were silently dropped and the contained element/xs:any particles kept only their own occurrence, producing a singular type instead of an array. Thread the inherited compositor occurrence through both recursions and combine it multiplicatively with each particle's own bounds (matching XSD group-multiplicity semantics), via two small helpers (combineOccurrence, applyInheritedOccurrence) mirroring a fix already validated in production against a local dist/ patch. Add test/unit/compiler-compositor-occurs.test.ts covering the issue's exact example, min/max multiplication, unbounded propagation, and xs:any wildcards. Update test/unit/client-choice-union-types.test.ts, whose all-optional-mode assertions were unknowingly encoding this same bug's output; the fix now produces the optional-field shape already documented in docs/concepts.md for xs:choice with its own minOccurs="0". Verified against a real-world production WSDL: the freshly generated catalog.json is structurally identical (all types, all min/max) to output previously validated via the user's own dist/ patch. Closes #141 --- CHANGELOG.md | 1 + src/compiler/schemaCompiler.ts | 47 ++++-- test/unit/client-choice-union-types.test.ts | 7 +- test/unit/compiler-compositor-occurs.test.ts | 156 +++++++++++++++++++ 4 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 test/unit/compiler-compositor-occurs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 503fda9..eea5b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## [Unreleased] +- fix(compiler): honor minOccurs/maxOccurs on wrapping sequence/choice/all compositors and xs:any wildcards - test(stream): wait for readable cleanup after cancellation on Node 26 ## [1.0.6] - 2026-08-29 diff --git a/src/compiler/schemaCompiler.ts b/src/compiler/schemaCompiler.ts index 22caf21..349a5dc 100644 --- a/src/compiler/schemaCompiler.ts +++ b/src/compiler/schemaCompiler.ts @@ -803,11 +803,9 @@ export function compileCatalog( // collectParticles' return type at the three call sites below. const collectWildcards = (node: any): CompiledWildcard[] => { const out: CompiledWildcard[] = []; - const recurse = (groupNode: any) => { + const recurse = (groupNode: any, inherited: {min: number; max: number | "unbounded"}) => { for (const a of getChildrenWithLocalName(groupNode, "any")) { - const min = a["@_minOccurs"] ? Number(a["@_minOccurs"]) : 1; - const maxAttr = a["@_maxOccurs"]; - const max = maxAttr === "unbounded" ? "unbounded" : maxAttr ? Number(maxAttr) : 1; + const {min, max} = applyInheritedOccurrence(readOccurrence(a), inherited); const pc = a["@_processContents"] as string | undefined; out.push({ min, @@ -818,11 +816,11 @@ export function compileCatalog( } for (const comp of ["sequence", "all", "choice"]) { for (const sub of getChildrenWithLocalName(groupNode, comp)) { - recurse(sub); + recurse(sub, combineOccurrence(inherited, readOccurrence(sub))); } } }; - recurse(node); + recurse(node, {min: 1, max: 1}); return out; }; @@ -845,6 +843,32 @@ export function compileCatalog( return {min, max}; }; + // A wrapping compositor (xs:sequence/xs:all/xs:choice) can carry its own + // minOccurs/maxOccurs, which XSD group-multiplicity semantics apply to every + // particle found inside it. combineOccurrence/applyInheritedOccurrence thread + // that inherited bound down through collectParticles/collectWildcards below. + const combineOccurrence = ( + a: {min: number; max: number | "unbounded"}, + b: {min: number; max: number | "unbounded"} + ): {min: number; max: number | "unbounded"} => ({ + min: a.min * b.min, + max: a.max === "unbounded" || b.max === "unbounded" ? "unbounded" : a.max * b.max, + }); + + const applyInheritedOccurrence = ( + particle: T, + inherited: {min: number; max: number | "unbounded"} + ): T => { + if (inherited.min === 1 && inherited.max === 1) return particle; + return { + ...particle, + min: particle.min * inherited.min, + max: particle.max === "unbounded" || inherited.max === "unbounded" + ? "unbounded" + : particle.max * inherited.max, + }; + }; + const localNameOf = (key: string): string => { const idx = key.indexOf(":"); return idx >= 0 ? key.slice(idx + 1) : key; @@ -918,20 +942,21 @@ export function compileCatalog( const collectParticles = (ownerTypeName: string, node: any): CompiledType["elems"] => { const out: CompiledType["elems"] = []; // process a compositor or element container recursively - const recurse = (groupNode: any) => { + const recurse = (groupNode: any, inherited: {min: number; max: number | "unbounded"}) => { // handle direct element children for (const e of getChildrenWithLocalName(groupNode, "element")) { const particle = compileElementParticle(ownerTypeName, e); - if (particle) out.push(particle); + if (particle) out.push(applyInheritedOccurrence(particle, inherited)); } - // recurse into nested compositor groups + // recurse into nested compositor groups, propagating each group's own + // occurrence bounds down onto the particles found inside it for (const comp of ["sequence", "all", "choice"]) { for (const sub of getChildrenWithLocalName(groupNode, comp)) { - recurse(sub); + recurse(sub, combineOccurrence(inherited, readOccurrence(sub))); } } }; - recurse(node); + recurse(node, {min: 1, max: 1}); return out; }; diff --git a/test/unit/client-choice-union-types.test.ts b/test/unit/client-choice-union-types.test.ts index 0a851e4..989a048 100644 --- a/test/unit/client-choice-union-types.test.ts +++ b/test/unit/client-choice-union-types.test.ts @@ -49,8 +49,11 @@ describe("client types: choice union mode", () => { expect(source).toContain("export interface SearchRequest {"); expect(source).toContain("tenantId: string;"); - expect(source).toContain("email: string;"); - expect(source).toContain("phone: number;"); + // The fixture's xs:choice carries its own minOccurs="0" (see SEARCH_CHOICE_SCHEMA), + // so both branch fields are optional here, matching the documented all-optional + // shape in docs/concepts.md ("emits parallel optional choice fields"). + expect(source).toContain("email?: string;"); + expect(source).toContain("phone?: number;"); expect(source).not.toContain("SearchRequestChoiceBase"); expect(source).not.toContain("SearchRequestChoice1"); }); diff --git a/test/unit/compiler-compositor-occurs.test.ts b/test/unit/compiler-compositor-occurs.test.ts new file mode 100644 index 0000000..f318cad --- /dev/null +++ b/test/unit/compiler-compositor-occurs.test.ts @@ -0,0 +1,156 @@ +import {afterAll, describe, expect, it} from "vitest"; +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import {compileCatalog, type CompiledCatalog} from "../../src/compiler/schemaCompiler.js"; +import {resolveCompilerOptions} from "../../src/config.js"; +import {loadWsdl} from "../../src/loader/wsdlLoader.js"; + +const tmpRoot = mkdtempSync(join(tmpdir(), "wsdl-compositor-occurs-")); + +afterAll(() => { + rmSync(tmpRoot, {recursive: true, force: true}); +}); + +function buildWsdl(schemaBody: string): string { + return ` + + + + ${schemaBody} + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +async function compileFromFixture(wsdl: string, name: string): Promise { + const wsdlPath = join(tmpRoot, `${name}.wsdl`); + writeFileSync(wsdlPath, wsdl, "utf8"); + const wsdlCatalog = await loadWsdl(wsdlPath); + return compileCatalog( + wsdlCatalog, + resolveCompilerOptions({}, {wsdl: wsdlPath, out: tmpRoot}), + ); +} + +describe("compiler: compositor-level minOccurs/maxOccurs", () => { + it("applies a wrapping xs:sequence's own maxOccurs to its element particles (issue #141)", async () => { + const schema = ` + + + + + + + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-max-2"); + const addresses = compiled.types.find((t) => t.elems.some((e) => e.name === "address")); + expect(addresses, "type carrying the address particle must be compiled").toBeTruthy(); + const address = addresses!.elems.find((e) => e.name === "address"); + expect(address).toMatchObject({min: 1, max: 2}); + }); + + it("multiplies min and max when both the compositor and the element declare occurrence bounds", async () => { + const schema = ` + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-min-0-elem-max-3"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + const item = pingResponse!.elems.find((e) => e.name === "item"); + // outer sequence: {min:0, max:1 (default)} x element: {min:1 (default), max:3} => {min:0, max:3} + expect(item).toMatchObject({min: 0, max: 3}); + }); + + it("propagates unbounded from a nested compositor rather than multiplying it as a number", async () => { + const schema = ` + + + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-unbounded"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + const row = pingResponse!.elems.find((e) => e.name === "row"); + expect(row).toMatchObject({min: 0, max: "unbounded"}); + }); + + it("applies a wrapping compositor's occurs to a nested xs:any wildcard", async () => { + const schema = ` + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "sequence-wildcard-max-2"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + expect(pingResponse!.wildcards).toEqual([ + {min: 1, max: 2, namespace: "##other", processContents: "lax"}, + ]); + }); + + it("leaves element occurrence untouched when no compositor declares its own bounds", async () => { + const schema = ` + + + + + + + + `; + const compiled = await compileFromFixture(buildWsdl(schema), "no-compositor-occurs"); + const pingResponse = compiled.types.find((t) => t.name === "PingResponse"); + const ok = pingResponse!.elems.find((e) => e.name === "ok"); + expect(ok).toMatchObject({min: 1, max: 1}); + }); +});