From dddea102ae2ce6a42b2dad5e1b53866e01ca9d98 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:41:33 -0600 Subject: [PATCH 1/2] feat(fmodata,typegen): add list type override with preserved custom list options --- .../content/docs/typegen/config-odata.mdx | 5 +- packages/fmodata/src/index.ts | 2 + packages/fmodata/src/orm/field-builders.ts | 200 ++++++++++ packages/fmodata/src/orm/index.ts | 2 + packages/fmodata/tests/orm-api.test.ts | 42 +++ packages/fmodata/tests/typescript.test.ts | 22 ++ packages/typegen/package.json | 6 +- .../typegen/src/fmodata/generateODataTypes.ts | 156 +++++++- packages/typegen/src/types.ts | 3 +- .../fmodata-type-overrides.snap.ts | 3 +- .../fmodata-preserve-customizations.test.ts | 86 +++++ .../typegen/tests/e2e/fmodata-typegen.test.ts | 2 +- packages/typegen/typegen.schema.json | 351 +++++++++++++----- packages/typegen/vitest.config.ts | 11 +- packages/typegen/vitest.e2e.config.ts | 10 + .../metadata-fields-dialog/fieldsColumns.tsx | 1 + .../metadata-fields-dialog/types.ts | 4 +- 17 files changed, 777 insertions(+), 129 deletions(-) create mode 100644 packages/typegen/vitest.e2e.config.ts diff --git a/apps/docs/content/docs/typegen/config-odata.mdx b/apps/docs/content/docs/typegen/config-odata.mdx index 9ea8fd12..45cb4659 100644 --- a/apps/docs/content/docs/typegen/config-odata.mdx +++ b/apps/docs/content/docs/typegen/config-odata.mdx @@ -212,10 +212,10 @@ Within each table's `fields` array, you can specify field-level overrides. description: "If true, this field will be excluded from generation", }, typeOverride: { - type: `"text" | "number" | "boolean" | "date" | "timestamp" | "container"`, + type: `"text" | "number" | "boolean" | "date" | "timestamp" | "container" | "list"`, required: false, description: - "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container", + "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list", }, }} /> @@ -235,6 +235,7 @@ Override the inferred field type from metadata. The available options are: - `"date"`: Treats the field as a date field - `"timestamp"`: Treats the field as a timestamp field - `"container"`: Treats the field as a container field +- `"list"`: Treats the field as a FileMaker return-delimited list via `listField()` (defaults to `string[]`) The typegen tool will attempt to infer the correct field type from the OData metadata. Use `typeOverride` only when you need to override the inferred type. diff --git a/packages/fmodata/src/index.ts b/packages/fmodata/src/index.ts index adee196e..709a31d7 100644 --- a/packages/fmodata/src/index.ts +++ b/packages/fmodata/src/index.ts @@ -92,6 +92,8 @@ export { isColumnFunction, isNotNull, isNull, + type ListFieldOptions, + listField, lt, lte, matchesPattern, diff --git a/packages/fmodata/src/orm/field-builders.ts b/packages/fmodata/src/orm/field-builders.ts index 8acd23aa..0d1d6750 100644 --- a/packages/fmodata/src/orm/field-builders.ts +++ b/packages/fmodata/src/orm/field-builders.ts @@ -7,6 +7,72 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; */ export type ContainerDbType = string & { readonly __container: true }; +const FILEMAKER_LIST_DELIMITER = "\r"; +const FILEMAKER_NEWLINE_REGEX = /\r\n|\n/g; + +type ValidationResult = { value: T } | { issues: readonly StandardSchemaV1.Issue[] }; + +export interface ListFieldOptions { + itemValidator?: StandardSchemaV1; + allowNull?: TAllowNull; +} + +function normalizeFileMakerNewlines(value: string): string { + return value.replace(FILEMAKER_NEWLINE_REGEX, FILEMAKER_LIST_DELIMITER); +} + +function splitFileMakerList(value: string): string[] { + const normalized = normalizeFileMakerNewlines(value); + if (normalized === "") { + return []; + } + return normalized.split(FILEMAKER_LIST_DELIMITER); +} + +function issue(message: string): StandardSchemaV1.Issue { + return { message }; +} + +function validateItemsWithSchema( + items: string[], + itemValidator: StandardSchemaV1, +): ValidationResult | Promise> { + const validations = items.map((item) => itemValidator["~standard"].validate(item)); + const hasAsyncValidation = validations.some((result) => result instanceof Promise); + + const finalize = (results: Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>) => { + const transformed: TItem[] = []; + const issues: StandardSchemaV1.Issue[] = []; + + results.forEach((result, index) => { + if ("issues" in result && result.issues) { + for (const validationIssue of result.issues) { + issues.push({ + ...validationIssue, + path: validationIssue.path ? [index, ...validationIssue.path] : [index], + }); + } + return; + } + if ("value" in result) { + transformed.push(result.value); + } + }); + + if (issues.length > 0) { + return { issues }; + } + + return { value: transformed }; + }; + + if (hasAsyncValidation) { + return Promise.all(validations).then((results) => finalize(results)); + } + + return finalize(validations as Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>); +} + /** * FieldBuilder provides a fluent API for defining table fields with type-safe metadata. * Supports chaining methods to configure primary keys, nullability, read-only status, entity IDs, and validators. @@ -170,6 +236,140 @@ export function textField(): FieldBuilder("text"); } +type ListOutput = TAllowNull extends true ? TItem[] | null : TItem[]; +type ListInput = TAllowNull extends true ? TItem[] | null : TItem[]; + +/** + * Create a text-backed FileMaker return-delimited list field. + * By default, null/empty input is normalized to an empty array (`allowNull: false`). + * + * @example + * listField() // output: string[], input: string[] + * listField({ allowNull: true }) // output/input: string[] | null + * listField({ itemValidator: z.coerce.number().int() }) // output/input: number[] + */ +export function listField(): FieldBuilder; +export function listField( + options: ListFieldOptions, +): FieldBuilder, ListInput, string | null, false>; +export function listField(options: { + itemValidator: StandardSchemaV1; + allowNull?: TAllowNull; +}): FieldBuilder, ListInput, string | null, false>; +export function listField( + options?: ListFieldOptions, +): FieldBuilder, ListInput, string | null, false> { + const allowNull = options?.allowNull ?? false; + const itemValidator = options?.itemValidator as StandardSchemaV1 | undefined; + + const readListSchema: StandardSchemaV1> = { + "~standard": { + version: 1, + vendor: "proofkit", + validate(input) { + if (input === null || input === undefined || input === "") { + return { value: (allowNull ? null : []) as ListOutput }; + } + + if (typeof input !== "string") { + return { issues: [issue("Expected a FileMaker list string or null")] }; + } + + const items = splitFileMakerList(input); + if (!itemValidator) { + return { value: items as ListOutput }; + } + + const validatedItems = validateItemsWithSchema(items, itemValidator); + if (validatedItems instanceof Promise) { + return validatedItems.then((result) => { + if ("issues" in result) { + return result; + } + return { value: result.value as ListOutput }; + }); + } + + if ("issues" in validatedItems) { + return validatedItems; + } + + return { value: validatedItems.value as ListOutput }; + }, + }, + }; + + const writeListSchema: StandardSchemaV1, string | null> = { + "~standard": { + version: 1, + vendor: "proofkit", + validate(input) { + if (input === null || input === undefined) { + return { value: allowNull ? null : "" }; + } + + if (!Array.isArray(input)) { + return { issues: [issue("Expected an array for FileMaker list field input")] }; + } + + if (!itemValidator) { + const nonStringItem = input.find((item) => typeof item !== "string"); + if (nonStringItem !== undefined) { + return { issues: [issue("Expected all list items to be strings without an itemValidator")] }; + } + const serialized = input.map((item) => normalizeFileMakerNewlines(item)).join(FILEMAKER_LIST_DELIMITER); + return { value: serialized }; + } + + const validateInputItems = input.map((item) => itemValidator["~standard"].validate(item)); + const hasAsyncValidation = validateInputItems.some((result) => result instanceof Promise); + + const serializeValidated = ( + results: Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>, + ): { value: string } | { issues: readonly StandardSchemaV1.Issue[] } => { + const validatedItems: TItem[] = []; + const issues: StandardSchemaV1.Issue[] = []; + + results.forEach((result, index) => { + if ("issues" in result && result.issues) { + for (const validationIssue of result.issues) { + issues.push({ + ...validationIssue, + path: validationIssue.path ? [index, ...validationIssue.path] : [index], + }); + } + return; + } + + if ("value" in result) { + validatedItems.push(result.value); + } + }); + + if (issues.length > 0) { + return { issues }; + } + + const serialized = validatedItems + .map((item) => normalizeFileMakerNewlines(typeof item === "string" ? item : String(item))) + .join(FILEMAKER_LIST_DELIMITER); + return { value: serialized }; + }; + + if (hasAsyncValidation) { + return Promise.all(validateInputItems).then((results) => serializeValidated(results)); + } + + return serializeValidated( + validateInputItems as Array<{ value: TItem } | { issues: readonly StandardSchemaV1.Issue[] }>, + ); + }, + }, + }; + + return textField().readValidator(readListSchema).writeValidator(writeListSchema); +} + /** * Create a number field (Edm.Decimal in FileMaker OData). * By default, number fields are nullable. diff --git a/packages/fmodata/src/orm/index.ts b/packages/fmodata/src/orm/index.ts index 97f4b21f..57d56cdd 100644 --- a/packages/fmodata/src/orm/index.ts +++ b/packages/fmodata/src/orm/index.ts @@ -9,6 +9,8 @@ export { containerField, dateField, FieldBuilder, + type ListFieldOptions, + listField, numberField, textField, timeField, diff --git a/packages/fmodata/tests/orm-api.test.ts b/packages/fmodata/tests/orm-api.test.ts index 926b8347..3ce7fd64 100644 --- a/packages/fmodata/tests/orm-api.test.ts +++ b/packages/fmodata/tests/orm-api.test.ts @@ -8,6 +8,7 @@ import { gt, isColumn, isColumnFunction, + listField, matchesPattern, numberField, or, @@ -67,6 +68,47 @@ describe("ORM API", () => { expect(config.outputValidator).toBe(readValidator); expect(config.inputValidator).toBe(writeValidator); }); + + it("should normalize list fields to empty arrays by default", async () => { + const field = listField(); + const config = field._getConfig(); + + const readValidator = config.outputValidator; + const writeValidator = config.inputValidator; + expect(readValidator).toBeDefined(); + expect(writeValidator).toBeDefined(); + + const readNull = await readValidator?.["~standard"].validate(null); + expect(readNull).toEqual({ value: [] }); + + const readNewlines = await readValidator?.["~standard"].validate("A\r\nB\nC"); + expect(readNewlines).toEqual({ value: ["A", "B", "C"] }); + + const writeArray = await writeValidator?.["~standard"].validate(["A", "B", "C"]); + expect(writeArray).toEqual({ value: "A\rB\rC" }); + }); + + it("should allow nullable list fields via allowNull option", async () => { + const field = listField({ allowNull: true }); + const config = field._getConfig(); + + const readNull = await config.outputValidator?.["~standard"].validate(null); + expect(readNull).toEqual({ value: null }); + + const writeNull = await config.inputValidator?.["~standard"].validate(null); + expect(writeNull).toEqual({ value: null }); + }); + + it("should validate and transform list items with itemValidator", async () => { + const field = listField({ itemValidator: z.coerce.number().int() }); + const config = field._getConfig(); + + const readResult = await config.outputValidator?.["~standard"].validate("1\r2\r3"); + expect(readResult).toEqual({ value: [1, 2, 3] }); + + const writeResult = await config.inputValidator?.["~standard"].validate([1, 2, 3]); + expect(writeResult).toEqual({ value: "1\r2\r3" }); + }); }); describe("Table Definition", () => { diff --git a/packages/fmodata/tests/typescript.test.ts b/packages/fmodata/tests/typescript.test.ts index 9d1b6e8a..1d02a3a4 100644 --- a/packages/fmodata/tests/typescript.test.ts +++ b/packages/fmodata/tests/typescript.test.ts @@ -25,6 +25,7 @@ import { fmTableOccurrence, getTableColumns, type InferTableSchema, + listField, numberField, textField, } from "@proofkit/fmodata"; @@ -214,6 +215,27 @@ describe("fmodata", () => { expect(queryBuilder.getQueryString).toBeDefined(); expect(typeof queryBuilder.getQueryString()).toBe("string"); }); + + it("should infer listField nullability and item types from options", () => { + const table = fmTableOccurrence("ListTypes", { + tags: listField(), + optionalTags: listField({ allowNull: true }), + ids: listField({ itemValidator: z.coerce.number().int() }), + optionalIds: listField({ itemValidator: z.coerce.number().int(), allowNull: true }), + }); + + expectTypeOf(table.tags).toEqualTypeOf(); + expectTypeOf(table.tags._phantomOutput).toEqualTypeOf(); + expectTypeOf(table.optionalTags._phantomOutput).toEqualTypeOf(); + expectTypeOf(table.ids._phantomOutput).toEqualTypeOf(); + expectTypeOf(table.optionalIds._phantomOutput).toEqualTypeOf(); + + const _typeChecks = () => { + // @ts-expect-error - listField options must be an object when options are provided + listField(z.string()); + }; + _typeChecks; + }); }); describe("BaseTable and TableOccurrence", () => { diff --git a/packages/typegen/package.json b/packages/typegen/package.json index f90dcc92..dacef61d 100644 --- a/packages/typegen/package.json +++ b/packages/typegen/package.json @@ -8,9 +8,9 @@ "dev": "pnpm build:watch", "dev:ui": "concurrently -n \"web,api\" -c \"cyan,magenta\" \"pnpm -C web dev\" \"pnpm run dev:api\"", "dev:api": "concurrently -n \"build,server\" -c \"cyan,magenta\" \"pnpm build:watch\" \"nodemon --watch dist/esm --delay 1 --exec 'node dist/esm/cli.js ui --port 3141 --no-open'\"", - "test": "vitest run", - "test:watch": "vitest --watch", - "test:e2e": "doppler run -- vitest run tests/e2e", + "test": "vitest run --config vitest.config.ts", + "test:watch": "vitest --watch --config vitest.config.ts", + "test:e2e": "doppler run -- vitest run --config vitest.e2e.config.ts", "typecheck": "tsc --noEmit", "build": "pnpm -C web build && pnpm vite build && node scripts/build-copy.js && publint --strict", "build:watch": "vite build --watch", diff --git a/packages/typegen/src/fmodata/generateODataTypes.ts b/packages/typegen/src/fmodata/generateODataTypes.ts index deaa063c..3e5f7673 100644 --- a/packages/typegen/src/fmodata/generateODataTypes.ts +++ b/packages/typegen/src/fmodata/generateODataTypes.ts @@ -22,6 +22,7 @@ const REGEX_ENTITY_ID = /\.entityId\(['"]([^'"]+)['"]\)/; const REGEX_FROM_MODULE = /from\s+['"]([^'"]+)['"]/; const REGEX_NAMED_IMPORTS = /\{([^}]+)\}/; const REGEX_IMPORT_ALIAS = /^(\w+)(?:\s+as\s+\w+)?$/; +const REGEX_LEADING_WHITESPACE = /^\s*/; interface GeneratedTO { varName: string; @@ -38,7 +39,7 @@ interface GeneratedTO { * Maps type override enum values to field builder functions from @proofkit/fmodata */ function mapTypeOverrideToFieldBuilder( - typeOverride: "text" | "number" | "boolean" | "date" | "timestamp" | "container", + typeOverride: "text" | "number" | "boolean" | "date" | "timestamp" | "container" | "list", ): string { switch (typeOverride) { case "text": @@ -53,6 +54,8 @@ function mapTypeOverrideToFieldBuilder( return "timestampField()"; case "container": return "containerField()"; + case "list": + return "listField()"; default: return "textField()"; } @@ -74,6 +77,7 @@ function applyAliasToFieldBuilder(fieldBuilder: string, importAliases: Map, needsZod: boolean): str if (usedFieldBuilders.has("containerField")) { fieldBuilderImports.push("containerField"); } + if (usedFieldBuilders.has("listField")) { + fieldBuilderImports.push("listField"); + } const imports = [`import { ${fieldBuilderImports.join(", ")} } from "@proofkit/fmodata"`]; @@ -921,6 +938,88 @@ function extractMethodNamesFromChain(chain: string): Set { return names; } +/** + * Extract the leading function call from a chain (e.g. `listField({...})`). + */ +function extractLeadingCall(chain: string): { name: string; text: string; end: number } | null { + const trimmed = chain.trimStart(); + const leadingWhitespaceLength = chain.length - trimmed.length; + + let i = 0; + while (i < trimmed.length && REGEX_IDENT_CHAR.test(trimmed[i] ?? "")) { + i++; + } + if (i === 0) { + return null; + } + + const name = trimmed.slice(0, i); + while (i < trimmed.length && REGEX_WHITESPACE.test(trimmed[i] ?? "")) { + i++; + } + + if (trimmed[i] !== "(") { + return null; + } + + let depth = 0; + let idx = i; + while (idx < trimmed.length) { + const ch = trimmed[idx] ?? ""; + if (ch === "'" || ch === '"' || ch === "`") { + const quote = ch; + idx++; + while (idx < trimmed.length) { + const qch = trimmed[idx] ?? ""; + if (qch === "\\") { + idx += 2; + continue; + } + if (qch === quote) { + idx++; + break; + } + idx++; + } + continue; + } + + if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + if (depth === 0) { + const end = idx + 1; + return { + name, + text: trimmed.slice(0, end), + end: end + leadingWhitespaceLength, + }; + } + } + idx++; + } + + return null; +} + +function splitFieldLinePrefix(newChain: string): { prefix: string; initializer: string } | null { + const separatorIndex = newChain.indexOf(":"); + if (separatorIndex === -1) { + return null; + } + + const prefix = newChain.slice(0, separatorIndex + 1); + const remainder = newChain.slice(separatorIndex + 1); + const leadingWhitespace = remainder.match(REGEX_LEADING_WHITESPACE)?.[0] ?? ""; + const initializer = remainder.slice(leadingWhitespace.length); + + return { + prefix: prefix + leadingWhitespace, + initializer, + }; +} + /** * Preserves user customizations from an existing field chain */ @@ -928,26 +1027,59 @@ function preserveUserCustomizations( existingField: ParsedField | undefined, newChain: string, fieldBuilder: string, + generatedFieldBuilder?: string, ): string { if (!existingField) { return newChain; } + let effectiveNewChain = newChain; + const isGeneratedListField = (generatedFieldBuilder ?? fieldBuilder).includes("listField("); + if (isGeneratedListField) { + const split = splitFieldLinePrefix(effectiveNewChain); + const existingLeadingCall = extractLeadingCall(existingField.fullChainText); + const generatedLeadingCall = split ? extractLeadingCall(split.initializer) : null; + + // If this field is still list-typed, preserve user-authored listField({...}) options + // by reusing the existing leading call and keeping the new generated suffix. + if ( + split && + existingLeadingCall && + generatedLeadingCall && + existingLeadingCall.name === generatedLeadingCall.name + ) { + const newInitializer = + existingLeadingCall.text + + split.initializer.slice(generatedLeadingCall.end).replace(REGEX_LEADING_WHITESPACE, ""); + effectiveNewChain = split.prefix + newInitializer; + } + } + const standardMethods = [".primaryKey()", ".readOnly()", ".notNull()", ".entityId(", ".comment("]; // Determine where the generator-owned base builder chain ends in the new chain // (before any standard methods added by the generator). - let baseChainEnd = newChain.length; + let baseChainEnd = effectiveNewChain.length; for (const method of standardMethods) { - const idx = newChain.indexOf(method); + const idx = effectiveNewChain.indexOf(method); if (idx !== -1 && idx < baseChainEnd) { baseChainEnd = idx; } } - const baseBuilderPrefix = newChain.slice(0, baseChainEnd); + const baseBuilderPrefix = effectiveNewChain.slice(0, baseChainEnd); const existingChainText = existingField.fullChainText; - const existingBaseEnd = existingChainText.startsWith(baseBuilderPrefix) ? baseBuilderPrefix.length : 0; + let existingBaseEnd = existingChainText.startsWith(baseBuilderPrefix) ? baseBuilderPrefix.length : 0; + + // For builder calls with nested method calls in arguments (e.g. listField({ itemValidator: z.enum(...) })), + // use the end of the existing leading builder call as the base boundary. This prevents nested calls + // inside arguments from being misidentified as outer user customizations. + const split = splitFieldLinePrefix(effectiveNewChain); + const existingLeadingCall = extractLeadingCall(existingChainText); + const generatedLeadingCall = split ? extractLeadingCall(split.initializer) : null; + if (existingLeadingCall && generatedLeadingCall && existingLeadingCall.name === generatedLeadingCall.name) { + existingBaseEnd = existingLeadingCall.end; + } // Methods in the generated field builder (e.g. readValidator, writeValidator for // Edm.Boolean) are generator-owned and must not be duplicated as user customizations. @@ -955,11 +1087,11 @@ function preserveUserCustomizations( const userCustomizations = extractUserCustomizations(existingChainText, existingBaseEnd, generatorMethods); if (!userCustomizations) { - return newChain; + return effectiveNewChain; } // Append extracted user customizations to the regenerated chain - return newChain + userCustomizations; + return effectiveNewChain + userCustomizations; } /** diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts index dd020883..3fbcaffb 100644 --- a/packages/typegen/src/types.ts +++ b/packages/typegen/src/types.ts @@ -104,11 +104,12 @@ const fieldOverride = z.object({ "date", // dateField() "timestamp", // timestampField() "container", // containerField() + "list", // listField() ]) .optional() .meta({ description: - "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container", + "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list", }), }); diff --git a/packages/typegen/tests/__snapshots__/fmodata-type-overrides.snap.ts b/packages/typegen/tests/__snapshots__/fmodata-type-overrides.snap.ts index 5629a3d7..1423cb6f 100644 --- a/packages/typegen/tests/__snapshots__/fmodata-type-overrides.snap.ts +++ b/packages/typegen/tests/__snapshots__/fmodata-type-overrides.snap.ts @@ -5,6 +5,7 @@ import { dateField, timestampField, containerField, + listField, } from "@proofkit/fmodata"; import { z } from "zod/v4"; @@ -29,7 +30,7 @@ export const Customers = fmTableOccurrence( .readValidator(z.coerce.boolean()) .writeValidator(z.boolean().transform((v) => (v ? 1 : 0))) .entityId("FMFID:100007"), - birth_date: textField().entityId("FMFID:100008"), + birth_date: listField().entityId("FMFID:100008"), created_at: timestampField().notNull().entityId("FMFID:100009"), modified_at: timestampField().entityId("FMFID:100010"), full_name: textField().readOnly().entityId("FMFID:100011"), diff --git a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts index 2c3fb3f7..ffed62dc 100644 --- a/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts +++ b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts @@ -320,4 +320,90 @@ describe("fmodata generateODataTypes preserves user customizations", () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it("preserves listField options when typeOverride remains list", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-")); + + try { + const metadata = makeMetadata({ + entitySetName: "MyTable", + entityTypeName: "NS.MyTable", + fields: [{ name: "tags", type: "Edm.String", fieldId: "F1" }], + }); + + const existingFilePath = path.join(tmpDir, "MyTable.ts"); + await fs.writeFile( + existingFilePath, + [ + `import { fmTableOccurrence, listField } from "@proofkit/fmodata";`, + `import { z } from "zod/v4";`, + "", + `export const MyTable = fmTableOccurrence("MyTable", {`, + ` tags: listField({ itemValidator: z.enum(["A", "B", "C"]), allowNull: true }).entityId("F1").transform((vals) => vals),`, + "}, {", + ` entityId: "T1",`, + "});", + "", + ].join("\n"), + "utf8", + ); + + await generateODataTypes(metadata, { + type: "fmodata", + path: tmpDir, + clearOldFiles: false, + tables: [{ tableName: "MyTable", fields: [{ fieldName: "tags", typeOverride: "list" }] }], + }); + + const regenerated = await fs.readFile(existingFilePath, "utf8"); + expect(regenerated).toContain(`tags: listField({ itemValidator: z.enum(["A", "B", "C"]), allowNull: true })`); + expect(regenerated).toContain(".transform((vals) => vals)"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("drops listField options when typeOverride changes away from list", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-fmodata-preserve-")); + + try { + const metadata = makeMetadata({ + entitySetName: "MyTable", + entityTypeName: "NS.MyTable", + fields: [{ name: "tags", type: "Edm.String", fieldId: "F1" }], + }); + + const existingFilePath = path.join(tmpDir, "MyTable.ts"); + await fs.writeFile( + existingFilePath, + [ + `import { fmTableOccurrence, listField } from "@proofkit/fmodata";`, + `import { z } from "zod/v4";`, + "", + `export const MyTable = fmTableOccurrence("MyTable", {`, + ` tags: listField({ itemValidator: z.enum(["A", "B", "C"]), allowNull: true }).entityId("F1"),`, + "}, {", + ` entityId: "T1",`, + "});", + "", + ].join("\n"), + "utf8", + ); + + await generateODataTypes(metadata, { + type: "fmodata", + path: tmpDir, + clearOldFiles: false, + tables: [{ tableName: "MyTable", fields: [{ fieldName: "tags", typeOverride: "text" }] }], + }); + + const regenerated = await fs.readFile(existingFilePath, "utf8"); + expect(regenerated).toContain(`tags: textField().entityId("F1")`); + expect(regenerated).not.toContain("listField("); + expect(regenerated).not.toContain("itemValidator"); + expect(regenerated).not.toContain("allowNull"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/typegen/tests/e2e/fmodata-typegen.test.ts b/packages/typegen/tests/e2e/fmodata-typegen.test.ts index b70e96f9..9527f2f0 100644 --- a/packages/typegen/tests/e2e/fmodata-typegen.test.ts +++ b/packages/typegen/tests/e2e/fmodata-typegen.test.ts @@ -491,7 +491,7 @@ describe("fmodata typegen - snapshot tests", () => { { fieldName: "email", typeOverride: "timestamp" }, { fieldName: "age", typeOverride: "boolean" }, { fieldName: "balance", typeOverride: "boolean" }, - { fieldName: "birth_date", typeOverride: "text" }, + { fieldName: "birth_date", typeOverride: "list" }, { fieldName: "notes", typeOverride: "container" }, ], }, diff --git a/packages/typegen/typegen.schema.json b/packages/typegen/typegen.schema.json index 5e2b8024..fc72fc9a 100644 --- a/packages/typegen/typegen.schema.json +++ b/packages/typegen/typegen.schema.json @@ -4,18 +4,22 @@ "properties": { "postGenerateCommand": { "description": "Optional CLI command to run after files are generated. Commonly used for formatting. Example: 'pnpm biome format --write .' or 'npx prettier --write src/'", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/__schema0" + } + ] }, "config": { "anyOf": [ { "type": "array", "items": { - "$ref": "#/definitions/__schema0" + "$ref": "#/definitions/__schema1" } }, { - "$ref": "#/definitions/__schema0" + "$ref": "#/definitions/__schema1" } ] } @@ -24,6 +28,9 @@ "additionalProperties": false, "definitions": { "__schema0": { + "type": "string" + }, + "__schema1": { "oneOf": [ { "type": "object", @@ -36,7 +43,7 @@ "type": "string" }, "envNames": { - "$ref": "#/definitions/__schema1" + "$ref": "#/definitions/__schema2" }, "layouts": { "default": [], @@ -45,25 +52,36 @@ "type": "object", "properties": { "layoutName": { - "description": "The layout name from your FileMaker solution", - "type": "string" + "type": "string", + "description": "The layout name from your FileMaker solution" }, "schemaName": { - "description": "A friendly name for the generated layout-specific client", - "type": "string" + "type": "string", + "description": "A friendly name for the generated layout-specific client" }, "valueLists": { "description": "If set to 'strict', the value lists will be validated to ensure that the values are correct. If set to 'allowEmpty', the value lists will be validated to ensure that the values are correct, but empty value lists will be allowed. If set to 'ignore', the value lists will not be validated and typed as `string`.", - "type": "string", - "enum": ["strict", "allowEmpty", "ignore"] + "allOf": [ + { + "$ref": "#/definitions/__schema4" + } + ] }, "generateClient": { "description": "If true, a layout-specific client will be generated (unless set to `false` at the top level)", - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/__schema5" + } + ] }, "strictNumbers": { "description": "If true, number fields will be typed as `number | null`. It's false by default because sometimes very large number will be returned as scientific notation via the Data API and therefore the type will be `number | string`.", - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/__schema6" + } + ] } }, "required": ["layoutName", "schemaName"], @@ -71,38 +89,42 @@ } }, "path": { - "$ref": "#/definitions/__schema2" + "$ref": "#/definitions/__schema7" }, "clearOldFiles": { - "$ref": "#/definitions/__schema3" + "$ref": "#/definitions/__schema9" }, "validator": { "description": "If set to 'zod', 'zod/v4', or 'zod/v3', the validator will be generated using zod, otherwise it will generated Typescript types only and no runtime validation will be performed", - "default": "zod/v4", - "anyOf": [ - { - "type": "string", - "enum": ["zod", "zod/v4", "zod/v3"] - }, + "allOf": [ { - "type": "boolean", - "const": false + "$ref": "#/definitions/__schema11" } ] }, "clientSuffix": { "description": "The suffix to be added to the schema name for each layout", - "default": "Layout", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/__schema12" + } + ] }, "generateClient": { "description": "If true, a layout-specific client will be generated for each layout provided, otherwise it will only generate the types. This option can be overridden for each layout individually.", - "default": true, - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/__schema13" + } + ] }, "webviewerScriptName": { "description": "The name of the webviewer script to be used. If this key is set, the generated client will use the @proofkit/webviewer adapter instead of the OttoFMS or Fetch adapter, which will only work when loaded inside of a FileMaker webviewer.", - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/__schema14" + } + ] } }, "required": ["type", "layouts"], @@ -121,92 +143,48 @@ "envNames": { "allOf": [ { - "$ref": "#/definitions/__schema1" + "$ref": "#/definitions/__schema2" } ] }, "path": { - "$ref": "#/definitions/__schema2" + "$ref": "#/definitions/__schema7" }, "reduceMetadata": { "description": "If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/__schema15" + } + ] }, "clearOldFiles": { - "$ref": "#/definitions/__schema3" + "$ref": "#/definitions/__schema9" }, "alwaysOverrideFieldNames": { "description": "If true (default), field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", - "default": true, - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/__schema16" + } + ] }, "tables": { - "description": "Required array of tables to generate. Only the tables specified here will be downloaded and generated. Each table can have field-level overrides for excluding fields, renaming variables, and overriding field types.", "default": [], - "type": "array", - "items": { - "type": "object", - "properties": { - "tableName": { - "description": "The entity set name (table occurrence name) to generate. This table will be included in metadata download and type generation.", - "type": "string" - }, - "variableName": { - "description": "Override the generated TypeScript variable name. The original entity set name is still used for the OData path.", - "type": "string" - }, - "fields": { - "description": "Field-specific overrides as an array", - "type": "array", - "items": { - "type": "object", - "properties": { - "fieldName": { - "description": "The field name this override applies to", - "type": "string" - }, - "exclude": { - "description": "If true, this field will be excluded from generation", - "type": "boolean" - }, - "typeOverride": { - "description": "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container", - "type": "string", - "enum": [ - "text", - "number", - "boolean", - "date", - "timestamp", - "container" - ] - } - }, - "required": ["fieldName"], - "additionalProperties": false - } - }, - "reduceMetadata": { - "description": "If undefined, the top-level setting will be used. If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", - "type": "boolean" - }, - "alwaysOverrideFieldNames": { - "description": "If undefined, the top-level setting will be used. If true, field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", - "type": "boolean" - }, - "includeAllFieldsByDefault": { - "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", - "type": "boolean" - } - }, - "required": ["tableName"], - "additionalProperties": false - } + "description": "Required array of tables to generate. Only the tables specified here will be downloaded and generated. Each table can have field-level overrides for excluding fields, renaming variables, and overriding field types.", + "allOf": [ + { + "$ref": "#/definitions/__schema17" + } + ] }, "includeAllFieldsByDefault": { "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", - "default": true, - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/__schema25" + } + ] } }, "required": ["type", "tables"], @@ -214,8 +192,15 @@ } ] }, - "__schema1": { + "__schema2": { "description": "If you're using other environment variables than the default, custom the NAMES of them here for the typegen to lookup their values when it runs.", + "allOf": [ + { + "$ref": "#/definitions/__schema3" + } + ] + }, + "__schema3": { "type": "object", "properties": { "server": { @@ -242,15 +227,185 @@ }, "additionalProperties": false }, - "__schema2": { + "__schema4": { + "type": "string", + "enum": ["strict", "allowEmpty", "ignore"] + }, + "__schema5": { + "type": "boolean" + }, + "__schema6": { + "type": "boolean" + }, + "__schema7": { "description": "The folder path to output the generated files", + "allOf": [ + { + "$ref": "#/definitions/__schema8" + } + ] + }, + "__schema8": { "default": "schema", "type": "string" }, - "__schema3": { + "__schema9": { "description": "If false, the path will not be cleared before the new files are written. Only the `client` and `generated` directories are cleared to allow for potential overrides to be kept.", + "allOf": [ + { + "$ref": "#/definitions/__schema10" + } + ] + }, + "__schema10": { "default": false, "type": "boolean" + }, + "__schema11": { + "default": "zod/v4", + "anyOf": [ + { + "type": "string", + "enum": ["zod", "zod/v4", "zod/v3"] + }, + { + "type": "boolean", + "const": false + } + ] + }, + "__schema12": { + "default": "Layout", + "type": "string" + }, + "__schema13": { + "default": true, + "type": "boolean" + }, + "__schema14": { + "type": "string" + }, + "__schema15": { + "type": "boolean" + }, + "__schema16": { + "default": true, + "type": "boolean" + }, + "__schema17": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tableName": { + "type": "string", + "description": "The entity set name (table occurrence name) to generate. This table will be included in metadata download and type generation." + }, + "variableName": { + "description": "Override the generated TypeScript variable name. The original entity set name is still used for the OData path.", + "allOf": [ + { + "$ref": "#/definitions/__schema18" + } + ] + }, + "fields": { + "description": "Field-specific overrides as an array", + "allOf": [ + { + "$ref": "#/definitions/__schema19" + } + ] + }, + "reduceMetadata": { + "description": "If undefined, the top-level setting will be used. If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", + "allOf": [ + { + "$ref": "#/definitions/__schema22" + } + ] + }, + "alwaysOverrideFieldNames": { + "description": "If undefined, the top-level setting will be used. If true, field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", + "allOf": [ + { + "$ref": "#/definitions/__schema23" + } + ] + }, + "includeAllFieldsByDefault": { + "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", + "allOf": [ + { + "$ref": "#/definitions/__schema24" + } + ] + } + }, + "required": ["tableName"], + "additionalProperties": false + } + }, + "__schema18": { + "type": "string" + }, + "__schema19": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fieldName": { + "type": "string", + "description": "The field name this override applies to" + }, + "exclude": { + "description": "If true, this field will be excluded from generation", + "allOf": [ + { + "$ref": "#/definitions/__schema20" + } + ] + }, + "typeOverride": { + "description": "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list", + "allOf": [ + { + "$ref": "#/definitions/__schema21" + } + ] + } + }, + "required": ["fieldName"], + "additionalProperties": false + } + }, + "__schema20": { + "type": "boolean" + }, + "__schema21": { + "type": "string", + "enum": [ + "text", + "number", + "boolean", + "date", + "timestamp", + "container", + "list" + ] + }, + "__schema22": { + "type": "boolean" + }, + "__schema23": { + "type": "boolean" + }, + "__schema24": { + "type": "boolean" + }, + "__schema25": { + "default": true, + "type": "boolean" } } } diff --git a/packages/typegen/vitest.config.ts b/packages/typegen/vitest.config.ts index 594afc5d..2c58fd98 100644 --- a/packages/typegen/vitest.config.ts +++ b/packages/typegen/vitest.config.ts @@ -1,16 +1,9 @@ import { defineConfig } from "vitest/config"; -// import dotenv from "dotenv"; -// import path from "path"; - -// // Load .env.local file explicitly -// dotenv.config({ path: path.resolve(__dirname, ".env.local") }); export default defineConfig({ test: { - testTimeout: 15_000, // 15 seconds, since we're making a network call to FM - setupFiles: ["./tests/setupEnv.ts"], // Add setup file - // Exclude E2E tests from default test runs - // Run E2E tests with: pnpm test:e2e + testTimeout: 15_000, + setupFiles: ["./tests/setupEnv.ts"], exclude: ["**/node_modules/**", "**/dist/**", "tests/e2e/**"], }, }); diff --git a/packages/typegen/vitest.e2e.config.ts b/packages/typegen/vitest.e2e.config.ts new file mode 100644 index 00000000..7fca53ba --- /dev/null +++ b/packages/typegen/vitest.e2e.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 15_000, + setupFiles: ["./tests/setupEnv.ts"], + include: ["tests/e2e/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/typegen/web/src/components/metadata-fields-dialog/fieldsColumns.tsx b/packages/typegen/web/src/components/metadata-fields-dialog/fieldsColumns.tsx index cd46f5c6..e1919ff5 100644 --- a/packages/typegen/web/src/components/metadata-fields-dialog/fieldsColumns.tsx +++ b/packages/typegen/web/src/components/metadata-fields-dialog/fieldsColumns.tsx @@ -133,6 +133,7 @@ export function createFieldsColumns({ Date Timestamp Container + List ); diff --git a/packages/typegen/web/src/components/metadata-fields-dialog/types.ts b/packages/typegen/web/src/components/metadata-fields-dialog/types.ts index ac71c084..fe9bb397 100644 --- a/packages/typegen/web/src/components/metadata-fields-dialog/types.ts +++ b/packages/typegen/web/src/components/metadata-fields-dialog/types.ts @@ -4,7 +4,7 @@ export interface FieldConfig { fieldName: string; exclude?: boolean; - typeOverride?: "text" | "number" | "boolean" | "date" | "timestamp" | "container"; + typeOverride?: "text" | "number" | "boolean" | "date" | "timestamp" | "container" | "list"; } /** @@ -46,7 +46,7 @@ export interface MetadataFieldsDialogProps { /** * Type override options available for field types */ -export type TypeOverrideValue = "text" | "number" | "boolean" | "date" | "timestamp" | "container"; +export type TypeOverrideValue = "text" | "number" | "boolean" | "date" | "timestamp" | "container" | "list"; /** * Stable empty array to prevent infinite re-renders From 40db5f87fa03fa579e4d7aa715fac36f16a5fe41 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:59:06 -0600 Subject: [PATCH 2/2] fix(fmodata): improve listField validation to handle undefined items This commit updates the listField function to use a more efficient check for non-string items in the input array. It also adds a test case to ensure that undefined list items are correctly rejected without throwing an error when no itemValidator is provided, enhancing input validation and error handling. --- packages/fmodata/src/orm/field-builders.ts | 4 ++-- packages/fmodata/tests/orm-api.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/fmodata/src/orm/field-builders.ts b/packages/fmodata/src/orm/field-builders.ts index 0d1d6750..08c52803 100644 --- a/packages/fmodata/src/orm/field-builders.ts +++ b/packages/fmodata/src/orm/field-builders.ts @@ -313,8 +313,8 @@ export function listField( } if (!itemValidator) { - const nonStringItem = input.find((item) => typeof item !== "string"); - if (nonStringItem !== undefined) { + const hasNonStringItem = input.some((item) => typeof item !== "string"); + if (hasNonStringItem) { return { issues: [issue("Expected all list items to be strings without an itemValidator")] }; } const serialized = input.map((item) => normalizeFileMakerNewlines(item)).join(FILEMAKER_LIST_DELIMITER); diff --git a/packages/fmodata/tests/orm-api.test.ts b/packages/fmodata/tests/orm-api.test.ts index 3ce7fd64..264e447c 100644 --- a/packages/fmodata/tests/orm-api.test.ts +++ b/packages/fmodata/tests/orm-api.test.ts @@ -109,6 +109,20 @@ describe("ORM API", () => { const writeResult = await config.inputValidator?.["~standard"].validate([1, 2, 3]); expect(writeResult).toEqual({ value: "1\r2\r3" }); }); + + it("should reject undefined list items without throwing when no itemValidator is provided", async () => { + const field = listField(); + const config = field._getConfig(); + + const writeResult = await config.inputValidator?.["~standard"].validate([ + "A", + undefined, + "C", + ] as unknown as string[]); + expect(writeResult).toEqual({ + issues: [{ message: "Expected all list items to be strings without an itemValidator" }], + }); + }); }); describe("Table Definition", () => {