diff --git a/apps/docs/content/docs/typegen/config-odata.mdx b/apps/docs/content/docs/typegen/config-odata.mdx index 9ea8fd1..45cb465 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 adee196..709a31d 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 8acd23a..08c5280 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 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); + 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 97f4b21..57d56cd 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 926b834..264e447 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,61 @@ 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" }); + }); + + 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", () => { diff --git a/packages/fmodata/tests/typescript.test.ts b/packages/fmodata/tests/typescript.test.ts index 9d1b6e8..1d02a3a 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 f90dcc9..dacef61 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 deaa063..3e5f767 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 dd02088..3fbcaff 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 5629a3d..1423cb6 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 2c3fb3f..ffed62d 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 b70e96f..9527f2f 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 5e2b802..fc72fc9 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 594afc5..2c58fd9 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 0000000..7fca53b --- /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 cd46f5c..e1919ff 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 ac71c08..fe9bb39 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