From 78b55444271a3a02f39c9eefdd9277d4ae9757fe Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:26:25 -0600 Subject: [PATCH 1/3] fix(typegen): prevent duplication of generator-owned methods (readValidator/writeValidator) for Edm.Boolean fields during regeneration --- ...x-typegen-boolean-validator-duplication.md | 5 ++ .../typegen/src/fmodata/generateODataTypes.ts | 40 ++++++++++++++-- .../fmodata-preserve-customizations.test.ts | 47 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-typegen-boolean-validator-duplication.md diff --git a/.changeset/fix-typegen-boolean-validator-duplication.md b/.changeset/fix-typegen-boolean-validator-duplication.md new file mode 100644 index 0000000..0dfa804 --- /dev/null +++ b/.changeset/fix-typegen-boolean-validator-duplication.md @@ -0,0 +1,5 @@ +--- +"@proofkit/typegen": patch +--- + +Fix typegen duplicating readValidator/writeValidator on Edm.Boolean fields during regeneration diff --git a/packages/typegen/src/fmodata/generateODataTypes.ts b/packages/typegen/src/fmodata/generateODataTypes.ts index 8c5f50f..0b6ef8b 100644 --- a/packages/typegen/src/fmodata/generateODataTypes.ts +++ b/packages/typegen/src/fmodata/generateODataTypes.ts @@ -315,7 +315,7 @@ function generateTableOccurrence( // Preserve user customizations from existing field if (matchedExistingField) { - line = preserveUserCustomizations(matchedExistingField, line); + line = preserveUserCustomizations(matchedExistingField, line, fieldBuilder); } // Add comma if not the last field @@ -435,7 +435,11 @@ interface ParsedTableOccurrence { /** * Extracts user customizations (like .inputValidator() and .outputValidator()) from a method chain */ -function extractUserCustomizations(chainText: string, baseChainEnd: number): string { +function extractUserCustomizations( + chainText: string, + baseChainEnd: number, + additionalStandardMethods?: Set, +): string { // We want to preserve user-added chained calls even if they were placed: // - before a standard method (e.g. textField().inputValidator(...).entityId(...)) // - on fields that have no standard methods at all (possible when reduceMetadata is true) @@ -447,6 +451,11 @@ function extractUserCustomizations(chainText: string, baseChainEnd: number): str // that can be appended to the regenerated chain. const standardMethodNames = new Set(["primaryKey", "readOnly", "notNull", "entityId", "comment"]); + if (additionalStandardMethods) { + for (const m of additionalStandardMethods) { + standardMethodNames.add(m); + } + } const start = Math.max(0, Math.min(baseChainEnd, chainText.length)); const tail = chainText.slice(start); @@ -824,10 +833,30 @@ function matchFieldByName(existingFields: Map, fieldName: s return existingFields.get(fieldName) || null; } +/** + * Extracts method names called in a chain expression. + * e.g. "numberField().readValidator(...).writeValidator(...)" => {"readValidator", "writeValidator"} + */ +function extractMethodNamesFromChain(chain: string): Set { + const names = new Set(); + const pattern = /\.(\w+)\s*(?:<[^>]*>)?\s*\(/g; + let m: RegExpExecArray | null; + while ((m = pattern.exec(chain)) !== null) { + if (m[1]) { + names.add(m[1]); + } + } + return names; +} + /** * Preserves user customizations from an existing field chain */ -function preserveUserCustomizations(existingField: ParsedField | undefined, newChain: string): string { +function preserveUserCustomizations( + existingField: ParsedField | undefined, + newChain: string, + fieldBuilder: string, +): string { if (!existingField) { return newChain; } @@ -848,7 +877,10 @@ function preserveUserCustomizations(existingField: ParsedField | undefined, newC const existingChainText = existingField.fullChainText; const existingBaseEnd = existingChainText.startsWith(baseBuilderPrefix) ? baseBuilderPrefix.length : 0; - const userCustomizations = extractUserCustomizations(existingChainText, existingBaseEnd); + // Methods in the generated field builder (e.g. readValidator, writeValidator for + // Edm.Boolean) are generator-owned and must not be duplicated as user customizations. + const generatorMethods = extractMethodNamesFromChain(fieldBuilder); + const userCustomizations = extractUserCustomizations(existingChainText, existingBaseEnd, generatorMethods); if (!userCustomizations) { return newChain; diff --git a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts index 0990d28..cd9e976 100644 --- a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts +++ b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts @@ -129,6 +129,53 @@ describe("fmodata generateODataTypes preserves user customizations", () => { } }); + it("does not duplicate generator-owned methods for Edm.Boolean fields", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-")); + + try { + const entitySetName = "MyTable"; + const entityTypeName = "NS.MyTable"; + const metadata = makeMetadata({ + entitySetName, + entityTypeName, + fields: [{ name: "is_active", type: "Edm.Boolean", fieldId: "F1" }], + }); + + const existingFilePath = path.join(tmpDir, "MyTable.ts"); + await fs.writeFile( + existingFilePath, + [ + `import { fmTableOccurrence, numberField } from "@proofkit/fmodata";`, + `import { z } from "zod/v4";`, + "", + `export const MyTable = fmTableOccurrence("MyTable", {`, + ` is_active: numberField().readValidator(z.coerce.boolean()).writeValidator(z.boolean().transform((v) => (v ? 1 : 0))).entityId("F1"),`, + `}, {`, + ` entityId: "T1",`, + `});`, + "", + ].join("\n"), + "utf8", + ); + + await generateODataTypes(metadata, { + type: "fmodata", + path: tmpDir, + clearOldFiles: false, + tables: [{ tableName: "MyTable" }], + }); + + const regenerated = await fs.readFile(existingFilePath, "utf8"); + // readValidator and writeValidator should each appear exactly once + const readValidatorCount = (regenerated.match(/readValidator/g) || []).length; + const writeValidatorCount = (regenerated.match(/writeValidator/g) || []).length; + expect(readValidatorCount).toBe(1); + expect(writeValidatorCount).toBe(1); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("preserves aliased imports when regenerating files", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-")); From c3f2677b23ab28c8fefd80bc4f94fc44b5505b1f Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:30:36 -0600 Subject: [PATCH 2/3] fix(typegen): optimize regex execution in extractMethodNamesFromChain function Refactored the regex execution in the extractMethodNamesFromChain function to improve performance by eliminating unnecessary variable declarations. Additionally, updated test cases to ensure proper formatting of generated output in fmodata-preserve-customizations test. --- packages/typegen/src/fmodata/generateODataTypes.ts | 5 +++-- .../tests/e2e/fmodata-preserve-customizations.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/typegen/src/fmodata/generateODataTypes.ts b/packages/typegen/src/fmodata/generateODataTypes.ts index 0b6ef8b..6258d60 100644 --- a/packages/typegen/src/fmodata/generateODataTypes.ts +++ b/packages/typegen/src/fmodata/generateODataTypes.ts @@ -840,11 +840,12 @@ function matchFieldByName(existingFields: Map, fieldName: s function extractMethodNamesFromChain(chain: string): Set { const names = new Set(); const pattern = /\.(\w+)\s*(?:<[^>]*>)?\s*\(/g; - let m: RegExpExecArray | null; - while ((m = pattern.exec(chain)) !== null) { + let m = pattern.exec(chain); + while (m !== null) { if (m[1]) { names.add(m[1]); } + m = pattern.exec(chain); } return names; } diff --git a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts index cd9e976..15353c9 100644 --- a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts +++ b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts @@ -150,9 +150,9 @@ describe("fmodata generateODataTypes preserves user customizations", () => { "", `export const MyTable = fmTableOccurrence("MyTable", {`, ` is_active: numberField().readValidator(z.coerce.boolean()).writeValidator(z.boolean().transform((v) => (v ? 1 : 0))).entityId("F1"),`, - `}, {`, + "}, {", ` entityId: "T1",`, - `});`, + "});", "", ].join("\n"), "utf8", From 8203a3368b1b0fac25ba00e725b0b3ae254ef66b Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:33:00 -0600 Subject: [PATCH 3/3] fix(typegen): parse chain depth in extractMethodNamesFromChain to avoid matching nested methods Co-Authored-By: Claude Opus 4.6 --- .../typegen/src/fmodata/generateODataTypes.ts | 83 +++++++++++++++++-- .../fmodata-preserve-customizations.test.ts | 49 +++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/packages/typegen/src/fmodata/generateODataTypes.ts b/packages/typegen/src/fmodata/generateODataTypes.ts index 6258d60..7e3edce 100644 --- a/packages/typegen/src/fmodata/generateODataTypes.ts +++ b/packages/typegen/src/fmodata/generateODataTypes.ts @@ -839,13 +839,84 @@ function matchFieldByName(existingFields: Map, fieldName: s */ function extractMethodNamesFromChain(chain: string): Set { const names = new Set(); - const pattern = /\.(\w+)\s*(?:<[^>]*>)?\s*\(/g; - let m = pattern.exec(chain); - while (m !== null) { - if (m[1]) { - names.add(m[1]); + let depth = 0; + let i = 0; + + function skipStringLiteral(quote: string): void { + i++; // skip opening quote + while (i < chain.length) { + if (chain[i] === "\\") { + i += 2; + continue; + } + if (chain[i] === quote) { + i++; + break; + } + i++; + } + } + + while (i < chain.length) { + const ch = chain[i] ?? ""; + // Skip string literals (handles quotes inside parens correctly) + if (ch === "'" || ch === '"' || ch === "`") { + skipStringLiteral(ch); + continue; + } + if (ch === "(") { + depth++; + i++; + continue; + } + if (ch === ")") { + depth--; + i++; + continue; + } + // Only match `.methodName(` at the top-level chain (depth 0) + if (ch === "." && depth === 0) { + i++; + const nameStart = i; + while (i < chain.length && REGEX_IDENT_CHAR.test(chain[i] ?? "")) { + i++; + } + if (i > nameStart) { + const name = chain.slice(nameStart, i); + while (i < chain.length && REGEX_WHITESPACE.test(chain[i] ?? "")) { + i++; + } + // Skip optional generic type args <...> + if (i < chain.length && chain[i] === "<") { + let angleDepth = 0; + while (i < chain.length) { + const c = chain[i] ?? ""; + if (c === "'" || c === '"' || c === "`") { + skipStringLiteral(c); + continue; + } + if (c === "<") { + angleDepth++; + } else if (c === ">") { + angleDepth--; + if (angleDepth === 0) { + i++; + break; + } + } + i++; + } + while (i < chain.length && REGEX_WHITESPACE.test(chain[i] ?? "")) { + i++; + } + } + if (i < chain.length && chain[i] === "(") { + names.add(name); + } + } + continue; } - m = pattern.exec(chain); + i++; } return names; } diff --git a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts index 15353c9..19c8d74 100644 --- a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts +++ b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts @@ -176,6 +176,55 @@ describe("fmodata generateODataTypes preserves user customizations", () => { } }); + it("preserves user .transform() on Edm.Boolean fields (not confused with nested .transform)", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-")); + + try { + const entitySetName = "MyTable"; + const entityTypeName = "NS.MyTable"; + const metadata = makeMetadata({ + entitySetName, + entityTypeName, + fields: [{ name: "is_active", type: "Edm.Boolean", fieldId: "F1" }], + }); + + const existingFilePath = path.join(tmpDir, "MyTable.ts"); + await fs.writeFile( + existingFilePath, + [ + `import { fmTableOccurrence, numberField } from "@proofkit/fmodata";`, + `import { z } from "zod/v4";`, + "", + `export const MyTable = fmTableOccurrence("MyTable", {`, + ` is_active: numberField().readValidator(z.coerce.boolean()).writeValidator(z.boolean().transform((v) => (v ? 1 : 0))).entityId("F1").transform((v) => !!v),`, + "}, {", + ` entityId: "T1",`, + "});", + "", + ].join("\n"), + "utf8", + ); + + await generateODataTypes(metadata, { + type: "fmodata", + path: tmpDir, + clearOldFiles: false, + tables: [{ tableName: "MyTable" }], + }); + + const regenerated = await fs.readFile(existingFilePath, "utf8"); + // The user-added .transform() should be preserved + expect(regenerated).toContain(".transform((v) => !!v)"); + // readValidator and writeValidator should each appear exactly once + const readValidatorCount = (regenerated.match(/readValidator/g) || []).length; + const writeValidatorCount = (regenerated.match(/writeValidator/g) || []).length; + expect(readValidatorCount).toBe(1); + expect(writeValidatorCount).toBe(1); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("preserves aliased imports when regenerating files", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-"));