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: 3 additions & 2 deletions apps/docs/content/docs/typegen/config-odata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}}
/>
Expand All @@ -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[]`)

<Callout type="info">
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.
Expand Down
2 changes: 2 additions & 0 deletions packages/fmodata/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export {
isColumnFunction,
isNotNull,
isNull,
type ListFieldOptions,
listField,
lt,
lte,
matchesPattern,
Expand Down
200 changes: 200 additions & 0 deletions packages/fmodata/src/orm/field-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = { value: T } | { issues: readonly StandardSchemaV1.Issue[] };

export interface ListFieldOptions<TItem = string, TAllowNull extends boolean = false> {
itemValidator?: StandardSchemaV1<unknown, TItem>;
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<TItem>(
items: string[],
itemValidator: StandardSchemaV1<unknown, TItem>,
): ValidationResult<TItem[]> | Promise<ValidationResult<TItem[]>> {
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.
Expand Down Expand Up @@ -170,6 +236,140 @@ export function textField(): FieldBuilder<string | null, string | null, string |
return new FieldBuilder<string | null, string | null, string | null, false>("text");
}

type ListOutput<TItem, TAllowNull extends boolean> = TAllowNull extends true ? TItem[] | null : TItem[];
type ListInput<TItem, TAllowNull extends boolean> = 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<string[], string[], string | null, false>;
export function listField<TAllowNull extends boolean = false>(
options: ListFieldOptions<string, TAllowNull>,
): FieldBuilder<ListOutput<string, TAllowNull>, ListInput<string, TAllowNull>, string | null, false>;
export function listField<TItem, TAllowNull extends boolean = false>(options: {
itemValidator: StandardSchemaV1<unknown, TItem>;
allowNull?: TAllowNull;
}): FieldBuilder<ListOutput<TItem, TAllowNull>, ListInput<TItem, TAllowNull>, string | null, false>;
export function listField<TItem = string, TAllowNull extends boolean = false>(
options?: ListFieldOptions<TItem, TAllowNull>,
): FieldBuilder<ListOutput<TItem, TAllowNull>, ListInput<TItem, TAllowNull>, string | null, false> {
const allowNull = options?.allowNull ?? false;
const itemValidator = options?.itemValidator as StandardSchemaV1<unknown, TItem> | undefined;

const readListSchema: StandardSchemaV1<string | null, ListOutput<TItem, TAllowNull>> = {
"~standard": {
version: 1,
vendor: "proofkit",
validate(input) {
if (input === null || input === undefined || input === "") {
return { value: (allowNull ? null : []) as ListOutput<TItem, TAllowNull> };
}

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<TItem, TAllowNull> };
}

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<TItem, TAllowNull> };
});
}

if ("issues" in validatedItems) {
return validatedItems;
}

return { value: validatedItems.value as ListOutput<TItem, TAllowNull> };
},
},
};

const writeListSchema: StandardSchemaV1<ListInput<TItem, TAllowNull>, 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.
Expand Down
2 changes: 2 additions & 0 deletions packages/fmodata/src/orm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export {
containerField,
dateField,
FieldBuilder,
type ListFieldOptions,
listField,
numberField,
textField,
timeField,
Expand Down
56 changes: 56 additions & 0 deletions packages/fmodata/tests/orm-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
gt,
isColumn,
isColumnFunction,
listField,
matchesPattern,
numberField,
or,
Expand Down Expand Up @@ -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", () => {
Expand Down
22 changes: 22 additions & 0 deletions packages/fmodata/tests/typescript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
fmTableOccurrence,
getTableColumns,
type InferTableSchema,
listField,
numberField,
textField,
} from "@proofkit/fmodata";
Expand Down Expand Up @@ -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<typeof table.tags>();
expectTypeOf(table.tags._phantomOutput).toEqualTypeOf<string[]>();
expectTypeOf(table.optionalTags._phantomOutput).toEqualTypeOf<string[] | null>();
expectTypeOf(table.ids._phantomOutput).toEqualTypeOf<number[]>();
expectTypeOf(table.optionalIds._phantomOutput).toEqualTypeOf<number[] | null>();

const _typeChecks = () => {
// @ts-expect-error - listField options must be an object when options are provided
listField(z.string());
};
_typeChecks;
});
});

describe("BaseTable and TableOccurrence", () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/typegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading