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
5 changes: 5 additions & 0 deletions .changeset/fix-typegen-boolean-validator-duplication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/typegen": patch
---

Fix typegen duplicating readValidator/writeValidator on Edm.Boolean fields during regeneration
112 changes: 108 additions & 4 deletions packages/typegen/src/fmodata/generateODataTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>,
): 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)
Expand All @@ -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);
Expand Down Expand Up @@ -824,10 +833,102 @@ function matchFieldByName(existingFields: Map<string, ParsedField>, 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<string> {
const names = new Set<string>();
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;
}
Expand All @@ -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;
Expand Down
96 changes: 96 additions & 0 deletions packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));

Expand Down
Loading