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..7e3edce 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,102 @@ 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(); + 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; + } + i++; + } + 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 +949,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..19c8d74 100644 --- a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts +++ b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts @@ -129,6 +129,102 @@ 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 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-"));