Skip to content

Commit 9aaa34d

Browse files
authored
Merge pull request #186 from proofgeist/codex/fix-typegen-validator-preserve
2 parents 903bf55 + fb0933f commit 9aaa34d

3 files changed

Lines changed: 150 additions & 0 deletions

File tree

.changeset/small-apes-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@proofkit/typegen": patch
3+
---
4+
5+
fix(typegen): preserve inline validator helpers in generated odata files

packages/typegen/src/fmodata/generateODataTypes.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ interface ParsedTableOccurrence {
447447
fieldsByEntityId: Map<string, ParsedField>; // keyed by entity ID
448448
existingImports: string[]; // All existing import statements as strings
449449
importAliases: Map<string, string>; // Map base name -> alias (e.g., "textField" -> "tf")
450+
preservedTopLevelStatements: string[];
450451
}
451452

452453
/**
@@ -776,6 +777,42 @@ function parseExistingTableFile(sourceFile: SourceFile): ParsedTableOccurrence |
776777
}
777778
}
778779

780+
const preservedTopLevelStatements: string[] = [];
781+
for (const statement of sourceFile.getStatements()) {
782+
if (statement.getKindName() === "ImportDeclaration") {
783+
continue;
784+
}
785+
786+
if (statement.getKindName() === "VariableStatement") {
787+
const variableStatement = statement as unknown as {
788+
getDeclarations(): Array<{ getName(): string }>;
789+
};
790+
const containsGeneratedTable = variableStatement
791+
.getDeclarations()
792+
.some((declaration) => declaration.getName() === varName);
793+
if (containsGeneratedTable) {
794+
continue;
795+
}
796+
}
797+
798+
if (statement.getKindName() === "ExportDeclaration") {
799+
const exportDeclaration = statement as unknown as {
800+
getNamedExports(): Array<{ getName(): string }>;
801+
};
802+
const exportsGeneratedTable = exportDeclaration
803+
.getNamedExports()
804+
.some((namedExport) => namedExport.getName() === varName);
805+
if (exportsGeneratedTable) {
806+
continue;
807+
}
808+
}
809+
810+
const statementText = statement.getFullText().trim();
811+
if (statementText) {
812+
preservedTopLevelStatements.push(statementText);
813+
}
814+
}
815+
779816
// Parse each field
780817
const fields = new Map<string, ParsedField>();
781818
const fieldsByEntityId = new Map<string, ParsedField>();
@@ -827,6 +864,7 @@ function parseExistingTableFile(sourceFile: SourceFile): ParsedTableOccurrence |
827864
fieldsByEntityId,
828865
existingImports,
829866
importAliases,
867+
preservedTopLevelStatements,
830868
};
831869
}
832870

@@ -1500,6 +1538,10 @@ export async function generateODataTypes(
15001538
// Build file content with removed fields commented out
15011539
let fileContent = `${finalImports}\n`;
15021540

1541+
if (existingFields?.preservedTopLevelStatements.length) {
1542+
fileContent += `${existingFields.preservedTopLevelStatements.join("\n\n")}\n\n`;
1543+
}
1544+
15031545
if (removedFields.length > 0) {
15041546
fileContent += "// ============================================================================\n";
15051547
fileContent += "// Removed fields (not found in metadata)\n";

packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,109 @@ describe("fmodata generateODataTypes preserves user customizations", () => {
271271
}
272272
});
273273

274+
it("preserves top-level validator helpers referenced by field chains", async () => {
275+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-"));
276+
277+
try {
278+
const entitySetName = "User";
279+
const entityTypeName = "NS.User";
280+
const metadata = makeMetadata({
281+
entitySetName,
282+
entityTypeName,
283+
fields: [{ name: "contact_json", type: "Edm.String", fieldId: "F1" }],
284+
});
285+
286+
const existingFilePath = path.join(tmpDir, "User.ts");
287+
await fs.writeFile(
288+
existingFilePath,
289+
[
290+
`import { fmTableOccurrence, textField } from "@proofkit/fmodata";`,
291+
`import { z } from "zod/v4";`,
292+
"",
293+
"const ZContactJson = z.union([z.string(), z.null(), z.undefined()]).transform((s) => s);",
294+
"",
295+
`export const User = fmTableOccurrence("User", {`,
296+
` contact_json: textField().entityId("F1").readValidator(ZContactJson),`,
297+
"}, {",
298+
` entityId: "T1",`,
299+
"});",
300+
"",
301+
].join("\n"),
302+
"utf8",
303+
);
304+
305+
await generateODataTypes(metadata, {
306+
type: "fmodata",
307+
path: tmpDir,
308+
clearOldFiles: false,
309+
tables: [{ tableName: "User" }],
310+
});
311+
312+
const regenerated = await fs.readFile(existingFilePath, "utf8");
313+
expect(regenerated).toContain("const ZContactJson = z");
314+
expect(regenerated).toContain(".union([z.string(), z.null(), z.undefined()])");
315+
expect(regenerated).toContain(".transform((s) => s);");
316+
expect(regenerated).toContain(`contact_json: textField().entityId("F1").readValidator(ZContactJson)`);
317+
} finally {
318+
await fs.rm(tmpDir, { recursive: true, force: true });
319+
}
320+
});
321+
322+
it("preserves unrelated top-level imports and helper code", async () => {
323+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-"));
324+
325+
try {
326+
const entitySetName = "Project";
327+
const entityTypeName = "NS.Project";
328+
const metadata = makeMetadata({
329+
entitySetName,
330+
entityTypeName,
331+
fields: [{ name: "status", type: "Edm.String", fieldId: "F1" }],
332+
});
333+
334+
const existingFilePath = path.join(tmpDir, "Project.ts");
335+
await fs.writeFile(
336+
existingFilePath,
337+
[
338+
`import { fmTableOccurrence, textField } from "@proofkit/fmodata";`,
339+
`import { DateTime } from "luxon";`,
340+
"",
341+
`const STATUS_LABELS = new Map([["open", "Open"]]);`,
342+
`function getGeneratedAt() {`,
343+
` return DateTime.utc().toISO();`,
344+
`}`,
345+
"",
346+
`export const generatedAt = getGeneratedAt();`,
347+
"",
348+
`export const Project = fmTableOccurrence("Project", {`,
349+
` status: textField().entityId("F1"),`,
350+
"}, {",
351+
` entityId: "T1",`,
352+
"});",
353+
"",
354+
].join("\n"),
355+
"utf8",
356+
);
357+
358+
await generateODataTypes(metadata, {
359+
type: "fmodata",
360+
path: tmpDir,
361+
clearOldFiles: false,
362+
tables: [{ tableName: "Project" }],
363+
});
364+
365+
const regenerated = await fs.readFile(existingFilePath, "utf8");
366+
expect(regenerated).toContain(`import { DateTime } from "luxon";`);
367+
expect(regenerated).toContain(`const STATUS_LABELS = new Map([["open", "Open"]]);`);
368+
expect(regenerated).toContain(`function getGeneratedAt()`);
369+
expect(regenerated).toContain(`return DateTime.utc().toISO();`);
370+
expect(regenerated).toContain(`export const generatedAt = getGeneratedAt();`);
371+
expect(regenerated).toContain(`status: textField().entityId("F1")`);
372+
} finally {
373+
await fs.rm(tmpDir, { recursive: true, force: true });
374+
}
375+
});
376+
274377
it("preserves custom validators and removes stale files when clearOldFiles is true", async () => {
275378
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-"));
276379

0 commit comments

Comments
 (0)