Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 0 additions & 35 deletions src/api/writer-generator/typescript/name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,39 +106,4 @@ export const tsExtensionFlatTypeName = (profileName: string, extensionName: stri

export const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_");

export const tsSliceMethodBaseName = (sliceName: string): string =>
uppercaseFirstLetter(normalizeTsName(sliceName) || "Slice");

export const tsExtensionMethodBaseName = (name: string): string =>
uppercaseFirstLetter(tsCamelCase(name) || "Extension");

export const tsQualifiedExtensionMethodBaseName = (name: string, path?: string): string => {
const rawPath =
path
?.split(".")
.filter((p) => p && p !== "extension")
.join("_") ?? "";
const pathPart = rawPath ? uppercaseFirstLetter(tsCamelCase(rawPath)) : "";
return `${pathPart}${uppercaseFirstLetter(tsCamelCase(name) || "Extension")}`;
};

export const tsQualifiedSliceMethodBaseName = (fieldName: string, sliceName: string): string => {
const fieldPart = uppercaseFirstLetter(tsCamelCase(fieldName) || "Field");
const slicePart = uppercaseFirstLetter(normalizeTsName(sliceName) || "Slice");
return `${fieldPart}${slicePart}`;
};

export const tsResolvedExtensionBaseName = (
extensionBaseNames: Record<string, string>,
url: string,
path: string,
fallbackName: string,
): string => extensionBaseNames[`${url}:${path}`] ?? fallbackName;

export const tsResolvedSliceBaseName = (
sliceBaseNames: Record<string, string>,
fieldName: string,
sliceName: string,
): string => sliceBaseNames[`${fieldName}:${sliceName}`] ?? sliceName;

export const tsValueFieldName = (id: TypeIdentifier): string => `value${uppercaseFirstLetter(id.name)}`;
10 changes: 2 additions & 8 deletions src/api/writer-generator/typescript/profile-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
tsExtensionFlatTypeName,
tsProfileClassName,
tsProfileModuleName,
tsResolvedExtensionBaseName,
tsResourceName,
tsValueFieldName,
} from "./name";
Expand Down Expand Up @@ -396,15 +395,10 @@ const generateGenericExtensionGetter = (w: TypeScript, info: ExtensionMethodInfo
});
};

export const generateExtensionMethods = (
w: TypeScript,
tsIndex: TypeSchemaIndex,
flatProfile: ProfileTypeSchema,
extensionBaseNames: Record<string, string>,
) => {
export const generateExtensionMethods = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => {
for (const ext of flatProfile.extensions ?? []) {
if (!ext.url) continue;
const baseName = tsResolvedExtensionBaseName(extensionBaseNames, ext.url, ext.path, ext.name);
const baseName = ext.nameCandidates.recommended;
const targetPath = ext.path.split(".").filter((segment) => segment !== "extension");
const extProfileInfo = resolveExtensionProfile(tsIndex, flatProfile.identifier.package, ext.url);
const info: ExtensionMethodInfo = {
Expand Down
22 changes: 7 additions & 15 deletions src/api/writer-generator/typescript/profile-slices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type { TypeSchemaIndex } from "@root/typeschema/utils";
import {
tsFieldName,
tsProfileClassName,
tsResolvedSliceBaseName,
tsResourceName,
tsSliceFlatAllTypeName,
tsSliceFlatTypeName,
Expand Down Expand Up @@ -101,6 +100,8 @@ export type SliceDef = {
/** Base type parameterized with the matched resource type (e.g. "BundleEntry<Patient>") */
typedBaseType: string;
sliceName: string;
/** Collision-free base name from nameCandidates.recommended (e.g. "VSCat", "SystolicBP") */
baseName: string;
match: Record<string, unknown>;
/** Required fields, already filtered (match keys and polymorphic base names removed) */
required: string[];
Expand Down Expand Up @@ -141,6 +142,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
baseType,
typedBaseType,
sliceName,
baseName: slice.nameCandidates.recommended,
match: slice.match ?? {},
required,
excluded: slice.excluded ?? [],
Expand All @@ -152,16 +154,11 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
});
});

export const generateSliceSetters = (
w: TypeScript,
sliceDefs: SliceDef[],
flatProfile: ProfileTypeSchema,
sliceBaseNames: Record<string, string>,
) => {
export const generateSliceSetters = (w: TypeScript, sliceDefs: SliceDef[], flatProfile: ProfileTypeSchema) => {
const profileClassName = tsProfileClassName(flatProfile);
const tsProfileName = tsResourceName(flatProfile.identifier);
for (const sliceDef of sliceDefs) {
const baseName = tsResolvedSliceBaseName(sliceBaseNames, sliceDef.fieldName, sliceDef.sliceName);
const baseName = sliceDef.baseName;
const methodName = `set${baseName}`;
const inputTypeName = tsSliceFlatTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName);
const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`;
Expand Down Expand Up @@ -225,17 +222,12 @@ export const generateSliceSetters = (
}
};

export const generateSliceGetters = (
w: TypeScript,
sliceDefs: SliceDef[],
flatProfile: ProfileTypeSchema,
sliceBaseNames: Record<string, string>,
) => {
export const generateSliceGetters = (w: TypeScript, sliceDefs: SliceDef[], flatProfile: ProfileTypeSchema) => {
const profileClassName = tsProfileClassName(flatProfile);
const tsProfileName = tsResourceName(flatProfile.identifier);
const defaultMode = w.opts.sliceGetterDefault ?? "flat";
for (const sliceDef of sliceDefs) {
const baseName = tsResolvedSliceBaseName(sliceBaseNames, sliceDef.fieldName, sliceDef.sliceName);
const baseName = sliceDef.baseName;
const getMethodName = `get${baseName}`;
const flatTypeName = tsSliceFlatAllTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName);
const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`;
Expand Down
95 changes: 5 additions & 90 deletions src/api/writer-generator/typescript/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
isNotChoiceDeclarationField,
isPrimitiveIdentifier,
isResourceIdentifier,
type ProfileExtension,
type ProfileTypeSchema,
packageMeta,
packageMetaToFhir,
Expand All @@ -17,19 +16,15 @@ import type { TypeSchemaIndex } from "@root/typeschema/utils";
import {
tsCamelCase,
tsExtensionFlatTypeName,
tsExtensionMethodBaseName,
tsFieldName,
tsModulePath,
tsNameFromCanonical,
tsPackageDir,
tsProfileClassName,
tsProfileModuleName,
tsQualifiedExtensionMethodBaseName,
tsQualifiedSliceMethodBaseName,
tsResourceName,
tsSliceFlatAllTypeName,
tsSliceFlatTypeName,
tsSliceMethodBaseName,
tsSliceStaticName,
} from "./name";
import {
Expand Down Expand Up @@ -560,11 +555,7 @@ const generateFactoryMethods = (
w.line();
};

const generateFieldAccessors = (
w: TypeScript,
factoryInfo: ProfileFactoryInfo,
extSliceMethodBaseNames: Set<string>,
) => {
const generateFieldAccessors = (w: TypeScript, factoryInfo: ProfileFactoryInfo) => {
w.line("// Field accessors");
for (const p of factoryInfo.params) {
const methodBaseName = uppercaseFirstLetter(p.name);
Expand All @@ -579,10 +570,8 @@ const generateFieldAccessors = (
w.line();
}

// Getter and setter methods for choice instance fields (skip if extension/slice has same name)
for (const a of factoryInfo.accessors) {
const methodBaseName = uppercaseFirstLetter(tsCamelCase(a.name));
if (extSliceMethodBaseNames.has(methodBaseName)) continue;
const fieldAccess = tsFieldName(a.name);
w.curlyBlock([`get${methodBaseName}`, "()", `: ${a.tsType} | undefined`], () => {
w.lineSM(`return ${tsGet("this.resource", fieldAccess)} as ${a.tsType} | undefined`);
Expand Down Expand Up @@ -726,78 +715,6 @@ const generateFlatInputType = (w: TypeScript, flatProfile: ProfileTypeSchema) =>
w.line();
};

type ResolvedProfileMethods = {
/** "url:path" → method base name (e.g., "Race" or "PathRace") */
extensions: Record<string, string>;
/** "fieldName:sliceName" → method base name */
slices: Record<string, string>;
/** All resolved base names (extensions + slices) for field accessor dedup */
allBaseNames: Set<string>;
};

type NameEntry = { key: string; candidates: string[] };

const countBy = (entries: NameEntry[], level: number): Record<string, number> =>
entries.reduce(
(counts, e) => {
const name = e.candidates[level] ?? "";
counts[name] = (counts[name] ?? 0) + 1;
return counts;
},
{} as Record<string, number>,
);

/** Resolve naming collisions across multiple levels of candidates.
* Each entry provides candidate names in priority order (e.g. base → qualified → discriminated). */
const resolveNameCollisions = (entries: NameEntry[]): Record<string, string> => {
const levels = entries[0]?.candidates.length ?? 0;

const resolve = (unresolved: NameEntry[], level: number): Record<string, string> => {
if (unresolved.length === 0 || level >= levels) return {};
const counts = countBy(unresolved, level);
const isLastLevel = level >= levels - 1;
const [resolved, colliding] = unresolved.reduce(
([res, col], e) => {
const name = e.candidates[level] ?? "";
return (counts[name] ?? 0) > 1 && !isLastLevel ? [res, [...col, e]] : [{ ...res, [e.key]: name }, col];
},
[{} as Record<string, string>, [] as NameEntry[]],
);
return { ...resolved, ...resolve(colliding, level + 1) };
};

return resolve(entries, 0);
};

const toRecord = (entries: NameEntry[], resolved: Record<string, string>): Record<string, string> =>
Object.fromEntries(entries.map((e) => [e.key, resolved[e.key] ?? e.candidates[0] ?? ""]));

const resolveProfileMethodBaseNames = (
extensions: ProfileExtension[],
sliceDefs: SliceDef[],
): ResolvedProfileMethods => {
const extensionEntries: NameEntry[] = extensions
.filter((ext) => ext.url)
.map((ext) => {
const base = tsExtensionMethodBaseName(ext.name);
const qualified = tsQualifiedExtensionMethodBaseName(ext.name, ext.path);
return { key: `${ext.url}:${ext.path}`, candidates: [base, qualified, `${qualified}Extension`] };
});

const sliceEntries: NameEntry[] = sliceDefs.map((slice) => {
const base = tsSliceMethodBaseName(slice.sliceName);
const qualified = tsQualifiedSliceMethodBaseName(slice.fieldName, slice.sliceName);
return { key: `${slice.fieldName}:${slice.sliceName}`, candidates: [base, qualified, `${qualified}Slice`] };
});

const resolved = resolveNameCollisions([...extensionEntries, ...sliceEntries]);
const extensionsRecords = toRecord(extensionEntries, resolved);
const slicesRecords = toRecord(sliceEntries, resolved);
const allBaseNames = new Set([...Object.values(extensionsRecords), ...Object.values(slicesRecords)]);

return { extensions: extensionsRecords, slices: slicesRecords, allBaseNames };
};

export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => {
const tsBaseResourceName = tsTypeFromIdentifier(flatProfile.base);
const profileClassName = tsProfileClassName(flatProfile);
Expand All @@ -815,23 +732,21 @@ export const generateProfileClass = (w: TypeScript, tsIndex: TypeSchemaIndex, fl
const canonicalUrl = flatProfile.identifier.url;
w.comment("CanonicalURL:", canonicalUrl, `(pkg: ${packageMetaToFhir(packageMeta(flatProfile))})`);

const resolvedMethodNames = resolveProfileMethodBaseNames(flatProfile.extensions ?? [], sliceDefs);

w.curlyBlock(["export", "class", profileClassName], () => {
w.lineSM(`static readonly canonicalUrl = ${JSON.stringify(canonicalUrl)}`);
w.line();
generateStaticSliceFields(w, sliceDefs);
w.lineSM(`private resource: ${tsBaseResourceName}`);
w.line();
generateFactoryMethods(w, tsIndex, flatProfile, factoryInfo);
generateFieldAccessors(w, factoryInfo, resolvedMethodNames.allBaseNames);
generateFieldAccessors(w, factoryInfo);

w.line("// Extensions");
generateExtensionMethods(w, tsIndex, flatProfile, resolvedMethodNames.extensions);
generateExtensionMethods(w, tsIndex, flatProfile);

w.line("// Slices");
generateSliceSetters(w, sliceDefs, flatProfile, resolvedMethodNames.slices);
generateSliceGetters(w, sliceDefs, flatProfile, resolvedMethodNames.slices);
generateSliceSetters(w, sliceDefs, flatProfile);
generateSliceGetters(w, sliceDefs, flatProfile);

w.line("// Validation");
generateValidateMethod(w, tsIndex, flatProfile);
Expand Down
8 changes: 5 additions & 3 deletions src/typeschema/core/field-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
} from "../types";
import { BINDABLE_TYPES, buildEnum } from "./binding";
import { mkBindingIdentifier, mkIdentifier } from "./identifier";
import { mkSliceNameCandidates } from "./name-candidates";
import { mkNestedIdentifier } from "./nested-types";

function isRequired(register: Register, fhirSchema: RichFHIRSchema, path: string[]): boolean {
Expand Down Expand Up @@ -238,7 +239,7 @@ const computeMatchFromSchema = (
return result;
};

const buildSlicing = (element: FHIRSchemaElement): FieldSlicing | undefined => {
const buildSlicing = (fieldName: string, element: FHIRSchemaElement): FieldSlicing | undefined => {
const slicing = element.slicing;
if (!slicing) return undefined;

Expand All @@ -255,6 +256,7 @@ const buildSlicing = (element: FHIRSchemaElement): FieldSlicing | undefined => {
required,
excluded,
elements,
nameCandidates: mkSliceNameCandidates(fieldName, name),
};
}

Expand Down Expand Up @@ -372,7 +374,7 @@ export const mkField = (
array: element.array || false,
min: element.min,
max: element.max,
slicing: buildSlicing(element),
slicing: buildSlicing(path[path.length - 1] ?? "", element),

choices: element.choices,
choiceOf: element.choiceOf,
Expand All @@ -396,6 +398,6 @@ export function mkNestedField(
array: element.array || false,
required: isRequired(register, fhirSchema, path),
excluded: isExcluded(register, fhirSchema, path),
slicing: buildSlicing(element),
slicing: buildSlicing(path[path.length - 1] ?? "", element),
};
}
Loading
Loading