diff --git a/examples/csharp/generated/base.cs b/examples/csharp/generated/base.cs index b56781cc..4679769b 100644 --- a/examples/csharp/generated/base.cs +++ b/examples/csharp/generated/base.cs @@ -122,12 +122,12 @@ public override string ToString() => } public class DataRequirement : Element { - public Element[]? CodeFilter { get; set; } - public Element[]? DateFilter { get; set; } + public DataRequirementCodeFilter[]? CodeFilter { get; set; } + public DataRequirementDateFilter[]? DateFilter { get; set; } public long? Limit { get; set; } public string[]? MustSupport { get; set; } public string[]? Profile { get; set; } - public Element[]? Sort { get; set; } + public DataRequirementSort[]? Sort { get; set; } public CodeableConcept? SubjectCodeableConcept { get; set; } public ResourceReference? SubjectReference { get; set; } public required string Type { get; set; } @@ -181,7 +181,7 @@ public class Dosage : BackboneElement { public CodeableConcept[]? AdditionalInstruction { get; set; } public bool? AsNeededBoolean { get; set; } public CodeableConcept? AsNeededCodeableConcept { get; set; } - public Element[]? DoseAndRate { get; set; } + public DosageDoseAndRate[]? DoseAndRate { get; set; } public Quantity? MaxDosePerAdministration { get; set; } public Quantity? MaxDosePerLifetime { get; set; } public Ratio? MaxDosePerPeriod { get; set; } @@ -230,12 +230,12 @@ public override string ToString() => public class ElementDefinition : BackboneElement { public string[]? Alias { get; set; } - public Element? Base { get; set; } - public Element? Binding { get; set; } + public ElementDefinitionBase? Base { get; set; } + public ElementDefinitionBinding? Binding { get; set; } public Coding[]? Code { get; set; } public string? Comment { get; set; } public string[]? Condition { get; set; } - public Element[]? Constraint { get; set; } + public ElementDefinitionConstraint[]? Constraint { get; set; } public string? ContentReference { get; set; } public Address? DefaultValueAddress { get; set; } public Age? DefaultValueAge { get; set; } @@ -288,7 +288,7 @@ public class ElementDefinition : BackboneElement { public UsageContext? DefaultValueUsageContext { get; set; } public string? DefaultValueUuid { get; set; } public string? Definition { get; set; } - public Element[]? Example { get; set; } + public ElementDefinitionExample[]? Example { get; set; } public Address? FixedAddress { get; set; } public Age? FixedAge { get; set; } public Annotation? FixedAnnotation { get; set; } @@ -343,7 +343,7 @@ public class ElementDefinition : BackboneElement { public string? IsModifierReason { get; set; } public bool? IsSummary { get; set; } public string? Label { get; set; } - public Element[]? Mapping { get; set; } + public ElementDefinitionMapping[]? Mapping { get; set; } public string? Max { get; set; } public int? MaxLength { get; set; } public string? MaxValueDate { get; set; } @@ -424,8 +424,8 @@ public class ElementDefinition : BackboneElement { public string? Short { get; set; } public bool? SliceIsConstraining { get; set; } public string? SliceName { get; set; } - public Element? Slicing { get; set; } - public Element[]? Type { get; set; } + public ElementDefinitionSlicing? Slicing { get; set; } + public ElementDefinitionType[]? Type { get; set; } public class ElementDefinitionBase : Element { public required string Max { get; set; } @@ -532,7 +532,7 @@ public override string ToString() => public class ElementDefinitionSlicing : Element { public string? Description { get; set; } - public Element[]? Discriminator { get; set; } + public ElementDefinitionSlicingDiscriminator[]? Discriminator { get; set; } public bool? Ordered { get; set; } public required SlicingRulesEnum Rules { get; set; } @@ -861,7 +861,7 @@ public class SubstanceAmount : BackboneElement { public string? AmountString { get; set; } public string? AmountText { get; set; } public CodeableConcept? AmountType { get; set; } - public Element? ReferenceRange { get; set; } + public SubstanceAmountReferenceRange? ReferenceRange { get; set; } public class SubstanceAmountReferenceRange : Element { public Quantity? HighLimit { get; set; } @@ -881,7 +881,7 @@ public override string ToString() => public class Timing : BackboneElement { public CodeableConcept? Code { get; set; } public string[]? Event { get; set; } - public Element? Repeat { get; set; } + public TimingRepeat? Repeat { get; set; } public class TimingRepeat : Element { public Duration? BoundsDuration { get; set; } diff --git a/examples/python/fhir_types/hl7_fhir_r4_core/base.py b/examples/python/fhir_types/hl7_fhir_r4_core/base.py index b511548d..03f97446 100644 --- a/examples/python/fhir_types/hl7_fhir_r4_core/base.py +++ b/examples/python/fhir_types/hl7_fhir_r4_core/base.py @@ -130,12 +130,12 @@ class DataRequirementSort(Element): class DataRequirement(Element): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") - code_filter: PyList[Element] | None = Field(None, alias="codeFilter", serialization_alias="codeFilter") - date_filter: PyList[Element] | None = Field(None, alias="dateFilter", serialization_alias="dateFilter") + code_filter: PyList[DataRequirementCodeFilter] | None = Field(None, alias="codeFilter", serialization_alias="codeFilter") + date_filter: PyList[DataRequirementDateFilter] | None = Field(None, alias="dateFilter", serialization_alias="dateFilter") limit: PositiveInt | None = Field(None, alias="limit", serialization_alias="limit") must_support: PyList[str] | None = Field(None, alias="mustSupport", serialization_alias="mustSupport") profile: PyList[str] | None = Field(None, alias="profile", serialization_alias="profile") - sort: PyList[Element] | None = Field(None, alias="sort", serialization_alias="sort") + sort: PyList[DataRequirementSort] | None = Field(None, alias="sort", serialization_alias="sort") subject_codeable_concept: CodeableConcept | None = Field(None, alias="subjectCodeableConcept", serialization_alias="subjectCodeableConcept") subject_reference: Reference | None = Field(None, alias="subjectReference", serialization_alias="subjectReference") type: str = Field(alias="type", serialization_alias="type") @@ -161,7 +161,7 @@ class Dosage(BackboneElement): additional_instruction: PyList[CodeableConcept] | None = Field(None, alias="additionalInstruction", serialization_alias="additionalInstruction") as_needed_boolean: bool | None = Field(None, alias="asNeededBoolean", serialization_alias="asNeededBoolean") as_needed_codeable_concept: CodeableConcept | None = Field(None, alias="asNeededCodeableConcept", serialization_alias="asNeededCodeableConcept") - dose_and_rate: PyList[Element] | None = Field(None, alias="doseAndRate", serialization_alias="doseAndRate") + dose_and_rate: PyList[DosageDoseAndRate] | None = Field(None, alias="doseAndRate", serialization_alias="doseAndRate") max_dose_per_administration: Quantity | None = Field(None, alias="maxDosePerAdministration", serialization_alias="maxDosePerAdministration") max_dose_per_lifetime: Quantity | None = Field(None, alias="maxDosePerLifetime", serialization_alias="maxDosePerLifetime") max_dose_per_period: Ratio | None = Field(None, alias="maxDosePerPeriod", serialization_alias="maxDosePerPeriod") @@ -381,7 +381,7 @@ class Timing(BackboneElement): model_config = ConfigDict(validate_by_name=True, serialize_by_alias=True, extra="forbid") code: CodeableConcept | None = Field(None, alias="code", serialization_alias="code") event: PyList[str] | None = Field(None, alias="event", serialization_alias="event") - repeat: Element | None = Field(None, alias="repeat", serialization_alias="repeat") + repeat: TimingRepeat | None = Field(None, alias="repeat", serialization_alias="repeat") class TriggerDefinition(Element): diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DataRequirement.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DataRequirement.ts index c723657f..5a010ea4 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DataRequirement.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/DataRequirement.ts @@ -38,15 +38,15 @@ export interface DataRequirementSort extends Element { // CanonicalURL: http://hl7.org/fhir/StructureDefinition/DataRequirement (pkg: hl7.fhir.r4.core#4.0.1) export interface DataRequirement extends Element { - codeFilter?: Element[]; - dateFilter?: Element[]; + codeFilter?: DataRequirementCodeFilter[]; + dateFilter?: DataRequirementDateFilter[]; limit?: number; _limit?: Element; mustSupport?: string[]; _mustSupport?: (Element | null)[]; profile?: string[]; _profile?: (Element | null)[]; - sort?: Element[]; + sort?: DataRequirementSort[]; subjectCodeableConcept?: CodeableConcept; subjectReference?: Reference<"Group">; type: string; diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Dosage.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Dosage.ts index 3f78d884..71bd52ac 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Dosage.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Dosage.ts @@ -33,7 +33,7 @@ export interface Dosage extends BackboneElement { asNeededBoolean?: boolean; _asNeededBoolean?: Element; asNeededCodeableConcept?: CodeableConcept; - doseAndRate?: Element[]; + doseAndRate?: DosageDoseAndRate[]; maxDosePerAdministration?: Quantity; maxDosePerLifetime?: Quantity; maxDosePerPeriod?: Ratio; diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Timing.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Timing.ts index 9af45cac..9564241e 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Timing.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/Timing.ts @@ -41,5 +41,5 @@ export interface Timing extends BackboneElement { code?: CodeableConcept<("BID" | "TID" | "QID" | "AM" | "PM" | "QD" | "QOD" | "Q1H" | "Q2H" | "Q3H" | "Q4H" | "Q6H" | "Q8H" | "BED" | "WK" | "MO" | string)>; event?: string[]; _event?: (Element | null)[]; - repeat?: Element; + repeat?: TimingRepeat; } diff --git a/examples/typescript-r4/fhir-types/type-tree.yaml b/examples/typescript-r4/fhir-types/type-tree.yaml index 24c2a925..f073e25c 100644 --- a/examples/typescript-r4/fhir-types/type-tree.yaml +++ b/examples/typescript-r4/fhir-types/type-tree.yaml @@ -66,6 +66,7 @@ hl7.fhir.r4.core: value-set: {} nested: http://hl7.org/fhir/StructureDefinition/Observation#component: {} + http://hl7.org/fhir/StructureDefinition/Observation#referenceRange: {} binding: {} profile: http://hl7.org/fhir/StructureDefinition/patient-birthPlace: {} diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index e27feeb6..6c07341d 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -37,7 +37,7 @@ export const tsModuleName = (id: Identifier): string => { // - http://hl7.org/fhir/5.0/StructureDefinition/extension-Subscription.topic (subscription_topic) // - http://hl7.org/fhir/5.0/StructureDefinition/extension-SubscriptionTopic (SubscriptionTopic) // And they should not clash the names. - return uppercaseFirstLetter(normalizeTsName(id.name)); + return uppercaseFirstLetter(tsResourceName(id)); }; export const tsModuleFileName = (id: Identifier): string => { @@ -65,7 +65,10 @@ export const tsResourceName = (id: Identifier): string => { const name = uppercaseFirstLetterOfEach((fragment ?? "").split(".")).join(""); return normalizeTsName([resourceName, name].join("")); } - return normalizeTsName(id.name); + const name = id.name.includes("/") + ? (extractNameFromCanonical(id.name as unknown as CanonicalUrl) ?? id.name) + : id.name; + return normalizeTsName(name); }; export const tsFieldName = (n: string): string => { diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index dc335fe9..939c13e4 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,4 +1,3 @@ -import { uppercaseFirstLetter } from "@root/api/writer-generator/utils"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; import { type CanonicalUrl, @@ -109,7 +108,7 @@ export class TypeScript extends Writer { if (["complex-type", "resource", "logical"].includes(dep.kind)) { imports.push({ tsPackage: `${importPrefix}${tsModulePath(dep)}`, - name: uppercaseFirstLetter(dep.name), + name: tsResourceName(dep), dep: dep, }); } else if (isNestedIdentifier(dep)) { @@ -148,13 +147,11 @@ export class TypeScript extends Writer { } generateComplexTypeReexports(schema: RegularTypeSchema) { - const complexTypeDeps = schema.dependencies?.filter(isComplexTypeIdentifier).map((dep) => ({ - tsPackage: `../${tsModulePath(dep)}`, - name: uppercaseFirstLetter(dep.name), - })); + const complexTypeDeps = schema.dependencies?.filter(isComplexTypeIdentifier); if (complexTypeDeps && complexTypeDeps.length > 0) { for (const dep of complexTypeDeps) { - this.lineSM(`export type { ${dep.name} } from "${dep.tsPackage}"`); + this.debugComment(dep); + this.lineSM(`export type { ${tsResourceName(dep)} } from "${`../${tsModulePath(dep)}`}"`); } this.line(); } diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index 47d02621..6fe070d4 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -133,7 +133,7 @@ export function buildFieldType( const refPath = element.elementReference .slice(1) // drop canonicalUrl .filter((_, i) => i % 2 === 1); // drop `elements` from path - return mkNestedIdentifier(register, fhirSchema, refPath, logger); + return mkNestedIdentifier(register, fhirSchema, refPath); } else if (element.type) { const url = register.ensureSpecializationCanonicalUrl(element.type); const fieldFs = register.resolveFs(fhirSchema.package_meta, url); @@ -206,34 +206,13 @@ export const mkField = ( }; }; -export function isNestedElement(element: FHIRSchemaElement): boolean { - const isBackbone = element.type === "BackboneElement"; - const isElement = - element.type === "Element" && element.elements !== undefined && Object.keys(element.elements).length > 0; - - // TODO: Observation <- vitalsigns <- bodyweight - // In Observation we have value[x] with choices - // In bodyweight we have valueQuantity with additional constaraints on it's elements - // So we need to build nested type from Quantity for here, but don't do that right now. - const elementsWithoutType = - // FIXME: understand and make a decision. - // Problem example: http://hl7.org/cda/stds/core/StructureDefinition/SubstanceAdministration -> consumable - // Don't generate nested type for that field, but defetly expect it. - element.type === undefined && - element.choiceOf === undefined && - element.elements !== undefined && - Object.keys(element.elements).length > 0; - return isBackbone || isElement || elementsWithoutType; -} - export function mkNestedField( register: Register, fhirSchema: RichFHIRSchema, path: string[], element: FHIRSchemaElement, - logger?: CodegenLogger, ): RegularField { - const nestedIdentifier = mkNestedIdentifier(register, fhirSchema, path, logger); + const nestedIdentifier = mkNestedIdentifier(register, fhirSchema, path); return { type: nestedIdentifier, array: element.array || false, diff --git a/src/typeschema/core/nested-types.ts b/src/typeschema/core/nested-types.ts index 1a432665..b2da3ad5 100644 --- a/src/typeschema/core/nested-types.ts +++ b/src/typeschema/core/nested-types.ts @@ -5,27 +5,78 @@ */ import type { FHIRSchema, FHIRSchemaElement } from "@atomic-ehr/fhirschema"; -import type { Register } from "@root/typeschema/register"; +import { mergeFsElementProps, type Register, resolveFsElementGenealogy } from "@root/typeschema/register"; import type { CodegenLogger } from "@root/utils/codegen-logger"; import type { CanonicalUrl, Field, Identifier, Name, NestedIdentifier, NestedType, RichFHIRSchema } from "../types"; -import { isNestedElement, mkField, mkNestedField } from "./field-builder"; +import { mkField, mkNestedField } from "./field-builder"; -export function mkNestedIdentifier( +/** + * Check whether the specialization chain defines structural sub-elements at `path`. + * "Structural" means the sub-elements define new fields, not just constrain + * fields of the element's own type. For example: + * - EN.item (type Base) has sub-elements family/given that Base doesn't define → structural + * - typeId (type II) has sub-elements root/extension that II itself defines → constraining + * - bodyweight.code (type CodeableConcept) has coding only in the constraint → not in specializations + */ +const hasStructuralElements = (register: Register, fhirSchema: RichFHIRSchema, path: string[]): boolean => { + const specializations = register.resolveFsSpecializations(fhirSchema.package_meta, fhirSchema.url); + const elemGens = resolveFsElementGenealogy(specializations, path); + const elemType = mergeFsElementProps(elemGens).type; + + let typeKeys: Set | undefined; + if (elemType) { + const typeUrl = register.ensureSpecializationCanonicalUrl(elemType); + const typeGenealogy = register.resolveFsGenealogy(fhirSchema.package_meta, typeUrl); + const keys = typeGenealogy.flatMap((fs) => Object.keys(fs.elements ?? {})); + if (keys.length > 0) typeKeys = new Set(keys); + } + + for (const elem of elemGens) { + if (!elem.elements || Object.keys(elem.elements).length === 0) continue; + if (typeKeys && !Object.keys(elem.elements).some((k) => !typeKeys.has(k))) continue; + return true; + } + return false; +}; + +/** + * Check if an element is structurally nested, using both the snapshot + * (for BackboneElement detection) and specialization-chain element analysis. + */ +export const isNestedElement = ( register: Register, fhirSchema: RichFHIRSchema, path: string[], - logger?: CodegenLogger, -): NestedIdentifier { - // NOTE: profiles should no redefine types, they should reuse already defined in previous specializations + snapshot: FHIRSchemaElement, + raw?: FHIRSchemaElement, +): boolean => { + if (snapshot.type === "BackboneElement") return true; + if (!raw?.elements || raw.choiceOf !== undefined) return false; + return hasStructuralElements(register, fhirSchema, path); +}; + +const collectNestedPaths = (fs: RichFHIRSchema): Set => { + if (!fs.elements) return new Set(); + return new Set( + collectNestedElements(fs, [], fs.elements) + .filter(([_, el]) => el.elements && Object.keys(el.elements).length > 0) + .map(([path]) => path.join(".")), + ); +}; + +export function mkNestedIdentifier(register: Register, fhirSchema: RichFHIRSchema, path: string[]): NestedIdentifier { + // Resolve nested type origins from the genealogy so inherited nested types + // (e.g. PN.item from EN) resolve to the defining type's nested type (EN#item). const nestedTypeOrigins = {} as Record; - if (fhirSchema.derivation === "constraint") { - const specializations = register.resolveFsSpecializations(fhirSchema.package_meta, fhirSchema.url); - const nestedTypeGenealogy = specializations - .map((fs) => mkNestedTypes(register, fs, logger)) - .filter((e) => e !== undefined) - .flat(); - for (const nt of nestedTypeGenealogy.reverse()) { - nestedTypeOrigins[nt.identifier.name] = nt.identifier.url; + const genealogy = + fhirSchema.derivation === "constraint" + ? register.resolveFsSpecializations(fhirSchema.package_meta, fhirSchema.url) + : register.resolveFsGenealogy(fhirSchema.package_meta, fhirSchema.url); + // Walk base-first so most-derived wins + for (const fs of [...genealogy].reverse()) { + const paths = collectNestedPaths(fs); + for (const p of paths) { + nestedTypeOrigins[p as Name] = `${fs.url}#${p}` as CanonicalUrl; } } const nestedName = path.join(".") as Name; @@ -51,14 +102,8 @@ function collectNestedElements( for (const [key, element] of Object.entries(elements)) { const path = [...parentPath, key]; - - if (isNestedElement(element)) { - nested.push([path, element]); - } - - if (element.elements) { - nested.push(...collectNestedElements(fhirSchema, path, element.elements)); - } + if (element.elements && element.choiceOf === undefined) nested.push([path, element]); + if (element.elements) nested.push(...collectNestedElements(fhirSchema, path, element.elements)); } return nested; @@ -73,12 +118,25 @@ function transformNestedElements( ): Record { const fields: Record = {}; - for (const [key, _element] of Object.entries(elements)) { + // Collect all sub-element keys from the genealogy chain, not just the current type. + // This ensures constraint profiles include inherited sub-elements from base types. + const genealogy = register.resolveFsGenealogy(fhirSchema.package_meta, fhirSchema.url); + const elemGenealogy = resolveFsElementGenealogy(genealogy, parentPath); + const allKeys = new Set(); + for (const elem of elemGenealogy) { + if (elem.elements) { + for (const k of Object.keys(elem.elements)) { + allKeys.add(k); + } + } + } + + for (const key of allKeys) { const path = [...parentPath, key]; const elemSnapshot = register.resolveElementSnapshot(fhirSchema, path); - if (isNestedElement(elemSnapshot)) { - fields[key] = mkNestedField(register, fhirSchema, path, elemSnapshot, logger); + if (isNestedElement(register, fhirSchema, path, elemSnapshot, elements[key])) { + fields[key] = mkNestedField(register, fhirSchema, path, elemSnapshot); } else { fields[key] = mkField(register, fhirSchema, path, elemSnapshot, logger); } @@ -94,13 +152,21 @@ export function mkNestedTypes( ): NestedType[] | undefined { if (!fhirSchema.elements) return undefined; - const nested = collectNestedElements(fhirSchema, [], fhirSchema.elements).filter( - ([_, element]) => element.elements && Object.keys(element.elements).length > 0, - ); + const nested = collectNestedElements(fhirSchema, [], fhirSchema.elements).filter(([path, element]) => { + if (!element.elements || Object.keys(element.elements).length === 0) return false; + // Verify the specialization chain also defines sub-elements for this path. + // This filters out false positives from constraint profiles that add sub-elements + // for constraining (e.g. bodyweight constraining code.coding — the base + // Observation.code has no sub-elements, so it's not a nested type). + if (element.type !== "BackboneElement") { + return hasStructuralElements(register, fhirSchema, path); + } + return true; + }); const nestedTypes = [] as NestedType[]; for (const [path, element] of nested) { - const identifier = mkNestedIdentifier(register, fhirSchema, path, logger); + const identifier = mkNestedIdentifier(register, fhirSchema, path); let baseName: Name; if (element.type === "BackboneElement" || !element.type) { diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 7fd95321..5f1797d7 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -23,9 +23,9 @@ import { } from "@typeschema/types"; import { collectBindingSchemas, extractValueSetConceptsByUrl } from "./binding"; -import { isNestedElement, mkField, mkNestedField } from "./field-builder"; +import { mkField, mkNestedField } from "./field-builder"; import { mkIdentifier, mkValueSetIdentifierByUrl } from "./identifier"; -import { extractNestedDependencies, mkNestedTypes } from "./nested-types"; +import { extractNestedDependencies, isNestedElement, mkNestedTypes } from "./nested-types"; import { extractProfileExtensions } from "./profile-extensions"; export function mkFields( @@ -48,8 +48,8 @@ export function mkFields( ); continue; } - if (isNestedElement(elemSnapshot)) { - fields[key] = mkNestedField(register, fhirSchema, path, elemSnapshot, logger); + if (isNestedElement(register, fhirSchema, path, elemSnapshot, elements[key])) { + fields[key] = mkNestedField(register, fhirSchema, path, elemSnapshot); } else { fields[key] = mkField(register, fhirSchema, path, elemSnapshot, logger); } diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 166ccfbc..205aed5a 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -258,7 +258,7 @@ export const registerFromManager = async ( const resolveElementSnapshot = (fhirSchema: RichFHIRSchema, path: string[]): FHIRSchemaElement => { const geneology = resolveFsGenealogy(fhirSchema.package_meta, fhirSchema.url); const elemGeneology = resolveFsElementGenealogy(geneology, path); - const elemSnapshot = fsElementSnapshot(elemGeneology); + const elemSnapshot = mergeFsElementProps(elemGeneology); return elemSnapshot; }; @@ -389,10 +389,14 @@ export const resolveFsElementGenealogy = (genealogy: RichFHIRSchema[], path: str .filter((elem) => elem !== undefined); }; -export function fsElementSnapshot(genealogy: FHIRSchemaElement[]): FHIRSchemaElement { +/** + * Merge scalar properties of an element across its genealogy chain. + * Sub-elements are intentionally stripped — use resolveFsElementGenealogy + * to access nested structure properly. + */ +export function mergeFsElementProps(genealogy: FHIRSchemaElement[]): FHIRSchemaElement { const revGenealogy = genealogy.reverse(); const snapshot = Object.assign({}, ...revGenealogy); - // NOTE: to avoid regeneration nested types snapshot.elements = undefined; return snapshot; } diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index 7e2debde..b666ba39 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -101,13 +101,13 @@ export interface CV extends CE { exports[`TypeScript CDA with Logical Model Promotion to Resource without resourceType 2`] = ` "export * from "./profiles"; export type { Act } from "./Act"; -export type { AD } from "./AD"; +export type { AD, ADItem } from "./AD"; export type { ADXP } from "./ADXP"; export type { AlternateIdentification } from "./AlternateIdentification"; export type { ANY } from "./ANY"; export type { AssignedAuthor } from "./AssignedAuthor"; export type { AssignedCustodian } from "./AssignedCustodian"; -export type { AssignedEntity } from "./AssignedEntity"; +export type { AssignedEntity, AssignedEntitySdtcPatient } from "./AssignedEntity"; export type { AssociatedEntity } from "./AssociatedEntity"; export type { Authenticator } from "./Authenticator"; export type { Author } from "./Author"; @@ -133,8 +133,8 @@ export type { Device } from "./Device"; export type { DocumentationOf } from "./DocumentationOf"; export type { ED } from "./ED"; export type { EIVL_TS } from "./EIVL_TS"; -export type { EN } from "./EN"; -export type { EncompassingEncounter } from "./EncompassingEncounter"; +export type { EN, ENItem } from "./EN"; +export type { EncompassingEncounter, EncompassingEncounterLocation, EncompassingEncounterResponsibleParty } from "./EncompassingEncounter"; export type { Encounter } from "./Encounter"; export type { EncounterParticipant } from "./EncounterParticipant"; export type { Entity } from "./Entity"; @@ -153,7 +153,7 @@ export type { Informant } from "./Informant"; export type { InformationRecipient } from "./InformationRecipient"; export type { InfrastructureRoot } from "./InfrastructureRoot"; export type { InFulfillmentOf } from "./InFulfillmentOf"; -export type { InFulfillmentOf1 } from "./InFulfillmentOf1"; +export type { InFulfillmentOf1, InFulfillmentOf1ActReference } from "./InFulfillmentOf1"; export type { INT } from "./INT"; export type { INT_POS } from "./INT_POS"; export type { IntendedRecipient } from "./IntendedRecipient"; @@ -172,9 +172,9 @@ export type { Material } from "./Material"; export { isMaterial } from "./Material"; export type { MO } from "./MO"; export type { NonXMLBody } from "./NonXMLBody"; -export type { Observation } from "./Observation"; +export type { Observation, ObservationReferenceRange } from "./Observation"; export type { ObservationMedia } from "./ObservationMedia"; -export type { ObservationRange } from "./ObservationRange"; +export type { ObservationRange, ObservationRangeSdtcPrecondition1 } from "./ObservationRange"; export type { ON, ONItem } from "./ON"; export type { Order } from "./Order"; export type { Organization } from "./Organization"; @@ -189,7 +189,7 @@ export type { Patient } from "./Patient"; export type { PatientRole } from "./PatientRole"; export type { Performer1 } from "./Performer1"; export type { Performer2 } from "./Performer2"; -export type { Person } from "./Person"; +export type { Person, PersonSdtcAsPatientRelationship } from "./Person"; export type { PIVL_TS } from "./PIVL_TS"; export type { Place } from "./Place"; export type { PlayingEntity } from "./PlayingEntity"; @@ -210,16 +210,16 @@ export type { RelatedEntity } from "./RelatedEntity"; export type { RelatedSubject } from "./RelatedSubject"; export type { RTO_PQ_PQ } from "./RTO_PQ_PQ"; export type { SC } from "./SC"; -export type { Section } from "./Section"; +export type { Section, SectionComponent } from "./Section"; export type { ServiceEvent } from "./ServiceEvent"; export type { Specimen } from "./Specimen"; export type { SpecimenRole } from "./SpecimenRole"; export type { ST } from "./ST"; -export type { StructuredBody } from "./StructuredBody"; +export type { StructuredBody, StructuredBodyComponent } from "./StructuredBody"; export type { Subject } from "./Subject"; export type { SubjectPerson } from "./SubjectPerson"; -export type { SubstanceAdministration } from "./SubstanceAdministration"; -export type { Supply } from "./Supply"; +export type { SubstanceAdministration, SubstanceAdministrationConsumable } from "./SubstanceAdministration"; +export type { Supply, SupplyProduct } from "./Supply"; export type { SXCM_TS } from "./SXCM_TS"; export type { SXPR_TS } from "./SXPR_TS"; export type { TEL } from "./TEL"; diff --git a/test/api/write-generator/multi-package/cda.test.ts b/test/api/write-generator/multi-package/cda.test.ts index 12a77a9b..f1daf1a2 100644 --- a/test/api/write-generator/multi-package/cda.test.ts +++ b/test/api/write-generator/multi-package/cda.test.ts @@ -38,7 +38,7 @@ describe("CDA", async () => { it("should generate CDA-specific types", () => { const files = Object.keys(result.filesGenerated); const cdaFiles = files.filter((f) => f.includes("hl7-cda-uv-core")); - expect(cdaFiles.length).toBe(78); + expect(cdaFiles.length).toBe(124); expect(files.some((f) => f.includes("/AD.ts") || f.includes("/CD.ts"))).toBeTrue(); }); diff --git a/test/unit/api/writer-generator/typescript/name.test.ts b/test/unit/api/writer-generator/typescript/name.test.ts new file mode 100644 index 00000000..42b4e81d --- /dev/null +++ b/test/unit/api/writer-generator/typescript/name.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { tsResourceName } from "@root/api/writer-generator/typescript/name"; +import type { CanonicalUrl, Identifier, Name } from "@root/typeschema/types"; + +const makeIdentifier = (props: { + kind: Identifier["kind"]; + name: string; + url?: string; + package?: string; + version?: string; +}): Identifier => { + return { + kind: props.kind, + package: props.package ?? "test-package", + version: props.version ?? "1.0.0", + name: props.name as Name, + url: (props.url ?? props.name) as CanonicalUrl, + }; +}; + +describe("tsResourceName", () => { + test("returns normalized name for simple resource identifier", () => { + const id = makeIdentifier({ kind: "resource", name: "Patient" }); + expect(tsResourceName(id)).toBe("Patient"); + }); + + test("returns normalized name for simple complex-type identifier", () => { + const id = makeIdentifier({ kind: "complex-type", name: "HumanName" }); + expect(tsResourceName(id)).toBe("HumanName"); + }); + + test("extracts name from URL when name is a full URL (CDA types)", () => { + const id = makeIdentifier({ + kind: "complex-type", + package: "hl7.cda.uv.core", + version: "2.0.2-sd", + name: "http://hl7.org/cda/stds/core/StructureDefinition/InfrastructureRoot", + url: "http://hl7.org/cda/stds/core/StructureDefinition/InfrastructureRoot", + }); + expect(tsResourceName(id)).toBe("InfrastructureRoot"); + }); + + test("extracts name from URL for resource identifier with URL name", () => { + const id = makeIdentifier({ + kind: "resource", + name: "http://hl7.org/cda/stds/core/StructureDefinition/ClinicalDocument", + url: "http://hl7.org/cda/stds/core/StructureDefinition/ClinicalDocument", + }); + expect(tsResourceName(id)).toBe("ClinicalDocument"); + }); + + test("normalizes special characters in name", () => { + const id = makeIdentifier({ kind: "resource", name: "Some-Type.Name" }); + expect(tsResourceName(id)).toBe("Some_Type_Name"); + }); + + test("handles nested identifier with fragment", () => { + const id = makeIdentifier({ + kind: "nested", + name: "Patient", + url: "http://hl7.org/fhir/StructureDefinition/Patient#Patient.contact", + }); + expect(tsResourceName(id)).toBe("PatientPatientContact"); + }); + + test("handles nested identifier without fragment", () => { + const id = makeIdentifier({ + kind: "nested", + name: "Patient", + url: "http://hl7.org/fhir/StructureDefinition/Patient", + }); + expect(tsResourceName(id)).toBe("Patient"); + }); + + test("handles primitive-type identifier", () => { + const id = makeIdentifier({ kind: "primitive-type", name: "string" }); + expect(tsResourceName(id)).toBe("string"); + }); + + test("escapes TypeScript keywords", () => { + const id = makeIdentifier({ kind: "resource", name: "class" }); + expect(tsResourceName(id)).toBe("class_"); + }); +}); diff --git a/test/unit/typeschema/field-builder.test.ts b/test/unit/typeschema/field-builder.test.ts index ab33c93d..d11ac95c 100644 --- a/test/unit/typeschema/field-builder.test.ts +++ b/test/unit/typeschema/field-builder.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import type { Register } from "@root/typeschema/register"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; -import { isNestedElement, mkField, mkNestedField } from "@typeschema/core/field-builder"; +import { mkField, mkNestedField } from "@typeschema/core/field-builder"; import type { ChoiceFieldDeclaration, Name, PackageMeta, RegularField } from "@typeschema/types"; import { mkR4Register, type PFS, registerFs } from "@typeschema-test/utils"; @@ -11,15 +10,9 @@ const registerAndMkField = (register: Register, fhirSchema: PFS, path: string[], return mkField(register, rfs, path, element); }; -const registerAndMkNestedField = ( - register: Register, - fhirSchema: PFS, - path: string[], - element: FHIRSchemaElement, - logger?: CodegenLogger, -) => { +const registerAndMkNestedField = (register: Register, fhirSchema: PFS, path: string[], element: FHIRSchemaElement) => { const rfs = registerFs(register, fhirSchema); - return mkNestedField(register, rfs, path, element, logger); + return mkNestedField(register, rfs, path, element); }; describe("Field Builder Core Logic", async () => { @@ -30,39 +23,6 @@ describe("Field Builder Core Logic", async () => { version: "1.0.0", }; - describe("isNestedElement", () => { - it("should identify nested elements with sub-elements", () => { - const element: FHIRSchemaElement = { - elements: { - subField1: { type: "string" }, - subField2: { type: "integer" }, - }, - }; - - expect(isNestedElement(element)).toBe(true); - }); - - it("should not identify simple elements as nested", () => { - const element: FHIRSchemaElement = { - type: "string", - }; - - expect(isNestedElement(element)).toBe(false); - }); - - it("should not identify elements with only type as nested", () => { - const element: FHIRSchemaElement = { - type: "CodeableConcept", - binding: { - strength: "required", - valueSet: "http://example.org/ValueSet/test", - }, - }; - - expect(isNestedElement(element)).toBe(false); - }); - }); - describe("buildField", () => { it("should build field for primitive type", async () => { const element: FHIRSchemaElement = { diff --git a/test/unit/typeschema/register.test.ts b/test/unit/typeschema/register.test.ts index 76ac3a45..22108515 100644 --- a/test/unit/typeschema/register.test.ts +++ b/test/unit/typeschema/register.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { FHIRSchema } from "@atomic-ehr/fhirschema"; import { type CanonicalUrl, enrichFHIRSchema, type Name } from "@root/typeschema/types"; -import { fsElementSnapshot, registerFromPackageMetas, resolveFsElementGenealogy } from "@typeschema/register"; +import { mergeFsElementProps, registerFromPackageMetas, resolveFsElementGenealogy } from "@typeschema/register"; type PFS = Partial; @@ -98,7 +98,7 @@ describe("Register tests", async () => { { min: 1 }, { array: true, type: "string" }, ]); - expect(fsElementSnapshot(resolveFsElementGenealogyT(flatGenealogy, ["foo"]))).toMatchObject({ + expect(mergeFsElementProps(resolveFsElementGenealogyT(flatGenealogy, ["foo"]))).toMatchObject({ array: true, min: 1, type: "string", @@ -108,7 +108,7 @@ describe("Register tests", async () => { { min: 12 }, { array: true, min: 0, type: "code" }, ]); - expect(fsElementSnapshot(resolveFsElementGenealogyT(flatGenealogy, ["bar"]))).toMatchObject({ + expect(mergeFsElementProps(resolveFsElementGenealogyT(flatGenealogy, ["bar"]))).toMatchObject({ array: true, min: 12, type: "code", @@ -135,7 +135,7 @@ describe("Register tests", async () => { { elements: { bar: { min: 1, type: "string" } } }, { elements: { bar: { array: true, type: "string" } }, type: "string" }, ]); - expect(fsElementSnapshot(resolveFsElementGenealogyT(deepGenealogy, ["foo"]))).toMatchObject({ + expect(mergeFsElementProps(resolveFsElementGenealogyT(deepGenealogy, ["foo"]))).toMatchObject({ type: "string", }); @@ -143,7 +143,7 @@ describe("Register tests", async () => { { min: 1, type: "string" }, { array: true, type: "string" }, ]); - expect(fsElementSnapshot(resolveFsElementGenealogyT(deepGenealogy, ["foo", "bar"]))).toMatchObject({ + expect(mergeFsElementProps(resolveFsElementGenealogyT(deepGenealogy, ["foo", "bar"]))).toMatchObject({ array: true, min: 1, type: "string", diff --git a/test/unit/typeschema/transformer/ccda.test.ts b/test/unit/typeschema/transformer/ccda.test.ts index 5df62266..5a240e5d 100644 --- a/test/unit/typeschema/transformer/ccda.test.ts +++ b/test/unit/typeschema/transformer/ccda.test.ts @@ -93,11 +93,11 @@ describe("TypeSchema CCDA generation", async () => { fields: { item: { type: { - kind: "complex-type", - package: "hl7.fhir.r5.core", - version: "5.0.0", - name: "Base", - url: "http://hl7.org/fhir/StructureDefinition/Base", + kind: "nested", + package: "hl7.cda.uv.core", + version: "2.0.1-sd", + name: "item", + url: "http://hl7.org/cda/stds/core/StructureDefinition/ON#item", }, required: false, excluded: false, @@ -121,64 +121,39 @@ describe("TypeSchema CCDA generation", async () => { url: "http://hl7.org/fhir/StructureDefinition/BackboneElement", }, fields: { + delimiter: { + type: { url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP" }, + array: true, + }, family: { - type: { - kind: "logical", - package: "hl7.cda.uv.core", - version: "2.0.1-sd", - name: "ENXP", - url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP", - }, - required: false, - excluded: false, + type: { url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP" }, array: true, }, given: { - type: { - kind: "logical", - package: "hl7.cda.uv.core", - version: "2.0.1-sd", - name: "ENXP", - url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP", - }, - required: false, - excluded: false, + type: { url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP" }, + array: true, + }, + prefix: { + type: { url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP" }, array: true, }, + suffix: { + type: { url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP" }, + array: true, + }, + xmlText: { + type: { url: "http://hl7.org/fhir/StructureDefinition/string" }, + }, }, }, ], description: 'A name for an organization. A sequence of name parts. Examples for organization name values are "Health Level Seven, Inc.", "Hospital", etc. An organization name may be as simple as a character string or may consist of several person name parts, such as, "Health Level 7", "Inc.". ON differs from EN because certain person related name parts are not possible.', dependencies: [ - { - kind: "logical", - package: "hl7.cda.uv.core", - version: "2.0.1-sd", - name: "EN", - url: "http://hl7.org/cda/stds/core/StructureDefinition/EN", - }, - { - kind: "logical", - package: "hl7.cda.uv.core", - version: "2.0.1-sd", - name: "ENXP", - url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP", - }, - { - kind: "complex-type", - package: "hl7.fhir.r5.core", - version: "5.0.0", - name: "BackboneElement", - url: "http://hl7.org/fhir/StructureDefinition/BackboneElement", - }, - { - kind: "complex-type", - package: "hl7.fhir.r5.core", - version: "5.0.0", - name: "Base", - url: "http://hl7.org/fhir/StructureDefinition/Base", - }, + { kind: "complex-type", url: "http://hl7.org/fhir/StructureDefinition/BackboneElement" }, + { kind: "logical", url: "http://hl7.org/cda/stds/core/StructureDefinition/EN" }, + { kind: "logical", url: "http://hl7.org/cda/stds/core/StructureDefinition/ENXP" }, + { kind: "primitive-type", url: "http://hl7.org/fhir/StructureDefinition/string" }, ], }); }); diff --git a/test/unit/typeschema/transformer/constraint.test.ts b/test/unit/typeschema/transformer/constraint.test.ts index 79629fc8..79584c7d 100644 --- a/test/unit/typeschema/transformer/constraint.test.ts +++ b/test/unit/typeschema/transformer/constraint.test.ts @@ -88,6 +88,61 @@ describe("TypeSchema Processing constraint generation", async () => { ]); }); + const D: PFS = { + url: "uri::D", + derivation: "specialization", + name: "d", + elements: { + foo: { + type: "BackboneElement", + elements: { + bar: { type: "string" }, + baz: { type: "integer" }, + qux: { type: "boolean" }, + }, + }, + }, + }; + const E: PFS = { + base: "uri::D", + url: "uri::E", + name: "e", + derivation: "constraint", + elements: { + foo: { + elements: { + bar: { min: 1 }, + }, + }, + }, + }; + it("Constraint profile nested type includes all inherited sub-elements", async () => { + await registerFsAndMkTs(r4, D); + expect(await registerFsAndMkTs(r4, E)).toMatchObject([ + { + identifier: { kind: "profile", name: "e", url: "uri::E" }, + base: { kind: "resource", name: "d", url: "uri::D" }, + fields: { + foo: { type: { kind: "nested", name: "foo", url: "uri::D#foo" } }, + }, + nested: [ + { + identifier: { kind: "nested", name: "foo", url: "uri::D#foo" }, + base: { url: "http://hl7.org/fhir/StructureDefinition/BackboneElement" }, + fields: { + bar: { + type: { url: "http://hl7.org/fhir/StructureDefinition/string" }, + min: 1, + }, + baz: { type: { url: "http://hl7.org/fhir/StructureDefinition/integer" } }, + qux: { type: { url: "http://hl7.org/fhir/StructureDefinition/boolean" } }, + }, + }, + ], + }, + ]); + }); + it("Use nested type in profile.", async () => { const profile = r4.resolveFs( r4Package, @@ -111,6 +166,11 @@ describe("TypeSchema Processing constraint generation", async () => { { kind: "primitive-type", url: "http://hl7.org/fhir/StructureDefinition/code" }, { kind: "resource", url: "http://hl7.org/fhir/StructureDefinition/CodeSystem" }, { kind: "nested", url: "http://hl7.org/fhir/StructureDefinition/CodeSystem#concept" }, + { + kind: "nested", + url: "http://hl7.org/fhir/StructureDefinition/CodeSystem#concept.designation", + }, + { kind: "nested", url: "http://hl7.org/fhir/StructureDefinition/CodeSystem#concept.property" }, { kind: "primitive-type", url: "http://hl7.org/fhir/StructureDefinition/markdown" }, { kind: "primitive-type", url: "http://hl7.org/fhir/StructureDefinition/string" }, { kind: "primitive-type", url: "http://hl7.org/fhir/StructureDefinition/uri" },