Skip to content

Commit 5d6449d

Browse files
authored
Merge pull request #129 from atomic-ehr/worktree-agent-a4909618
TS: Array setters/getters for unbounded (max: *) slices
2 parents 5da8a8d + 7370f72 commit 5d6449d

10 files changed

Lines changed: 322 additions & 150 deletions

File tree

assets/api/writer-generator/typescript/profile-helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,26 @@ export const getArraySlice = <T>(list: readonly T[] | undefined, match: Record<s
296296
return list.find((item) => matchesValue(item, match));
297297
};
298298

299+
/** Return all elements in `list` that satisfy the slice discriminator `match`. */
300+
export const getArraySliceAll = <T>(list: readonly T[] | undefined, match: Record<string, unknown>): T[] => {
301+
if (!list) return [];
302+
return list.filter((item) => matchesValue(item, match));
303+
};
304+
305+
/**
306+
* Replace all elements matching `match` in `list` with `newItems`.
307+
* Each new item has the discriminator values applied via {@link applySliceMatch}
308+
* before this call, so this helper only handles the array surgery.
309+
*/
310+
export const setArraySliceAll = <T>(list: T[], match: Record<string, unknown>, newItems: T[]): void => {
311+
// Remove all existing items that match the discriminator
312+
for (let i = list.length - 1; i >= 0; i--) {
313+
if (matchesValue(list[i], match)) list.splice(i, 1);
314+
}
315+
// Append new items
316+
list.push(...newItems);
317+
};
318+
299319
// ---------------------------------------------------------------------------
300320
// Validation helpers
301321
//

examples/local-package-folder/profile-typed-bundle.test.ts

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,62 +17,112 @@ import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
1717
const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] };
1818
const activePatient: Patient = { resourceType: "Patient", active: true };
1919
const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" };
20+
const acmeOrg: Organization = { resourceType: "Organization", name: "Acme" };
2021

21-
describe("type-discriminated bundle slices", () => {
22-
test("create() starts with no entry — PatientEntry must be set by user", () => {
22+
describe("demo: single-element slice (max: 1) — PatientEntry", () => {
23+
test("create, set, and validate a typed patient entry", () => {
2324
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
24-
expect(bundle.toResource().entry).toBeUndefined();
25+
26+
// No entry yet — validation fails (PatientEntry min: 1)
2527
expect(bundle.validate().errors).toEqual([
2628
"ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0",
2729
]);
28-
});
2930

30-
test("setPatientEntry inserts a typed patient entry", () => {
31-
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
31+
// Set a single patient entry — typed as BundleEntry<Patient>
3232
bundle.setPatientEntry({ resource: smithPatient });
33+
expect(bundle.validate().errors).toEqual([]);
3334

35+
// Getter returns the entry with resource narrowed to Patient
3436
const entry = bundle.getPatientEntry()!;
3537
expect(entry.resource).toEqual(smithPatient);
36-
expect(bundle.validate().errors).toEqual([]);
3738
});
3839

39-
test("setPatientEntry replaces existing patient entry (no duplicates)", () => {
40+
test("setPatientEntry replaces existing entry (no duplicates)", () => {
4041
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
4142
bundle.setPatientEntry({ resource: smithPatient });
4243
bundle.setPatientEntry({ resource: activePatient });
4344

44-
const entries = bundle.toResource().entry!;
45-
expect(entries).toHaveLength(1);
46-
expect(entries[0]!.resource).toEqual(activePatient);
45+
// Only one patient entry — the second call replaced the first
46+
expect(bundle.toResource().entry).toHaveLength(1);
47+
expect(bundle.getPatientEntry()!.resource).toEqual(activePatient);
4748
});
49+
});
4850

49-
test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => {
50-
const bundle = ExampleTypedBundleProfile.create({ type: "collection" });
51-
bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient });
51+
describe("demo: unbounded slice (max: *) — OrganizationEntry", () => {
52+
test("setter accepts an array, getter returns an array", () => {
53+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" }).setPatientEntry({
54+
resource: activePatient,
55+
});
56+
57+
// Set multiple organization entries at once
58+
bundle.setOrganizationEntry([{ resource: clinicOrg }, { resource: acmeOrg }]);
5259

53-
const flat = bundle.getPatientEntry("flat")!;
54-
expect(flat.fullUrl).toBe("urn:uuid:patient-1");
55-
expect(flat.resource).toEqual(activePatient);
60+
// Getter returns all matching entries as an array (undefined if none)
61+
const orgs = bundle.getOrganizationEntry()!;
62+
expect(orgs).toHaveLength(2);
63+
expect(orgs[0]!.resource).toEqual(clinicOrg);
64+
expect(orgs[1]!.resource).toEqual(acmeOrg);
65+
66+
// Total entries: 1 patient + 2 organizations
67+
expect(bundle.toResource().entry).toHaveLength(3);
5668
});
5769

58-
test("fluent chaining across slice setters", () => {
70+
test("setOrganizationEntry replaces all previous org entries", () => {
5971
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
6072
.setPatientEntry({ resource: activePatient })
61-
.setOrganizationEntry({ resource: clinicOrg });
73+
.setOrganizationEntry([{ resource: clinicOrg }, { resource: acmeOrg }]);
74+
75+
// Replace with a single org — previous two are removed
76+
bundle.setOrganizationEntry([{ resource: { resourceType: "Organization", name: "NewCo" } }]);
77+
78+
const orgs = bundle.getOrganizationEntry()!;
79+
expect(orgs).toHaveLength(1);
80+
expect(orgs[0]!.resource!.name).toBe("NewCo");
6281

63-
expect(bundle.toResource().entry).toHaveLength(2);
82+
// Patient entry unaffected
6483
expect(bundle.getPatientEntry()!.resource).toEqual(activePatient);
65-
expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg);
6684
});
6785

68-
test("setOrganizationEntry replaces existing org entry (same discriminator)", () => {
86+
test("append to existing entries via spread", () => {
6987
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
7088
.setPatientEntry({ resource: activePatient })
71-
.setOrganizationEntry({ resource: clinicOrg })
72-
.setOrganizationEntry({ resource: { resourceType: "Organization", name: "Acme" } });
89+
.setOrganizationEntry([{ resource: clinicOrg }]);
90+
91+
// Append a new org by spreading existing entries
92+
bundle.setOrganizationEntry([...(bundle.getOrganizationEntry() ?? []), { resource: acmeOrg }]);
93+
94+
const orgs = bundle.getOrganizationEntry()!;
95+
expect(orgs).toHaveLength(2);
96+
expect(orgs[0]!.resource).toEqual(clinicOrg);
97+
expect(orgs[1]!.resource).toEqual(acmeOrg);
98+
});
99+
100+
test("empty array removes all org entries, getter returns undefined", () => {
101+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
102+
.setPatientEntry({ resource: activePatient })
103+
.setOrganizationEntry([{ resource: clinicOrg }]);
104+
105+
bundle.setOrganizationEntry([]);
106+
107+
// No matching entries — returns undefined, not empty array
108+
expect(bundle.getOrganizationEntry()).toBeUndefined();
109+
// Patient entry still present
110+
expect(bundle.toResource().entry).toHaveLength(1);
111+
});
112+
});
113+
114+
describe("fluent chaining across slice types", () => {
115+
test("chain single and array setters", () => {
116+
const bundle = ExampleTypedBundleProfile.create({ type: "collection" })
117+
.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient })
118+
.setOrganizationEntry([
119+
{ fullUrl: "urn:uuid:org-1", resource: clinicOrg },
120+
{ fullUrl: "urn:uuid:org-2", resource: acmeOrg },
121+
]);
73122

74-
const entries = bundle.toResource().entry!;
75-
expect(entries).toHaveLength(2);
76-
expect(bundle.getOrganizationEntry()!.resource!.name).toBe("Acme");
123+
expect(bundle.toResource().entry).toHaveLength(3);
124+
expect(bundle.getPatientEntry()!.fullUrl).toBe("urn:uuid:patient-1");
125+
expect(bundle.getOrganizationEntry()![0]!.fullUrl).toBe("urn:uuid:org-1");
126+
expect(bundle.getOrganizationEntry()![1]!.fullUrl).toBe("urn:uuid:org-2");
77127
});
78128
});

examples/typescript-r4/fhir-types/profile-helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,26 @@ export const getArraySlice = <T>(list: readonly T[] | undefined, match: Record<s
296296
return list.find((item) => matchesValue(item, match));
297297
};
298298

299+
/** Return all elements in `list` that satisfy the slice discriminator `match`. */
300+
export const getArraySliceAll = <T>(list: readonly T[] | undefined, match: Record<string, unknown>): T[] => {
301+
if (!list) return [];
302+
return list.filter((item) => matchesValue(item, match));
303+
};
304+
305+
/**
306+
* Replace all elements matching `match` in `list` with `newItems`.
307+
* Each new item has the discriminator values applied via {@link applySliceMatch}
308+
* before this call, so this helper only handles the array surgery.
309+
*/
310+
export const setArraySliceAll = <T>(list: T[], match: Record<string, unknown>, newItems: T[]): void => {
311+
// Remove all existing items that match the discriminator
312+
for (let i = list.length - 1; i >= 0; i--) {
313+
if (matchesValue(list[i], match)) list.splice(i, 1);
314+
}
315+
// Append new items
316+
list.push(...newItems);
317+
};
318+
299319
// ---------------------------------------------------------------------------
300320
// Validation helpers
301321
//

examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
setArraySlice,
2424
getArraySlice,
2525
ensureSliceDefaults,
26+
setArraySliceAll,
27+
getArraySliceAll,
2628
wrapSliceChoice,
2729
unwrapSliceChoice,
2830
isExtension,
@@ -186,15 +188,11 @@ export class USCoreEthnicityExtensionProfile {
186188
return this
187189
}
188190

189-
public setExtensionDetailed (input?: USCoreEthnicityExtension_Extension_DetailedSliceFlat | Extension): this {
191+
public setExtensionDetailed (input: (USCoreEthnicityExtension_Extension_DetailedSliceFlat | Extension)[]): this {
190192
const match = USCoreEthnicityExtensionProfile.detailedSliceMatch
191-
if (input && matchesValue(input, match)) {
192-
setArraySlice(this.resource.extension ??= [], match, input as Extension)
193-
return this
194-
}
195-
const wrapped = wrapSliceChoice<Extension>(input ?? {}, "valueCoding")
196-
const value = applySliceMatch<Extension>(wrapped, match)
197-
setArraySlice(this.resource.extension ??= [], match, value)
193+
const arr = this.resource.extension ??= []
194+
const values = input.map(item => matchesValue(item, match) ? item as Extension : applySliceMatch<Extension>(wrapSliceChoice<Extension>(item, "valueCoding"), match))
195+
setArraySliceAll(arr, match, values)
198196
return this
199197
}
200198

@@ -220,15 +218,15 @@ export class USCoreEthnicityExtensionProfile {
220218
return unwrapSliceChoice<USCoreEthnicityExtension_Extension_OmbCategorySliceFlatAll>(item, ["url"], "valueCoding")
221219
}
222220

223-
public getExtensionDetailed(mode: 'flat'): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | undefined;
224-
public getExtensionDetailed(mode: 'raw'): Extension | undefined;
225-
public getExtensionDetailed(): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | undefined;
226-
public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | Extension | undefined {
221+
public getExtensionDetailed(mode: 'flat'): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll[] | undefined;
222+
public getExtensionDetailed(mode: 'raw'): Extension[] | undefined;
223+
public getExtensionDetailed(): USCoreEthnicityExtension_Extension_DetailedSliceFlatAll[] | undefined;
224+
public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): (USCoreEthnicityExtension_Extension_DetailedSliceFlatAll | Extension)[] | undefined {
227225
const match = USCoreEthnicityExtensionProfile.detailedSliceMatch
228-
const item = getArraySlice(this.resource.extension, match)
229-
if (!item) return undefined
230-
if (mode === 'raw') return item
231-
return unwrapSliceChoice<USCoreEthnicityExtension_Extension_DetailedSliceFlatAll>(item, ["url"], "valueCoding")
226+
const items = getArraySliceAll(this.resource.extension, match)
227+
if (items.length === 0) return undefined
228+
if (mode === 'raw') return items
229+
return items.map(item => unwrapSliceChoice<USCoreEthnicityExtension_Extension_DetailedSliceFlatAll>(item, ["url"], "valueCoding"))
232230
}
233231

234232
public getExtensionText(mode: 'flat'): USCoreEthnicityExtension_Extension_TextSliceFlatAll | undefined;

examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
setArraySlice,
2424
getArraySlice,
2525
ensureSliceDefaults,
26+
setArraySliceAll,
27+
getArraySliceAll,
2628
wrapSliceChoice,
2729
unwrapSliceChoice,
2830
isExtension,
@@ -186,15 +188,11 @@ export class USCoreRaceExtensionProfile {
186188
return this
187189
}
188190

189-
public setExtensionDetailed (input?: USCoreRaceExtension_Extension_DetailedSliceFlat | Extension): this {
191+
public setExtensionDetailed (input: (USCoreRaceExtension_Extension_DetailedSliceFlat | Extension)[]): this {
190192
const match = USCoreRaceExtensionProfile.detailedSliceMatch
191-
if (input && matchesValue(input, match)) {
192-
setArraySlice(this.resource.extension ??= [], match, input as Extension)
193-
return this
194-
}
195-
const wrapped = wrapSliceChoice<Extension>(input ?? {}, "valueCoding")
196-
const value = applySliceMatch<Extension>(wrapped, match)
197-
setArraySlice(this.resource.extension ??= [], match, value)
193+
const arr = this.resource.extension ??= []
194+
const values = input.map(item => matchesValue(item, match) ? item as Extension : applySliceMatch<Extension>(wrapSliceChoice<Extension>(item, "valueCoding"), match))
195+
setArraySliceAll(arr, match, values)
198196
return this
199197
}
200198

@@ -220,15 +218,15 @@ export class USCoreRaceExtensionProfile {
220218
return unwrapSliceChoice<USCoreRaceExtension_Extension_OmbCategorySliceFlatAll>(item, ["url"], "valueCoding")
221219
}
222220

223-
public getExtensionDetailed(mode: 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll | undefined;
224-
public getExtensionDetailed(mode: 'raw'): Extension | undefined;
225-
public getExtensionDetailed(): USCoreRaceExtension_Extension_DetailedSliceFlatAll | undefined;
226-
public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll | Extension | undefined {
221+
public getExtensionDetailed(mode: 'flat'): USCoreRaceExtension_Extension_DetailedSliceFlatAll[] | undefined;
222+
public getExtensionDetailed(mode: 'raw'): Extension[] | undefined;
223+
public getExtensionDetailed(): USCoreRaceExtension_Extension_DetailedSliceFlatAll[] | undefined;
224+
public getExtensionDetailed (mode: 'flat' | 'raw' = 'flat'): (USCoreRaceExtension_Extension_DetailedSliceFlatAll | Extension)[] | undefined {
227225
const match = USCoreRaceExtensionProfile.detailedSliceMatch
228-
const item = getArraySlice(this.resource.extension, match)
229-
if (!item) return undefined
230-
if (mode === 'raw') return item
231-
return unwrapSliceChoice<USCoreRaceExtension_Extension_DetailedSliceFlatAll>(item, ["url"], "valueCoding")
226+
const items = getArraySliceAll(this.resource.extension, match)
227+
if (items.length === 0) return undefined
228+
if (mode === 'raw') return items
229+
return items.map(item => unwrapSliceChoice<USCoreRaceExtension_Extension_DetailedSliceFlatAll>(item, ["url"], "valueCoding"))
232230
}
233231

234232
public getExtensionText(mode: 'flat'): USCoreRaceExtension_Extension_TextSliceFlatAll | undefined;

examples/typescript-us-core/fhir-types/profile-helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,26 @@ export const getArraySlice = <T>(list: readonly T[] | undefined, match: Record<s
296296
return list.find((item) => matchesValue(item, match));
297297
};
298298

299+
/** Return all elements in `list` that satisfy the slice discriminator `match`. */
300+
export const getArraySliceAll = <T>(list: readonly T[] | undefined, match: Record<string, unknown>): T[] => {
301+
if (!list) return [];
302+
return list.filter((item) => matchesValue(item, match));
303+
};
304+
305+
/**
306+
* Replace all elements matching `match` in `list` with `newItems`.
307+
* Each new item has the discriminator values applied via {@link applySliceMatch}
308+
* before this call, so this helper only handles the array surgery.
309+
*/
310+
export const setArraySliceAll = <T>(list: T[], match: Record<string, unknown>, newItems: T[]): void => {
311+
// Remove all existing items that match the discriminator
312+
for (let i = list.length - 1; i >= 0; i--) {
313+
if (matchesValue(list[i], match)) list.splice(i, 1);
314+
}
315+
// Append new items
316+
list.push(...newItems);
317+
};
318+
299319
// ---------------------------------------------------------------------------
300320
// Validation helpers
301321
//

0 commit comments

Comments
 (0)