From 86e320e1a5c5012db97422d5608fa2e80b438ad1 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 16:05:03 +0400 Subject: [PATCH 1/9] feat(typeschema): precompute derived slice facts on profile snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldSlice gains effectiveRequired, constrainedChoice, autoStub and resourceType, populated once at snapshot build. The TS and Python writers consume them instead of re-deriving the same semantics per language, which removes duplicated collectors and fixes two divergences that duplication had already produced: - Python auto-stubbed required slices that need user data beyond the discriminator match (e.g. US Core race/ethnicity text slices) — extension profiles now keep 'extension' as an optional create() param populated via sub-extension setters, mirroring the TS Raw type; - validateSliceFields no longer requires choice-declaration base names (phantom 'value' key that never exists in FHIR JSON) inside slices. --- .../python/profile-factory.ts | 15 ++- .../writer-generator/python/profile-slices.ts | 42 +------ .../python/profile-validation.ts | 19 +-- .../typescript/profile-slices.ts | 66 ++-------- .../typescript/profile-validation.ts | 17 +-- src/typeschema/types.ts | 11 ++ src/typeschema/utils.ts | 114 ++++++++++++++---- 7 files changed, 138 insertions(+), 146 deletions(-) diff --git a/src/api/writer-generator/python/profile-factory.ts b/src/api/writer-generator/python/profile-factory.ts index 95fd182c..6cbd9609 100644 --- a/src/api/writer-generator/python/profile-factory.ts +++ b/src/api/writer-generator/python/profile-factory.ts @@ -175,10 +175,17 @@ export const collectProfileFactoryInfo = ( if (isNotChoiceDeclarationField(field)) { const sliceNames = collectRequiredSliceNames(field, flatProfile.slicing?.[name]); - if (sliceNames) { + // Extension profiles populate `extension` via sub-extension slice + // setters — keep it optional in create() even when no slice is + // auto-stubbable (mirrors the optional `extension` in the TS Raw type). + const slicedExtensionProfileField = + name === "extension" && + flatProfile.base.name === "Extension" && + flatProfile.slicing?.extension?.slices !== undefined; + if (sliceNames || slicedExtensionProfileField) { if (field.type) { const pyType = fieldPyType(field, resolveRef, tsIndex); - sliceAutoFields.push({ name, pyType, typeId: field.type, sliceNames }); + sliceAutoFields.push({ name, pyType, typeId: field.type, sliceNames: sliceNames ?? [] }); autoAccessors.push({ name, pyType, typeId: field.type, refComment: pyReferenceComment(field) }); } continue; @@ -269,7 +276,9 @@ export const generateCreateResource = ( for (const f of factoryInfo.sliceAutoFields) { const fieldName = pyFieldName(f.name, fmt); const matchRefs = f.sliceNames.map((s) => `cls.${pySliceStaticName(s)}`); - if (matchRefs.length === 1) { + if (matchRefs.length === 0) { + w.line(`${fieldName}_with_defaults = list(${fieldName} or [])`); + } else if (matchRefs.length === 1) { w.line(`${fieldName}_with_defaults = ensure_slice_defaults(list(${fieldName} or []), ${matchRefs[0]})`); } else { w.line(`${fieldName}_with_defaults = ensure_slice_defaults(`); diff --git a/src/api/writer-generator/python/profile-slices.ts b/src/api/writer-generator/python/profile-slices.ts index 25fa7c3f..cf348984 100644 --- a/src/api/writer-generator/python/profile-slices.ts +++ b/src/api/writer-generator/python/profile-slices.ts @@ -1,7 +1,6 @@ import { type ConstrainedChoiceInfo, type FieldSlicing, - isChoiceDeclarationField, isNotChoiceDeclarationField, isPrimitiveIdentifier, isTypeDiscriminated, @@ -40,10 +39,8 @@ export const collectRequiredSliceNames = ( fieldSlicing: FieldSlicing | undefined, ): string[] | undefined => { if (!field.array || !fieldSlicing?.slices) return undefined; - // Type-discriminated slices ("type" discriminator) require explicit typed setters — no stubs. - 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) + .filter(([_, s]) => s.autoStub) .map(([name]) => name); return names.length > 0 ? names : undefined; }; @@ -93,52 +90,23 @@ export const normalizeMatchForPython = ( return result; }; -const extractTypeDiscriminatorResource = ( - isTypeDiscriminated: boolean, - rawMatch: Record | undefined, -): string | undefined => { - if (!isTypeDiscriminated || !rawMatch) return undefined; - for (const val of Object.values(rawMatch)) { - if (val !== null && typeof val === "object" && !Array.isArray(val)) { - const rt = (val as Record).resourceType; - if (typeof rt === "string") return rt; - } - } - return undefined; -}; - export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: SnapshotProfileTypeSchema): SliceDef[] => { - const pkgName = flatProfile.identifier.package; 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) { - for (const [n, f] of Object.entries(baseSchema.fields)) { - if (isChoiceDeclarationField(f)) choiceBaseNames.add(n); - } - } + const typeDiscriminated = 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; + const cc = slice.constrainedChoice; // Skip flattening for primitive types — can't wrap/unwrap under a variant key. const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined; - const typeDiscriminated = isTypeDiscriminated(fieldSlicing); - const typeDiscriminatorResource = extractTypeDiscriminatorResource( - typeDiscriminated, - slice.match as Record | undefined, - ); return { fieldName, sliceName, match: normalizeMatchForPython(tsIndex, slice.match ?? {}, baseSchema), - required, + required: slice.effectiveRequired ?? [], array: Boolean(field.array), max: slice.max ?? 0, constrainedChoice, @@ -146,7 +114,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot field.type && !isPrimitiveIdentifier(field.type) ? pyTypeFromIdentifier(field.type) : undefined, elementTypeId: field.type && !isPrimitiveIdentifier(field.type) ? field.type : undefined, isTypeDiscriminated: typeDiscriminated, - typeDiscriminatorResource, + typeDiscriminatorResource: slice.resourceType, nameCandidates: slice.nameCandidates, }; }); diff --git a/src/api/writer-generator/python/profile-validation.ts b/src/api/writer-generator/python/profile-validation.ts index 4f9d7988..36af6b50 100644 --- a/src/api/writer-generator/python/profile-validation.ts +++ b/src/api/writer-generator/python/profile-validation.ts @@ -128,18 +128,16 @@ const collectRegularFieldValidation = ( pushListValidation(errorLines, "errors", "validate_reference", [JSON.stringify(pyName)], allowed); } if (fieldSlicing?.slices) { - collectSliceValidation(field, fieldSlicing, pyName, helpers, errorLines, tsIndex, formatName); + collectSliceValidation(fieldSlicing, pyName, helpers, errorLines, formatName); } } }; const collectSliceValidation = ( - field: RegularField | ChoiceFieldInstance, fieldSlicing: FieldSlicing, name: string, helpers: Set, errorLines: string[], - tsIndex: TypeSchemaIndex, formatName: (s: string) => string, ): void => { if (!fieldSlicing.slices) return; @@ -154,17 +152,10 @@ const collectSliceValidation = ( `errors.extend(validate_slice_cardinality(self._resource, profile_name, ${JSON.stringify(name)}, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${min}, ${max}))`, ); } - // Collect required fields within the slice element - const sliceRequiredFields: string[] = []; - const matchKeys = new Set(Object.keys(match)); - for (const rf of slice.required ?? []) { - if (!matchKeys.has(rf)) sliceRequiredFields.push(pyFieldName(rf, formatName)); - } - // Constrained choice: the single variant is required - if (field.type && slice.elements) { - const cc = tsIndex.constrainedChoice(field.type.package, field.type, slice.elements); - if (cc) sliceRequiredFields.push(pyFieldName(cc.variant, formatName)); - } + // Required fields within the slice element; for a constrained choice + // the single variant is required + const sliceRequiredFields = (slice.effectiveRequired ?? []).map((rf) => pyFieldName(rf, formatName)); + if (slice.constrainedChoice) sliceRequiredFields.push(pyFieldName(slice.constrainedChoice.variant, formatName)); if (sliceRequiredFields.length > 0) { helpers.add("validate_slice_fields"); pushListValidation( diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index 188853d4..2e303441 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -1,7 +1,6 @@ import { type ConstrainedChoiceInfo, type FieldSlicing, - isChoiceDeclarationField, isNotChoiceDeclarationField, isPrimitiveIdentifier, isTypeDiscriminated, @@ -21,54 +20,24 @@ import { import { tsGet, tsTypeFromIdentifier } from "./utils"; import type { TypeScript } from "./writer"; -/** Collect choice declaration field names from a base type schema */ -const collectChoiceBaseNames = (tsIndex: TypeSchemaIndex, typeId: TypeIdentifier): Set => { - const names = new Set(); - const schema = tsIndex.resolveType(typeId); - if (schema && "fields" in schema && schema.fields) { - for (const [name, f] of Object.entries(schema.fields)) { - if (isChoiceDeclarationField(f)) names.add(name); - } - } - return names; -}; - -/** Extract resource type name from a type-discriminator match (e.g. {"resource":{"resourceType":"Patient"}} → "Patient") */ -export const extractResourceTypeFromMatch = (match: Record): string | undefined => { - for (const value of Object.values(match)) { - if (typeof value !== "object" || value === null) continue; - const obj = value as Record; - if (typeof obj.resourceType === "string") return obj.resourceType; - const nested = extractResourceTypeFromMatch(obj); - if (nested) return nested; - } - return undefined; -}; - export const collectTypesFromSlices = ( tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema, addType: (typeId: TypeIdentifier) => void, ) => { - const pkgName = snapshot.identifier.package; 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; - if (cc) addType(cc.variantType); + if (slice.constrainedChoice) addType(slice.constrainedChoice.variantType); // For type discriminator slices, also import the matched resource type - if (isTypeDisc && slice.match) { - const resourceTypeName = extractResourceTypeFromMatch(slice.match); - if (resourceTypeName) { - const resourceSchema = tsIndex.schemas.find( - (s) => s.identifier.name === resourceTypeName && s.identifier.kind === "resource", - ); - if (resourceSchema) addType(resourceSchema.identifier); - } + if (slice.resourceType) { + const resourceSchema = tsIndex.schemas.find( + (s) => s.identifier.name === slice.resourceType && s.identifier.kind === "resource", + ); + if (resourceSchema) addType(resourceSchema.identifier); } } } @@ -87,14 +56,8 @@ export const collectRequiredSliceNames = ( 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)); - const requiredBeyondMatch = (s.required ?? []).filter((name) => !matchKeys.has(name)); - return requiredBeyondMatch.length === 0; - }) + .filter(([_, s]) => s.autoStub) .map(([name]) => name); return names.length > 0 ? names : undefined; }; @@ -119,26 +82,19 @@ export type SliceDef = { max: number; }; -export const collectSliceDefs = (tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema): SliceDef[] => +export const collectSliceDefs = (_tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema): SliceDef[] => 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; + const cc = slice.constrainedChoice; // 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; + const typedBaseType = slice.resourceType ? `${baseType}<${slice.resourceType}>` : baseType; return { fieldName, baseType, @@ -146,7 +102,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, snapshot: SnapshotPro sliceName, baseName: slice.nameCandidates.recommended, match: slice.match ?? {}, - required, + required: slice.effectiveRequired ?? [], excluded: slice.excluded ?? [], array: Boolean(field.array), constrainedChoice, diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index 154acc1d..68859bbb 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -18,7 +18,7 @@ export const collectRegularFieldValidation = ( field: RegularField | ChoiceFieldInstance, resolveRef: (ref: TypeIdentifier) => TypeIdentifier, canonicalUrlExpr?: { url: string; expr: string }, - tsIndex?: TypeSchemaIndex, + _tsIndex?: TypeSchemaIndex, fieldSlicing?: FieldSlicing, ) => { if (field.excluded) { @@ -60,17 +60,10 @@ export const collectRegularFieldValidation = ( `...validateSliceCardinality(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${min}, ${max})`, ); } - // Collect required fields within the slice element - const sliceRequiredFields: string[] = []; - const matchKeys = new Set(Object.keys(match)); - for (const rf of slice.required ?? []) { - if (!matchKeys.has(rf)) sliceRequiredFields.push(rf); - } - // Constrained choice: the single variant is required - if (tsIndex && field.type && slice.elements) { - const cc = tsIndex.constrainedChoice(field.type.package, field.type, slice.elements); - if (cc) sliceRequiredFields.push(cc.variant); - } + // Required fields within the slice element; for a constrained + // choice the single variant is required + const sliceRequiredFields = [...(slice.effectiveRequired ?? [])]; + if (slice.constrainedChoice) sliceRequiredFields.push(slice.constrainedChoice.variant); if (sliceRequiredFields.length > 0) { errors.push( `...validateSliceFields(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${JSON.stringify(sliceRequiredFields)})`, diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index f6ed9d45..0b684841 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -333,6 +333,17 @@ export interface FieldSlice { excluded?: string[]; elements?: string[]; nameCandidates: NameCandidates; + + // Derived facts, populated on profile snapshots at snapshot-build time so + // writers share one implementation instead of re-deriving them per language. + /** `required` minus match keys and choice-declaration base names */ + effectiveRequired?: string[]; + /** The single choice variant this slice constrains (e.g. BP component → valueQuantity) */ + constrainedChoice?: ConstrainedChoiceInfo; + /** Slice can be auto-populated with just the discriminator match values */ + autoStub?: boolean; + /** Matched resource type for type-discriminated slices (e.g. Bundle entry → "Patient") */ + resourceType?: string; } export interface ExtensionSubField { diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index acf7081d..2aabfc2d 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -15,6 +15,7 @@ import { type ConstrainedChoiceInfo, concatIdentifiers, type Field, + type FieldSlice, type FieldSlicing, type GenericParam, type Identifier, @@ -25,12 +26,14 @@ import { isLogicalTypeSchema, isNestedIdentifier, isNestedTypeSchema, + isNotChoiceDeclarationField, isProfileTypeSchema, isResourceIdentifier, isResourceTypeSchema, isSnapshotProfileIdentifier, isSnapshotProfileTypeSchema, isSpecializationTypeSchema, + isTypeDiscriminated, type LogicalIdentifier, type LogicalTypeSchema, type NestedIdentifier, @@ -690,6 +693,91 @@ export const mkTypeSchemaIndex = ( }; }; + const constrainedChoice = ( + pkgName: PkgName, + baseTypeId: TypeIdentifier, + sliceElements: string[], + ): ConstrainedChoiceInfo | undefined => { + const baseSchema = resolveByUrl(pkgName, baseTypeId.url as CanonicalUrl); + if (!baseSchema || !("fields" in baseSchema) || !baseSchema.fields) return undefined; + for (const [fieldName, field] of Object.entries(baseSchema.fields)) { + if (!isChoiceDeclarationField(field)) continue; + const matchingVariants = field.choices.filter((c) => sliceElements.includes(c)); + if (matchingVariants.length !== 1) continue; + const variantName = matchingVariants[0] as string; + const variantField = baseSchema.fields[variantName]; + if (!variantField || !isChoiceInstanceField(variantField)) continue; + return { + choiceBase: fieldName, + variant: variantName, + variantType: variantField.type, + allChoiceNames: field.choices, + }; + } + return undefined; + }; + + /** Extract the matched resource type from a type-discriminator match (e.g. {"resource":{"resourceType":"Patient"}}) */ + const extractResourceTypeFromMatch = (match: Record): string | undefined => { + for (const value of Object.values(match)) { + if (typeof value !== "object" || value === null) continue; + const obj = value as Record; + if (typeof obj.resourceType === "string") return obj.resourceType; + const nested = extractResourceTypeFromMatch(obj); + if (nested) return nested; + } + return undefined; + }; + + /** Populate the derived per-slice facts (effectiveRequired, constrainedChoice, + * autoStub, resourceType) on a snapshot's slicing map. Slices are copied — + * the source profile schemas stay untouched. */ + const enrichSliceInfo = ( + slicing: Record, + fields: Record, + pkgName: PkgName, + ): Record => { + const result: Record = {}; + for (const [fieldName, fieldSlicing] of Object.entries(slicing)) { + if (!fieldSlicing.slices) { + result[fieldName] = fieldSlicing; + continue; + } + const field = fields[fieldName]; + const fieldType = field && isNotChoiceDeclarationField(field) ? field.type : undefined; + const typeSchema = fieldType ? resolveType(fieldType) : undefined; + const choiceBaseNames = new Set( + typeSchema && "fields" in typeSchema && typeSchema.fields + ? Object.keys(typeSchema.fields).filter((n) => isChoiceDeclarationField(typeSchema.fields?.[n])) + : [], + ); + const typeDisc = isTypeDiscriminated(fieldSlicing); + const slices: Record = {}; + for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { + const matchKeys = new Set(Object.keys(slice.match ?? {})); + const required = slice.required ?? []; + const effectiveRequired = required.filter((n) => !matchKeys.has(n) && !choiceBaseNames.has(n)); + // Stub eligibility keeps choice-base names: a slice requiring its + // choice (e.g. BP component value[x]) needs user data, not a stub. + const requiredBeyondMatch = required.filter((n) => !matchKeys.has(n)); + const autoStub = + !typeDisc && (slice.min ?? 0) >= 1 && matchKeys.size > 0 && requiredBeyondMatch.length === 0; + const cc = + fieldType && slice.elements ? constrainedChoice(pkgName, fieldType, slice.elements) : undefined; + const resourceType = typeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; + slices[sliceName] = { + ...slice, + ...(effectiveRequired.length > 0 ? { effectiveRequired } : {}), + ...(cc ? { constrainedChoice: cc } : {}), + ...(autoStub ? { autoStub } : {}), + ...(resourceType ? { resourceType } : {}), + }; + } + result[fieldName] = { ...fieldSlicing, slices }; + } + return result; + }; + const buildProfileSnapshot = (schema: ProfileTypeSchema): SnapshotProfileTypeSchema => { const flat = flatProfile(schema); const flatFields = flat.fields ?? {}; @@ -735,7 +823,7 @@ export const mkTypeSchemaIndex = ( base: flat.base, description: flat.description, fields: flatFields, - slicing: flat.slicing, + slicing: flat.slicing ? enrichSliceInfo(flat.slicing, flatFields, schema.identifier.package) : undefined, inheritedRequiredFields: inheritedRequiredFields.length > 0 ? inheritedRequiredFields : undefined, extensions: flat.extensions, dependencies: flat.dependencies, @@ -758,30 +846,6 @@ export const mkTypeSchemaIndex = ( const collectSnapshotProfiles = (): SnapshotProfileTypeSchema[] => Object.values(snapshotIndex).flatMap((byPkg) => Object.values(byPkg)); - const constrainedChoice = ( - pkgName: PkgName, - baseTypeId: TypeIdentifier, - sliceElements: string[], - ): ConstrainedChoiceInfo | undefined => { - const baseSchema = resolveByUrl(pkgName, baseTypeId.url as CanonicalUrl); - if (!baseSchema || !("fields" in baseSchema) || !baseSchema.fields) return undefined; - for (const [fieldName, field] of Object.entries(baseSchema.fields)) { - if (!isChoiceDeclarationField(field)) continue; - const matchingVariants = field.choices.filter((c) => sliceElements.includes(c)); - if (matchingVariants.length !== 1) continue; - const variantName = matchingVariants[0] as string; - const variantField = baseSchema.fields[variantName]; - if (!variantField || !isChoiceInstanceField(variantField)) continue; - return { - choiceBase: fieldName, - variant: variantName, - variantType: variantField.type, - allChoiceNames: field.choices, - }; - } - return undefined; - }; - const isWithMetaField = (profile: ProfileTypeSchema | SnapshotProfileTypeSchema): boolean => { const genealogy = tryHierarchy(profile); if (!genealogy) return false; From 7864d2a67ae9f024c2333549d280576960cc96a2 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 16:05:03 +0400 Subject: [PATCH 2/9] test: update snapshots for derived slice facts --- .../__snapshots__/introspection.test.ts.snap | 3 ++- .../api/write-generator/__snapshots__/python.test.ts.snap | 8 ++++---- .../write-generator/__snapshots__/typescript.test.ts.snap | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/api/write-generator/__snapshots__/introspection.test.ts.snap b/test/api/write-generator/__snapshots__/introspection.test.ts.snap index 6fb1829b..db0dc027 100644 --- a/test/api/write-generator/__snapshots__/introspection.test.ts.snap +++ b/test/api/write-generator/__snapshots__/introspection.test.ts.snap @@ -3163,7 +3163,8 @@ exports[`IntrospectionWriter - flat profile output Check bodyweight flat profile "CategoryVSCatSlice" ], "recommended": "VSCat" - } + }, + "autoStub": true } } } diff --git a/test/api/write-generator/__snapshots__/python.test.ts.snap b/test/api/write-generator/__snapshots__/python.test.ts.snap index 98e4389a..60c4b3c4 100644 --- a/test/api/write-generator/__snapshots__/python.test.ts.snap +++ b/test/api/write-generator/__snapshots__/python.test.ts.snap @@ -1922,7 +1922,7 @@ class UscoreRaceExtension: @classmethod def create_resource(cls, *, extension: list[Extension] | None = None) -> Extension: - extension_with_defaults = ensure_slice_defaults(list(extension or []), cls._text_slice_match) + extension_with_defaults = list(extension or []) return build_resource(Extension, url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", extension=extension_with_defaults) @@ -2092,16 +2092,16 @@ class UscoreRaceExtension: errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"ombCategory"}, "ombCategory", 0, 6)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"ombCategory"}, "ombCategory", [ - "value","valueCoding" + "valueCoding" ])) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"detailed"}, "detailed", [ - "value","valueCoding" + "valueCoding" ])) errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"text"}, "text", 1, 1)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"text"}, "text", [ - "value","valueString" + "valueString" ])) errors.extend(validate_required(self._resource, profile_name, "url")) errors.extend(validate_fixed_value(self._resource, profile_name, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race")) diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index ea68c285..86948369 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -2362,10 +2362,10 @@ export class USCoreRaceExtensionProfile { errors: [ ...validateRequired(res, profileName, "extension"), ...validateSliceCardinality(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", 0, 6), - ...validateSliceFields(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", ["value","valueCoding"]), - ...validateSliceFields(res, profileName, "extension", {"url":"detailed"}, "detailed", ["value","valueCoding"]), + ...validateSliceFields(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", ["valueCoding"]), + ...validateSliceFields(res, profileName, "extension", {"url":"detailed"}, "detailed", ["valueCoding"]), ...validateSliceCardinality(res, profileName, "extension", {"url":"text"}, "text", 1, 1), - ...validateSliceFields(res, profileName, "extension", {"url":"text"}, "text", ["value","valueString"]), + ...validateSliceFields(res, profileName, "extension", {"url":"text"}, "text", ["valueString"]), ...validateRequired(res, profileName, "url"), ...validateFixedValue(res, profileName, "url", USCoreRaceExtensionProfile.canonicalUrl), ], From 361debb9493e7c441b67b9618777e02aac6f3324 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 16:05:03 +0400 Subject: [PATCH 3/9] chore(examples): regenerate US Core extension profiles --- .../profiles/extension_nationality.py | 20 ++++++++++++++----- .../extension_uscore_ethnicity_extension.py | 8 ++++---- .../extension_uscore_race_extension.py | 8 ++++---- ...ion_uscore_tribal_affiliation_extension.py | 6 +++--- .../Extension_USCoreEthnicityExtension.ts | 6 +++--- .../profiles/Extension_USCoreRaceExtension.ts | 6 +++--- ...ension_USCoreTribalAffiliationExtension.ts | 4 ++-- 7 files changed, 34 insertions(+), 24 deletions(-) diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/profiles/extension_nationality.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/profiles/extension_nationality.py index a218df20..20fb412f 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/profiles/extension_nationality.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_r4_core/profiles/extension_nationality.py @@ -10,7 +10,8 @@ from fhir_types.hl7_fhir_r4_core.base import Extension from fhir_types.hl7_fhir_r4_core.base import CodeableConcept, Period from fhir_types.profile_helpers import ( - _get_key, build_resource, get_extension_value, is_extension, push_extension, validate_fixed_value, validate_required + _get_key, build_resource, ensure_slice_defaults, get_extension_value, is_extension, push_extension, validate_fixed_value, \ + validate_required ) @@ -38,16 +39,25 @@ def apply(cls, resource: Extension) -> "NationalityExtension": return cls(resource) @classmethod - def create_resource(cls) -> Extension: - return build_resource(Extension, url="http://hl7.org/fhir/StructureDefinition/patient-nationality") + def create_resource(cls, *, extension: list[Extension] | None = None) -> Extension: + extension_with_defaults = list(extension or []) + + return build_resource(Extension, url="http://hl7.org/fhir/StructureDefinition/patient-nationality", extension=extension_with_defaults) @classmethod - def create(cls) -> "NationalityExtension": - return cls.apply(cls.create_resource()) + def create(cls, *, extension: list[Extension] | None = None) -> "NationalityExtension": + return cls.apply(cls.create_resource(extension=extension)) def to_resource(self) -> Extension: return self._resource + def get_extension(self) -> list[Extension] | None: + return cast('list[Extension] | None', getattr(self._resource, "extension", None)) + + def set_extension(self, value: list[Extension]) -> "NationalityExtension": + setattr(self._resource, "extension", value) + return self + def get_url(self) -> str | None: return cast('str | None', getattr(self._resource, "url", None)) diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_ethnicity_extension.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_ethnicity_extension.py index df04873c..26a284b4 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_ethnicity_extension.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_ethnicity_extension.py @@ -47,7 +47,7 @@ def apply(cls, resource: Extension) -> "UscoreEthnicityExtension": @classmethod def create_resource(cls, *, extension: list[Extension] | None = None) -> Extension: - extension_with_defaults = ensure_slice_defaults(list(extension or []), cls._text_slice_match) + extension_with_defaults = list(extension or []) return build_resource(Extension, url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", extension=extension_with_defaults) @@ -217,16 +217,16 @@ def validate(self) -> dict[str, list[str]]: errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"ombCategory"}, "ombCategory", 0, 1)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"ombCategory"}, "ombCategory", [ - "value","valueCoding" + "valueCoding" ])) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"detailed"}, "detailed", [ - "value","valueCoding" + "valueCoding" ])) errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"text"}, "text", 1, 1)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"text"}, "text", [ - "value","valueString" + "valueString" ])) errors.extend(validate_required(self._resource, profile_name, "url")) errors.extend(validate_fixed_value(self._resource, profile_name, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity")) diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_race_extension.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_race_extension.py index 34bb4b32..eec33d8d 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_race_extension.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_race_extension.py @@ -50,7 +50,7 @@ def apply(cls, resource: Extension) -> "UscoreRaceExtension": @classmethod def create_resource(cls, *, extension: list[Extension] | None = None) -> Extension: - extension_with_defaults = ensure_slice_defaults(list(extension or []), cls._text_slice_match) + extension_with_defaults = list(extension or []) return build_resource(Extension, url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", extension=extension_with_defaults) @@ -220,16 +220,16 @@ def validate(self) -> dict[str, list[str]]: errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"ombCategory"}, "ombCategory", 0, 6)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"ombCategory"}, "ombCategory", [ - "value","valueCoding" + "valueCoding" ])) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"detailed"}, "detailed", [ - "value","valueCoding" + "valueCoding" ])) errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"text"}, "text", 1, 1)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"text"}, "text", [ - "value","valueString" + "valueString" ])) errors.extend(validate_required(self._resource, profile_name, "url")) errors.extend(validate_fixed_value(self._resource, profile_name, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race")) diff --git a/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_tribal_affiliation_extension.py b/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_tribal_affiliation_extension.py index de5dc3c9..0467395b 100644 --- a/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_tribal_affiliation_extension.py +++ b/examples/python-r4-us-core/fhir_types/hl7_fhir_us_core/profiles/extension_uscore_tribal_affiliation_extension.py @@ -43,7 +43,7 @@ def apply(cls, resource: Extension) -> "UscoreTribalAffiliationExtension": @classmethod def create_resource(cls, *, extension: list[Extension] | None = None) -> Extension: - extension_with_defaults = ensure_slice_defaults(list(extension or []), cls._tribal_affiliation_slice_match) + extension_with_defaults = list(extension or []) return build_resource(Extension, url="http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation", extension=extension_with_defaults) @@ -167,12 +167,12 @@ def validate(self) -> dict[str, list[str]]: errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"tribalAffiliation"}, "tribalAffiliation", 1, 1)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"tribalAffiliation"}, "tribalAffiliation", [ - "value","valueCodeableConcept" + "valueCodeableConcept" ])) errors.extend(validate_slice_cardinality(self._resource, profile_name, "extension", {"url":"isEnrolled"}, "isEnrolled", 0, 1)) errors.extend( validate_slice_fields(self._resource, profile_name, "extension", {"url":"isEnrolled"}, "isEnrolled", [ - "value","valueBoolean" + "valueBoolean" ])) errors.extend(validate_required(self._resource, profile_name, "url")) errors.extend(validate_fixed_value(self._resource, profile_name, "url", "http://hl7.org/fhir/us/core/StructureDefinition/us-core-tribal-affiliation")) diff --git a/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts b/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts index 02d37f75..7e5a2339 100644 --- a/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts +++ b/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreEthnicityExtension.ts @@ -254,10 +254,10 @@ export class USCoreEthnicityExtensionProfile { errors: [ ...validateRequired(res, profileName, "extension"), ...validateSliceCardinality(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", 0, 1), - ...validateSliceFields(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", ["value","valueCoding"]), - ...validateSliceFields(res, profileName, "extension", {"url":"detailed"}, "detailed", ["value","valueCoding"]), + ...validateSliceFields(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", ["valueCoding"]), + ...validateSliceFields(res, profileName, "extension", {"url":"detailed"}, "detailed", ["valueCoding"]), ...validateSliceCardinality(res, profileName, "extension", {"url":"text"}, "text", 1, 1), - ...validateSliceFields(res, profileName, "extension", {"url":"text"}, "text", ["value","valueString"]), + ...validateSliceFields(res, profileName, "extension", {"url":"text"}, "text", ["valueString"]), ...validateRequired(res, profileName, "url"), ...validateFixedValue(res, profileName, "url", USCoreEthnicityExtensionProfile.canonicalUrl), ], diff --git a/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts b/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts index 3b342f17..f74623a4 100644 --- a/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts +++ b/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts @@ -254,10 +254,10 @@ export class USCoreRaceExtensionProfile { errors: [ ...validateRequired(res, profileName, "extension"), ...validateSliceCardinality(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", 0, 6), - ...validateSliceFields(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", ["value","valueCoding"]), - ...validateSliceFields(res, profileName, "extension", {"url":"detailed"}, "detailed", ["value","valueCoding"]), + ...validateSliceFields(res, profileName, "extension", {"url":"ombCategory"}, "ombCategory", ["valueCoding"]), + ...validateSliceFields(res, profileName, "extension", {"url":"detailed"}, "detailed", ["valueCoding"]), ...validateSliceCardinality(res, profileName, "extension", {"url":"text"}, "text", 1, 1), - ...validateSliceFields(res, profileName, "extension", {"url":"text"}, "text", ["value","valueString"]), + ...validateSliceFields(res, profileName, "extension", {"url":"text"}, "text", ["valueString"]), ...validateRequired(res, profileName, "url"), ...validateFixedValue(res, profileName, "url", USCoreRaceExtensionProfile.canonicalUrl), ], diff --git a/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts b/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts index af73bcca..6a0b749d 100644 --- a/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts +++ b/examples/typescript-r4-us-core/fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreTribalAffiliationExtension.ts @@ -209,9 +209,9 @@ export class USCoreTribalAffiliationExtensionProfile { errors: [ ...validateRequired(res, profileName, "extension"), ...validateSliceCardinality(res, profileName, "extension", {"url":"tribalAffiliation"}, "tribalAffiliation", 1, 1), - ...validateSliceFields(res, profileName, "extension", {"url":"tribalAffiliation"}, "tribalAffiliation", ["value","valueCodeableConcept"]), + ...validateSliceFields(res, profileName, "extension", {"url":"tribalAffiliation"}, "tribalAffiliation", ["valueCodeableConcept"]), ...validateSliceCardinality(res, profileName, "extension", {"url":"isEnrolled"}, "isEnrolled", 0, 1), - ...validateSliceFields(res, profileName, "extension", {"url":"isEnrolled"}, "isEnrolled", ["value","valueBoolean"]), + ...validateSliceFields(res, profileName, "extension", {"url":"isEnrolled"}, "isEnrolled", ["valueBoolean"]), ...validateRequired(res, profileName, "url"), ...validateFixedValue(res, profileName, "url", USCoreTribalAffiliationExtensionProfile.canonicalUrl), ], From 22a5c3b6756918a37e604c12bde102ceae2cadfd Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 18:09:15 +0400 Subject: [PATCH 4/9] ref: extract per-slice enrichment into enrichSlice helper --- src/typeschema/utils.ts | 64 +++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 2aabfc2d..f6b7b0f2 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -729,9 +729,38 @@ export const mkTypeSchemaIndex = ( return undefined; }; + type SliceEnrichmentContext = { + pkgName: PkgName; + fieldType: TypeIdentifier | undefined; + choiceBaseNames: Set; + typeDiscriminated: boolean; + }; + + /** Compute the derived facts for one slice; returns a copy — the source + * profile schemas stay untouched. */ + const enrichSlice = (slice: FieldSlice, ctx: SliceEnrichmentContext): FieldSlice => { + const matchKeys = new Set(Object.keys(slice.match ?? {})); + const required = slice.required ?? []; + const effectiveRequired = required.filter((n) => !matchKeys.has(n) && !ctx.choiceBaseNames.has(n)); + // Stub eligibility keeps choice-base names: a slice requiring its + // choice (e.g. BP component value[x]) needs user data, not a stub. + const requiredBeyondMatch = required.filter((n) => !matchKeys.has(n)); + const autoStub = + !ctx.typeDiscriminated && (slice.min ?? 0) >= 1 && matchKeys.size > 0 && requiredBeyondMatch.length === 0; + const cc = + ctx.fieldType && slice.elements ? constrainedChoice(ctx.pkgName, ctx.fieldType, slice.elements) : undefined; + const resourceType = ctx.typeDiscriminated ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; + return { + ...slice, + ...(effectiveRequired.length > 0 ? { effectiveRequired } : {}), + ...(cc ? { constrainedChoice: cc } : {}), + ...(autoStub ? { autoStub } : {}), + ...(resourceType ? { resourceType } : {}), + }; + }; + /** Populate the derived per-slice facts (effectiveRequired, constrainedChoice, - * autoStub, resourceType) on a snapshot's slicing map. Slices are copied — - * the source profile schemas stay untouched. */ + * autoStub, resourceType) on a snapshot's slicing map. */ const enrichSliceInfo = ( slicing: Record, fields: Record, @@ -751,28 +780,15 @@ export const mkTypeSchemaIndex = ( ? Object.keys(typeSchema.fields).filter((n) => isChoiceDeclarationField(typeSchema.fields?.[n])) : [], ); - const typeDisc = isTypeDiscriminated(fieldSlicing); - const slices: Record = {}; - for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { - const matchKeys = new Set(Object.keys(slice.match ?? {})); - const required = slice.required ?? []; - const effectiveRequired = required.filter((n) => !matchKeys.has(n) && !choiceBaseNames.has(n)); - // Stub eligibility keeps choice-base names: a slice requiring its - // choice (e.g. BP component value[x]) needs user data, not a stub. - const requiredBeyondMatch = required.filter((n) => !matchKeys.has(n)); - const autoStub = - !typeDisc && (slice.min ?? 0) >= 1 && matchKeys.size > 0 && requiredBeyondMatch.length === 0; - const cc = - fieldType && slice.elements ? constrainedChoice(pkgName, fieldType, slice.elements) : undefined; - const resourceType = typeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; - slices[sliceName] = { - ...slice, - ...(effectiveRequired.length > 0 ? { effectiveRequired } : {}), - ...(cc ? { constrainedChoice: cc } : {}), - ...(autoStub ? { autoStub } : {}), - ...(resourceType ? { resourceType } : {}), - }; - } + const ctx: SliceEnrichmentContext = { + pkgName, + fieldType, + choiceBaseNames, + typeDiscriminated: isTypeDiscriminated(fieldSlicing), + }; + const slices = Object.fromEntries( + Object.entries(fieldSlicing.slices).map(([sliceName, slice]) => [sliceName, enrichSlice(slice, ctx)]), + ); result[fieldName] = { ...fieldSlicing, slices }; } return result; From 4d87fe5dc3069bf2cc8bb99139af59e51b6cb607 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 18:10:36 +0400 Subject: [PATCH 5/9] test(typeschema): cover derived slice fact enrichment --- test/unit/typeschema/slice-enrichment.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/unit/typeschema/slice-enrichment.test.ts diff --git a/test/unit/typeschema/slice-enrichment.test.ts b/test/unit/typeschema/slice-enrichment.test.ts new file mode 100644 index 00000000..93b9d8f1 --- /dev/null +++ b/test/unit/typeschema/slice-enrichment.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test"; +import { mkTypeSchemaIndex } from "@root/typeschema/utils"; +import type { CanonicalUrl, TypeSchema } from "@typeschema/types"; +import { mkErrorLogger, mkR4Register, r4Package, registerFs, resolveTs } from "@typeschema-test/utils"; + +const myPkg = { name: "mypackage", version: "0.0.0" }; + +/** Derived slice facts (effectiveRequired, constrainedChoice, autoStub, + * resourceType) are computed once at snapshot build by enrichSliceInfo. */ +describe("slice enrichment on profile snapshots", async () => { + const r4 = await mkR4Register(); + const logger = mkErrorLogger(); + + registerFs(r4, { + url: "http://example.org/StructureDefinition/TestVitals", + name: "TestVitals", + base: "http://hl7.org/fhir/StructureDefinition/Observation", + derivation: "constraint", + kind: "resource", + elements: { + category: { + type: "CodeableConcept", + array: true, + slicing: { + discriminator: [{ type: "pattern", path: "$this" }], + rules: "open", + slices: { + VSCat: { min: 1, max: 1, match: { coding: [{ code: "vital-signs" }] } }, + }, + }, + }, + component: { + array: true, + slicing: { + discriminator: [{ type: "pattern", path: "code" }], + rules: "open", + slices: { + Sys: { + min: 1, + max: 1, + match: { code: { coding: [{ code: "8480-6" }] } }, + schema: { + required: ["value"], + elements: { code: {}, valueQuantity: {} }, + }, + }, + }, + }, + }, + }, + }); + + registerFs(r4, { + url: "http://example.org/StructureDefinition/TestBundle", + name: "TestBundle", + base: "http://hl7.org/fhir/StructureDefinition/Bundle", + derivation: "constraint", + kind: "resource", + elements: { + entry: { + array: true, + slicing: { + discriminator: [{ type: "type", path: "resource" }], + rules: "open", + slices: { + Pat: { + min: 1, + max: 1, + match: { resource: { resourceType: "Patient" } } as Record, + }, + }, + }, + }, + }, + }); + + const schemas: TypeSchema[] = []; + for (const [pkg, url] of [ + [myPkg, "http://example.org/StructureDefinition/TestVitals"], + [myPkg, "http://example.org/StructureDefinition/TestBundle"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/Observation"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/Bundle"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/DomainResource"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/Resource"], + ] as const) { + schemas.push(...(await resolveTs(r4, pkg, url as CanonicalUrl, logger))); + } + const tsIndex = mkTypeSchemaIndex(schemas, { register: r4, logger }); + + const snapshot = (name: string) => { + const snap = tsIndex.collectSnapshotProfiles().find((s) => s.identifier.name === name); + if (!snap) throw new Error(`No snapshot for ${name}`); + return snap; + }; + + it("marks match-only required slices as autoStub", () => { + const slice = snapshot("TestVitals").slicing?.category?.slices?.VSCat; + expect(slice?.autoStub).toBeTrue(); + expect(slice?.effectiveRequired).toBeUndefined(); + expect(slice?.constrainedChoice).toBeUndefined(); + expect(slice?.resourceType).toBeUndefined(); + }); + + it("resolves constrained choices and keeps the autoStub/effectiveRequired asymmetry", () => { + const slice = snapshot("TestVitals").slicing?.component?.slices?.Sys; + // The slice narrows component.value[x] to valueQuantity. + expect(slice?.constrainedChoice?.choiceBase).toBe("value"); + expect(slice?.constrainedChoice?.variant).toBe("valueQuantity"); + expect(String(slice?.constrainedChoice?.variantType.name)).toBe("Quantity"); + // "value" is a choice-declaration base name, never a real JSON key: + // it is dropped from effectiveRequired (validation must not check it) … + expect(slice?.effectiveRequired).toBeUndefined(); + // … but it still blocks auto-stubbing — the slice needs user data + // (a valueQuantity), so a match-only stub would be invalid. + expect(slice?.autoStub).toBeUndefined(); + }); + + it("extracts the resource type for type-discriminated slices", () => { + const slice = snapshot("TestBundle").slicing?.entry?.slices?.Pat; + expect(slice?.resourceType).toBe("Patient"); + // Type-discriminated slices are never auto-stubbed: the stub would + // only set resourceType, the user must provide the typed resource. + expect(slice?.autoStub).toBeUndefined(); + }); +}); From fa5e34e3fee59bf3e61093679fe1bcb26d9328a2 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 18:17:45 +0400 Subject: [PATCH 6/9] feat(typeschema): model slice match as a discriminated SliceMatch union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldSlice.match becomes { kind: "value" | "type", value } — the runtime discriminator values keep their shape under 'value', while 'kind' states whether they are fixed/pattern discriminator values or a resource-type discriminator. Empty matches are no longer stored, so consumers test presence instead of key-counting. Generated output is unchanged. --- src/api/writer-generator/python/profile-slices.ts | 4 ++-- .../writer-generator/python/profile-validation.ts | 4 ++-- .../writer-generator/typescript/profile-slices.ts | 6 +++--- .../typescript/profile-validation.ts | 4 ++-- src/typeschema/core/field-builder.ts | 10 +++++++--- src/typeschema/types.ts | 12 +++++++++++- src/typeschema/utils.ts | 4 ++-- .../__snapshots__/introspection.test.ts.snap | 15 +++++++++------ 8 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/api/writer-generator/python/profile-slices.ts b/src/api/writer-generator/python/profile-slices.ts index cf348984..207992ef 100644 --- a/src/api/writer-generator/python/profile-slices.ts +++ b/src/api/writer-generator/python/profile-slices.ts @@ -97,7 +97,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot const baseSchema = tsIndex.resolveType(field.type); const typeDiscriminated = isTypeDiscriminated(fieldSlicing); return Object.entries(fieldSlicing.slices) - .filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0) + .filter(([_, slice]) => slice.match !== undefined) .map(([sliceName, slice]) => { const cc = slice.constrainedChoice; // Skip flattening for primitive types — can't wrap/unwrap under a variant key. @@ -105,7 +105,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot return { fieldName, sliceName, - match: normalizeMatchForPython(tsIndex, slice.match ?? {}, baseSchema), + match: normalizeMatchForPython(tsIndex, slice.match?.value ?? {}, baseSchema), required: slice.effectiveRequired ?? [], array: Boolean(field.array), max: slice.max ?? 0, diff --git a/src/api/writer-generator/python/profile-validation.ts b/src/api/writer-generator/python/profile-validation.ts index 36af6b50..5ff24961 100644 --- a/src/api/writer-generator/python/profile-validation.ts +++ b/src/api/writer-generator/python/profile-validation.ts @@ -142,8 +142,8 @@ const collectSliceValidation = ( ): void => { if (!fieldSlicing.slices) return; for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { - const match = slice.match ?? {}; - if (Object.keys(match).length === 0) continue; + const match = slice.match?.value; + if (!match) continue; if (slice.min !== undefined || slice.max !== undefined) { const min = slice.min ?? 0; const max = slice.max ?? 0; diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index 2e303441..758f825c 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -29,7 +29,7 @@ export const collectTypesFromSlices = ( const field = snapshot.fields[fieldName]; if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) continue; for (const slice of Object.values(fieldSlicing.slices)) { - if (Object.keys(slice.match ?? {}).length > 0) { + if (slice.match !== undefined) { addType(field.type); if (slice.constrainedChoice) addType(slice.constrainedChoice.variantType); // For type discriminator slices, also import the matched resource type @@ -89,7 +89,7 @@ export const collectSliceDefs = (_tsIndex: TypeSchemaIndex, snapshot: SnapshotPr const baseType = tsTypeFromIdentifier(field.type); const isTypeDisc = isTypeDiscriminated(fieldSlicing); return Object.entries(fieldSlicing.slices) - .filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0) + .filter(([_, slice]) => slice.match !== undefined) .map(([sliceName, slice]) => { const cc = slice.constrainedChoice; // Skip flattening for primitive types — can't intersect object with boolean/string/etc. @@ -101,7 +101,7 @@ export const collectSliceDefs = (_tsIndex: TypeSchemaIndex, snapshot: SnapshotPr typedBaseType, sliceName, baseName: slice.nameCandidates.recommended, - match: slice.match ?? {}, + match: slice.match?.value ?? {}, required: slice.effectiveRequired ?? [], excluded: slice.excluded ?? [], array: Boolean(field.array), diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index 68859bbb..b10090e5 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -51,8 +51,8 @@ export const collectRegularFieldValidation = ( if (fieldSlicing?.slices) { for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) { - const match = slice.match ?? {}; - if (Object.keys(match).length === 0) continue; + const match = slice.match?.value; + if (!match) continue; if (slice.min !== undefined || slice.max !== undefined) { const min = slice.min ?? 0; const max = slice.max ?? 0; diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index d0182e41..c3b91736 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -20,6 +20,7 @@ import type { ProfileIdentifier, RegularField, RichFHIRSchema, + SliceMatch, TypeIdentifier, ValueConstraint, } from "../types"; @@ -294,16 +295,19 @@ export const buildSlicing = (fieldName: string, element: FHIRSchemaElement): Fie const slicing = element.slicing; if (!slicing) return undefined; + const matchKind: SliceMatch["kind"] = slicing.discriminator?.some((d) => d.type === "type") ? "type" : "value"; const slices: Record = {}; for (const [name, slice] of Object.entries(slicing.slices ?? {})) { if (!slice) continue; const { required, excluded, elements } = slice.schema ? extractSliceFieldNames(slice.schema) : {}; + const matchValue = isEmptyMatch(slice.match) + ? computeMatchFromSchema(slicing.discriminator ?? [], slice.schema) + : (slice.match as Record | undefined); slices[name] = { min: slice.min, max: slice.max, - match: isEmptyMatch(slice.match) - ? computeMatchFromSchema(slicing.discriminator ?? [], slice.schema) - : (slice.match as Record | undefined), + match: + matchValue && Object.keys(matchValue).length > 0 ? { kind: matchKind, value: matchValue } : undefined, required, excluded, elements, diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 0b684841..5fbc41f5 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -325,10 +325,20 @@ export type NameCandidates = { recommended: string; }; +/** How a slice is recognized in instance data. `value` holds the discriminator + * values keyed by element name and is used verbatim for runtime matching; + * `kind` says where those values come from — fixed/pattern discriminator + * values (`"value"`) or a resource-type discriminator (`"type"`, e.g. + * Bundle.entry sliced by entry.resource resourceType). */ +export type SliceMatch = { + kind: "value" | "type"; + value: Record; +}; + export interface FieldSlice { min?: number; max?: number; - match?: Record; + match?: SliceMatch; required?: string[]; excluded?: string[]; elements?: string[]; diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index f6b7b0f2..2b5ee900 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -739,7 +739,7 @@ export const mkTypeSchemaIndex = ( /** Compute the derived facts for one slice; returns a copy — the source * profile schemas stay untouched. */ const enrichSlice = (slice: FieldSlice, ctx: SliceEnrichmentContext): FieldSlice => { - const matchKeys = new Set(Object.keys(slice.match ?? {})); + const matchKeys = new Set(Object.keys(slice.match?.value ?? {})); const required = slice.required ?? []; const effectiveRequired = required.filter((n) => !matchKeys.has(n) && !ctx.choiceBaseNames.has(n)); // Stub eligibility keeps choice-base names: a slice requiring its @@ -749,7 +749,7 @@ export const mkTypeSchemaIndex = ( !ctx.typeDiscriminated && (slice.min ?? 0) >= 1 && matchKeys.size > 0 && requiredBeyondMatch.length === 0; const cc = ctx.fieldType && slice.elements ? constrainedChoice(ctx.pkgName, ctx.fieldType, slice.elements) : undefined; - const resourceType = ctx.typeDiscriminated ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined; + const resourceType = ctx.typeDiscriminated ? extractResourceTypeFromMatch(slice.match?.value ?? {}) : undefined; return { ...slice, ...(effectiveRequired.length > 0 ? { effectiveRequired } : {}), diff --git a/test/api/write-generator/__snapshots__/introspection.test.ts.snap b/test/api/write-generator/__snapshots__/introspection.test.ts.snap index db0dc027..4c7cff1b 100644 --- a/test/api/write-generator/__snapshots__/introspection.test.ts.snap +++ b/test/api/write-generator/__snapshots__/introspection.test.ts.snap @@ -3143,12 +3143,15 @@ exports[`IntrospectionWriter - flat profile output Check bodyweight flat profile "min": 1, "max": 1, "match": { - "coding": [ - { - "code": "vital-signs", - "system": "http://terminology.hl7.org/CodeSystem/observation-category" - } - ] + "kind": "value", + "value": { + "coding": [ + { + "code": "vital-signs", + "system": "http://terminology.hl7.org/CodeSystem/observation-category" + } + ] + } }, "required": [ "coding" From 78ffce75599de603e603fded633f5faecaf1ad19 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 18:21:08 +0400 Subject: [PATCH 7/9] feat(typeschema): merge inherited slices when a profile re-slices a field flatProfile previously replaced the whole inherited FieldSlicing when a child profile restated slicing on the same field. Now the child refines it: its header and same-name slices win, inherited slices survive. --- src/typeschema/utils.ts | 19 ++++- test/unit/typeschema/slice-enrichment.test.ts | 77 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 2b5ee900..1583848c 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -637,8 +637,23 @@ export const mkTypeSchemaIndex = ( 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); + // Slice-level merge: a re-slicing profile refines the inherited slice + // set — its header and same-name slices win, inherited slices survive. + if (schema.slicing) { + for (const [fieldName, fieldSlicing] of Object.entries(schema.slicing)) { + const base = mergedSlicing[fieldName]; + if (!base) { + mergedSlicing[fieldName] = fieldSlicing; + continue; + } + const slices = { ...base.slices, ...fieldSlicing.slices }; + mergedSlicing[fieldName] = { + ...base, + ...fieldSlicing, + ...(Object.keys(slices).length > 0 ? { slices } : {}), + }; + } + } if (!schema.fields) continue; for (const [fieldName, fieldConstraints] of Object.entries(schema.fields)) { diff --git a/test/unit/typeschema/slice-enrichment.test.ts b/test/unit/typeschema/slice-enrichment.test.ts index 93b9d8f1..d3bc0ece 100644 --- a/test/unit/typeschema/slice-enrichment.test.ts +++ b/test/unit/typeschema/slice-enrichment.test.ts @@ -123,3 +123,80 @@ describe("slice enrichment on profile snapshots", async () => { expect(slice?.autoStub).toBeUndefined(); }); }); + +describe("slicing merge across the profile chain", async () => { + const r4 = await mkR4Register(); + const logger = mkErrorLogger(); + + registerFs(r4, { + url: "http://example.org/StructureDefinition/SlicedParent", + name: "SlicedParent", + base: "http://hl7.org/fhir/StructureDefinition/Observation", + derivation: "constraint", + kind: "resource", + elements: { + category: { + type: "CodeableConcept", + array: true, + slicing: { + discriminator: [{ type: "pattern", path: "$this" }], + rules: "open", + slices: { + FromParent: { min: 1, max: 1, match: { coding: [{ code: "parent" }] } }, + }, + }, + }, + }, + }); + + registerFs(r4, { + url: "http://example.org/StructureDefinition/SlicedChild", + name: "SlicedChild", + base: "http://example.org/StructureDefinition/SlicedParent", + derivation: "constraint", + kind: "resource", + elements: { + category: { + type: "CodeableConcept", + array: true, + slicing: { + discriminator: [{ type: "pattern", path: "$this" }], + rules: "open", + slices: { + FromChild: { max: 1, match: { coding: [{ code: "child" }] } }, + FromParent: { min: 0, max: 1, match: { coding: [{ code: "parent-refined" }] } }, + }, + }, + }, + }, + }); + + const schemas: TypeSchema[] = []; + for (const [pkg, url] of [ + [myPkg, "http://example.org/StructureDefinition/SlicedParent"], + [myPkg, "http://example.org/StructureDefinition/SlicedChild"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/Observation"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/DomainResource"], + [r4Package, "http://hl7.org/fhir/StructureDefinition/Resource"], + ] as const) { + schemas.push(...(await resolveTs(r4, pkg, url as CanonicalUrl, logger))); + } + const tsIndex = mkTypeSchemaIndex(schemas, { register: r4, logger }); + + const childSnapshot = () => { + const snap = tsIndex.collectSnapshotProfiles().find((s) => s.identifier.name === "SlicedChild"); + if (!snap) throw new Error("No snapshot for SlicedChild"); + return snap; + }; + + it("re-slicing keeps inherited slices and adds new ones", () => { + const slices = childSnapshot().slicing?.category?.slices ?? {}; + expect(Object.keys(slices).sort()).toEqual(["FromChild", "FromParent"]); + }); + + it("same-name slices from the leaf win", () => { + const fromParent = childSnapshot().slicing?.category?.slices?.FromParent; + expect(fromParent?.match?.value).toEqual({ coding: [{ code: "parent-refined" }] }); + expect(fromParent?.min).toBe(0); + }); +}); From 4f0368fa9a36f8b4bf7b8bee9e40ed259fa9eb3c Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 24 Jul 2026 18:34:23 +0400 Subject: [PATCH 8/9] feat(typeschema): fully populate extension-style slice entries; make extension-slice ownership explicit Extension-style slices carry their discriminator value as schema.url and their cardinality on the slice schema (fhirschema convention); buildSlicing now recovers both, so the slicing map has no degenerate entries. Writers replace the accidental skip (missing match) with an explicit rule: on resource-based profiles, extension/modifierExtension slices are owned by the ProfileExtension mechanism; on Extension-based profiles the slice machinery keeps generating the sub-extension slice API as before. Generated output is unchanged. --- .../python/profile-factory.ts | 6 ++++- .../writer-generator/python/profile-slices.ts | 2 ++ .../python/profile-validation.ts | 5 +++- .../typescript/profile-slices.ts | 3 +++ .../typescript/profile-validation.ts | 5 +++- .../writer-generator/typescript/profile.ts | 7 ++++-- src/api/writer-generator/utils.ts | 5 ++++ src/typeschema/core/field-builder.ts | 25 +++++++++++++++---- .../kbv-condition-diagnosis.test.ts.snap | 14 +++++++++++ 9 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/api/writer-generator/python/profile-factory.ts b/src/api/writer-generator/python/profile-factory.ts index 6cbd9609..92218470 100644 --- a/src/api/writer-generator/python/profile-factory.ts +++ b/src/api/writer-generator/python/profile-factory.ts @@ -1,3 +1,4 @@ +import { isExtensionOwnedField } from "@root/api/writer-generator/utils"; import { type ChoiceFieldInstance, type Field, @@ -174,7 +175,10 @@ export const collectProfileFactoryInfo = ( } if (isNotChoiceDeclarationField(field)) { - const sliceNames = collectRequiredSliceNames(field, flatProfile.slicing?.[name]); + const sliceNames = + isExtensionOwnedField(name) && flatProfile.base.name !== "Extension" + ? undefined + : collectRequiredSliceNames(field, flatProfile.slicing?.[name]); // Extension profiles populate `extension` via sub-extension slice // setters — keep it optional in create() even when no slice is // auto-stubbable (mirrors the optional `extension` in the TS Raw type). diff --git a/src/api/writer-generator/python/profile-slices.ts b/src/api/writer-generator/python/profile-slices.ts index 207992ef..ccd16742 100644 --- a/src/api/writer-generator/python/profile-slices.ts +++ b/src/api/writer-generator/python/profile-slices.ts @@ -1,3 +1,4 @@ +import { isExtensionOwnedField } from "@root/api/writer-generator/utils"; import { type ConstrainedChoiceInfo, type FieldSlicing, @@ -92,6 +93,7 @@ export const normalizeMatchForPython = ( export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: SnapshotProfileTypeSchema): SliceDef[] => { return Object.entries(flatProfile.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => { + if (isExtensionOwnedField(fieldName) && flatProfile.base.name !== "Extension") return []; const field = flatProfile.fields[fieldName]; if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) return []; const baseSchema = tsIndex.resolveType(field.type); diff --git a/src/api/writer-generator/python/profile-validation.ts b/src/api/writer-generator/python/profile-validation.ts index 5ff24961..5719210a 100644 --- a/src/api/writer-generator/python/profile-validation.ts +++ b/src/api/writer-generator/python/profile-validation.ts @@ -1,3 +1,4 @@ +import { isExtensionOwnedField } from "@root/api/writer-generator/utils"; import { type ChoiceFieldInstance, type FieldSlicing, @@ -61,7 +62,9 @@ export const collectValidateBody = ( } collectRegularFieldValidation( field, - flatProfile.slicing?.[name], + isExtensionOwnedField(name) && flatProfile.base.name !== "Extension" + ? undefined + : flatProfile.slicing?.[name], pyName, helpers, errorLines, diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index 758f825c..50ca239e 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -1,3 +1,4 @@ +import { isExtensionOwnedField } from "@root/api/writer-generator/utils"; import { type ConstrainedChoiceInfo, type FieldSlicing, @@ -26,6 +27,7 @@ export const collectTypesFromSlices = ( addType: (typeId: TypeIdentifier) => void, ) => { for (const [fieldName, fieldSlicing] of Object.entries(snapshot.slicing ?? {})) { + if (isExtensionOwnedField(fieldName) && snapshot.base.name !== "Extension") continue; const field = snapshot.fields[fieldName]; if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) continue; for (const slice of Object.values(fieldSlicing.slices)) { @@ -84,6 +86,7 @@ export type SliceDef = { export const collectSliceDefs = (_tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema): SliceDef[] => Object.entries(snapshot.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => { + if (isExtensionOwnedField(fieldName) && snapshot.base.name !== "Extension") return []; const field = snapshot.fields[fieldName]; if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) return []; const baseType = tsTypeFromIdentifier(field.type); diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index b10090e5..2ecb4267 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -1,3 +1,4 @@ +import { isExtensionOwnedField } from "@root/api/writer-generator/utils"; import { type ChoiceFieldInstance, type FieldSlicing, @@ -109,7 +110,9 @@ export const generateValidateMethod = ( tsIndex.findLastSpecializationByIdentifier, canonicalUrlExpr, tsIndex, - snapshot.slicing?.[name], + isExtensionOwnedField(name) && snapshot.base.name !== "Extension" + ? undefined + : snapshot.slicing?.[name], ); } diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index d3d49dba..ab66ded8 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -1,4 +1,4 @@ -import { pascalCase, uppercaseFirstLetter } from "@root/api/writer-generator/utils"; +import { isExtensionOwnedField, pascalCase, uppercaseFirstLetter } from "@root/api/writer-generator/utils"; import { type CanonicalUrl, isChoiceDeclarationField, @@ -166,7 +166,10 @@ export const collectProfileFactoryInfo = ( } if (isNotChoiceDeclarationField(field)) { - const sliceNames = collectRequiredSliceNames(field, snapshot.slicing?.[name]); + const sliceNames = + isExtensionOwnedField(name) && snapshot.base.name !== "Extension" + ? undefined + : collectRequiredSliceNames(field, snapshot.slicing?.[name]); if (sliceNames) { if (field.type) { const tsType = fieldTsType(field, resolveRef, isFamilyType); diff --git a/src/api/writer-generator/utils.ts b/src/api/writer-generator/utils.ts index 67b7e46c..23174bb6 100644 --- a/src/api/writer-generator/utils.ts +++ b/src/api/writer-generator/utils.ts @@ -63,3 +63,8 @@ export function deepEqual(obj1: T, obj2: T): boolean { return keys1.every((key) => keys2.includes(key) && deepEqual(obj1[key], obj2[key])); } + +/** Slices on `extension`/`modifierExtension` are owned by the ProfileExtension + * mechanism — the generic slice machinery must not double-generate for them. */ +export const isExtensionOwnedField = (fieldName: string): boolean => + fieldName === "extension" || fieldName === "modifierExtension"; diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index c3b91736..84d4804c 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -291,21 +291,36 @@ const computeMatchFromSchema = ( return result; }; +/** Extension-style slices carry their discriminator value as `schema.url` + * (fhirschema convention) rather than under `elements` — recover the match + * when the slicing discriminates on `url`. */ +const extensionUrlMatch = ( + discriminators: FHIRSchemaDiscriminator[], + schema: FHIRSchemaElement | undefined, +): Record | undefined => { + if (!schema || typeof schema.url !== "string") return undefined; + const byUrl = discriminators.some((d) => (d.type === "value" || d.type === "pattern") && d.path === "url"); + return byUrl ? { url: schema.url } : undefined; +}; + export const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => { const slicing = element.slicing; if (!slicing) return undefined; - const matchKind: SliceMatch["kind"] = slicing.discriminator?.some((d) => d.type === "type") ? "type" : "value"; + const discriminators = slicing.discriminator ?? []; + const matchKind: SliceMatch["kind"] = discriminators.some((d) => d.type === "type") ? "type" : "value"; const slices: Record = {}; for (const [name, slice] of Object.entries(slicing.slices ?? {})) { if (!slice) continue; const { required, excluded, elements } = slice.schema ? extractSliceFieldNames(slice.schema) : {}; - const matchValue = isEmptyMatch(slice.match) - ? computeMatchFromSchema(slicing.discriminator ?? [], slice.schema) + const computed = isEmptyMatch(slice.match) + ? computeMatchFromSchema(discriminators, slice.schema) : (slice.match as Record | undefined); + const matchValue = isEmptyMatch(computed) ? extensionUrlMatch(discriminators, slice.schema) : computed; slices[name] = { - min: slice.min, - max: slice.max, + // Extension-style slices keep their cardinality on the slice schema. + min: slice.min ?? slice.schema?.min, + max: slice.max ?? slice.schema?.max, match: matchValue && Object.keys(matchValue).length > 0 ? { kind: matchKind, value: matchValue } : undefined, required, 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 3c337369..b97676bf 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 @@ -2236,7 +2236,14 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "rules": "open", "slices": { "Feststellungsdatum": { + "min": 0, "max": 1, + "match": { + "kind": "value", + "value": { + "url": "http://hl7.org/fhir/StructureDefinition/condition-assertedDate" + } + }, "nameCandidates": { "candidates": [ "Feststellungsdatum", @@ -3051,7 +3058,14 @@ exports[`KBV Condition Diagnosis generation (kbv.basis@1.9.0) TypeSchema: KBV_PR "rules": "open", "slices": { "Feststellungsdatum": { + "min": 0, "max": 1, + "match": { + "kind": "value", + "value": { + "url": "http://hl7.org/fhir/StructureDefinition/condition-assertedDate" + } + }, "nameCandidates": { "candidates": [ "Feststellungsdatum", From 4f92d18992e60a50f9baa89b667d12eac35b7d26 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Mon, 27 Jul 2026 10:24:39 +0400 Subject: [PATCH 9/9] docs: document slicing model and FieldReference in the TypeSchema JSON schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds field-slicing/field-slice/slice-match/slice-discriminator definitions — including the snapshot-time derived facts (effectiveRequired, constrainedChoice, autoStub, resourceType) — attaches the schema-level 'slicing' map next to 'fields', and updates 'reference' to the { resource, profiles } shape. --- docs/type-schema.schema.json | 443 ++++++++++++++++++++++++++++++----- 1 file changed, 381 insertions(+), 62 deletions(-) diff --git a/docs/type-schema.schema.json b/docs/type-schema.schema.json index 4dd7433b..7d3e0685 100644 --- a/docs/type-schema.schema.json +++ b/docs/type-schema.schema.json @@ -9,13 +9,27 @@ "type-schema-identifier": { "type": "object", "oneOf": [ - { "$ref": "#/definitions/with-primitive-type-kind" }, - { "$ref": "#/definitions/with-valueset-kind" }, - { "$ref": "#/definitions/with-complex-type-kind" }, - { "$ref": "#/definitions/with-resource-kind" }, - { "$ref": "#/definitions/with-nested-kind" }, - { "$ref": "#/definitions/with-logical-kind" }, - { "$ref": "#/definitions/with-binding-kind" } + { + "$ref": "#/definitions/with-primitive-type-kind" + }, + { + "$ref": "#/definitions/with-valueset-kind" + }, + { + "$ref": "#/definitions/with-complex-type-kind" + }, + { + "$ref": "#/definitions/with-resource-kind" + }, + { + "$ref": "#/definitions/with-nested-kind" + }, + { + "$ref": "#/definitions/with-logical-kind" + }, + { + "$ref": "#/definitions/with-binding-kind" + } ], "properties": { "kind": { @@ -39,56 +53,76 @@ "description": "A canonical URL identifying this resource uniquely in the FHIR ecosystem" } }, - "required": ["kind", "package", "version", "name", "url"], + "required": [ + "kind", + "package", + "version", + "name", + "url" + ], "additionalProperties": false }, "with-primitive-type-kind": { "type": "object", "description": "Constraint helper to ensure the kind is primitive-type", "properties": { - "kind": { "const": "primitive-type" } + "kind": { + "const": "primitive-type" + } } }, "with-valueset-kind": { "type": "object", "description": "Constraint helper to ensure the kind is value-set", "properties": { - "kind": { "const": "value-set" } + "kind": { + "const": "value-set" + } } }, "with-binding-kind": { "type": "object", "description": "Constraint helper to ensure the kind is value-set", "properties": { - "kind": { "const": "binding" } + "kind": { + "const": "binding" + } } }, "with-complex-type-kind": { "type": "object", "description": "Constraint helper to ensure the kind is complex-type", "properties": { - "kind": { "const": "complex-type" } + "kind": { + "const": "complex-type" + } } }, "with-resource-kind": { "type": "object", "description": "Constraint helper to ensure the kind is resource", "properties": { - "kind": { "const": "resource" } + "kind": { + "const": "resource" + } } }, "with-nested-kind": { "type": "object", "description": "Constraint helper to ensure the kind is nested", "properties": { - "kind": { "const": "nested" } + "kind": { + "const": "nested" + } } }, "with-logical-kind": { "type": "object", "description": "Constraint helper to ensure the kind is logical", "properties": { - "kind": { "const": "logical" } + "kind": { + "const": "logical" + } } }, "field.regular": { @@ -101,11 +135,28 @@ "description": "The data type of this field" }, "reference": { - "type": "array", - "description": "Reference to other types that this field can point to", - "items": { - "$ref": "#/definitions/type-schema-identifier" - } + "type": "object", + "description": "Reference targets, split into two independent facts: base resource types a reference literal may point at, and profile conformance expectations from targetProfile", + "properties": { + "resource": { + "type": "array", + "description": "Base resource types a reference value may point at; profile targets are resolved to their base specialization and deduplicated", + "items": { + "$ref": "#/definitions/type-schema-identifier" + } + }, + "profiles": { + "type": "array", + "description": "Profile conformance expectations from targetProfile — reference values in instances always use base resource types", + "items": { + "$ref": "#/definitions/type-schema-identifier" + } + } + }, + "required": [ + "resource" + ], + "additionalProperties": false }, "required": { "type": "boolean", @@ -124,13 +175,19 @@ }, "binding": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-binding-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-binding-kind" + } ] }, "enum": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "For code fields, the set of valid values when bound to a required value set" }, "min": { @@ -142,10 +199,11 @@ "description": "Maximum limit of items for an array" } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false }, - "field.polymorphic-declaration": { "title": "Polymorphic value[x] field declaration", "description": "The base declaration for a FHIR choice type (e.g., value[x])", @@ -153,7 +211,9 @@ "properties": { "choices": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "The names of all concrete type options for this choice field" }, "required": { @@ -180,10 +240,11 @@ "description": "Maximum limit of items for an array" } }, - "required": ["choices"], + "required": [ + "choices" + ], "additionalProperties": false }, - "field.polymorphic-instance": { "title": "Polymorphic value[x] field instance", "description": "A specific type option for a FHIR choice type (e.g., valueString, valueInteger)", @@ -221,14 +282,20 @@ }, "binding": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-binding-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-binding-kind" + } ], "description": "For coded choices, information about the value set binding" }, "enum": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "For code fields, the set of valid values when bound to a required value set" }, "min": { @@ -240,7 +307,10 @@ "description": "Maximum limit of items for an array" } }, - "required": ["type", "choiceOf"], + "required": [ + "type", + "choiceOf" + ], "additionalProperties": false }, "dependencies": { @@ -249,9 +319,193 @@ "items": { "$ref": "#/definitions/type-schema-identifier" } + }, + "slice-discriminator": { + "title": "Slice discriminator", + "description": "How slice membership is decided (ElementDefinition.slicing.discriminator)", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "value", + "exists", + "pattern", + "type", + "profile", + "position" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "slice-match": { + "title": "Slice match", + "description": "How a slice is recognized in instance data. 'value' holds the discriminator values keyed by element name and is used verbatim for runtime matching; 'kind' says whether they come from fixed/pattern discriminator values ('value') or a resource-type discriminator ('type')", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "value", + "type" + ] + }, + "value": { + "type": "object" + } + }, + "required": [ + "kind", + "value" + ], + "additionalProperties": false + }, + "field-slice": { + "title": "Field slice", + "description": "One slice of a sliced element. Besides the source facts, profile snapshots carry derived facts computed at snapshot build so generators share one implementation", + "type": "object", + "properties": { + "min": { + "type": "number", + "description": "Minimum number of elements matching this slice" + }, + "max": { + "type": "number", + "description": "Maximum number of elements matching this slice (0 = unbounded)" + }, + "match": { + "$ref": "#/definitions/slice-match" + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required sub-element names inside a matched element" + }, + "excluded": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Prohibited sub-element names inside a matched element" + }, + "elements": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sub-element names the slice constrains" + }, + "nameCandidates": { + "type": "object", + "description": "Collision-free name candidates for generated accessors", + "properties": { + "candidates": { + "type": "array", + "items": { + "type": "string" + } + }, + "recommended": { + "type": "string" + } + }, + "required": [ + "candidates", + "recommended" + ], + "additionalProperties": false + }, + "effectiveRequired": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Derived (profile snapshots): 'required' minus match keys and choice-declaration base names — the sub-elements validators must check" + }, + "constrainedChoice": { + "type": "object", + "description": "Derived (profile snapshots): the single choice variant this slice constrains (e.g. BP component value[x] narrowed to valueQuantity)", + "properties": { + "choiceBase": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "variantType": { + "$ref": "#/definitions/type-schema-identifier" + }, + "allChoiceNames": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "choiceBase", + "variant", + "variantType", + "allChoiceNames" + ], + "additionalProperties": false + }, + "autoStub": { + "type": "boolean", + "description": "Derived (profile snapshots): the slice can be auto-populated with just the discriminator match values" + }, + "resourceType": { + "type": "string", + "description": "Derived (profile snapshots): matched resource type for type-discriminated slices (e.g. Bundle entry -> Patient)" + } + }, + "required": [ + "nameCandidates" + ], + "additionalProperties": false + }, + "field-slicing": { + "title": "Field slicing", + "description": "Slicing definition for one sliced element, keyed by field name in the schema-level 'slicing' map — kept apart from 'fields': fields describe the data shape, slicing is an independent constraint layer", + "type": "object", + "properties": { + "discriminator": { + "type": "array", + "items": { + "$ref": "#/definitions/slice-discriminator" + } + }, + "rules": { + "type": "string", + "enum": [ + "open", + "closed", + "openAtEnd" + ] + }, + "ordered": { + "type": "boolean" + }, + "slices": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/field-slice" + } + } + }, + "additionalProperties": false } }, - "oneOf": [ { "title": "Type Schema for Primitive Type", @@ -260,8 +514,12 @@ "properties": { "identifier": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-primitive-type-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-primitive-type-kind" + } ], "description": "The unique identifier for this primitive type" }, @@ -278,10 +536,12 @@ "description": "Other types that this primitive type depends on" } }, - "required": ["identifier", "base"], + "required": [ + "identifier", + "base" + ], "additionalProperties": false }, - { "title": "Type Schema for Resource, Complex Type, Logical", "description": "Schema for FHIR resources, complex types, and logical types", @@ -289,12 +549,20 @@ "properties": { "identifier": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, + { + "$ref": "#/definitions/type-schema-identifier" + }, { "oneOf": [ - { "$ref": "#/definitions/with-resource-kind" }, - { "$ref": "#/definitions/with-complex-type-kind" }, - { "$ref": "#/definitions/with-logical-kind" } + { + "$ref": "#/definitions/with-resource-kind" + }, + { + "$ref": "#/definitions/with-complex-type-kind" + }, + { + "$ref": "#/definitions/with-logical-kind" + } ] } ], @@ -313,12 +581,25 @@ "description": "The fields contained in this resource or type", "additionalProperties": { "anyOf": [ - { "$ref": "#/definitions/field.regular" }, - { "$ref": "#/definitions/field.polymorphic-declaration" }, - { "$ref": "#/definitions/field.polymorphic-instance" } + { + "$ref": "#/definitions/field.regular" + }, + { + "$ref": "#/definitions/field.polymorphic-declaration" + }, + { + "$ref": "#/definitions/field.polymorphic-instance" + } ] } }, + "slicing": { + "type": "object", + "description": "Slicing definitions keyed by field name, kept apart from 'fields'", + "additionalProperties": { + "$ref": "#/definitions/field-slicing" + } + }, "nested": { "type": "array", "description": "BackboneElement types nested within this resource or type", @@ -327,8 +608,12 @@ "properties": { "identifier": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-nested-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-nested-kind" + } ], "description": "The unique identifier for this nested type" }, @@ -341,14 +626,30 @@ "description": "The fields contained in this nested type", "additionalProperties": { "anyOf": [ - { "$ref": "#/definitions/field.regular" }, - { "$ref": "#/definitions/field.polymorphic-declaration" }, - { "$ref": "#/definitions/field.polymorphic-instance" } + { + "$ref": "#/definitions/field.regular" + }, + { + "$ref": "#/definitions/field.polymorphic-declaration" + }, + { + "$ref": "#/definitions/field.polymorphic-instance" + } ] } + }, + "slicing": { + "type": "object", + "description": "Slicing definitions keyed by field name, kept apart from 'fields'", + "additionalProperties": { + "$ref": "#/definitions/field-slicing" + } } }, - "required": ["identifier", "base"], + "required": [ + "identifier", + "base" + ], "additionalProperties": false } }, @@ -360,10 +661,11 @@ } } }, - "required": ["identifier"], + "required": [ + "identifier" + ], "additionalProperties": false }, - { "title": "Type Schema for ValueSet", "description": "Schema for FHIR value sets that define terminology bindings", @@ -371,8 +673,12 @@ "properties": { "identifier": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-valueset-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-valueset-kind" + } ], "description": "The unique identifier for this value set" }, @@ -399,7 +705,9 @@ "description": "The code system URL that defines this code" } }, - "required": ["code"], + "required": [ + "code" + ], "additionalProperties": false } }, @@ -408,18 +716,23 @@ "description": "Complex value set composition rules when the value set is defined as a composition of other value sets" } }, - "required": ["identifier"], + "required": [ + "identifier" + ], "additionalProperties": false }, - { "title": "Type Schema for Binding", "type": "object", "properties": { "identifier": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-binding-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-binding-kind" + } ], "description": "The unique identifier for this value set" }, @@ -443,8 +756,12 @@ }, "valueset": { "allOf": [ - { "$ref": "#/definitions/type-schema-identifier" }, - { "$ref": "#/definitions/with-valueset-kind" } + { + "$ref": "#/definitions/type-schema-identifier" + }, + { + "$ref": "#/definitions/with-valueset-kind" + } ] }, "dependencies": { @@ -455,7 +772,9 @@ } } }, - "required": ["identifier"], + "required": [ + "identifier" + ], "additionalProperties": false } ]