diff --git a/assets/api/writer-generator/typescript/profile-helpers.ts b/assets/api/writer-generator/typescript/profile-helpers.ts index 239ec0d0..2c159297 100644 --- a/assets/api/writer-generator/typescript/profile-helpers.ts +++ b/assets/api/writer-generator/typescript/profile-helpers.ts @@ -296,6 +296,26 @@ export const getArraySlice = (list: readonly T[] | undefined, match: Record matchesValue(item, match)); }; +/** Return all elements in `list` that satisfy the slice discriminator `match`. */ +export const getArraySliceAll = (list: readonly T[] | undefined, match: Record): T[] => { + if (!list) return []; + return list.filter((item) => matchesValue(item, match)); +}; + +/** + * Replace all elements matching `match` in `list` with `newItems`. + * Each new item has the discriminator values applied via {@link applySliceMatch} + * before this call, so this helper only handles the array surgery. + */ +export const setArraySliceAll = (list: T[], match: Record, newItems: T[]): void => { + // Remove all existing items that match the discriminator + for (let i = list.length - 1; i >= 0; i--) { + if (matchesValue(list[i], match)) list.splice(i, 1); + } + // Append new items + list.push(...newItems); +}; + // --------------------------------------------------------------------------- // Validation helpers // diff --git a/examples/local-package-folder/profile-typed-bundle.test.ts b/examples/local-package-folder/profile-typed-bundle.test.ts index a47a5feb..02aa258a 100644 --- a/examples/local-package-folder/profile-typed-bundle.test.ts +++ b/examples/local-package-folder/profile-typed-bundle.test.ts @@ -17,62 +17,112 @@ import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient"; const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] }; const activePatient: Patient = { resourceType: "Patient", active: true }; const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" }; +const acmeOrg: Organization = { resourceType: "Organization", name: "Acme" }; -describe("type-discriminated bundle slices", () => { - test("create() starts with no entry — PatientEntry must be set by user", () => { +describe("demo: single-element slice (max: 1) — PatientEntry", () => { + test("create, set, and validate a typed patient entry", () => { const bundle = ExampleTypedBundleProfile.create({ type: "collection" }); - expect(bundle.toResource().entry).toBeUndefined(); + + // No entry yet — validation fails (PatientEntry min: 1) expect(bundle.validate().errors).toEqual([ "ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0", ]); - }); - test("setPatientEntry inserts a typed patient entry", () => { - const bundle = ExampleTypedBundleProfile.create({ type: "collection" }); + // Set a single patient entry — typed as BundleEntry bundle.setPatientEntry({ resource: smithPatient }); + expect(bundle.validate().errors).toEqual([]); + // Getter returns the entry with resource narrowed to Patient const entry = bundle.getPatientEntry()!; expect(entry.resource).toEqual(smithPatient); - expect(bundle.validate().errors).toEqual([]); }); - test("setPatientEntry replaces existing patient entry (no duplicates)", () => { + test("setPatientEntry replaces existing entry (no duplicates)", () => { const bundle = ExampleTypedBundleProfile.create({ type: "collection" }); bundle.setPatientEntry({ resource: smithPatient }); bundle.setPatientEntry({ resource: activePatient }); - const entries = bundle.toResource().entry!; - expect(entries).toHaveLength(1); - expect(entries[0]!.resource).toEqual(activePatient); + // Only one patient entry — the second call replaced the first + expect(bundle.toResource().entry).toHaveLength(1); + expect(bundle.getPatientEntry()!.resource).toEqual(activePatient); }); +}); - test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => { - const bundle = ExampleTypedBundleProfile.create({ type: "collection" }); - bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient }); +describe("demo: unbounded slice (max: *) — OrganizationEntry", () => { + test("setter accepts an array, getter returns an array", () => { + const bundle = ExampleTypedBundleProfile.create({ type: "collection" }).setPatientEntry({ + resource: activePatient, + }); + + // Set multiple organization entries at once + bundle.setOrganizationEntry([{ resource: clinicOrg }, { resource: acmeOrg }]); - const flat = bundle.getPatientEntry("flat")!; - expect(flat.fullUrl).toBe("urn:uuid:patient-1"); - expect(flat.resource).toEqual(activePatient); + // Getter returns all matching entries as an array (undefined if none) + const orgs = bundle.getOrganizationEntry()!; + expect(orgs).toHaveLength(2); + expect(orgs[0]!.resource).toEqual(clinicOrg); + expect(orgs[1]!.resource).toEqual(acmeOrg); + + // Total entries: 1 patient + 2 organizations + expect(bundle.toResource().entry).toHaveLength(3); }); - test("fluent chaining across slice setters", () => { + test("setOrganizationEntry replaces all previous org entries", () => { const bundle = ExampleTypedBundleProfile.create({ type: "collection" }) .setPatientEntry({ resource: activePatient }) - .setOrganizationEntry({ resource: clinicOrg }); + .setOrganizationEntry([{ resource: clinicOrg }, { resource: acmeOrg }]); + + // Replace with a single org — previous two are removed + bundle.setOrganizationEntry([{ resource: { resourceType: "Organization", name: "NewCo" } }]); + + const orgs = bundle.getOrganizationEntry()!; + expect(orgs).toHaveLength(1); + expect(orgs[0]!.resource!.name).toBe("NewCo"); - expect(bundle.toResource().entry).toHaveLength(2); + // Patient entry unaffected expect(bundle.getPatientEntry()!.resource).toEqual(activePatient); - expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg); }); - test("setOrganizationEntry replaces existing org entry (same discriminator)", () => { + test("append to existing entries via spread", () => { const bundle = ExampleTypedBundleProfile.create({ type: "collection" }) .setPatientEntry({ resource: activePatient }) - .setOrganizationEntry({ resource: clinicOrg }) - .setOrganizationEntry({ resource: { resourceType: "Organization", name: "Acme" } }); + .setOrganizationEntry([{ resource: clinicOrg }]); + + // Append a new org by spreading existing entries + bundle.setOrganizationEntry([...(bundle.getOrganizationEntry() ?? []), { resource: acmeOrg }]); + + const orgs = bundle.getOrganizationEntry()!; + expect(orgs).toHaveLength(2); + expect(orgs[0]!.resource).toEqual(clinicOrg); + expect(orgs[1]!.resource).toEqual(acmeOrg); + }); + + test("empty array removes all org entries, getter returns undefined", () => { + const bundle = ExampleTypedBundleProfile.create({ type: "collection" }) + .setPatientEntry({ resource: activePatient }) + .setOrganizationEntry([{ resource: clinicOrg }]); + + bundle.setOrganizationEntry([]); + + // No matching entries — returns undefined, not empty array + expect(bundle.getOrganizationEntry()).toBeUndefined(); + // Patient entry still present + expect(bundle.toResource().entry).toHaveLength(1); + }); +}); + +describe("fluent chaining across slice types", () => { + test("chain single and array setters", () => { + const bundle = ExampleTypedBundleProfile.create({ type: "collection" }) + .setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient }) + .setOrganizationEntry([ + { fullUrl: "urn:uuid:org-1", resource: clinicOrg }, + { fullUrl: "urn:uuid:org-2", resource: acmeOrg }, + ]); - const entries = bundle.toResource().entry!; - expect(entries).toHaveLength(2); - expect(bundle.getOrganizationEntry()!.resource!.name).toBe("Acme"); + expect(bundle.toResource().entry).toHaveLength(3); + expect(bundle.getPatientEntry()!.fullUrl).toBe("urn:uuid:patient-1"); + expect(bundle.getOrganizationEntry()![0]!.fullUrl).toBe("urn:uuid:org-1"); + expect(bundle.getOrganizationEntry()![1]!.fullUrl).toBe("urn:uuid:org-2"); }); }); diff --git a/examples/typescript-r4/fhir-types/profile-helpers.ts b/examples/typescript-r4/fhir-types/profile-helpers.ts index 239ec0d0..2c159297 100644 --- a/examples/typescript-r4/fhir-types/profile-helpers.ts +++ b/examples/typescript-r4/fhir-types/profile-helpers.ts @@ -296,6 +296,26 @@ export const getArraySlice = (list: readonly T[] | undefined, match: Record matchesValue(item, match)); }; +/** Return all elements in `list` that satisfy the slice discriminator `match`. */ +export const getArraySliceAll = (list: readonly T[] | undefined, match: Record): T[] => { + if (!list) return []; + return list.filter((item) => matchesValue(item, match)); +}; + +/** + * Replace all elements matching `match` in `list` with `newItems`. + * Each new item has the discriminator values applied via {@link applySliceMatch} + * before this call, so this helper only handles the array surgery. + */ +export const setArraySliceAll = (list: T[], match: Record, newItems: T[]): void => { + // Remove all existing items that match the discriminator + for (let i = list.length - 1; i >= 0; i--) { + if (matchesValue(list[i], match)) list.splice(i, 1); + } + // Append new items + list.push(...newItems); +}; + // --------------------------------------------------------------------------- // Validation helpers // diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts index 4794ccf3..cf40c2ef 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts @@ -23,6 +23,8 @@ import { setArraySlice, getArraySlice, ensureSliceDefaults, + setArraySliceAll, + getArraySliceAll, wrapSliceChoice, unwrapSliceChoice, isExtension, @@ -186,15 +188,11 @@ export class USCoreEthnicityExtensionProfile { return this } - public setExtensionDetailed (input?: USCoreEthnicityExtension_Extension_DetailedSliceFlat | Extension): this { + public setExtensionDetailed (input: (USCoreEthnicityExtension_Extension_DetailedSliceFlat | Extension)[]): this { const match = USCoreEthnicityExtensionProfile.detailedSliceMatch - if (input && matchesValue(input, match)) { - setArraySlice(this.resource.extension ??= [], match, input as Extension) - return this - } - const wrapped = wrapSliceChoice(input ?? {}, "valueCoding") - const value = applySliceMatch(wrapped, match) - setArraySlice(this.resource.extension ??= [], match, value) + const arr = this.resource.extension ??= [] + const values = input.map(item => matchesValue(item, match) ? item as Extension : applySliceMatch(wrapSliceChoice(item, "valueCoding"), match)) + setArraySliceAll(arr, match, values) return this } @@ -220,15 +218,15 @@ export class USCoreEthnicityExtensionProfile { return unwrapSliceChoice(item, ["url"], "valueCoding") } - public getExtensionDetailed(mode: 'flat'): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | undefined; - public getExtensionDetailed(mode: 'raw'): Extension | undefined; - public getExtensionDetailed(): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | undefined; - public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | Extension | undefined { + public getExtensionDetailed(mode: 'flat'): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll[] | undefined; + public getExtensionDetailed(mode: 'raw'): Extension[] | undefined; + public getExtensionDetailed(): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll[] | undefined; + public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): (USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | Extension)[] | undefined { const match = USCoreEthnicityExtensionProfile.detailedSliceMatch - const item = getArraySlice(this.resource.extension, match) - if (!item) return undefined - if (mode === 'raw') return item - return unwrapSliceChoice(item, ["url"], "valueCoding") + const items = getArraySliceAll(this.resource.extension, match) + if (items.length === 0) return undefined + if (mode === 'raw') return items + return items.map(item => unwrapSliceChoice(item, ["url"], "valueCoding")) } public getExtensionText(mode: 'flat'): USCoreEthnicityExtension_Extension_TextSliceFlatAll | undefined; diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts index da6e4171..a6e85b68 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts @@ -23,6 +23,8 @@ import { setArraySlice, getArraySlice, ensureSliceDefaults, + setArraySliceAll, + getArraySliceAll, wrapSliceChoice, unwrapSliceChoice, isExtension, @@ -186,15 +188,11 @@ export class USCoreRaceExtensionProfile { return this } - public setExtensionDetailed (input?: USCoreRaceExtension_Extension_DetailedSliceFlat | Extension): this { + public setExtensionDetailed (input: (USCoreRaceExtension_Extension_DetailedSliceFlat | Extension)[]): this { const match = USCoreRaceExtensionProfile.detailedSliceMatch - if (input && matchesValue(input, match)) { - setArraySlice(this.resource.extension ??= [], match, input as Extension) - return this - } - const wrapped = wrapSliceChoice(input ?? {}, "valueCoding") - const value = applySliceMatch(wrapped, match) - setArraySlice(this.resource.extension ??= [], match, value) + const arr = this.resource.extension ??= [] + const values = input.map(item => matchesValue(item, match) ? item as Extension : applySliceMatch(wrapSliceChoice(item, "valueCoding"), match)) + setArraySliceAll(arr, match, values) return this } @@ -220,15 +218,15 @@ export class USCoreRaceExtensionProfile { return unwrapSliceChoice(item, ["url"], "valueCoding") } - public getExtensionDetailed(mode: 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll | undefined; - public getExtensionDetailed(mode: 'raw'): Extension | undefined; - public getExtensionDetailed(): USCoreRaceExtension_Extension_DetailedSliceFlatAll | undefined; - public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll | Extension | undefined { + public getExtensionDetailed(mode: 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll[] | undefined; + public getExtensionDetailed(mode: 'raw'): Extension[] | undefined; + public getExtensionDetailed(): USCoreRaceExtension_Extension_DetailedSliceFlatAll[] | undefined; + public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): (USCoreRaceExtension_Extension_DetailedSliceFlatAll | Extension)[] | undefined { const match = USCoreRaceExtensionProfile.detailedSliceMatch - const item = getArraySlice(this.resource.extension, match) - if (!item) return undefined - if (mode === 'raw') return item - return unwrapSliceChoice(item, ["url"], "valueCoding") + const items = getArraySliceAll(this.resource.extension, match) + if (items.length === 0) return undefined + if (mode === 'raw') return items + return items.map(item => unwrapSliceChoice(item, ["url"], "valueCoding")) } public getExtensionText(mode: 'flat'): USCoreRaceExtension_Extension_TextSliceFlatAll | undefined; diff --git a/examples/typescript-us-core/fhir-types/profile-helpers.ts b/examples/typescript-us-core/fhir-types/profile-helpers.ts index 239ec0d0..2c159297 100644 --- a/examples/typescript-us-core/fhir-types/profile-helpers.ts +++ b/examples/typescript-us-core/fhir-types/profile-helpers.ts @@ -296,6 +296,26 @@ export const getArraySlice = (list: readonly T[] | undefined, match: Record matchesValue(item, match)); }; +/** Return all elements in `list` that satisfy the slice discriminator `match`. */ +export const getArraySliceAll = (list: readonly T[] | undefined, match: Record): T[] => { + if (!list) return []; + return list.filter((item) => matchesValue(item, match)); +}; + +/** + * Replace all elements matching `match` in `list` with `newItems`. + * Each new item has the discriminator values applied via {@link applySliceMatch} + * before this call, so this helper only handles the array surgery. + */ +export const setArraySliceAll = (list: T[], match: Record, newItems: T[]): void => { + // Remove all existing items that match the discriminator + for (let i = list.length - 1; i >= 0; i--) { + if (matchesValue(list[i], match)) list.splice(i, 1); + } + // Append new items + list.push(...newItems); +}; + // --------------------------------------------------------------------------- // Validation helpers // diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index a56ec722..655c1c4b 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -109,6 +109,8 @@ export type SliceDef = { constrainedChoice: ConstrainedChoiceInfo | undefined; /** True when the slice uses a type discriminator (match by resourceType) */ typeDiscriminator: boolean; + /** Max cardinality of the slice. 0 or undefined = unbounded ("*"), positive = exact limit. */ + max: number; }; export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): SliceDef[] => @@ -145,6 +147,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT array: Boolean(field.array), constrainedChoice, typeDiscriminator: isTypeDisc, + max: slice.max ?? 0, }; }); }); @@ -165,35 +168,59 @@ export const generateSliceSetters = ( const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); const baseType = sliceDef.typedBaseType; - // Make input optional when there are no required fields (input can be empty object) - const inputOptional = sliceDef.required.length === 0; - const unionType = `${inputTypeName} | ${baseType}`; - const paramSignature = inputOptional ? `(input?: ${unionType}): this` : `(input: ${unionType}): this`; - w.curlyBlock(["public", methodName, paramSignature], () => { - w.line(`const match = ${matchRef}`); - w.curlyBlock(["if", "(input && matchesValue(input, match))"], () => { + const isUnbounded = sliceDef.array && (sliceDef.max === 0 || sliceDef.max === undefined); + + if (isUnbounded) { + // Unbounded slice: accept an array of items + const unionType = `(${inputTypeName} | ${baseType})[]`; + const paramSignature = `(input: ${unionType}): this`; + w.curlyBlock(["public", methodName, paramSignature], () => { + w.line(`const match = ${matchRef}`); + w.line(`const arr = ${fieldAccess} ??= []`); + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + w.line( + `const values = input.map(item => matchesValue(item, match) ? item as ${baseType} : applySliceMatch<${baseType}>(wrapSliceChoice<${baseType}>(item, ${JSON.stringify(cc.variant)}), match))`, + ); + } else { + w.line( + `const values = input.map(item => matchesValue(item, match) ? item as ${baseType} : applySliceMatch<${baseType}>(item, match))`, + ); + } + w.line("setArraySliceAll(arr, match, values)"); + w.line("return this"); + }); + } else { + // Single-element slice (max: 1): keep existing behavior + const inputOptional = sliceDef.required.length === 0; + const unionType = `${inputTypeName} | ${baseType}`; + const paramSignature = inputOptional ? `(input?: ${unionType}): this` : `(input: ${unionType}): this`; + w.curlyBlock(["public", methodName, paramSignature], () => { + w.line(`const match = ${matchRef}`); + w.curlyBlock(["if", "(input && matchesValue(input, match))"], () => { + if (sliceDef.array) { + w.line(`setArraySlice(${fieldAccess} ??= [], match, input as ${baseType})`); + } else { + w.line(`${fieldAccess} = input as ${baseType}`); + } + w.line("return this"); + }); + const inputExpr = inputOptional ? "input ?? {}" : "input"; + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + w.line(`const wrapped = wrapSliceChoice<${baseType}>(${inputExpr}, ${JSON.stringify(cc.variant)})`); + w.line(`const value = applySliceMatch<${baseType}>(wrapped, match)`); + } else { + w.line(`const value = applySliceMatch<${baseType}>(${inputExpr}, match)`); + } if (sliceDef.array) { - w.line(`setArraySlice(${fieldAccess} ??= [], match, input as ${baseType})`); + w.line(`setArraySlice(${fieldAccess} ??= [], match, value)`); } else { - w.line(`${fieldAccess} = input as ${baseType}`); + w.line(`${fieldAccess} = value`); } w.line("return this"); }); - const inputExpr = inputOptional ? "input ?? {}" : "input"; - if (sliceDef.constrainedChoice) { - const cc = sliceDef.constrainedChoice; - w.line(`const wrapped = wrapSliceChoice<${baseType}>(${inputExpr}, ${JSON.stringify(cc.variant)})`); - w.line(`const value = applySliceMatch<${baseType}>(wrapped, match)`); - } else { - w.line(`const value = applySliceMatch<${baseType}>(${inputExpr}, match)`); - } - if (sliceDef.array) { - w.line(`setArraySlice(${fieldAccess} ??= [], match, value)`); - } else { - w.line(`${fieldAccess} = value`); - } - w.line("return this"); - }); + } w.line(); } }; @@ -216,44 +243,84 @@ export const generateSliceGetters = ( const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); const baseType = sliceDef.typedBaseType; - const defaultReturn = defaultMode === "raw" ? baseType : flatTypeName; + const isUnbounded = sliceDef.array && (sliceDef.max === 0 || sliceDef.max === undefined); - // Overload signatures - w.lineSM(`public ${getMethodName}(mode: 'flat'): ${flatTypeName} | undefined`); - w.lineSM(`public ${getMethodName}(mode: 'raw'): ${baseType} | undefined`); - w.lineSM(`public ${getMethodName}(): ${defaultReturn} | undefined`); + if (isUnbounded) { + // Unbounded slice: return an array or undefined + const defaultReturn = defaultMode === "raw" ? `${baseType}[]` : `${flatTypeName}[]`; - // Implementation - w.curlyBlock( - [ - "public", - getMethodName, - `(mode: 'flat' | 'raw' = '${defaultMode}'): ${flatTypeName} | ${baseType} | undefined`, - ], - () => { - w.line(`const match = ${matchRef}`); - if (sliceDef.array) { - w.line(`const item = getArraySlice(${fieldAccess}, match)`); - w.line("if (!item) return undefined"); - } else { - w.line(`const item = ${fieldAccess}`); - w.line("if (!item || !matchesValue(item, match)) return undefined"); - } - if (sliceDef.typeDiscriminator) { - w.line(`if (mode === 'raw') return item as ${baseType}`); - } else { - w.line("if (mode === 'raw') return item"); - } - if (sliceDef.constrainedChoice) { - const cc = sliceDef.constrainedChoice; - w.line( - `return unwrapSliceChoice<${flatTypeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`, - ); - } else { - w.line(`return item as unknown as ${flatTypeName}`); - } - }, - ); + // Overload signatures + w.lineSM(`public ${getMethodName}(mode: 'flat'): ${flatTypeName}[] | undefined`); + w.lineSM(`public ${getMethodName}(mode: 'raw'): ${baseType}[] | undefined`); + w.lineSM(`public ${getMethodName}(): ${defaultReturn} | undefined`); + + // Implementation + w.curlyBlock( + [ + "public", + getMethodName, + `(mode: 'flat' | 'raw' = '${defaultMode}'): (${flatTypeName} | ${baseType})[] | undefined`, + ], + () => { + w.line(`const match = ${matchRef}`); + w.line(`const items = getArraySliceAll(${fieldAccess}, match)`); + w.line("if (items.length === 0) return undefined"); + if (sliceDef.typeDiscriminator) { + w.line(`if (mode === 'raw') return items as ${baseType}[]`); + } else { + w.line("if (mode === 'raw') return items"); + } + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + w.line( + `return items.map(item => unwrapSliceChoice<${flatTypeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)}))`, + ); + } else { + w.line(`return items as unknown as ${flatTypeName}[]`); + } + }, + ); + } else { + // Single-element slice: return single item or undefined + const defaultReturn = defaultMode === "raw" ? baseType : flatTypeName; + + // Overload signatures + w.lineSM(`public ${getMethodName}(mode: 'flat'): ${flatTypeName} | undefined`); + w.lineSM(`public ${getMethodName}(mode: 'raw'): ${baseType} | undefined`); + w.lineSM(`public ${getMethodName}(): ${defaultReturn} | undefined`); + + // Implementation + w.curlyBlock( + [ + "public", + getMethodName, + `(mode: 'flat' | 'raw' = '${defaultMode}'): ${flatTypeName} | ${baseType} | undefined`, + ], + () => { + w.line(`const match = ${matchRef}`); + if (sliceDef.array) { + w.line(`const item = getArraySlice(${fieldAccess}, match)`); + w.line("if (!item) return undefined"); + } else { + w.line(`const item = ${fieldAccess}`); + w.line("if (!item || !matchesValue(item, match)) return undefined"); + } + if (sliceDef.typeDiscriminator) { + w.line(`if (mode === 'raw') return item as ${baseType}`); + } else { + w.line("if (mode === 'raw') return item"); + } + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + w.line( + `return unwrapSliceChoice<${flatTypeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`, + ); + } else { + w.line(`return item as unknown as ${flatTypeName}`); + } + }, + ); + } w.line(); } }; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 45b90da2..3d54a4d8 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -246,6 +246,8 @@ const generateProfileHelpersImport = ( if (canonicalUrl && hasMeta) imports.push("ensureProfile"); if (sliceDefs.length > 0 || factoryInfo.sliceAutoFields.length > 0) imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults"); + const hasUnboundedSlice = sliceDefs.some((s) => s.array && (s.max === 0 || s.max === undefined)); + if (hasUnboundedSlice) imports.push("setArraySliceAll", "getArraySliceAll"); if (extensions.some((ext) => ext.path.split(".").some((s) => s !== "extension"))) imports.push("ensurePath"); if (extensions.some((ext) => ext.isComplex && ext.subExtensions)) imports.push("extractComplexExtension"); if (sliceDefs.some((s) => s.constrainedChoice)) imports.push("wrapSliceChoice", "unwrapSliceChoice"); diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index 27eed4b5..a77ca9ab 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -1822,6 +1822,8 @@ import { setArraySlice, getArraySlice, ensureSliceDefaults, + setArraySliceAll, + getArraySliceAll, wrapSliceChoice, unwrapSliceChoice, isExtension, @@ -1985,15 +1987,11 @@ export class USCoreRaceExtensionProfile { return this } - public setExtensionDetailed (input?: USCoreRaceExtension_Extension_DetailedSliceFlat | Extension): this { + public setExtensionDetailed (input: (USCoreRaceExtension_Extension_DetailedSliceFlat | Extension)[]): this { const match = USCoreRaceExtensionProfile.detailedSliceMatch - if (input && matchesValue(input, match)) { - setArraySlice(this.resource.extension ??= [], match, input as Extension) - return this - } - const wrapped = wrapSliceChoice(input ?? {}, "valueCoding") - const value = applySliceMatch(wrapped, match) - setArraySlice(this.resource.extension ??= [], match, value) + const arr = this.resource.extension ??= [] + const values = input.map(item => matchesValue(item, match) ? item as Extension : applySliceMatch(wrapSliceChoice(item, "valueCoding"), match)) + setArraySliceAll(arr, match, values) return this } @@ -2019,15 +2017,15 @@ export class USCoreRaceExtensionProfile { return unwrapSliceChoice(item, ["url"], "valueCoding") } - public getExtensionDetailed(mode: 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll | undefined; - public getExtensionDetailed(mode: 'raw'): Extension | undefined; - public getExtensionDetailed(): USCoreRaceExtension_Extension_DetailedSliceFlatAll | undefined; - public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll | Extension | undefined { + public getExtensionDetailed(mode: 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll[] | undefined; + public getExtensionDetailed(mode: 'raw'): Extension[] | undefined; + public getExtensionDetailed(): USCoreRaceExtension_Extension_DetailedSliceFlatAll[] | undefined; + public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): (USCoreRaceExtension_Extension_DetailedSliceFlatAll | Extension)[] | undefined { const match = USCoreRaceExtensionProfile.detailedSliceMatch - const item = getArraySlice(this.resource.extension, match) - if (!item) return undefined - if (mode === 'raw') return item - return unwrapSliceChoice(item, ["url"], "valueCoding") + const items = getArraySliceAll(this.resource.extension, match) + if (items.length === 0) return undefined + if (mode === 'raw') return items + return items.map(item => unwrapSliceChoice(item, ["url"], "valueCoding")) } public getExtensionText(mode: 'flat'): USCoreRaceExtension_Extension_TextSliceFlatAll | undefined; diff --git a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap index 0057d278..1418558c 100644 --- a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap @@ -50,6 +50,8 @@ import { setArraySlice, getArraySlice, ensureSliceDefaults, + setArraySliceAll, + getArraySliceAll, validateRequired, validateExcluded, validateFixedValue, @@ -135,14 +137,11 @@ export class ExampleTypedBundleProfile { return this } - public setOrganizationEntry (input?: ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry): this { + public setOrganizationEntry (input: (ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry)[]): this { const match = ExampleTypedBundleProfile.OrganizationEntrySliceMatch - if (input && matchesValue(input, match)) { - setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) - return this - } - const value = applySliceMatch>(input ?? {}, match) - setArraySlice(this.resource.entry ??= [], match, value) + const arr = this.resource.entry ??= [] + const values = input.map(item => matchesValue(item, match) ? item as BundleEntry : applySliceMatch>(item, match)) + setArraySliceAll(arr, match, values) return this } @@ -157,15 +156,15 @@ export class ExampleTypedBundleProfile { return item as unknown as ExampleTypedBundle_Entry_PatientEntrySliceFlatAll } - public getOrganizationEntry(mode: 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll | undefined; - public getOrganizationEntry(mode: 'raw'): BundleEntry | undefined; - public getOrganizationEntry(): ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll | undefined; - public getOrganizationEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll | BundleEntry | undefined { + public getOrganizationEntry(mode: 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll[] | undefined; + public getOrganizationEntry(mode: 'raw'): BundleEntry[] | undefined; + public getOrganizationEntry(): ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll[] | undefined; + public getOrganizationEntry (mode: 'flat' | 'raw' = 'flat'): (ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll | BundleEntry)[] | undefined { const match = ExampleTypedBundleProfile.OrganizationEntrySliceMatch - const item = getArraySlice(this.resource.entry, match) - if (!item) return undefined - if (mode === 'raw') return item as BundleEntry - return item as unknown as ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll + const items = getArraySliceAll(this.resource.entry, match) + if (items.length === 0) return undefined + if (mode === 'raw') return items as BundleEntry[] + return items as unknown as ExampleTypedBundle_Entry_OrganizationEntrySliceFlatAll[] } // Validation