diff --git a/CLAUDE.md b/CLAUDE.md index dc2efd61c..e549cac86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,11 +97,13 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators ## Pull Request Style -- PR body should be a bullet list summarizing changes — no section headers, no test plan. +- PR body should be a bullet list summarizing changes — no test plan section. - Use two-level nesting to group related items when the list is long; keep it flat when short. + - Use `##` section headers to group changes by concern when the PR spans multiple topics (e.g. renames, new features, config changes). - Keep bullets concise and focused on what changed, not why. - When a PR changes generated code or user-facing API, include before/after code examples. - Add a short motivation line before each example explaining why the change was made. +- When a PR changes user-facing config (generation scripts, tree shake rules, APIBuilder options), show the config diff as a before/after code block. ## Development Guidelines diff --git a/README.md b/README.md index 411e4be72..731adcbf5 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Tree shaking optimizes the generated output by including only the resources you }) ``` -This feature automatically resolves and includes all dependencies (referenced types, base resources, nested types) while excluding unused resources, significantly reducing the size of generated code and improving compilation times. +This feature automatically resolves and includes all dependencies (referenced types, base resources, nested types, and extension definitions used by profiles) while excluding unused resources, significantly reducing the size of generated code and improving compilation times. ##### Field-Level Tree Shaking @@ -259,6 +259,7 @@ Beyond resource-level filtering, tree shaking supports fine-grained field select - `selectFields`: Only includes the specified fields (whitelist approach) - `ignoreFields`: Removes specified fields, keeps everything else (blacklist approach) - These options are **mutually exclusive** - you cannot use both in the same rule +- `ignoreExtensions`: Removes specific extensions from a profile by canonical URL **Polymorphic Field Handling:** diff --git a/docs/posts/typescript-profiles-deep-dive.md b/docs/posts/typescript-profiles-deep-dive.md index cee698760..1661c58de 100644 --- a/docs/posts/typescript-profiles-deep-dive.md +++ b/docs/posts/typescript-profiles-deep-dive.md @@ -293,7 +293,13 @@ new APIBuilder() .generate(); ``` -The generator resolves dependencies automatically. If you include `bodyweight`, it knows to include `Observation`, `DomainResource`, and any types referenced by the profile's fields. +The generator resolves dependencies automatically. If you include `bodyweight`, it knows to include `Observation`, `DomainResource`, and any types referenced by the profile's fields. Extension definitions used by profiles (e.g., `us-core-race`, `us-core-ethnicity`) are also auto-collected — you don't need to list them manually. To exclude specific extensions, use `ignoreExtensions`: + +```typescript +"http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient": { + ignoreExtensions: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-genderIdentity"] +} +``` Generated files land in a `profiles/` subdirectory alongside the base types: diff --git a/examples/typescript-us-core/fhir-types/README.md b/examples/typescript-us-core/fhir-types/README.md index f1c6fa652..d343743fe 100644 --- a/examples/typescript-us-core/fhir-types/README.md +++ b/examples/typescript-us-core/fhir-types/README.md @@ -1995,7 +1995,6 @@ - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter#hospitalization.dischargeDisposition_binding` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter#type_binding` -- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-extension-questionnaire-uri` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-genderIdentity` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-goal` @@ -2012,8 +2011,6 @@ - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-immunization#vaccineCode_binding` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-implantable-device` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-implantable-device#type_binding` -- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex` -- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-jurisdiction` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-location` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-location#address.state_binding` @@ -2066,7 +2063,6 @@ - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-pulse-oximetry` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-pulse-oximetry#code_binding` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-questionnaireresponse` -- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-race` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-relatedperson` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-relatedperson#relationship_binding` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-respiratory-rate` @@ -2086,7 +2082,6 @@ - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-specimen#type_binding` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-treatment-intervention-preference` - `http://hl7.org/fhir/us/core/StructureDefinition/us-core-treatment-intervention-preference#category_binding` -- `http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation` - `http://hl7.org/fhir/us/core/StructureDefinition/uscdi-requirement` - `http://hl7.org/fhir/us/core/ValueSet/us-core-clinical-note-type` - `http://hl7.org/fhir/us/core/ValueSet/us-core-clinical-result-observation-category` diff --git a/examples/typescript-us-core/generate.ts b/examples/typescript-us-core/generate.ts index b6aa82d0f..306223426 100644 --- a/examples/typescript-us-core/generate.ts +++ b/examples/typescript-us-core/generate.ts @@ -15,11 +15,6 @@ if (require.main === module) { "http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient": {}, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure": {}, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight": {}, - "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity": {}, - "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race": {}, - "http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation": {}, - "http://hl7.org/fhir/us/core/StructureDefinition/us-core-individual-sex": {}, - "http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed": {}, }, }, }) diff --git a/src/api/writer-generator/typescript/profile-extensions.ts b/src/api/writer-generator/typescript/profile-extensions.ts index 510c34db4..a6f59ded3 100644 --- a/src/api/writer-generator/typescript/profile-extensions.ts +++ b/src/api/writer-generator/typescript/profile-extensions.ts @@ -241,7 +241,7 @@ const generateComplexExtensionSetter = (w: TypeScript, info: ExtensionMethodInfo w.curlyBlock(["public", setMethodName, `(input: ${inputTypeName}): this`], () => { w.line("const subExtensions: Extension[] = []"); for (const sub of ext.subExtensions ?? []) { - const valueField = sub.valueType ? tsValueFieldName(sub.valueType) : "value"; + const valueField = sub.valueFieldType ? tsValueFieldName(sub.valueFieldType) : "value"; if (sub.max === "*") { w.curlyBlock(["if", `(input.${sub.name})`], () => { w.curlyBlock(["for", `(const item of input.${sub.name})`], () => { @@ -282,7 +282,7 @@ const generateComplexExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo generateExtensionGetterOverloads(w, ext, targetPath, getMethodName, inputType, extProfileInfo, () => { const configItems = (ext.subExtensions ?? []).map((sub) => { - const valueField = sub.valueType ? tsValueFieldName(sub.valueType) : "value"; + const valueField = sub.valueFieldType ? tsValueFieldName(sub.valueFieldType) : "value"; const isArray = sub.max === "*"; return `{ name: "${sub.url}", valueField: "${valueField}", isArray: ${isArray} }`; }); @@ -295,7 +295,7 @@ const generateComplexExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo const generateSingleValueExtensionSetter = (w: TypeScript, tsIndex: TypeSchemaIndex, info: ExtensionMethodInfo) => { const { ext, setMethodName, targetPath, extProfileInfo } = info; - const firstValueType = ext.valueTypes?.[0]; + const firstValueType = ext.valueFieldTypes?.[0]; if (!firstValueType) return; const valueType = tsTypeFromIdentifier(firstValueType); const valueField = tsValueFieldName(firstValueType); @@ -335,7 +335,7 @@ const generateSingleValueExtensionSetter = (w: TypeScript, tsIndex: TypeSchemaIn const generateSingleValueExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo) => { const { ext, getMethodName, targetPath, extProfileInfo } = info; - const firstValueType = ext.valueTypes?.[0]; + const firstValueType = ext.valueFieldTypes?.[0]; if (!firstValueType) return; const valueType = tsTypeFromIdentifier(firstValueType); const valueField = tsValueFieldName(firstValueType); @@ -403,7 +403,7 @@ export const generateExtensionMethods = ( generateComplexExtensionSetter(w, info); w.line(); generateComplexExtensionGetter(w, info); - } else if (ext.valueTypes?.length === 1 && ext.valueTypes[0]) { + } else if (ext.valueFieldTypes?.length === 1 && ext.valueFieldTypes[0]) { generateSingleValueExtensionSetter(w, tsIndex, info); w.line(); generateSingleValueExtensionGetter(w, info); @@ -427,21 +427,21 @@ export const collectTypesFromExtensions = ( if (ext.isComplex && ext.subExtensions) { needsExtensionType = true; for (const sub of ext.subExtensions) { - if (!sub.valueType) continue; + if (!sub.valueFieldType) continue; const resolvedType = tsIndex.resolveByUrl( flatProfile.identifier.package, - sub.valueType.url as CanonicalUrl, + sub.valueFieldType.url as CanonicalUrl, ); - addType(resolvedType?.identifier ?? sub.valueType); + addType(resolvedType?.identifier ?? sub.valueFieldType); } - } else if (ext.valueTypes && ext.valueTypes.length === 1) { + } else if (ext.valueFieldTypes && ext.valueFieldTypes.length === 1) { needsExtensionType = true; - if (ext.valueTypes[0]) { + if (ext.valueFieldTypes[0]) { const resolvedType = tsIndex.resolveByUrl( flatProfile.identifier.package, - ext.valueTypes[0].url as CanonicalUrl, + ext.valueFieldTypes[0].url as CanonicalUrl, ); - addType(resolvedType?.identifier ?? ext.valueTypes[0]); + addType(resolvedType?.identifier ?? ext.valueFieldTypes[0]); } } else { needsExtensionType = true; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index ab915b3df..80b1006ec 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -628,7 +628,7 @@ const generateInlineExtensionInputTypes = (w: TypeScript, tsIndex: TypeSchemaInd const typeName = tsExtensionFlatTypeName(tsProfileName, ext.name); w.curlyBlock(["export", "type", typeName, "="], () => { for (const sub of ext.subExtensions ?? []) { - const tsType = sub.valueType ? tsTypeFromIdentifier(sub.valueType) : "unknown"; + const tsType = sub.valueFieldType ? tsTypeFromIdentifier(sub.valueFieldType) : "unknown"; const isArray = sub.max === "*"; const isRequired = sub.min !== undefined && sub.min > 0; w.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`); diff --git a/src/typeschema/core/profile-extensions.ts b/src/typeschema/core/profile-extensions.ts index 78b3428f9..abdd83cb0 100644 --- a/src/typeschema/core/profile-extensions.ts +++ b/src/typeschema/core/profile-extensions.ts @@ -13,14 +13,15 @@ import { concatIdentifiers, type ExtensionSubField, type Identifier, - type Name, type ProfileExtension, + type ProfileIdentifier, type RichFHIRSchema, } from "@typeschema/types"; import { buildFieldType } from "./field-builder"; +import { mkIdentifier } from "./identifier"; -const extractExtensionValueTypes = ( +const extractExtensionValueFieldTypes = ( register: Register, fhirSchema: RichFHIRSchema, extensionUrl: CanonicalUrl, @@ -29,14 +30,14 @@ const extractExtensionValueTypes = ( const extensionSchema = register.resolveFs(fhirSchema.package_meta, extensionUrl); if (!extensionSchema?.elements) return undefined; - const valueTypes: Identifier[] = []; + const valueFieldTypes: Identifier[] = []; for (const [key, element] of Object.entries(extensionSchema.elements)) { if (element.choiceOf !== "value" && !key.startsWith("value")) continue; const fieldType = buildFieldType(register, extensionSchema, [key], element, logger); - if (fieldType) valueTypes.push(fieldType); + if (fieldType) valueFieldTypes.push(fieldType); } - return concatIdentifiers(valueTypes); + return concatIdentifiers(valueFieldTypes); }; const extractLegacySubExtensions = ( @@ -63,7 +64,7 @@ const extractLegacySubExtensions = ( subExtensions.push({ name: sliceName, url: element.url ?? sliceName, - valueType, + valueFieldType: valueType, min: element.min, max: element.max !== undefined ? String(element.max) : undefined, }); @@ -71,7 +72,11 @@ const extractLegacySubExtensions = ( return subExtensions; }; -const extractSlicingSubExtensions = (extensionSchema: RichFHIRSchema): ExtensionSubField[] => { +const extractSlicingSubExtensions = ( + register: Register, + extensionSchema: RichFHIRSchema, + logger?: CodegenLog, +): ExtensionSubField[] => { const subExtensions: ExtensionSubField[] = []; const extensionElement = extensionSchema.elements?.extension as any; const slices = extensionElement?.slicing?.slices; @@ -86,22 +91,14 @@ const extractSlicingSubExtensions = (extensionSchema: RichFHIRSchema): Extension for (const [elemKey, elemValue] of Object.entries(schema.elements ?? {})) { const elem = elemValue as any; if (elem.choiceOf !== "value" && !elemKey.startsWith("value")) continue; - if (elem.type) { - valueType = { - kind: "complex-type" as const, - package: extensionSchema.package_meta.name, - version: extensionSchema.package_meta.version, - name: elem.type as any, - url: `http://hl7.org/fhir/StructureDefinition/${elem.type}` as CanonicalUrl, - }; - break; - } + valueType = buildFieldType(register, extensionSchema, [elemKey], elem, logger); + if (valueType) break; } subExtensions.push({ name: sliceName, url: slice.match?.url ?? sliceName, - valueType, + valueFieldType: valueType, min: schema._required ? 1 : (schema.min ?? 0), // biome-ignore lint/style/noNestedTernary : okay here max: schema.max !== undefined ? String(schema.max) : schema.array ? "*" : "1", @@ -120,7 +117,7 @@ const extractSubExtensions = ( if (!extensionSchema?.elements) return undefined; const legacySubs = extractLegacySubExtensions(register, extensionSchema, logger); - const slicingSubs = extractSlicingSubExtensions(extensionSchema); + const slicingSubs = extractSlicingSubExtensions(register, extensionSchema, logger); const subExtensions = [...legacySubs, ...slicingSubs]; return subExtensions.length > 0 ? subExtensions : undefined; @@ -135,7 +132,7 @@ export const extractProfileExtensions = ( const addExtensionEntry = (path: string[], name: string, schema: FHIRSchemaElement) => { let url = schema.url as CanonicalUrl | undefined; - let valueTypes = url ? extractExtensionValueTypes(register, fhirSchema, url, logger) : undefined; + let valueFieldTypes = url ? extractExtensionValueFieldTypes(register, fhirSchema, url, logger) : undefined; const subExtensions = url ? extractSubExtensions(register, fhirSchema, url, logger) : undefined; // For extension profiles, sub-extension entries may lack a url. @@ -144,33 +141,32 @@ export const extractProfileExtensions = ( const sliceSchema = (fhirSchema.elements?.extension as any)?.slicing?.slices?.[name]?.schema; if (sliceSchema) { url = (sliceSchema.elements?.url?.fixed?.value ?? name) as CanonicalUrl; - for (const [_elemKey, elemValue] of Object.entries(sliceSchema.elements ?? {})) { - const elem = elemValue as { choiceOf?: string; type?: string }; - if (elem.choiceOf === "value" && elem.type) { - valueTypes = [ - { - kind: "complex-type" as const, - package: fhirSchema.package_meta.name, - version: fhirSchema.package_meta.version, - name: elem.type as Name, - url: `http://hl7.org/fhir/StructureDefinition/${elem.type}` as CanonicalUrl, - }, - ]; - break; + for (const [elemKey, elemValue] of Object.entries(sliceSchema.elements ?? {})) { + const elem = elemValue as FHIRSchemaElement; + if (elem.choiceOf === "value" || elemKey.startsWith("value")) { + const ft = buildFieldType(register, fhirSchema, [elemKey], elem, logger); + if (ft) { + valueFieldTypes = [ft]; + break; + } } } } } const isComplex = subExtensions && subExtensions.length > 0; + const extFs = url ? register.resolveFs(fhirSchema.package_meta, url) : undefined; + const profile = extFs ? (mkIdentifier(extFs) as ProfileIdentifier) : undefined; + extensions.push({ name, path: [...path, "extension"].join("."), url, + profile, min: schema.min, max: schema.max !== undefined ? String(schema.max) : undefined, mustSupport: schema.mustSupport, - valueTypes, + valueFieldTypes, subExtensions, isComplex, }); diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 6e759bfa0..2670dfa0d 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -10,6 +10,7 @@ import type { CodegenLog } from "@root/utils/log"; import type { Register } from "@typeschema/register"; import { concatIdentifiers, + extractExtensionDeps, type Field, type Identifier, isNestedIdentifier, @@ -139,8 +140,8 @@ function transformFhirSchemaResource( const extensions = fhirSchema.derivation === "constraint" ? extractProfileExtensions(register, fhirSchema, logger) : undefined; - const extensionDeps = extensions?.flatMap((ext) => ext.valueTypes ?? []) ?? []; - const dependencies = extractDependencies(identifier, base, fields, nested); + const extensionDeps = extensions?.flatMap(extractExtensionDeps); + const dependencies = concatIdentifiers(extractDependencies(identifier, base, fields, nested), extensionDeps); const typeSchema: TypeSchema = { identifier, @@ -148,7 +149,7 @@ function transformFhirSchemaResource( fields, nested, description: fhirSchema.description, - dependencies: concatIdentifiers(dependencies, extensionDeps), + dependencies, extensions, }; diff --git a/src/typeschema/ir/tree-shake.ts b/src/typeschema/ir/tree-shake.ts index cc5a0bc70..fe0b555d2 100644 --- a/src/typeschema/ir/tree-shake.ts +++ b/src/typeschema/ir/tree-shake.ts @@ -3,6 +3,8 @@ import type { CodegenLog } from "@root/utils/log"; import { extractDependencies } from "../core/transformer"; import { type CanonicalUrl, + concatIdentifiers, + extractExtensionDeps, type Field, isBindingSchema, isChoiceDeclarationField, @@ -15,6 +17,7 @@ import { isValueSetTypeSchema, type NestedType, type PkgName, + type ProfileTypeSchema, type RegularTypeSchema, type TypeSchema, } from "../types"; @@ -135,6 +138,16 @@ const mutableIgnoreFields = (schema: RegularTypeSchema, ignoreFields: string[]) } }; +const mutableIgnoreExtensions = (schema: ProfileTypeSchema, ignoreExtensions: string[]) => { + if (!schema.extensions) return; + for (const url of ignoreExtensions) { + if (!schema.extensions.some((ext) => ext.url === url)) + throw new Error(`Extension ${url} not found in profile ${schema.identifier.url}`); + } + schema.extensions = schema.extensions.filter((ext) => !ext.url || !ignoreExtensions.includes(ext.url)); + if (schema.extensions.length === 0) schema.extensions = undefined; +}; + const mutableFillReport = (report: TreeShakeReport, tsIndex: TypeSchemaIndex, shakedIndex: TypeSchemaIndex) => { const packages = Object.keys(tsIndex.schemasByPackage); const shakedPackages = Object.keys(shakedIndex.schemasByPackage); @@ -188,6 +201,10 @@ export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _lo mutableIgnoreFields(schema, rule.ignoreFields); } + if (isProfileTypeSchema(schema) && rule.ignoreExtensions) { + mutableIgnoreExtensions(schema, rule.ignoreExtensions); + } + if (schema.nested) { const usedTypes = new Set(); const collectUsedNestedTypes = (s: RegularTypeSchema | NestedType) => { @@ -208,7 +225,11 @@ export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _lo schema.nested = schema.nested.filter((n) => usedTypes.has(n.identifier.url)); } - schema.dependencies = extractDependencies(schema.identifier, schema.base, schema.fields, schema.nested); + const extDeps = isProfileTypeSchema(schema) ? schema.extensions?.flatMap(extractExtensionDeps) : undefined; + schema.dependencies = concatIdentifiers( + extractDependencies(schema.identifier, schema.base, schema.fields, schema.nested), + extDeps, + ); return schema; }; diff --git a/src/typeschema/ir/types.ts b/src/typeschema/ir/types.ts index 431d93daa..0b5e42a35 100644 --- a/src/typeschema/ir/types.ts +++ b/src/typeschema/ir/types.ts @@ -21,7 +21,7 @@ export type LogicalPromotionConf = Record; export type TreeShakeConf = Record>; -export type TreeShakeRule = { ignoreFields?: string[]; selectFields?: string[] }; +export type TreeShakeRule = { ignoreFields?: string[]; selectFields?: string[]; ignoreExtensions?: string[] }; export type IrReport = { treeShake?: TreeShakeReport; diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 6772f64a2..329ae6f93 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -99,7 +99,7 @@ type ResourceIdentifier = { kind: "resource" } & IdentifierBase; export type ValueSetIdentifier = { kind: "value-set" } & IdentifierBase; export type NestedIdentifier = { kind: "nested" } & IdentifierBase; export type BindingIdentifier = { kind: "binding" } & IdentifierBase; -type ProfileIdentifier = { kind: "profile" } & IdentifierBase; +export type ProfileIdentifier = { kind: "profile" } & IdentifierBase; type LogicalIdentifier = { kind: "logical" } & IdentifierBase; export type Identifier = @@ -237,23 +237,30 @@ export interface FieldSlice { export interface ExtensionSubField { name: string; url: string; - valueType?: Identifier; + valueFieldType?: Identifier; min?: number; max?: string; } export interface ProfileExtension { + profile?: ProfileIdentifier; name: string; path: string; url?: string; min?: number; max?: string; mustSupport?: boolean; - valueTypes?: Identifier[]; + valueFieldTypes?: Identifier[]; subExtensions?: ExtensionSubField[]; isComplex?: boolean; } +export const extractExtensionDeps = (ext: ProfileExtension): Identifier[] => [ + ...(ext.valueFieldTypes ?? []), + ...(ext.profile ? [ext.profile] : []), + ...(ext.subExtensions?.flatMap((sub) => (sub.valueFieldType ? [sub.valueFieldType] : [])) ?? []), +]; + export interface RegularTypeSchema { // TODO: restrict to ResourceIdentifier | ComplexTypeIdentifier | LogicalIdentifier identifier: Identifier; diff --git a/test/unit/typeschema/ir/tree-shake.test.ts b/test/unit/typeschema/ir/tree-shake.test.ts index 60c6b8b64..cdb89762e 100644 --- a/test/unit/typeschema/ir/tree-shake.test.ts +++ b/test/unit/typeschema/ir/tree-shake.test.ts @@ -7,7 +7,14 @@ import { treeShakeTypeSchema, } from "@root/typeschema/ir/tree-shake"; import { registerFromPackageMetas } from "@root/typeschema/register"; -import type { CanonicalUrl, RegularTypeSchema } from "@root/typeschema/types"; +import type { + CanonicalUrl, + Identifier, + Name, + ProfileIdentifier, + ProfileTypeSchema, + RegularTypeSchema, +} from "@root/typeschema/types"; import { mkIndex, mkR4Register, mkTestLogger, r4Package, r5Package, resolveTs } from "@typeschema-test/utils"; describe("treeShake specific TypeSchema", async () => { @@ -74,6 +81,13 @@ describe("treeShake specific TypeSchema", async () => { expect(JSON.stringify(patient, null, 2)).toBe(JSON.stringify(patientOrigin, null, 2)); }); + it("ignoreExtensions on non-profile schema is no-op", () => { + const patient = treeShakeTypeSchema(patientOrigin, { + ignoreExtensions: ["http://example.com/ext/race"], + }); + expect(JSON.stringify(patient, null, 2)).toBe(JSON.stringify(patientOrigin, null, 2)); + }); + it("Select and Ignore fields should be mutually exclusive", () => { expect(() => { treeShakeTypeSchema(patientOrigin, { @@ -259,3 +273,113 @@ describe("treeShake specific TypeSchema", async () => { }); }); }); + +describe("ignoreExtensions", () => { + const mkDep = (url: string): Identifier => ({ + kind: "complex-type", + name: url.split("/").pop()! as Name, + url: url as CanonicalUrl, + package: "test", + version: "1.0.0", + }); + + const mkProfileId = (url: string): ProfileIdentifier => ({ + kind: "profile", + name: url.split("/").pop()! as Name, + url: url as CanonicalUrl, + package: "test", + version: "1.0.0", + }); + + const mkProfile = (): ProfileTypeSchema => ({ + identifier: mkProfileId("http://example.com/TestProfile"), + base: mkDep("http://hl7.org/fhir/StructureDefinition/Patient"), + extensions: [ + { + name: "race", + path: "Patient.extension", + url: "http://example.com/ext/race", + profile: mkProfileId("http://example.com/ext/race"), + valueFieldTypes: [mkDep("http://hl7.org/fhir/StructureDefinition/Coding")], + }, + { + name: "ethnicity", + path: "Patient.extension", + url: "http://example.com/ext/ethnicity", + profile: mkProfileId("http://example.com/ext/ethnicity"), + valueFieldTypes: [mkDep("http://hl7.org/fhir/StructureDefinition/CodeableConcept")], + }, + { + name: "birthsex", + path: "Patient.extension", + url: "http://example.com/ext/birthsex", + profile: mkProfileId("http://example.com/ext/birthsex"), + }, + ], + }); + + it("removes matching extensions from profile", () => { + const profile = mkProfile(); + const result = treeShakeTypeSchema(profile, { + ignoreExtensions: ["http://example.com/ext/race"], + }) as ProfileTypeSchema; + expect(result.extensions).toHaveLength(2); + expect(result.extensions?.find((e) => e.url === "http://example.com/ext/race")).toBeUndefined(); + expect(result.extensions?.find((e) => e.url === "http://example.com/ext/ethnicity")).toBeDefined(); + expect(result.extensions?.find((e) => e.url === "http://example.com/ext/birthsex")).toBeDefined(); + }); + + it("throws error on non-existent extension URL", () => { + const profile = mkProfile(); + expect(() => { + treeShakeTypeSchema(profile, { + ignoreExtensions: ["http://example.com/ext/nonexistent"], + }); + }).toThrowError( + "Extension http://example.com/ext/nonexistent not found in profile http://example.com/TestProfile", + ); + }); + + it("empty ignoreExtensions array is no-op", () => { + const profile = mkProfile(); + const result = treeShakeTypeSchema(profile, { + ignoreExtensions: [], + }) as ProfileTypeSchema; + expect(result.extensions).toHaveLength(3); + }); + + it("dependencies are recalculated (ignored extension deps not in output)", () => { + const profile = mkProfile(); + const result = treeShakeTypeSchema(profile, { + ignoreExtensions: ["http://example.com/ext/race"], + }) as ProfileTypeSchema; + // Coding was only a dep of the "race" extension, so it should be gone + expect( + result.dependencies?.find((d) => d.url === "http://hl7.org/fhir/StructureDefinition/Coding"), + ).toBeUndefined(); + // race definition identifier should be gone + expect(result.dependencies?.find((d) => d.url === "http://example.com/ext/race")).toBeUndefined(); + // CodeableConcept is still a dep of the "ethnicity" extension + expect( + result.dependencies?.find((d) => d.url === "http://hl7.org/fhir/StructureDefinition/CodeableConcept"), + ).toBeDefined(); + // ethnicity definition identifier should still be there + expect(result.dependencies?.find((d) => d.url === "http://example.com/ext/ethnicity")).toBeDefined(); + // Patient base dep should still be there + expect( + result.dependencies?.find((d) => d.url === "http://hl7.org/fhir/StructureDefinition/Patient"), + ).toBeDefined(); + }); + + it("removing all extensions sets extensions to undefined", () => { + const profile = mkProfile(); + const result = treeShakeTypeSchema(profile, { + ignoreExtensions: [ + "http://example.com/ext/race", + "http://example.com/ext/ethnicity", + "http://example.com/ext/birthsex", + ], + }) as ProfileTypeSchema; + expect(result.extensions).toBeUndefined(); + }); +});