diff --git a/src/api/writer-generator/python.ts b/src/api/writer-generator/python.ts index 7c87b979..3357af69 100644 --- a/src/api/writer-generator/python.ts +++ b/src/api/writer-generator/python.ts @@ -5,7 +5,13 @@ import { fileURLToPath } from "node:url"; import { camelCase, pascalCase, snakeCase, uppercaseFirstLetterOfEach } from "@root/api/writer-generator/utils"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer.ts"; import { groupByPackages, sortAsDeclarationSequence, type TypeSchemaIndex } from "@root/typeschema/utils"; -import type { EnumDefinition, Field, SpecializationTypeSchema, TypeIdentifier } from "@typeschema/types.ts"; +import { + type EnumDefinition, + type Field, + isResourceTypeSchema, + type SpecializationTypeSchema, + type TypeIdentifier, +} from "@typeschema/types.ts"; const PRIMITIVE_TYPE_MAP: Record = { boolean: "bool", @@ -430,13 +436,13 @@ export class Python extends Writer { return; } - if (schema.identifier.kind === "resource") { + if (isResourceTypeSchema(schema)) { this.generateResourceTypeField(schema); } this.generateFields(schema, schema.identifier.name); - if (schema.identifier.kind === "resource") { + if (isResourceTypeSchema(schema)) { this.generateResourceMethods(schema); } } diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 7cc31cb7..655c5545 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -7,12 +7,10 @@ import { isChoiceDeclarationField, isComplexTypeIdentifier, isLogicalTypeSchema, - isNestedIdentifier, isPrimitiveIdentifier, isProfileTypeSchema, isResourceTypeSchema, isSpecializationTypeSchema, - type Name, packageMeta, packageMetaToFhir, type SpecializationTypeSchema, @@ -156,14 +154,6 @@ export class TypeScript extends Writer { name: tsResourceName(dep), dep: dep, }); - } else if (isNestedIdentifier(dep)) { - const ndep = { ...dep }; - ndep.name = tsNameFromCanonical(dep.url) as Name; - imports.push({ - tsPackage: `${importPrefix}${tsModulePath(ndep)}`, - name: tsResourceName(dep), - dep: dep, - }); } else { skipped.push(dep); } @@ -214,8 +204,6 @@ export class TypeScript extends Writer { const genericTypes = ["Reference", "Coding", "CodeableConcept"]; if (genericTypes.includes(schema.identifier.name)) { name = `${schema.identifier.name}`; - } else if (schema.identifier.kind === "nested") { - name = tsResourceName(schema.identifier); } else { name = tsResourceName(schema.identifier); } @@ -336,7 +324,7 @@ export class TypeScript extends Writer { generateProfileClass(this, tsIndex, flatProfile); }); }); - } else if (["complex-type", "resource", "logical"].includes(schema.identifier.kind)) { + } else if (isSpecializationTypeSchema(schema)) { this.cat(`${tsModuleFileName(schema.identifier)}`, () => { this.generateDisclaimer(); this.generateDependenciesImports(tsIndex, schema); diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index e52c121d..9c37fb43 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -4,6 +4,7 @@ * Core transformation logic for converting FHIRSchema to TypeSchema format */ +import assert from "node:assert"; import type { FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import { shouldSkipCanonical } from "@root/typeschema/skip-hack"; import type { CodegenLog } from "@root/utils/log"; @@ -12,12 +13,15 @@ import { concatIdentifiers, extractExtensionDeps, type Field, + type Identifier, isNestedIdentifier, isProfileIdentifier, type NestedTypeSchema, + type ProfileIdentifier, packageMetaToFhir, type RichFHIRSchema, type RichValueSet, + type SpecializationTypeSchema, type TypeIdentifier, type TypeSchema, type ValueSetTypeSchema, @@ -92,33 +96,50 @@ export async function transformValueSet( }; } -export function extractDependencies( - identifier: TypeIdentifier, +const collectRawDeps = ( base: TypeIdentifier | undefined, fields: Record | undefined, nestedTypes: NestedTypeSchema[] | undefined, -): TypeIdentifier[] | undefined { - const deps = []; +): TypeIdentifier[] => { + const deps: TypeIdentifier[] = []; if (base) deps.push(base); if (fields) deps.push(...extractFieldDependencies(fields)); if (nestedTypes) deps.push(...extractNestedDependencies(nestedTypes)); + return deps; +}; - const localNestedTypeUrls = new Set(nestedTypes?.map((nt) => nt.identifier.url)); +export const extractDependencies = ( + identifier: Identifier, + base: TypeIdentifier | undefined, + fields: Record | undefined, + nestedTypes: NestedTypeSchema[] | undefined, +): Identifier[] | undefined => { + const deps = collectRawDeps(base, fields, nestedTypes); - const filtered = deps.filter((dep) => { + const filtered = deps.filter((dep): dep is Identifier => { if (dep.url === identifier.url) return false; - if (isProfileIdentifier(identifier)) return true; - if (!isNestedIdentifier(dep)) return true; - return !localNestedTypeUrls.has(dep.url); + if (isNestedIdentifier(dep)) return false; + return true; }); return concatIdentifiers(filtered); -} +}; + +export const extractProfileDependencies = ( + identifier: ProfileIdentifier, + base: TypeIdentifier | undefined, + fields: Record | undefined, + nestedTypes: NestedTypeSchema[] | undefined, +): TypeIdentifier[] | undefined => { + const deps = collectRawDeps(base, fields, nestedTypes); + const filtered = deps.filter((dep) => dep.url !== identifier.url); + return concatIdentifiers(filtered); +}; export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSchema, logger?: CodegenLog): TypeSchema[] { const identifier = mkIdentifier(fhirSchema); - let base: TypeIdentifier | undefined; + let base: Identifier | undefined; if (fhirSchema.base) { const baseFs = register.resolveFs( fhirSchema.package_meta, @@ -128,27 +149,44 @@ export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSche throw new Error( `Base resource not found '${fhirSchema.base}' for <${fhirSchema.url}> from ${packageMetaToFhir(fhirSchema.package_meta)}`, ); - base = mkIdentifier(baseFs); + const baseId = mkIdentifier(baseFs); + assert(!isNestedIdentifier(baseId), `Unexpected nested base for ${fhirSchema.url}`); + base = baseId; } const fields = mkFields(register, fhirSchema, [], fhirSchema.elements, logger); const nested = mkNestedTypes(register, fhirSchema, logger); - const extensions = - fhirSchema.derivation === "constraint" ? extractProfileExtensions(register, fhirSchema, logger) : undefined; - const extensionDeps = extensions?.flatMap(extractExtensionDeps); - const dependencies = concatIdentifiers(extractDependencies(identifier, base, fields, nested), extensionDeps); - - const typeSchema: TypeSchema = { - identifier, - base, - fields, - nested, - description: fhirSchema.description, - dependencies, - extensions, - typeFamily: undefined, // NOTE: should be populateTypeFamily later. - }; + let typeSchema: TypeSchema; + if (fhirSchema.derivation === "constraint") { + if (!base) throw new Error(`Profile ${fhirSchema.url} must have a base type`); + assert(isProfileIdentifier(identifier)); + const extensions = extractProfileExtensions(register, fhirSchema, logger); + const extensionDeps = extensions?.flatMap(extractExtensionDeps); + const rawDeps = extractProfileDependencies(identifier, base, fields, nested); + typeSchema = { + identifier, + base, + fields, + nested, + description: fhirSchema.description, + dependencies: concatIdentifiers(rawDeps, extensionDeps), + extensions, + }; + } else { + assert(!isNestedIdentifier(identifier), `Unexpected nested identifier for ${fhirSchema.url}`); + const rawDeps = extractDependencies(identifier, base, fields, nested); + const specialization: SpecializationTypeSchema = { + identifier, + base, + fields, + nested, + description: fhirSchema.description, + dependencies: rawDeps, + typeFamily: undefined, // NOTE: should be populateTypeFamily later. + }; + typeSchema = specialization; + } const bindingSchemas = collectBindingSchemas(register, fhirSchema, logger); return [typeSchema, ...bindingSchemas]; diff --git a/src/typeschema/ir/logic-promotion.ts b/src/typeschema/ir/logic-promotion.ts index f0a7184e..033190bb 100644 --- a/src/typeschema/ir/logic-promotion.ts +++ b/src/typeschema/ir/logic-promotion.ts @@ -3,6 +3,7 @@ import { type Field, type Identifier, isChoiceDeclarationField, + isLogicalTypeSchema, isPrimitiveTypeSchema, isProfileTypeSchema, isSpecializationTypeSchema, @@ -25,7 +26,7 @@ export const promoteLogical = (tsIndex: TypeSchemaIndex, promotes: LogicalPromot .map((schema) => { const promo = promoteSets[schema.identifier.package]?.has(schema.identifier.url); if (!promo) return undefined; - if (schema.identifier.kind !== "logical") + if (!isLogicalTypeSchema(schema)) throw new Error(`Unexpected schema kind: ${JSON.stringify(schema.identifier)}`); return [identifierToString(schema.identifier), { ...schema.identifier, kind: "resource" }] as const; }) diff --git a/src/typeschema/ir/tree-shake.ts b/src/typeschema/ir/tree-shake.ts index 540a4e8a..b979045d 100644 --- a/src/typeschema/ir/tree-shake.ts +++ b/src/typeschema/ir/tree-shake.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import type { CodegenLog } from "@root/utils/log"; -import { extractDependencies } from "../core/transformer"; +import { extractDependencies, extractProfileDependencies } from "../core/transformer"; import { type CanonicalUrl, concatIdentifiers, @@ -15,7 +15,6 @@ import { isProfileTypeSchema, isSpecializationTypeSchema, isValueSetTypeSchema, - type NestedTypeSchema, type PkgName, type ProfileTypeSchema, type SpecializationTypeSchema, @@ -78,7 +77,7 @@ export const packageTreeShakeReadme = (report: TypeSchemaIndex | IrReport, pkgNa return lines.join("\n"); }; -const mutableSelectFields = (schema: SpecializationTypeSchema, selectFields: string[]) => { +const mutableSelectFields = (schema: SpecializationTypeSchema | ProfileTypeSchema, selectFields: string[]) => { const selectedFields: Record = {}; const selectPolimorphic: Record = {}; @@ -113,7 +112,7 @@ const mutableSelectFields = (schema: SpecializationTypeSchema, selectFields: str schema.fields = selectedFields; }; -const mutableIgnoreFields = (schema: SpecializationTypeSchema, ignoreFields: string[]) => { +const mutableIgnoreFields = (schema: SpecializationTypeSchema | ProfileTypeSchema, ignoreFields: string[]) => { for (const fieldName of ignoreFields) { const field = schema.fields?.[fieldName]; if (!schema.fields || !field) throw new Error(`Field ${fieldName} not found`); @@ -207,7 +206,7 @@ export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _lo if (schema.nested) { const usedTypes = new Set(); - const collectUsedNestedTypes = (s: SpecializationTypeSchema | NestedTypeSchema) => { + const collectUsedNestedTypes = (s: { fields?: Record }) => { Object.values(s.fields ?? {}) .filter(isNotChoiceDeclarationField) .filter((f) => isNestedIdentifier(f.type)) @@ -225,11 +224,16 @@ export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _lo schema.nested = schema.nested.filter((n) => usedTypes.has(n.identifier.url)); } - const extDeps = isProfileTypeSchema(schema) ? schema.extensions?.flatMap(extractExtensionDeps) : undefined; - schema.dependencies = concatIdentifiers( - extractDependencies(schema.identifier, schema.base, schema.fields, schema.nested), - extDeps, - ); + if (isProfileTypeSchema(schema)) { + const extDeps = schema.extensions?.flatMap(extractExtensionDeps); + schema.dependencies = concatIdentifiers( + extractProfileDependencies(schema.identifier, schema.base, schema.fields, schema.nested), + extDeps, + ); + } else { + assert(!isNestedIdentifier(schema.identifier)); + schema.dependencies = extractDependencies(schema.identifier, schema.base, schema.fields, schema.nested); + } return schema; }; diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 964509d3..264fbd35 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -137,12 +137,14 @@ export const isProfileIdentifier = (id: TypeIdentifier | undefined): id is Profi return id?.kind === "profile"; }; -export const concatIdentifiers = (...sources: (TypeIdentifier[] | undefined)[]): TypeIdentifier[] | undefined => { +export const concatIdentifiers = ( + ...sources: (T[] | undefined)[] +): T[] | undefined => { const entries = sources - .filter((s): s is TypeIdentifier[] => s !== undefined) - .flatMap((s) => s.map((id): [string, TypeIdentifier] => [id.url, id])); + .filter((s): s is T[] => s !== undefined) + .flatMap((s) => s.map((id): [string, T] => [id.url, id])); if (entries.length === 0) return undefined; - const deduped = Object.values(Object.fromEntries(entries) as Record); + const deduped = Object.values(Object.fromEntries(entries) as Record); return deduped.sort((a, b) => a.url.localeCompare(b.url)); }; @@ -271,7 +273,7 @@ export interface SpecializationTypeSchema { description?: string; fields?: { [k: string]: Field }; nested?: NestedTypeSchema[]; - dependencies?: TypeIdentifier[]; + dependencies?: Identifier[]; /** Transitive children grouped by kind (e.g. Resource → { resources: [DomainResource, Patient, …] }) */ typeFamily?: { resources?: ResourceIdentifier[]; diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 6d7803d3..c8c49a4b 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -29,15 +29,15 @@ import { /////////////////////////////////////////////////////////// // TypeSchema processing -export const groupByPackages = (typeSchemas: TypeSchema[]): Record => { - const grouped = {} as Record; +export const groupByPackages = (typeSchemas: T[]): Record => { + const grouped = {} as Record; for (const ts of typeSchemas) { const pkgName = ts.identifier.package; if (!grouped[pkgName]) grouped[pkgName] = []; grouped[pkgName].push(ts); } for (const [packageName, typeSchemas] of Object.entries(grouped)) { - const dict: Record = {}; + const dict: Record = {}; for (const ts of typeSchemas) { dict[JSON.stringify(ts.identifier)] = ts; } @@ -231,6 +231,7 @@ export const mkTypeSchemaIndex = ( } } if (index[url]?.[pkgName]) return index[url]?.[pkgName]; + if (nestedIndex[url]?.[pkgName]) return nestedIndex[url]?.[pkgName]; logger?.dryWarn(`Type '${url}' not found in '${pkgName}'`); // Fallback: search across all packages when type exists elsewhere diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index 76015e35..ff9f0977 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -533,8 +533,8 @@ import type { Quantity } from "../../hl7-fhir-r4-core/Quantity"; import type { Reference } from "../../hl7-fhir-r4-core/Reference"; export type Observation_bp_Category_VSCatSliceFlat = Omit; -export type Observation_bp_Component_SystolicBPSliceFlat = Omit; -export type Observation_bp_Component_DiastolicBPSliceFlat = Omit; +export type Observation_bp_Component_SystolicBPSliceFlat = Omit & Quantity; +export type Observation_bp_Component_DiastolicBPSliceFlat = Omit & Quantity; import { buildResource, @@ -545,6 +545,8 @@ import { getArraySlice, ensureSliceDefaults, stripMatchKeys, + wrapSliceChoice, + unwrapSliceChoice, validateRequired, validateExcluded, validateFixedValue, @@ -714,7 +716,8 @@ export class observation_bpProfile { setArraySlice(this.resource.component ??= [], match, input as ObservationComponent) return this } - const value = applySliceMatch(input ?? {}, match) + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) setArraySlice(this.resource.component ??= [], match, value) return this } @@ -725,7 +728,8 @@ export class observation_bpProfile { setArraySlice(this.resource.component ??= [], match, input as ObservationComponent) return this } - const value = applySliceMatch(input ?? {}, match) + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) setArraySlice(this.resource.component ??= [], match, value) return this } @@ -749,7 +753,7 @@ export class observation_bpProfile { const item = getArraySlice(this.resource.component, match) if (!item) return undefined if (mode === 'raw') return item - return stripMatchKeys(item, ["code"]) + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getDiastolicBP(mode: 'flat'): Observation_bp_Component_DiastolicBPSliceFlat | undefined; @@ -760,7 +764,7 @@ export class observation_bpProfile { const item = getArraySlice(this.resource.component, match) if (!item) return undefined if (mode === 'raw') return item - return stripMatchKeys(item, ["code"]) + return unwrapSliceChoice(item, ["code"], "valueQuantity") } // Validation