Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ LINT = bunx biome check --write
TEST = bun test
VERSION = $(shell cat package.json | grep version | sed -E 's/ *"version": "//' | sed -E 's/",.*//')

.PHONY: all typecheck test-typeschema test-register test-codegen test-typescript-r4-example
.PHONY: all typecheck test-typeschema test-register test-codegen test-typescript-r4-example test-local-package-folder-example

all: test-codegen test-typescript-r4-example test-typescript-us-core-example test-typescript-ccda-example test-typescript-sql-on-fhir-example lint-unsafe test-all-example-generation
all: test-codegen test-typescript-r4-example test-typescript-us-core-example test-typescript-ccda-example test-typescript-sql-on-fhir-example test-local-package-folder-example lint-unsafe test-all-example-generation

generate-types:
bun run scripts/generate-types.ts
Expand Down Expand Up @@ -84,6 +84,11 @@ test-typescript-ccda-example: typecheck
./examples/typescript-ccda/demo-cda.test.ts \
./examples/typescript-ccda/demo-ccda.test.ts

test-local-package-folder-example: typecheck
bun run examples/local-package-folder/generate.ts
$(TYPECHECK) --project examples/local-package-folder/tsconfig.json
$(TEST) ./examples/local-package-folder/

test-mustache-java-r4-example: typecheck format lint
bun run examples/mustache/mustache-java-r4-gen.ts
$(TYPECHECK) --project examples/mustache/tsconfig.examples-mustache.json
Expand Down
4 changes: 2 additions & 2 deletions docs/design/slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ bp.validate();
- No compile-time enforcement of slice constraints
- No dedicated types for slice elements (see Refine below)
- Array ordering is not enforced by the setter
- Only `value`/`pattern` discriminator types are supported; `type`, `profile`, and `exists` discriminators are not yet implemented
- Only `value`/`pattern`/`type` (resource type) discriminator types are supported; `profile` and `exists` discriminators are not yet implemented

## Refine (Not Implemented)

Expand Down Expand Up @@ -154,7 +154,7 @@ class USCoreBloodPressureProfile {
|---|---|---|
| `value` | Supported | Fixed value matching (most common) |
| `pattern` | Supported | Pattern matching on element |
| `type` | Not supported | Discriminate by element type |
| `type` | Partial | Resource type discrimination (by `resourceType` field) |
| `profile` | Not supported | Discriminate by profile URL |
| `exists` | Not supported | Discriminate by field presence |

Expand Down
10 changes: 7 additions & 3 deletions examples/local-package-folder/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { APIBuilder, prettyReport } from "../../src/api";
const __dirname = Path.dirname(fileURLToPath(import.meta.url));

async function generateFromLocalPackageFolder() {
const builder = new APIBuilder({
logLevel: "INFO",
});
const builder = new APIBuilder();

const report = await builder
.localStructureDefinitions({
Expand All @@ -21,9 +19,15 @@ async function generateFromLocalPackageFolder() {
treeShake: {
"example.folder.structures": {
"http://example.org/fhir/StructureDefinition/ExampleNotebook": {},
"http://example.org/fhir/StructureDefinition/ExampleTypedBundle": {},
},
"hl7.fhir.r4.core": {
"http://hl7.org/fhir/StructureDefinition/Patient": {},
"http://hl7.org/fhir/StructureDefinition/Organization": {},
},
},
})
.introspection({ typeSchemas: "ts/" })
.outputTo("./examples/local-package-folder/fhir-types")
.generate();

Expand Down
108 changes: 108 additions & 0 deletions examples/local-package-folder/profile-typed-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Type discriminator slicing demo — ExampleTypedBundle profile.
*
* The profile slices Bundle.entry[] by resource type:
* - PatientEntry (min: 1, max: 1) — entry where resource is Patient
* - OrganizationEntry (min: 0, max: *) — entry where resource is Organization
*/

import { describe, expect, test } from "bun:test";
import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle";
import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization";
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";

const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" });

const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] };
const jonesPatient: Patient = { resourceType: "Patient", name: [{ family: "Jones" }] };
const activePatient: Patient = { resourceType: "Patient", active: true };
const acmeOrg: Organization = { resourceType: "Organization", name: "Acme Corp" };
const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" };

describe("type-discriminated bundle slices", () => {
test("create() auto-populates a PatientEntry stub (min: 1)", () => {
const bundle = createBundle();
const entry = bundle.toResource().entry;
expect(entry).toHaveLength(1);
expect(entry![0]!.resource).toEqual({ resourceType: "Patient" });
});

test("setPatientEntry inserts a typed patient entry", () => {
const bundle = createBundle();
bundle.setPatientEntry({ resource: smithPatient });

const entry = bundle.getPatientEntry()!;
expect(entry.resource).toEqual(smithPatient);
});

test("setPatientEntry replaces existing patient entry (no duplicates)", () => {
const bundle = createBundle();
bundle.setPatientEntry({ resource: smithPatient });
bundle.setPatientEntry({ resource: jonesPatient });

const patients = bundle.toResource().entry!.filter((e) => e.resource?.resourceType === "Patient");
expect(patients).toHaveLength(1);
expect(bundle.getPatientEntry()!.resource).toEqual(jonesPatient);
});

test("setOrganizationEntry adds an organization entry", () => {
const bundle = createBundle();
bundle.setOrganizationEntry({ resource: acmeOrg });

expect(bundle.getOrganizationEntry()!.resource).toEqual(acmeOrg);
});

test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => {
const bundle = createBundle();
bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient });

const flat = bundle.getPatientEntry("flat")!;
expect(flat.fullUrl).toBe("urn:uuid:patient-1");
expect(flat.resource).toEqual(activePatient);
});

test("validate() checks PatientEntry cardinality", () => {
const bundle = ExampleTypedBundleProfile.apply({
resourceType: "Bundle",
type: "collection",
});
const { errors } = bundle.validate();
expect(errors).toEqual(["ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0"]);
});

test("fluent chaining across slice setters", () => {
const bundle = createBundle()
.setPatientEntry({ resource: activePatient })
.setOrganizationEntry({ resource: clinicOrg });

expect(bundle.getPatientEntry()!.resource).toEqual(activePatient);
expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg);
expect(bundle.toResource().entry).toHaveLength(2);
});

test("set/get PatientEntry with full BundleEntry input", () => {
const bundle = createBundle();
bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient });

const raw = bundle.getPatientEntry("raw")!;
expect(raw.fullUrl).toBe("urn:uuid:p1");
expect(raw.resource).toEqual(smithPatient);

const flat = bundle.getPatientEntry("flat")!;
expect(flat.fullUrl).toBe("urn:uuid:p1");
expect(flat.resource).toEqual(smithPatient);
});

test("set/get OrganizationEntry with full BundleEntry input", () => {
const bundle = createBundle();
bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg });

const raw = bundle.getOrganizationEntry("raw")!;
expect(raw.fullUrl).toBe("urn:uuid:o1");
expect(raw.resource).toEqual(acmeOrg);

const flat = bundle.getOrganizationEntry("flat")!;
expect(flat.fullUrl).toBe("urn:uuid:o1");
expect(flat.resource).toEqual(acmeOrg);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"resourceType": "StructureDefinition",
"id": "example-typed-bundle",
"url": "http://example.org/fhir/StructureDefinition/ExampleTypedBundle",
"version": "0.0.1",
"name": "ExampleTypedBundle",
"title": "Example Typed Bundle",
"status": "draft",
"date": "2024-01-01",
"publisher": "Atomic Example Studio",
"description": "A Bundle profile that slices entry[] by resource type — used to test type discriminator support.",
"fhirVersion": "4.0.1",
"kind": "resource",
"abstract": false,
"type": "Bundle",
"baseDefinition": "http://hl7.org/fhir/StructureDefinition/Bundle",
"derivation": "constraint",
"differential": {
"element": [
{
"id": "Bundle.entry",
"path": "Bundle.entry",
"slicing": {
"discriminator": [
{
"type": "type",
"path": "resource"
}
],
"rules": "open",
"ordered": false
}
},
{
"id": "Bundle.entry:PatientEntry",
"path": "Bundle.entry",
"sliceName": "PatientEntry",
"min": 1,
"max": "1",
"mustSupport": true
},
{
"id": "Bundle.entry:PatientEntry.resource",
"path": "Bundle.entry.resource",
"min": 1,
"type": [
{
"code": "Patient"
}
]
},
{
"id": "Bundle.entry:OrganizationEntry",
"path": "Bundle.entry",
"sliceName": "OrganizationEntry",
"min": 0,
"max": "*"
},
{
"id": "Bundle.entry:OrganizationEntry.resource",
"path": "Bundle.entry.resource",
"min": 1,
"type": [
{
"code": "Organization"
}
]
}
]
}
}
4 changes: 4 additions & 0 deletions examples/local-package-folder/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["."]
}
8 changes: 7 additions & 1 deletion src/api/writer-generator/typescript/profile-slices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export type SliceDef = {
excluded: string[];
array: boolean;
constrainedChoice: ConstrainedChoiceInfo | undefined;
/** True when the slice uses a type discriminator (match by resourceType) */
typeDiscriminator: boolean;
};

export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): SliceDef[] =>
Expand All @@ -77,6 +79,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
const baseType = tsTypeFromIdentifier(field.type);
const pkgName = flatProfile.identifier.package;
const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type);
const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false;
return Object.entries(field.slicing.slices)
.filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0)
.map(([sliceName, slice]) => {
Expand All @@ -98,6 +101,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
excluded: slice.excluded ?? [],
array: Boolean(field.array),
constrainedChoice,
typeDiscriminator: isTypeDisc,
};
});
});
Expand Down Expand Up @@ -193,7 +197,9 @@ export const generateSliceGetters = (
w.line("if (!item || !matchesValue(item, match)) return undefined");
}
w.line("if (mode === 'raw') return item");
if (sliceDef.constrainedChoice) {
if (sliceDef.typeDiscriminator) {
w.line(`return item as ${typeName}`);
} else if (sliceDef.constrainedChoice) {
const cc = sliceDef.constrainedChoice;
w.line(`return unwrapSliceChoice<${typeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`);
} else {
Expand Down
4 changes: 2 additions & 2 deletions src/api/writer-generator/typescript/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ const generateProfileHelpersImport = (
imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults");
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.length > 0) imports.push("stripMatchKeys");
if (sliceDefs.some((s) => !s.typeDiscriminator)) imports.push("stripMatchKeys");
if (sliceDefs.some((s) => s.constrainedChoice)) imports.push("wrapSliceChoice", "unwrapSliceChoice");
if (extensions.some((ext) => ext.url)) imports.push("isExtension", "getExtensionValue", "pushExtension");
if (Object.keys(flatProfile.fields ?? {}).length > 0)
Expand Down Expand Up @@ -564,7 +564,7 @@ const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema,
const tsProfileName = tsResourceName(flatProfile.identifier);
for (const sliceDef of sliceDefs) {
const typeName = tsSliceFlatTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName);
const matchFields = Object.keys(sliceDef.match);
const matchFields = sliceDef.typeDiscriminator ? [] : Object.keys(sliceDef.match);
const allExcluded = [...new Set([...sliceDef.excluded, ...matchFields])];
if (sliceDef.constrainedChoice) {
const cc = sliceDef.constrainedChoice;
Expand Down
35 changes: 31 additions & 4 deletions src/typeschema/core/field-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,21 +172,48 @@ const collectDiscriminatorValue = (
collectDiscriminatorValue(element, segments, index + 1, result);
};

/**
* For type discriminators, navigate the discriminator path through schema.elements
* and read the `type` field. If type is a simple name (not a URL), treat as FHIR
* resource type and set `{ <path>: { resourceType: "<type>" } }`.
*/
const computeTypeDiscriminatorMatch = (
path: string,
schema: FHIRSchemaElement,
result: Record<string, unknown>,
): void => {
if (path === "$this") return;
const segments = path.split(".");
let elem: FHIRSchemaElement | undefined = schema;
for (const seg of segments) {
elem = elem?.elements?.[seg];
if (!elem) return;
}
const typeName = elem.type;
if (!typeName || typeName.includes("/")) return;
setNestedValue(result, segments, { resourceType: typeName });
};

/**
* Computes match values by navigating the slice's schema elements along discriminator paths.
* Used when a slice has an empty match but the discriminator values are nested deeper
* (e.g., component slices in BP where the discriminator crosses a nested slicing boundary).
*/
const computeMatchFromSchema = (
discriminators: Array<{ path: string }>,
discriminators: Array<{ type?: string; path: string }>,
schema: FHIRSchemaElement | undefined,
): Record<string, unknown> | undefined => {
if (!schema?.elements || !discriminators || discriminators.length === 0) return undefined;
if (!schema || !discriminators || discriminators.length === 0) return undefined;

const result: Record<string, unknown> = {};
for (const disc of discriminators) {
const segments = disc.path.split(".");
collectDiscriminatorValue(schema, segments, 0, result);
if (disc.type === "type") {
computeTypeDiscriminatorMatch(disc.path, schema, result);
} else {
if (!schema.elements) continue;
const segments = disc.path.split(".");
collectDiscriminatorValue(schema, segments, 0, result);
}
}
return Object.keys(result).length > 0 ? result : undefined;
};
Expand Down
Loading
Loading