diff --git a/src/api/writer-generator/python/profile-factory.ts b/src/api/writer-generator/python/profile-factory.ts index b7ca5f40..95fd182c 100644 --- a/src/api/writer-generator/python/profile-factory.ts +++ b/src/api/writer-generator/python/profile-factory.ts @@ -174,7 +174,7 @@ export const collectProfileFactoryInfo = ( } if (isNotChoiceDeclarationField(field)) { - const sliceNames = collectRequiredSliceNames(field); + const sliceNames = collectRequiredSliceNames(field, flatProfile.slicing?.[name]); if (sliceNames) { if (field.type) { const pyType = fieldPyType(field, resolveRef, tsIndex); diff --git a/src/api/writer-generator/python/profile-slices.ts b/src/api/writer-generator/python/profile-slices.ts index b0d56f17..25fa7c3f 100644 --- a/src/api/writer-generator/python/profile-slices.ts +++ b/src/api/writer-generator/python/profile-slices.ts @@ -1,8 +1,10 @@ import { type ConstrainedChoiceInfo, + type FieldSlicing, isChoiceDeclarationField, isNotChoiceDeclarationField, isPrimitiveIdentifier, + isTypeDiscriminated, type NameCandidates, type RegularField, type SnapshotProfileTypeSchema, @@ -33,11 +35,14 @@ export type SliceDef = { nameCandidates: NameCandidates; }; -export const collectRequiredSliceNames = (field: RegularField): string[] | undefined => { - if (!field.array || !field.slicing?.slices) return undefined; +export const collectRequiredSliceNames = ( + field: RegularField, + fieldSlicing: FieldSlicing | undefined, +): string[] | undefined => { + if (!field.array || !fieldSlicing?.slices) return undefined; // Type-discriminated slices ("type" discriminator) require explicit typed setters — no stubs. - if (field.slicing.discriminator?.some((d) => d.type === "type")) return undefined; - const names = Object.entries(field.slicing.slices) + if (isTypeDiscriminated(fieldSlicing)) return undefined; + const names = Object.entries(fieldSlicing.slices) .filter(([_, s]) => s.min !== undefined && s.min >= 1 && s.match && Object.keys(s.match).length > 0) .map(([name]) => name); return names.length > 0 ? names : undefined; @@ -104,8 +109,9 @@ const extractTypeDiscriminatorResource = ( export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: SnapshotProfileTypeSchema): SliceDef[] => { const pkgName = flatProfile.identifier.package; - return Object.entries(flatProfile.fields).flatMap(([fieldName, field]) => { - if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return []; + return Object.entries(flatProfile.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => { + const field = flatProfile.fields[fieldName]; + if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) return []; const choiceBaseNames = new Set(); const baseSchema = tsIndex.resolveType(field.type); if (baseSchema && "fields" in baseSchema && baseSchema.fields) { @@ -113,7 +119,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot if (isChoiceDeclarationField(f)) choiceBaseNames.add(n); } } - return Object.entries(field.slicing.slices) + return Object.entries(fieldSlicing.slices) .filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0) .map(([sliceName, slice]) => { const matchFields = Object.keys(slice.match ?? {}); @@ -123,9 +129,9 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined; // Skip flattening for primitive types — can't wrap/unwrap under a variant key. const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined; - const isTypeDiscriminated = field.slicing?.discriminator?.some((d) => d.type === "type") ?? false; + const typeDiscriminated = isTypeDiscriminated(fieldSlicing); const typeDiscriminatorResource = extractTypeDiscriminatorResource( - isTypeDiscriminated, + typeDiscriminated, slice.match as Record | undefined, ); return { @@ -139,7 +145,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot elementTypeName: field.type && !isPrimitiveIdentifier(field.type) ? pyTypeFromIdentifier(field.type) : undefined, elementTypeId: field.type && !isPrimitiveIdentifier(field.type) ? field.type : undefined, - isTypeDiscriminated, + isTypeDiscriminated: typeDiscriminated, typeDiscriminatorResource, nameCandidates: slice.nameCandidates, }; diff --git a/src/api/writer-generator/python/profile-validation.ts b/src/api/writer-generator/python/profile-validation.ts index d00e5885..4f9d7988 100644 --- a/src/api/writer-generator/python/profile-validation.ts +++ b/src/api/writer-generator/python/profile-validation.ts @@ -1,5 +1,6 @@ import { type ChoiceFieldInstance, + type FieldSlicing, isChoiceDeclarationField, isChoiceInstanceField, isNotChoiceDeclarationField, @@ -58,7 +59,16 @@ export const collectValidateBody = ( } continue; } - collectRegularFieldValidation(field, pyName, helpers, errorLines, warningLines, tsIndex, formatName); + collectRegularFieldValidation( + field, + flatProfile.slicing?.[name], + pyName, + helpers, + errorLines, + warningLines, + tsIndex, + formatName, + ); } // Base-resource required fields the profile chain did not re-state. // Emitted here (not via the regular field loop) because they intentionally @@ -75,6 +85,7 @@ export const collectValidateBody = ( const collectRegularFieldValidation = ( field: RegularField | ChoiceFieldInstance, + fieldSlicing: FieldSlicing | undefined, pyName: string, helpers: Set, errorLines: string[], @@ -116,22 +127,23 @@ const collectRegularFieldValidation = ( const allowed = field.reference.resource.map((ref) => tsIndex.findLastSpecializationByIdentifier(ref).name); pushListValidation(errorLines, "errors", "validate_reference", [JSON.stringify(pyName)], allowed); } - if (field.slicing?.slices) { - collectSliceValidation(field, pyName, helpers, errorLines, tsIndex, formatName); + if (fieldSlicing?.slices) { + collectSliceValidation(field, fieldSlicing, pyName, helpers, errorLines, tsIndex, formatName); } } }; const collectSliceValidation = ( field: RegularField | ChoiceFieldInstance, + fieldSlicing: FieldSlicing, name: string, helpers: Set, errorLines: string[], tsIndex: TypeSchemaIndex, formatName: (s: string) => string, ): void => { - if (!field.slicing?.slices) return; - for (const [sliceName, slice] of Object.entries(field.slicing.slices)) { + if (!fieldSlicing.slices) return; + for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { const match = slice.match ?? {}; if (Object.keys(match).length === 0) continue; if (slice.min !== undefined || slice.max !== undefined) { diff --git a/src/api/writer-generator/typescript/profile-extensions.ts b/src/api/writer-generator/typescript/profile-extensions.ts index fc14d308..83da085b 100644 --- a/src/api/writer-generator/typescript/profile-extensions.ts +++ b/src/api/writer-generator/typescript/profile-extensions.ts @@ -82,9 +82,10 @@ export const valueFieldToTsType = (valueField: string): string => { */ export const collectSubExtensionSlices = (extProfile: SnapshotProfileTypeSchema): SubExtensionSliceInfo[] => { const extensionField = extProfile.fields.extension; - if (!extensionField || isChoiceDeclarationField(extensionField) || !extensionField.slicing?.slices) return []; + const extensionSlicing = extProfile.slicing?.extension; + if (!extensionField || isChoiceDeclarationField(extensionField) || !extensionSlicing?.slices) return []; const result: SubExtensionSliceInfo[] = []; - for (const [sliceName, slice] of Object.entries(extensionField.slicing.slices)) { + for (const [sliceName, slice] of Object.entries(extensionSlicing.slices)) { const valueField = extractValueField(slice.elements); if (!valueField) continue; const tsType = valueFieldToTsType(valueField); diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index 2bbaea0e..188853d4 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -1,8 +1,10 @@ import { type ConstrainedChoiceInfo, + type FieldSlicing, isChoiceDeclarationField, isNotChoiceDeclarationField, isPrimitiveIdentifier, + isTypeDiscriminated, type RegularField, type SnapshotProfileTypeSchema, type TypeIdentifier, @@ -49,10 +51,11 @@ export const collectTypesFromSlices = ( addType: (typeId: TypeIdentifier) => void, ) => { const pkgName = snapshot.identifier.package; - for (const field of Object.values(snapshot.fields)) { - if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) continue; - const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false; - for (const slice of Object.values(field.slicing.slices)) { + for (const [fieldName, fieldSlicing] of Object.entries(snapshot.slicing ?? {})) { + const field = snapshot.fields[fieldName]; + if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) continue; + const isTypeDisc = isTypeDiscriminated(fieldSlicing); + for (const slice of Object.values(fieldSlicing.slices)) { if (Object.keys(slice.match ?? {}).length > 0) { addType(field.type); const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined; @@ -79,11 +82,13 @@ export const collectTypesFromSlices = ( * - The field uses a type discriminator (e.g. Bundle entry.resource) — the stub only sets * resourceType, the user must provide the actual typed resource */ -export const collectRequiredSliceNames = (field: RegularField): string[] | undefined => { - if (!field.array || !field.slicing?.slices) return undefined; - const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false; - if (isTypeDisc) return undefined; - const names = Object.entries(field.slicing.slices) +export const collectRequiredSliceNames = ( + field: RegularField, + fieldSlicing: FieldSlicing | undefined, +): string[] | undefined => { + if (!field.array || !fieldSlicing?.slices) return undefined; + if (isTypeDiscriminated(fieldSlicing)) return undefined; + const names = Object.entries(fieldSlicing.slices) .filter(([_, s]) => { if (s.min === undefined || s.min < 1 || !s.match || Object.keys(s.match).length === 0) return false; const matchKeys = new Set(Object.keys(s.match)); @@ -115,44 +120,41 @@ export type SliceDef = { }; export const collectSliceDefs = (tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema): SliceDef[] => - Object.entries(snapshot.fields) - .filter(([_, field]) => isNotChoiceDeclarationField(field) && field.slicing?.slices) - .flatMap(([fieldName, field]) => { - if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return []; - const baseType = tsTypeFromIdentifier(field.type); - const pkgName = snapshot.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]) => { - const matchFields = Object.keys(slice.match ?? {}); - const required = (slice.required ?? []).filter( - (name) => !matchFields.includes(name) && !choiceBaseNames.has(name), - ); - const cc = slice.elements - ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) - : undefined; - // Skip flattening for primitive types — can't intersect object with boolean/string/etc. - const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined; - const resourceType = isTypeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; - const typedBaseType = resourceType ? `${baseType}<${resourceType}>` : baseType; - return { - fieldName, - baseType, - typedBaseType, - sliceName, - baseName: slice.nameCandidates.recommended, - match: slice.match ?? {}, - required, - excluded: slice.excluded ?? [], - array: Boolean(field.array), - constrainedChoice, - typeDiscriminator: isTypeDisc, - max: slice.max ?? 0, - }; - }); - }); + Object.entries(snapshot.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => { + const field = snapshot.fields[fieldName]; + if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) return []; + const baseType = tsTypeFromIdentifier(field.type); + const pkgName = snapshot.identifier.package; + const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type); + const isTypeDisc = isTypeDiscriminated(fieldSlicing); + return Object.entries(fieldSlicing.slices) + .filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0) + .map(([sliceName, slice]) => { + const matchFields = Object.keys(slice.match ?? {}); + const required = (slice.required ?? []).filter( + (name) => !matchFields.includes(name) && !choiceBaseNames.has(name), + ); + const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined; + // Skip flattening for primitive types — can't intersect object with boolean/string/etc. + const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined; + const resourceType = isTypeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; + const typedBaseType = resourceType ? `${baseType}<${resourceType}>` : baseType; + return { + fieldName, + baseType, + typedBaseType, + sliceName, + baseName: slice.nameCandidates.recommended, + match: slice.match ?? {}, + required, + excluded: slice.excluded ?? [], + array: Boolean(field.array), + constrainedChoice, + typeDiscriminator: isTypeDisc, + max: slice.max ?? 0, + }; + }); + }); export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], snapshot: SnapshotProfileTypeSchema) => { const profileClassName = tsProfileClassName(snapshot); diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index 0423af78..154acc1d 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -1,5 +1,6 @@ import { type ChoiceFieldInstance, + type FieldSlicing, isChoiceDeclarationField, isChoiceInstanceField, type RegularField, @@ -18,6 +19,7 @@ export const collectRegularFieldValidation = ( resolveRef: (ref: TypeIdentifier) => TypeIdentifier, canonicalUrlExpr?: { url: string; expr: string }, tsIndex?: TypeSchemaIndex, + fieldSlicing?: FieldSlicing, ) => { if (field.excluded) { errors.push(`...validateExcluded(res, profileName, ${JSON.stringify(name)})`); @@ -47,8 +49,8 @@ export const collectRegularFieldValidation = ( `...validateReference(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.reference.resource.map((ref) => resolveRef(ref).name))})`, ); - if (field.slicing?.slices) { - for (const [sliceName, slice] of Object.entries(field.slicing.slices)) { + if (fieldSlicing?.slices) { + for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { const match = slice.match ?? {}; if (Object.keys(match).length === 0) continue; if (slice.min !== undefined || slice.max !== undefined) { @@ -114,6 +116,7 @@ export const generateValidateMethod = ( tsIndex.findLastSpecializationByIdentifier, canonicalUrlExpr, tsIndex, + snapshot.slicing?.[name], ); } diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 1ff1739f..d3d49dba 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -166,7 +166,7 @@ export const collectProfileFactoryInfo = ( } if (isNotChoiceDeclarationField(field)) { - const sliceNames = collectRequiredSliceNames(field); + const sliceNames = collectRequiredSliceNames(field, snapshot.slicing?.[name]); if (sliceNames) { if (field.type) { const tsType = fieldTsType(field, resolveRef, isFamilyType); diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index 18b9d286..d0182e41 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -290,7 +290,7 @@ const computeMatchFromSchema = ( return result; }; -const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => { +export const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => { const slicing = element.slicing; if (!slicing) return undefined; @@ -435,7 +435,6 @@ export const mkField = ( array: element.array || false, min: element.min, max: element.max, - slicing: buildSlicing(path[path.length - 1] ?? "", element), choices: element.choices, choiceOf: element.choiceOf, @@ -459,6 +458,5 @@ export function mkNestedField( array: element.array || false, required: isRequired(register, fhirSchema, path), excluded: isExcluded(register, fhirSchema, path), - slicing: buildSlicing(path[path.length - 1] ?? "", element), }; } diff --git a/src/typeschema/core/name-candidates.ts b/src/typeschema/core/name-candidates.ts index 2245c0a7..c89addca 100644 --- a/src/typeschema/core/name-candidates.ts +++ b/src/typeschema/core/name-candidates.ts @@ -99,9 +99,9 @@ export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void => candidates: ext.nameCandidates.candidates, })); - const sliceEntries: NameEntry[] = Object.entries(profile.fields ?? {}).flatMap(([fieldName, field]) => { - if (!("slicing" in field) || !field.slicing?.slices) return []; - return Object.entries(field.slicing.slices).map(([sliceName, slice]) => ({ + const sliceEntries: NameEntry[] = Object.entries(profile.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => { + if (!fieldSlicing.slices) return []; + return Object.entries(fieldSlicing.slices).map(([sliceName, slice]) => ({ key: `slice:${fieldName}:${sliceName}`, candidates: slice.nameCandidates.candidates, })); @@ -121,9 +121,9 @@ export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void => if (resolved[key]) ext.nameCandidates.recommended = resolved[key]; } - for (const [fieldName, field] of Object.entries(profile.fields ?? {})) { - if (!("slicing" in field) || !field.slicing?.slices) continue; - for (const [sliceName, slice] of Object.entries(field.slicing.slices)) { + for (const [fieldName, fieldSlicing] of Object.entries(profile.slicing ?? {})) { + if (!fieldSlicing.slices) continue; + for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { const key = `slice:${fieldName}:${sliceName}`; if (resolved[key]) slice.nameCandidates.recommended = resolved[key]; } diff --git a/src/typeschema/core/nested-types.ts b/src/typeschema/core/nested-types.ts index e6bac1b6..8b6bd282 100644 --- a/src/typeschema/core/nested-types.ts +++ b/src/typeschema/core/nested-types.ts @@ -10,13 +10,14 @@ import type { CodegenLog } from "@root/utils/log"; import type { CanonicalUrl, Field, + FieldSlicing, Name, NestedIdentifier, NestedTypeSchema, RichFHIRSchema, TypeIdentifier, } from "../types"; -import { mkField, mkNestedField } from "./field-builder"; +import { buildSlicing, mkField, mkNestedField } from "./field-builder"; /** * Check whether the specialization chain defines structural sub-elements at `path`. @@ -123,8 +124,9 @@ function transformNestedElements( parentPath: string[], elements: Record, logger?: CodegenLog, -): Record { +): { fields: Record; slicing?: Record } { const fields: Record = {}; + const slicing: Record = {}; // 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. @@ -148,9 +150,11 @@ function transformNestedElements( } else { fields[key] = mkField(register, fhirSchema, path, elemSnapshot, logger); } + const fieldSlicing = buildSlicing(key, elemSnapshot); + if (fieldSlicing) slicing[key] = fieldSlicing; } - return fields; + return { fields, slicing: Object.keys(slicing).length > 0 ? slicing : undefined }; } export function mkNestedTypes( @@ -193,12 +197,13 @@ export function mkNestedTypes( url: baseUrl, }; - const fields = transformNestedElements(register, fhirSchema, path, element.elements ?? {}, logger); + const { fields, slicing } = transformNestedElements(register, fhirSchema, path, element.elements ?? {}, logger); const nestedType: NestedTypeSchema = { identifier, base, fields, + slicing, }; nestedTypes.push(nestedType); } diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 391bce33..aaa68bf2 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -13,6 +13,7 @@ import { concatIdentifiers, extractExtensionDeps, type Field, + type FieldSlicing, type Identifier, isNestedIdentifier, type NestedTypeSchema, @@ -28,7 +29,7 @@ import { } from "@typeschema/types"; import { collectBindingSchemas, extractValueSetConceptsByUrl } from "./binding"; -import { mkField, mkNestedField } from "./field-builder"; +import { buildSlicing, mkField, mkNestedField } from "./field-builder"; import { mkIdentifier, mkValueSetIdentifierByUrl } from "./identifier"; import { assignRecommendedBaseNames } from "./name-candidates"; import { extractNestedDependencies, isNestedElement, mkNestedTypes } from "./nested-types"; @@ -40,10 +41,11 @@ export function mkFields( parentPath: string[], elements: Record | undefined, logger?: CodegenLog, -): Record | undefined { - if (!elements) return undefined; +): { fields?: Record; slicing?: Record } { + if (!elements) return {}; const fields: Record = {}; + const slicing: Record = {}; for (const key of register.getAllElementKeys(elements)) { const path = [...parentPath, key]; const elemSnapshot = register.resolveElementSnapshot(fhirSchema, path); @@ -60,9 +62,11 @@ export function mkFields( } else { fields[key] = mkField(register, fhirSchema, path, elemSnapshot, logger, elements[key]); } + const fieldSlicing = buildSlicing(key, elemSnapshot); + if (fieldSlicing) slicing[key] = fieldSlicing; } - return fields; + return { fields, slicing: Object.keys(slicing).length > 0 ? slicing : undefined }; } function extractFieldDependencies(fields: Record): TypeIdentifier[] { @@ -153,7 +157,7 @@ export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSche base = baseId; } - const fields = mkFields(register, fhirSchema, [], fhirSchema.elements, logger); + const { fields, slicing } = mkFields(register, fhirSchema, [], fhirSchema.elements, logger); const nested = mkNestedTypes(register, fhirSchema, logger); const bindingSchemas = collectBindingSchemas(register, fhirSchema, logger); @@ -167,6 +171,7 @@ export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSche identifier, base, fields, + slicing, nested, description: fhirSchema.description, dependencies: concatIdentifiers(rawDeps, extensionDeps), @@ -195,6 +200,7 @@ export function transformFhirSchema(register: Register, fhirSchema: RichFHIRSche identifier, base, fields, + slicing, nested, description: fhirSchema.description, dependencies: extractDependencies(identifier, base, fields, nested), diff --git a/src/typeschema/ir/tree-shake.ts b/src/typeschema/ir/tree-shake.ts index f6f1bfa6..f07a346d 100644 --- a/src/typeschema/ir/tree-shake.ts +++ b/src/typeschema/ir/tree-shake.ts @@ -212,6 +212,12 @@ export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _lo mutableIgnoreExtensions(schema, rule.ignoreExtensions); } + // Slicing entries follow their fields: drop slicing for fields removed above. + if (schema.slicing) { + const kept = Object.fromEntries(Object.entries(schema.slicing).filter(([name]) => schema.fields?.[name])); + schema.slicing = Object.keys(kept).length > 0 ? kept : undefined; + } + if (schema.nested) { const usedTypes = new Set(); const collectUsedNestedTypes = (s: { fields?: Record }) => { diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 2a163a22..f6ed9d45 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -255,6 +255,8 @@ export interface NestedTypeSchema { identifier: NestedIdentifier; base: TypeIdentifier; fields: Record; + /** Slicing definitions keyed by field name, kept apart from `fields` */ + slicing?: Record; generic?: GenericInfo; } @@ -263,6 +265,8 @@ export interface ProfileTypeSchema { base: TypeIdentifier; description?: string; fields?: Record; + /** Slicing definitions keyed by field name, kept apart from `fields` */ + slicing?: Record; extensions?: ProfileExtension[]; dependencies?: TypeIdentifier[]; nested?: NestedTypeSchema[]; @@ -274,6 +278,8 @@ export interface SnapshotProfileTypeSchema { base: TypeIdentifier; description?: string; fields: Record; + /** Slicing definitions keyed by field name, kept apart from `fields` */ + slicing?: Record; /** Top-level required field names inherited from the base resource that the * profile chain does not re-state in its differential. Needed so that * validate() emits validateRequired() for base-FHIR cardinality (e.g. for @@ -291,13 +297,22 @@ export const isSnapshotProfileTypeSchema = (s: TypeSchemaGuardInput): s is Snaps return s?.identifier.kind === "profile-snapshot"; }; +export type SliceDiscriminator = { + type: "value" | "exists" | "pattern" | "type" | "profile" | "position"; + path: string; +}; + export interface FieldSlicing { - discriminator?: FS.FHIRSchemaDiscriminator[]; - rules?: FS.SlicingRules; + discriminator?: SliceDiscriminator[]; + rules?: "open" | "closed" | "openAtEnd"; ordered?: boolean; slices?: Record; } +/** Whether the slicing discriminates by type (`$this`/type — e.g. Bundle.entry.resource by resourceType) */ +export const isTypeDiscriminated = (slicing: FieldSlicing | undefined): boolean => + slicing?.discriminator?.some((d) => d.type === "type") ?? false; + export type ConstrainedChoiceInfo = { choiceBase: string; variant: string; @@ -352,6 +367,8 @@ type SpecializationTypeSchemaBody = { base?: TypeIdentifier; description?: string; fields?: { [k: string]: Field }; + /** Slicing definitions keyed by field name, kept apart from `fields` */ + slicing?: Record; nested?: NestedTypeSchema[]; dependencies?: Identifier[]; /** Transitive children grouped by kind (e.g. Resource → { resources: [DomainResource, Patient, …] }) */ @@ -385,7 +402,6 @@ export interface RegularField { enum?: EnumDefinition; min?: number; max?: number; - slicing?: FieldSlicing; valueConstraint?: ValueConstraint; mustSupport?: boolean; } @@ -411,7 +427,6 @@ export interface ChoiceFieldInstance { enum?: EnumDefinition; min?: number; max?: number; - slicing?: FieldSlicing; valueConstraint?: ValueConstraint; mustSupport?: boolean; } diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 23eb9a0f..acf7081d 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -15,6 +15,7 @@ import { type ConstrainedChoiceInfo, concatIdentifiers, type Field, + type FieldSlicing, type GenericParam, type Identifier, isChoiceDeclarationField, @@ -630,8 +631,11 @@ export const mkTypeSchemaIndex = ( throw new Error(`No non-constraint schema found in hierarchy for ${schema.identifier.name}`); const mergedFields = {} as Record; + const mergedSlicing = {} as Record; for (const anySchema of constraintSchemas.slice().reverse()) { const schema = anySchema as SpecializationTypeSchema; + // Leaf-most slicing wins per field, mirroring the field merge below. + if (schema.slicing) Object.assign(mergedSlicing, schema.slicing); if (!schema.fields) continue; for (const [fieldName, fieldConstraints] of Object.entries(schema.fields)) { @@ -680,6 +684,7 @@ export const mkTypeSchemaIndex = ( ...schema, base: nonConstraintSchema.identifier, fields: narrowedFields, + slicing: Object.keys(mergedSlicing).length > 0 ? mergedSlicing : undefined, dependencies: dependencies, extensions: mergedExtensions.length > 0 ? mergedExtensions : undefined, }; @@ -730,6 +735,7 @@ export const mkTypeSchemaIndex = ( base: flat.base, description: flat.description, fields: flatFields, + slicing: flat.slicing, inheritedRequiredFields: inheritedRequiredFields.length > 0 ? inheritedRequiredFields : undefined, extensions: flat.extensions, dependencies: flat.dependencies, diff --git a/test/api/write-generator/__snapshots__/introspection.test.ts.snap b/test/api/write-generator/__snapshots__/introspection.test.ts.snap index 90ec2fa4..6fb1829b 100644 --- a/test/api/write-generator/__snapshots__/introspection.test.ts.snap +++ b/test/api/write-generator/__snapshots__/introspection.test.ts.snap @@ -2786,48 +2786,6 @@ exports[`IntrospectionWriter - flat profile output Check bodyweight flat profile "excluded": false, "array": true, "min": 1, - "slicing": { - "discriminator": [ - { - "type": "value", - "path": "coding.code" - }, - { - "type": "value", - "path": "coding.system" - } - ], - "rules": "open", - "ordered": false, - "slices": { - "VSCat": { - "min": 1, - "max": 1, - "match": { - "coding": [ - { - "code": "vital-signs", - "system": "http://terminology.hl7.org/CodeSystem/observation-category" - } - ] - }, - "required": [ - "coding" - ], - "elements": [ - "coding" - ], - "nameCandidates": { - "candidates": [ - "VSCat", - "CategoryVSCat", - "CategoryVSCatSlice" - ], - "recommended": "VSCat" - } - } - } - }, "binding": { "kind": "binding", "package": "shared", @@ -3166,6 +3124,50 @@ exports[`IntrospectionWriter - flat profile output Check bodyweight flat profile ] } }, + "slicing": { + "category": { + "discriminator": [ + { + "type": "value", + "path": "coding.code" + }, + { + "type": "value", + "path": "coding.system" + } + ], + "rules": "open", + "ordered": false, + "slices": { + "VSCat": { + "min": 1, + "max": 1, + "match": { + "coding": [ + { + "code": "vital-signs", + "system": "http://terminology.hl7.org/CodeSystem/observation-category" + } + ] + }, + "required": [ + "coding" + ], + "elements": [ + "coding" + ], + "nameCandidates": { + "candidates": [ + "VSCat", + "CategoryVSCat", + "CategoryVSCatSlice" + ], + "recommended": "VSCat" + } + } + } + } + }, "dependencies": [ { "kind": "complex-type", diff --git a/test/api/write-generator/__snapshots__/kbv-condition-diagnosis.test.ts.snap b/test/api/write-generator/__snapshots__/kbv-condition-diagnosis.test.ts.snap index 28e3ff47..3c337369 100644 --- a/test/api/write-generator/__snapshots__/kbv-condition-diagnosis.test.ts.snap +++ b/test/api/write-generator/__snapshots__/kbv-condition-diagnosis.test.ts.snap @@ -1850,29 +1850,7 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR }, "required": false, "excluded": false, - "array": true, - "slicing": { - "discriminator": [ - { - "type": "value", - "path": "url" - } - ], - "rules": "open", - "slices": { - "Feststellungsdatum": { - "max": 1, - "nameCandidates": { - "candidates": [ - "Feststellungsdatum", - "ExtensionFeststellungsdatum", - "ExtensionFeststellungsdatumSlice" - ], - "recommended": "ExtensionFeststellungsdatum" - } - } - } - } + "array": true }, "clinicalStatus": { "type": { @@ -2042,15 +2020,6 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "required": false, "excluded": false, "array": false, - "slicing": { - "discriminator": [ - { - "type": "type", - "path": "$this" - } - ], - "rules": "open" - }, "choices": [ "onsetDateTime", "onsetAge", @@ -2115,15 +2084,6 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "required": false, "excluded": false, "array": false, - "slicing": { - "discriminator": [ - { - "type": "type", - "path": "$this" - } - ], - "rules": "open" - }, "choices": [ "abatementDateTime", "abatementAge", @@ -2265,6 +2225,48 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "array": true } }, + "slicing": { + "extension": { + "discriminator": [ + { + "type": "value", + "path": "url" + } + ], + "rules": "open", + "slices": { + "Feststellungsdatum": { + "max": 1, + "nameCandidates": { + "candidates": [ + "Feststellungsdatum", + "ExtensionFeststellungsdatum", + "ExtensionFeststellungsdatumSlice" + ], + "recommended": "ExtensionFeststellungsdatum" + } + } + } + }, + "onset": { + "discriminator": [ + { + "type": "type", + "path": "$this" + } + ], + "rules": "open" + }, + "abatement": { + "discriminator": [ + { + "type": "type", + "path": "$this" + } + ], + "rules": "open" + } + }, "nested": [ { "identifier": { @@ -2663,29 +2665,7 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR }, "required": false, "excluded": false, - "array": true, - "slicing": { - "discriminator": [ - { - "type": "value", - "path": "url" - } - ], - "rules": "open", - "slices": { - "Feststellungsdatum": { - "max": 1, - "nameCandidates": { - "candidates": [ - "Feststellungsdatum", - "ExtensionFeststellungsdatum", - "ExtensionFeststellungsdatumSlice" - ], - "recommended": "ExtensionFeststellungsdatum" - } - } - } - } + "array": true }, "clinicalStatus": { "type": { @@ -2855,15 +2835,6 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "required": false, "excluded": false, "array": false, - "slicing": { - "discriminator": [ - { - "type": "type", - "path": "$this" - } - ], - "rules": "open" - }, "choices": [ "onsetDateTime", "onsetAge", @@ -2928,15 +2899,6 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "required": false, "excluded": false, "array": false, - "slicing": { - "discriminator": [ - { - "type": "type", - "path": "$this" - } - ], - "rules": "open" - }, "choices": [ "abatementDateTime", "abatementAge", @@ -3078,6 +3040,48 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "array": true } }, + "slicing": { + "extension": { + "discriminator": [ + { + "type": "value", + "path": "url" + } + ], + "rules": "open", + "slices": { + "Feststellungsdatum": { + "max": 1, + "nameCandidates": { + "candidates": [ + "Feststellungsdatum", + "ExtensionFeststellungsdatum", + "ExtensionFeststellungsdatumSlice" + ], + "recommended": "ExtensionFeststellungsdatum" + } + } + } + }, + "onset": { + "discriminator": [ + { + "type": "type", + "path": "$this" + } + ], + "rules": "open" + }, + "abatement": { + "discriminator": [ + { + "type": "type", + "path": "$this" + } + ], + "rules": "open" + } + }, "extensions": [ { "name": "Feststellungsdatum",