Skip to content

Commit f85a6f8

Browse files
committed
feat(typeschema): store slicing separately from fields
Slicing moves from RegularField/ChoiceFieldInstance into a schema-level 'slicing: Record<fieldName, FieldSlicing>' on specializations, profiles, snapshots and nested types. Fields describe the data shape; slicing is an independent constraint layer keyed by element name, so choice declarations, choice instances and regular fields are covered uniformly. flatProfile merges slicing maps leaf-wins per field (mirroring the field merge) and tree shaking prunes slicing entries for removed fields. Also redefine the slicing vocabulary locally instead of borrowing fhirschema types: SliceDiscriminator and inline rules union replace FS.FHIRSchemaDiscriminator and FS.SlicingRules, and the repeated 'discriminator.some(type === "type")' checks collapse into the isTypeDiscriminated predicate. Generated TS/Python output is byte-identical.
1 parent 7eb71a6 commit f85a6f8

14 files changed

Lines changed: 150 additions & 90 deletions

‎src/api/writer-generator/python/profile-factory.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export const collectProfileFactoryInfo = (
174174
}
175175

176176
if (isNotChoiceDeclarationField(field)) {
177-
const sliceNames = collectRequiredSliceNames(field);
177+
const sliceNames = collectRequiredSliceNames(field, flatProfile.slicing?.[name]);
178178
if (sliceNames) {
179179
if (field.type) {
180180
const pyType = fieldPyType(field, resolveRef, tsIndex);

‎src/api/writer-generator/python/profile-slices.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
type ConstrainedChoiceInfo,
3+
type FieldSlicing,
34
isChoiceDeclarationField,
45
isNotChoiceDeclarationField,
56
isPrimitiveIdentifier,
7+
isTypeDiscriminated,
68
type NameCandidates,
79
type RegularField,
810
type SnapshotProfileTypeSchema,
@@ -33,11 +35,14 @@ export type SliceDef = {
3335
nameCandidates: NameCandidates;
3436
};
3537

36-
export const collectRequiredSliceNames = (field: RegularField): string[] | undefined => {
37-
if (!field.array || !field.slicing?.slices) return undefined;
38+
export const collectRequiredSliceNames = (
39+
field: RegularField,
40+
fieldSlicing: FieldSlicing | undefined,
41+
): string[] | undefined => {
42+
if (!field.array || !fieldSlicing?.slices) return undefined;
3843
// Type-discriminated slices ("type" discriminator) require explicit typed setters — no stubs.
39-
if (field.slicing.discriminator?.some((d) => d.type === "type")) return undefined;
40-
const names = Object.entries(field.slicing.slices)
44+
if (isTypeDiscriminated(fieldSlicing)) return undefined;
45+
const names = Object.entries(fieldSlicing.slices)
4146
.filter(([_, s]) => s.min !== undefined && s.min >= 1 && s.match && Object.keys(s.match).length > 0)
4247
.map(([name]) => name);
4348
return names.length > 0 ? names : undefined;
@@ -104,16 +109,17 @@ const extractTypeDiscriminatorResource = (
104109

105110
export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: SnapshotProfileTypeSchema): SliceDef[] => {
106111
const pkgName = flatProfile.identifier.package;
107-
return Object.entries(flatProfile.fields).flatMap(([fieldName, field]) => {
108-
if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return [];
112+
return Object.entries(flatProfile.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => {
113+
const field = flatProfile.fields[fieldName];
114+
if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) return [];
109115
const choiceBaseNames = new Set<string>();
110116
const baseSchema = tsIndex.resolveType(field.type);
111117
if (baseSchema && "fields" in baseSchema && baseSchema.fields) {
112118
for (const [n, f] of Object.entries(baseSchema.fields)) {
113119
if (isChoiceDeclarationField(f)) choiceBaseNames.add(n);
114120
}
115121
}
116-
return Object.entries(field.slicing.slices)
122+
return Object.entries(fieldSlicing.slices)
117123
.filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0)
118124
.map(([sliceName, slice]) => {
119125
const matchFields = Object.keys(slice.match ?? {});
@@ -123,9 +129,9 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot
123129
const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined;
124130
// Skip flattening for primitive types — can't wrap/unwrap under a variant key.
125131
const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined;
126-
const isTypeDiscriminated = field.slicing?.discriminator?.some((d) => d.type === "type") ?? false;
132+
const typeDiscriminated = isTypeDiscriminated(fieldSlicing);
127133
const typeDiscriminatorResource = extractTypeDiscriminatorResource(
128-
isTypeDiscriminated,
134+
typeDiscriminated,
129135
slice.match as Record<string, unknown> | undefined,
130136
);
131137
return {
@@ -139,7 +145,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: Snapshot
139145
elementTypeName:
140146
field.type && !isPrimitiveIdentifier(field.type) ? pyTypeFromIdentifier(field.type) : undefined,
141147
elementTypeId: field.type && !isPrimitiveIdentifier(field.type) ? field.type : undefined,
142-
isTypeDiscriminated,
148+
isTypeDiscriminated: typeDiscriminated,
143149
typeDiscriminatorResource,
144150
nameCandidates: slice.nameCandidates,
145151
};

‎src/api/writer-generator/python/profile-validation.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type ChoiceFieldInstance,
3+
type FieldSlicing,
34
isChoiceDeclarationField,
45
isChoiceInstanceField,
56
isNotChoiceDeclarationField,
@@ -58,7 +59,16 @@ export const collectValidateBody = (
5859
}
5960
continue;
6061
}
61-
collectRegularFieldValidation(field, pyName, helpers, errorLines, warningLines, tsIndex, formatName);
62+
collectRegularFieldValidation(
63+
field,
64+
flatProfile.slicing?.[name],
65+
pyName,
66+
helpers,
67+
errorLines,
68+
warningLines,
69+
tsIndex,
70+
formatName,
71+
);
6272
}
6373
// Base-resource required fields the profile chain did not re-state.
6474
// Emitted here (not via the regular field loop) because they intentionally
@@ -75,6 +85,7 @@ export const collectValidateBody = (
7585

7686
const collectRegularFieldValidation = (
7787
field: RegularField | ChoiceFieldInstance,
88+
fieldSlicing: FieldSlicing | undefined,
7889
pyName: string,
7990
helpers: Set<string>,
8091
errorLines: string[],
@@ -116,22 +127,23 @@ const collectRegularFieldValidation = (
116127
const allowed = field.reference.resource.map((ref) => tsIndex.findLastSpecializationByIdentifier(ref).name);
117128
pushListValidation(errorLines, "errors", "validate_reference", [JSON.stringify(pyName)], allowed);
118129
}
119-
if (field.slicing?.slices) {
120-
collectSliceValidation(field, pyName, helpers, errorLines, tsIndex, formatName);
130+
if (fieldSlicing?.slices) {
131+
collectSliceValidation(field, fieldSlicing, pyName, helpers, errorLines, tsIndex, formatName);
121132
}
122133
}
123134
};
124135

125136
const collectSliceValidation = (
126137
field: RegularField | ChoiceFieldInstance,
138+
fieldSlicing: FieldSlicing,
127139
name: string,
128140
helpers: Set<string>,
129141
errorLines: string[],
130142
tsIndex: TypeSchemaIndex,
131143
formatName: (s: string) => string,
132144
): void => {
133-
if (!field.slicing?.slices) return;
134-
for (const [sliceName, slice] of Object.entries(field.slicing.slices)) {
145+
if (!fieldSlicing.slices) return;
146+
for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) {
135147
const match = slice.match ?? {};
136148
if (Object.keys(match).length === 0) continue;
137149
if (slice.min !== undefined || slice.max !== undefined) {

‎src/api/writer-generator/typescript/profile-extensions.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ export const valueFieldToTsType = (valueField: string): string => {
8282
*/
8383
export const collectSubExtensionSlices = (extProfile: SnapshotProfileTypeSchema): SubExtensionSliceInfo[] => {
8484
const extensionField = extProfile.fields.extension;
85-
if (!extensionField || isChoiceDeclarationField(extensionField) || !extensionField.slicing?.slices) return [];
85+
const extensionSlicing = extProfile.slicing?.extension;
86+
if (!extensionField || isChoiceDeclarationField(extensionField) || !extensionSlicing?.slices) return [];
8687
const result: SubExtensionSliceInfo[] = [];
87-
for (const [sliceName, slice] of Object.entries(extensionField.slicing.slices)) {
88+
for (const [sliceName, slice] of Object.entries(extensionSlicing.slices)) {
8889
const valueField = extractValueField(slice.elements);
8990
if (!valueField) continue;
9091
const tsType = valueFieldToTsType(valueField);

‎src/api/writer-generator/typescript/profile-slices.ts‎

Lines changed: 49 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
type ConstrainedChoiceInfo,
3+
type FieldSlicing,
34
isChoiceDeclarationField,
45
isNotChoiceDeclarationField,
56
isPrimitiveIdentifier,
7+
isTypeDiscriminated,
68
type RegularField,
79
type SnapshotProfileTypeSchema,
810
type TypeIdentifier,
@@ -49,10 +51,11 @@ export const collectTypesFromSlices = (
4951
addType: (typeId: TypeIdentifier) => void,
5052
) => {
5153
const pkgName = snapshot.identifier.package;
52-
for (const field of Object.values(snapshot.fields)) {
53-
if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) continue;
54-
const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false;
55-
for (const slice of Object.values(field.slicing.slices)) {
54+
for (const [fieldName, fieldSlicing] of Object.entries(snapshot.slicing ?? {})) {
55+
const field = snapshot.fields[fieldName];
56+
if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) continue;
57+
const isTypeDisc = isTypeDiscriminated(fieldSlicing);
58+
for (const slice of Object.values(fieldSlicing.slices)) {
5659
if (Object.keys(slice.match ?? {}).length > 0) {
5760
addType(field.type);
5861
const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined;
@@ -79,11 +82,13 @@ export const collectTypesFromSlices = (
7982
* - The field uses a type discriminator (e.g. Bundle entry.resource) — the stub only sets
8083
* resourceType, the user must provide the actual typed resource
8184
*/
82-
export const collectRequiredSliceNames = (field: RegularField): string[] | undefined => {
83-
if (!field.array || !field.slicing?.slices) return undefined;
84-
const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false;
85-
if (isTypeDisc) return undefined;
86-
const names = Object.entries(field.slicing.slices)
85+
export const collectRequiredSliceNames = (
86+
field: RegularField,
87+
fieldSlicing: FieldSlicing | undefined,
88+
): string[] | undefined => {
89+
if (!field.array || !fieldSlicing?.slices) return undefined;
90+
if (isTypeDiscriminated(fieldSlicing)) return undefined;
91+
const names = Object.entries(fieldSlicing.slices)
8792
.filter(([_, s]) => {
8893
if (s.min === undefined || s.min < 1 || !s.match || Object.keys(s.match).length === 0) return false;
8994
const matchKeys = new Set(Object.keys(s.match));
@@ -115,44 +120,41 @@ export type SliceDef = {
115120
};
116121

117122
export const collectSliceDefs = (tsIndex: TypeSchemaIndex, snapshot: SnapshotProfileTypeSchema): SliceDef[] =>
118-
Object.entries(snapshot.fields)
119-
.filter(([_, field]) => isNotChoiceDeclarationField(field) && field.slicing?.slices)
120-
.flatMap(([fieldName, field]) => {
121-
if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return [];
122-
const baseType = tsTypeFromIdentifier(field.type);
123-
const pkgName = snapshot.identifier.package;
124-
const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type);
125-
const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false;
126-
return Object.entries(field.slicing.slices)
127-
.filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0)
128-
.map(([sliceName, slice]) => {
129-
const matchFields = Object.keys(slice.match ?? {});
130-
const required = (slice.required ?? []).filter(
131-
(name) => !matchFields.includes(name) && !choiceBaseNames.has(name),
132-
);
133-
const cc = slice.elements
134-
? tsIndex.constrainedChoice(pkgName, field.type, slice.elements)
135-
: undefined;
136-
// Skip flattening for primitive types — can't intersect object with boolean/string/etc.
137-
const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined;
138-
const resourceType = isTypeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined;
139-
const typedBaseType = resourceType ? `${baseType}<${resourceType}>` : baseType;
140-
return {
141-
fieldName,
142-
baseType,
143-
typedBaseType,
144-
sliceName,
145-
baseName: slice.nameCandidates.recommended,
146-
match: slice.match ?? {},
147-
required,
148-
excluded: slice.excluded ?? [],
149-
array: Boolean(field.array),
150-
constrainedChoice,
151-
typeDiscriminator: isTypeDisc,
152-
max: slice.max ?? 0,
153-
};
154-
});
155-
});
123+
Object.entries(snapshot.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => {
124+
const field = snapshot.fields[fieldName];
125+
if (!isNotChoiceDeclarationField(field) || !fieldSlicing.slices || !field.type) return [];
126+
const baseType = tsTypeFromIdentifier(field.type);
127+
const pkgName = snapshot.identifier.package;
128+
const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type);
129+
const isTypeDisc = isTypeDiscriminated(fieldSlicing);
130+
return Object.entries(fieldSlicing.slices)
131+
.filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0)
132+
.map(([sliceName, slice]) => {
133+
const matchFields = Object.keys(slice.match ?? {});
134+
const required = (slice.required ?? []).filter(
135+
(name) => !matchFields.includes(name) && !choiceBaseNames.has(name),
136+
);
137+
const cc = slice.elements ? tsIndex.constrainedChoice(pkgName, field.type, slice.elements) : undefined;
138+
// Skip flattening for primitive types — can't intersect object with boolean/string/etc.
139+
const constrainedChoice = cc && !isPrimitiveIdentifier(cc.variantType) ? cc : undefined;
140+
const resourceType = isTypeDisc ? extractResourceTypeFromMatch(slice.match ?? {}) : undefined;
141+
const typedBaseType = resourceType ? `${baseType}<${resourceType}>` : baseType;
142+
return {
143+
fieldName,
144+
baseType,
145+
typedBaseType,
146+
sliceName,
147+
baseName: slice.nameCandidates.recommended,
148+
match: slice.match ?? {},
149+
required,
150+
excluded: slice.excluded ?? [],
151+
array: Boolean(field.array),
152+
constrainedChoice,
153+
typeDiscriminator: isTypeDisc,
154+
max: slice.max ?? 0,
155+
};
156+
});
157+
});
156158

157159
export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], snapshot: SnapshotProfileTypeSchema) => {
158160
const profileClassName = tsProfileClassName(snapshot);

‎src/api/writer-generator/typescript/profile-validation.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type ChoiceFieldInstance,
3+
type FieldSlicing,
34
isChoiceDeclarationField,
45
isChoiceInstanceField,
56
type RegularField,
@@ -18,6 +19,7 @@ export const collectRegularFieldValidation = (
1819
resolveRef: (ref: TypeIdentifier) => TypeIdentifier,
1920
canonicalUrlExpr?: { url: string; expr: string },
2021
tsIndex?: TypeSchemaIndex,
22+
fieldSlicing?: FieldSlicing,
2123
) => {
2224
if (field.excluded) {
2325
errors.push(`...validateExcluded(res, profileName, ${JSON.stringify(name)})`);
@@ -47,8 +49,8 @@ export const collectRegularFieldValidation = (
4749
`...validateReference(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.reference.resource.map((ref) => resolveRef(ref).name))})`,
4850
);
4951

50-
if (field.slicing?.slices) {
51-
for (const [sliceName, slice] of Object.entries(field.slicing.slices)) {
52+
if (fieldSlicing?.slices) {
53+
for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) {
5254
const match = slice.match ?? {};
5355
if (Object.keys(match).length === 0) continue;
5456
if (slice.min !== undefined || slice.max !== undefined) {
@@ -114,6 +116,7 @@ export const generateValidateMethod = (
114116
tsIndex.findLastSpecializationByIdentifier,
115117
canonicalUrlExpr,
116118
tsIndex,
119+
snapshot.slicing?.[name],
117120
);
118121
}
119122

‎src/api/writer-generator/typescript/profile.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export const collectProfileFactoryInfo = (
166166
}
167167

168168
if (isNotChoiceDeclarationField(field)) {
169-
const sliceNames = collectRequiredSliceNames(field);
169+
const sliceNames = collectRequiredSliceNames(field, snapshot.slicing?.[name]);
170170
if (sliceNames) {
171171
if (field.type) {
172172
const tsType = fieldTsType(field, resolveRef, isFamilyType);

‎src/typeschema/core/field-builder.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ const computeMatchFromSchema = (
290290
return result;
291291
};
292292

293-
const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => {
293+
export const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => {
294294
const slicing = element.slicing;
295295
if (!slicing) return undefined;
296296

@@ -435,7 +435,6 @@ export const mkField = (
435435
array: element.array || false,
436436
min: element.min,
437437
max: element.max,
438-
slicing: buildSlicing(path[path.length - 1] ?? "", element),
439438

440439
choices: element.choices,
441440
choiceOf: element.choiceOf,
@@ -459,6 +458,5 @@ export function mkNestedField(
459458
array: element.array || false,
460459
required: isRequired(register, fhirSchema, path),
461460
excluded: isExcluded(register, fhirSchema, path),
462-
slicing: buildSlicing(path[path.length - 1] ?? "", element),
463461
};
464462
}

‎src/typeschema/core/name-candidates.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void =>
9999
candidates: ext.nameCandidates.candidates,
100100
}));
101101

102-
const sliceEntries: NameEntry[] = Object.entries(profile.fields ?? {}).flatMap(([fieldName, field]) => {
103-
if (!("slicing" in field) || !field.slicing?.slices) return [];
104-
return Object.entries(field.slicing.slices).map(([sliceName, slice]) => ({
102+
const sliceEntries: NameEntry[] = Object.entries(profile.slicing ?? {}).flatMap(([fieldName, fieldSlicing]) => {
103+
if (!fieldSlicing.slices) return [];
104+
return Object.entries(fieldSlicing.slices).map(([sliceName, slice]) => ({
105105
key: `slice:${fieldName}:${sliceName}`,
106106
candidates: slice.nameCandidates.candidates,
107107
}));
@@ -121,9 +121,9 @@ export const assignRecommendedBaseNames = (profile: ProfileTypeSchema): void =>
121121
if (resolved[key]) ext.nameCandidates.recommended = resolved[key];
122122
}
123123

124-
for (const [fieldName, field] of Object.entries(profile.fields ?? {})) {
125-
if (!("slicing" in field) || !field.slicing?.slices) continue;
126-
for (const [sliceName, slice] of Object.entries(field.slicing.slices)) {
124+
for (const [fieldName, fieldSlicing] of Object.entries(profile.slicing ?? {})) {
125+
if (!fieldSlicing.slices) continue;
126+
for (const [sliceName, slice] of Object.entries(fieldSlicing.slices)) {
127127
const key = `slice:${fieldName}:${sliceName}`;
128128
if (resolved[key]) slice.nameCandidates.recommended = resolved[key];
129129
}

0 commit comments

Comments
 (0)