Skip to content

Commit d18e50d

Browse files
authored
Merge pull request #41 from atomic-ehr/treeshake-update
Fix tree shake bugs related to nested types & add selectFields to the rules
2 parents 283b979 + 5830491 commit d18e50d

8 files changed

Lines changed: 1992 additions & 78 deletions

File tree

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ yarn add @atomic-ehr/codegen
8484
See the [examples/](examples/) directory for working demonstrations:
8585

8686
- **[typescript-r4/](examples/typescript-r4/)** - FHIR R4 type generation with resource creation demo and profile usage
87-
- **[typescript-ccda/](examples/typescript-ccda/)** - C-CDA on FHIR type generation
87+
- **[typescript-ccda/](examples/typescript-ccda/)** - C-CDA on FHIR type generation
8888
- **[typescript-sql-on-fhir/](examples/typescript-sql-on-fhir/)** - SQL on FHIR ViewDefinition with tree shaking
8989
- **[python/](examples/python/)** - Python/Pydantic model generation with configurable field formats
9090
- **[csharp/](examples/csharp/)** - C# class generation with namespace configuration
@@ -193,6 +193,32 @@ Tree shaking optimizes the generated output by including only the resources you
193193

194194
This feature automatically resolves and includes all dependencies (referenced types, base resources, nested types) while excluding unused resources, significantly reducing the size of generated code and improving compilation times.
195195

196+
##### Field-Level Tree Shaking
197+
198+
Beyond resource-level filtering, tree shaking supports fine-grained field selection using `selectFields` (whitelist) or `ignoreFields` (blacklist):
199+
200+
```typescript
201+
.treeShake({
202+
"hl7.fhir.r4.core#4.0.1": {
203+
"http://hl7.org/fhir/StructureDefinition/Patient": {
204+
selectFields: ["id", "name", "birthDate", "gender"]
205+
},
206+
"http://hl7.org/fhir/StructureDefinition/Observation": {
207+
ignoreFields: ["performer", "note"]
208+
}
209+
}
210+
})
211+
```
212+
213+
**Configuration Rules:**
214+
- `selectFields`: Only includes the specified fields (whitelist approach)
215+
- `ignoreFields`: Removes specified fields, keeps everything else (blacklist approach)
216+
- These options are **mutually exclusive** - you cannot use both in the same rule
217+
218+
**Polymorphic Field Handling:**
219+
220+
FHIR choice types (like `multipleBirth[x]` which can be boolean or integer) are handled intelligently. Selecting/ignoring the base field affects all variants, while targeting specific variants only affects those types.
221+
196222
### Generation
197223

198224
The generation stage uses a `WriterGenerator` system that transforms Type Schema into target language code. The architecture consists of:

src/api/builder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { CSharp } from "@root/api/writer-generator/csharp/csharp.ts";
1313
import { Python, type PythonGeneratorOptions } from "@root/api/writer-generator/python";
1414
import { generateTypeSchemas } from "@root/typeschema";
1515
import { registerFromManager } from "@root/typeschema/register";
16-
import { mkTypeSchemaIndex, type TreeShake, type TypeSchemaIndex, treeShake } from "@root/typeschema/utils";
16+
import { type TreeShake, treeShake } from "@root/typeschema/tree-shake";
17+
import { mkTypeSchemaIndex, type TypeSchemaIndex } from "@root/typeschema/utils";
1718
import {
1819
extractNameFromCanonical,
1920
type PackageMeta,

src/typeschema/tree-shake.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import assert from "node:assert";
2+
import type { CodegenLogger } from "@root/utils/codegen-logger";
3+
import { extractDependencies } from "./core/transformer";
4+
import type { ResolutionTree } from "./register";
5+
import {
6+
type CanonicalUrl,
7+
type Field,
8+
isBindingSchema,
9+
isChoiceDeclarationField,
10+
isChoiceInstanceField,
11+
isNestedIdentifier,
12+
isNotChoiceDeclarationField,
13+
isPrimitiveTypeSchema,
14+
isSpecializationTypeSchema,
15+
isValueSetTypeSchema,
16+
type NestedType,
17+
type RegularTypeSchema,
18+
type TypeSchema,
19+
} from "./types";
20+
import { mkTypeSchemaIndex, type TypeSchemaIndex } from "./utils";
21+
22+
export type TreeShake = Record<string, Record<string, TreeShakeRule>>;
23+
24+
export type TreeShakeRule = { ignoreFields?: string[]; selectFields?: string[] };
25+
26+
const mutableSelectFields = (schema: RegularTypeSchema, selectFields: string[]) => {
27+
const selectedFields: Record<string, Field> = {};
28+
29+
const selectPolimorphic: Record<string, { declaration?: string[]; instances?: string[] }> = {};
30+
for (const fieldName of selectFields) {
31+
const field = schema.fields?.[fieldName];
32+
if (!schema.fields || !field) throw new Error(`Field ${fieldName} not found`);
33+
34+
if (isChoiceDeclarationField(field)) {
35+
if (!selectPolimorphic[fieldName]) selectPolimorphic[fieldName] = {};
36+
selectPolimorphic[fieldName].declaration = field.choices;
37+
} else if (isChoiceInstanceField(field)) {
38+
const choiceName = field.choiceOf;
39+
if (!selectPolimorphic[choiceName]) selectPolimorphic[choiceName] = {};
40+
selectPolimorphic[choiceName].instances = [...(selectPolimorphic[choiceName].instances ?? []), fieldName];
41+
} else {
42+
selectedFields[fieldName] = field;
43+
}
44+
}
45+
46+
for (const [choiceName, { declaration, instances }] of Object.entries(selectPolimorphic)) {
47+
const choices = instances ?? declaration;
48+
assert(choices);
49+
for (const choiceInstanceName of choices) {
50+
const field = schema.fields?.[choiceInstanceName];
51+
assert(field);
52+
selectedFields[choiceInstanceName] = field;
53+
}
54+
const decl = schema.fields?.[choiceName];
55+
assert(decl);
56+
selectedFields[choiceName] = { ...decl, choices: choices };
57+
}
58+
schema.fields = selectedFields;
59+
};
60+
61+
const mutableIgnoreFields = (schema: RegularTypeSchema, ignoreFields: string[]) => {
62+
for (const fieldName of ignoreFields) {
63+
const field = schema.fields?.[fieldName];
64+
if (!schema.fields || !field) throw new Error(`Field ${fieldName} not found`);
65+
if (schema.fields) {
66+
if (isChoiceDeclarationField(field)) {
67+
for (const choiceName of field.choices) {
68+
delete schema.fields[choiceName];
69+
}
70+
}
71+
72+
if (isChoiceInstanceField(field)) {
73+
const choiceDeclaration = schema.fields[field.choiceOf];
74+
assert(isChoiceDeclarationField(choiceDeclaration));
75+
choiceDeclaration.choices = choiceDeclaration.choices.filter((c) => c !== fieldName);
76+
if (choiceDeclaration.choices.length === 0) {
77+
delete schema.fields[field.choiceOf];
78+
}
79+
}
80+
81+
delete schema.fields[fieldName];
82+
}
83+
}
84+
};
85+
86+
export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _logger?: CodegenLogger): TypeSchema => {
87+
schema = structuredClone(schema);
88+
if (isPrimitiveTypeSchema(schema) || isValueSetTypeSchema(schema) || isBindingSchema(schema)) return schema;
89+
90+
if (rule.selectFields) {
91+
if (rule.ignoreFields) throw new Error("Cannot use both ignoreFields and selectFields in the same rule");
92+
mutableSelectFields(schema, rule.selectFields);
93+
}
94+
95+
if (rule.ignoreFields) {
96+
if (rule.selectFields) throw new Error("Cannot use both ignoreFields and selectFields in the same rule");
97+
mutableIgnoreFields(schema, rule.ignoreFields);
98+
}
99+
100+
if (schema.nested) {
101+
const usedTypes = new Set<CanonicalUrl>();
102+
const collectUsedNestedTypes = (s: RegularTypeSchema | NestedType) => {
103+
Object.values(s.fields ?? {})
104+
.filter(isNotChoiceDeclarationField)
105+
.filter((f) => isNestedIdentifier(f.type))
106+
.forEach((f) => {
107+
const url = f.type.url;
108+
if (!usedTypes.has(url)) {
109+
usedTypes.add(url);
110+
const nestedTypeDef = schema.nested?.find((f) => f.identifier.url === url);
111+
assert(nestedTypeDef);
112+
collectUsedNestedTypes(nestedTypeDef);
113+
}
114+
});
115+
};
116+
collectUsedNestedTypes(schema);
117+
schema.nested = schema.nested.filter((n) => usedTypes.has(n.identifier.url));
118+
}
119+
120+
schema.dependencies = extractDependencies(schema.identifier, schema.base, schema.fields, schema.nested);
121+
return schema;
122+
};
123+
124+
export const treeShake = (
125+
tsIndex: TypeSchemaIndex,
126+
treeShake: TreeShake,
127+
{ resolutionTree, logger }: { resolutionTree?: ResolutionTree; logger?: CodegenLogger },
128+
): TypeSchemaIndex => {
129+
const focusedSchemas: TypeSchema[] = [];
130+
for (const [pkgId, requires] of Object.entries(treeShake)) {
131+
for (const [url, rule] of Object.entries(requires)) {
132+
const schema = tsIndex.resolveByUrl(pkgId, url as CanonicalUrl);
133+
if (!schema) throw new Error(`Schema not found for ${pkgId} ${url}`);
134+
const shaked = treeShakeTypeSchema(schema, rule);
135+
focusedSchemas.push(shaked);
136+
}
137+
}
138+
const collectDeps = (schemas: TypeSchema[], acc: Record<string, TypeSchema>): TypeSchema[] => {
139+
if (schemas.length === 0) return Object.values(acc);
140+
for (const schema of schemas) {
141+
acc[JSON.stringify(schema.identifier)] = schema;
142+
}
143+
144+
const newSchemas: TypeSchema[] = [];
145+
146+
for (const schema of schemas) {
147+
if (isSpecializationTypeSchema(schema)) {
148+
if (!schema.dependencies) continue;
149+
schema.dependencies.forEach((dep) => {
150+
const depSchema = tsIndex.resolve(dep);
151+
if (!depSchema)
152+
throw new Error(
153+
`Dependent schema ${JSON.stringify(dep)} not found for ${JSON.stringify(schema.identifier)}`,
154+
);
155+
const id = JSON.stringify(depSchema.identifier);
156+
if (!acc[id]) newSchemas.push(depSchema);
157+
});
158+
if (schema.nested) {
159+
for (const nest of schema.nested) {
160+
if (isNestedIdentifier(nest.identifier)) continue;
161+
const id = JSON.stringify(nest.identifier);
162+
if (!acc[id]) newSchemas.push(nest);
163+
}
164+
}
165+
}
166+
}
167+
return collectDeps(newSchemas, acc);
168+
};
169+
170+
const shaked = collectDeps(focusedSchemas, {});
171+
return mkTypeSchemaIndex(shaked, { resolutionTree, logger });
172+
};

src/typeschema/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,11 @@ export const isChoiceDeclarationField = (field: Field | undefined): field is Cho
313313
return (field as ChoiceFieldDeclaration).choices !== undefined;
314314
};
315315

316+
export const isChoiceInstanceField = (field: Field | undefined): field is ChoiceFieldInstance => {
317+
if (!field) return false;
318+
return (field as ChoiceFieldInstance).choiceOf !== undefined;
319+
};
320+
316321
export type TypeschemaParserOptions = {
317322
format?: "auto" | "ndjson" | "json";
318323
validate?: boolean;

src/typeschema/utils.ts

Lines changed: 0 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,16 @@ import * as afs from "node:fs/promises";
22
import * as Path from "node:path";
33
import type { CodegenLogger } from "@root/utils/codegen-logger";
44
import * as YAML from "yaml";
5-
import { extractDependencies } from "./core/transformer";
65
import type { ResolutionTree } from "./register";
76
import {
87
type CanonicalUrl,
98
type Field,
109
type Identifier,
11-
isBindingSchema,
1210
isComplexTypeTypeSchema,
1311
isLogicalTypeSchema,
14-
isNestedIdentifier,
15-
isPrimitiveTypeSchema,
1612
isProfileTypeSchema,
1713
isResourceTypeSchema,
1814
isSpecializationTypeSchema,
19-
isValueSetTypeSchema,
2015
type ProfileTypeSchema,
2116
type RegularTypeSchema,
2217
type TypeSchema,
@@ -44,77 +39,6 @@ export const groupByPackages = (typeSchemas: TypeSchema[]) => {
4439
return grouped;
4540
};
4641

47-
export type TypeSchemaShakeRule = { ignoreFields?: string[] };
48-
49-
export type TreeShake = Record<string, Record<string, TypeSchemaShakeRule>>;
50-
51-
export const treeShakeTypeSchema = (
52-
schema: TypeSchema,
53-
rule: TypeSchemaShakeRule,
54-
_logger?: CodegenLogger,
55-
): TypeSchema => {
56-
schema = structuredClone(schema);
57-
if (isPrimitiveTypeSchema(schema) || isValueSetTypeSchema(schema) || isBindingSchema(schema)) return schema;
58-
59-
for (const fieldName of rule.ignoreFields ?? []) {
60-
if (schema.fields && !schema.fields[fieldName]) throw new Error(`Field ${fieldName} not found`);
61-
if (schema.fields) {
62-
delete schema.fields[fieldName];
63-
}
64-
}
65-
66-
schema.dependencies = extractDependencies(schema.identifier, schema.base, schema.fields, schema.nested);
67-
68-
return schema;
69-
};
70-
71-
export const treeShake = (
72-
tsIndex: TypeSchemaIndex,
73-
treeShake: TreeShake,
74-
{ resolutionTree, logger }: { resolutionTree?: ResolutionTree; logger?: CodegenLogger },
75-
): TypeSchemaIndex => {
76-
const focusedSchemas: TypeSchema[] = [];
77-
for (const [pkgId, requires] of Object.entries(treeShake)) {
78-
for (const [url, rule] of Object.entries(requires)) {
79-
const schema = tsIndex.resolveByUrl(pkgId, url as CanonicalUrl);
80-
if (!schema) throw new Error(`Schema not found for ${pkgId} ${url}`);
81-
const shaked = treeShakeTypeSchema(schema, rule);
82-
focusedSchemas.push(shaked);
83-
}
84-
}
85-
const collectDeps = (schemas: TypeSchema[], acc: Record<string, TypeSchema>): TypeSchema[] => {
86-
if (schemas.length === 0) return Object.values(acc);
87-
for (const schema of schemas) {
88-
acc[JSON.stringify(schema.identifier)] = schema;
89-
}
90-
91-
const newSchemas: TypeSchema[] = [];
92-
93-
for (const schema of schemas) {
94-
if (isSpecializationTypeSchema(schema)) {
95-
if (!schema.dependencies) continue;
96-
schema.dependencies.forEach((dep) => {
97-
const depSchema = tsIndex.resolve(dep);
98-
if (!depSchema) throw new Error(`Schema not found for ${dep}`);
99-
const id = JSON.stringify(depSchema.identifier);
100-
if (!acc[id]) newSchemas.push(depSchema);
101-
});
102-
if (schema.nested) {
103-
for (const nest of schema.nested) {
104-
if (isNestedIdentifier(nest.identifier)) continue;
105-
const id = JSON.stringify(nest.identifier);
106-
if (!acc[id]) newSchemas.push(nest);
107-
}
108-
}
109-
}
110-
}
111-
return collectDeps(newSchemas, acc);
112-
};
113-
114-
const shaked = collectDeps(focusedSchemas, {});
115-
return mkTypeSchemaIndex(shaked, { resolutionTree, logger });
116-
};
117-
11842
const buildDependencyGraph = (schemas: RegularTypeSchema[]): Record<string, string[]> => {
11943
const nameToMap: Record<string, RegularTypeSchema> = {};
12044
for (const schema of schemas) {

0 commit comments

Comments
 (0)