diff --git a/.changeset/odata-string-functions.md b/.changeset/odata-string-functions.md new file mode 100644 index 00000000..b8d68f8f --- /dev/null +++ b/.changeset/odata-string-functions.md @@ -0,0 +1,5 @@ +--- +"@proofkit/fmodata": minor +--- + +Add OData string functions: `matchesPattern`, `tolower`, `toupper`, `trim` diff --git a/apps/docs/content/docs/fmodata/methods.mdx b/apps/docs/content/docs/fmodata/methods.mdx index 3654f081..3aa0c5d3 100644 --- a/apps/docs/content/docs/fmodata/methods.mdx +++ b/apps/docs/content/docs/fmodata/methods.mdx @@ -58,6 +58,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`. | `contains(column, value)` | Contains substring | `contains(users.name, "John")` | | `startsWith(column, value)` | Starts with | `startsWith(users.email, "admin")` | | `endsWith(column, value)` | Ends with | `endsWith(users.email, ".com")` | +| `matchesPattern(column, pattern)` | Matches regex pattern | `matchesPattern(users.name, "^A.*e$")` | ### Array Operators @@ -81,6 +82,25 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`. | `or(...filters)` | Logical OR | `or(eq(users.role, "admin"), eq(users.role, "moderator"))` | | `not(filter)` | Logical NOT | `not(eq(users.active, false))` | +### String Transform Functions + +| Function | Description | Example | +|----------|-------------|---------| +| `tolower(column)` | Convert to lowercase | `eq(tolower(users.name), "john")` | +| `toupper(column)` | Convert to uppercase | `eq(toupper(users.name), "JOHN")` | +| `trim(column)` | Remove leading/trailing whitespace | `eq(trim(users.name), "John")` | + +String transforms can be nested and used with any operator: + +```typescript +// Nested transforms +eq(tolower(trim(users.name)), "john") + +// With other operators +contains(tolower(users.name), "john") +startsWith(toupper(users.email), "ADMIN") +``` + ## Sort Helpers | Helper | Description | Example | diff --git a/apps/docs/content/docs/fmodata/queries.mdx b/apps/docs/content/docs/fmodata/queries.mdx index 1af34f6f..b3629b0f 100644 --- a/apps/docs/content/docs/fmodata/queries.mdx +++ b/apps/docs/content/docs/fmodata/queries.mdx @@ -97,6 +97,7 @@ const result = await db - `contains()` - Contains substring - `startsWith()` - Starts with - `endsWith()` - Ends with +- `matchesPattern()` - Matches regex pattern **Array:** - `inArray()` - Value in array @@ -111,6 +112,11 @@ const result = await db - `or()` - Logical OR - `not()` - Logical NOT +**String Transforms:** +- `tolower()` - Convert to lowercase for comparison +- `toupper()` - Convert to uppercase for comparison +- `trim()` - Remove leading/trailing whitespace + ## Sorting Sort results using `orderBy()` with column references: diff --git a/packages/fmodata/src/index.ts b/packages/fmodata/src/index.ts index f2790349..adee196e 100644 --- a/packages/fmodata/src/index.ts +++ b/packages/fmodata/src/index.ts @@ -61,6 +61,7 @@ export { asc, // Column references type Column, + type ColumnFunction, calcField, containerField, contains, @@ -88,10 +89,12 @@ export { type InferTableSchema, inArray, isColumn, + isColumnFunction, isNotNull, isNull, lt, lte, + matchesPattern, ne, not, notInArray, @@ -104,6 +107,9 @@ export { textField, timeField, timestampField, + tolower, + toupper, + trim, } from "./orm/index"; // Utility types for type annotations export type { diff --git a/packages/fmodata/src/orm/column.ts b/packages/fmodata/src/orm/column.ts index 7abaacf5..1344b259 100644 --- a/packages/fmodata/src/orm/column.ts +++ b/packages/fmodata/src/orm/column.ts @@ -1,4 +1,5 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { needsFieldQuoting } from "../client/builders/select-utils"; /** * Column represents a type-safe reference to a table field. @@ -89,6 +90,56 @@ export function isColumn(value: any): value is Column { return value instanceof Column; } +/** + * ColumnFunction wraps a Column with an OData string function (tolower, toupper, trim). + * Since it extends Column, it passes `isColumn()` checks and works with all existing operators. + * Supports nesting: `tolower(trim(col))` → `tolower(trim(name))`. + */ +export class ColumnFunction< + // biome-ignore lint/suspicious/noExplicitAny: Default type parameter for flexibility + TOutput = any, + TInput = TOutput, + TableName extends string = string, + IsContainer extends boolean = false, +> extends Column { + readonly fnName: string; + readonly innerColumn: Column; + + constructor( + fnName: string, + innerColumn: Column, + ) { + super({ + fieldName: innerColumn.fieldName, + entityId: innerColumn.entityId, + tableName: innerColumn.tableName, + tableEntityId: innerColumn.tableEntityId, + inputValidator: innerColumn.inputValidator, + }); + this.fnName = fnName; + this.innerColumn = innerColumn; + } + + toFilterString(useEntityIds?: boolean): string { + if (isColumnFunction(this.innerColumn)) { + return `${this.fnName}(${this.innerColumn.toFilterString(useEntityIds)})`; + } + const fieldIdentifier = this.innerColumn.getFieldIdentifier(useEntityIds); + const quoted = needsFieldQuoting(fieldIdentifier) + ? `"${fieldIdentifier}"` + : fieldIdentifier; + return `${this.fnName}(${quoted})`; + } +} + +/** + * Type guard to check if a value is a ColumnFunction instance. + */ +// biome-ignore lint/suspicious/noExplicitAny: Type guard accepting any value type +export function isColumnFunction(value: any): value is ColumnFunction { + return value instanceof ColumnFunction; +} + /** * Create a Column with proper type inference from the inputValidator. * This helper ensures TypeScript can infer TInput from the validator's input type. diff --git a/packages/fmodata/src/orm/index.ts b/packages/fmodata/src/orm/index.ts index 4a4057c3..97f4b21f 100644 --- a/packages/fmodata/src/orm/index.ts +++ b/packages/fmodata/src/orm/index.ts @@ -2,7 +2,7 @@ // Field builders - main API for defining table schemas // Column references - used in queries and filters -export { Column, isColumn } from "./column"; +export { Column, ColumnFunction, isColumn, isColumnFunction } from "./column"; export { type ContainerDbType, calcField, @@ -32,6 +32,7 @@ export { isOrderByExpression, lt, lte, + matchesPattern, ne, not, notInArray, @@ -39,6 +40,9 @@ export { OrderByExpression, or, startsWith, + tolower, + toupper, + trim, } from "./operators"; // Table definition - fmTableOccurrence function diff --git a/packages/fmodata/src/orm/operators.ts b/packages/fmodata/src/orm/operators.ts index 732fdea9..e3f9a6e3 100644 --- a/packages/fmodata/src/orm/operators.ts +++ b/packages/fmodata/src/orm/operators.ts @@ -1,6 +1,5 @@ import { needsFieldQuoting } from "../client/builders/select-utils"; -import type { Column } from "./column"; -import { isColumn } from "./column"; +import { type Column, ColumnFunction, isColumn, isColumnFunction } from "./column"; /** * FilterExpression represents a filter condition that can be used in where() clauses. @@ -48,6 +47,8 @@ export class FilterExpression { return this._functionOp("startswith", useEntityIds); case "endsWith": return this._functionOp("endswith", useEntityIds); + case "matchesPattern": + return this._functionOp("matchesPattern", useEntityIds); // Null checks case "isNull": @@ -152,6 +153,10 @@ export class FilterExpression { useEntityIds?: boolean, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration column?: Column, ): string { + if (isColumnFunction(operand)) { + return operand.toFilterString(useEntityIds); + } + if (isColumn(operand)) { const fieldIdentifier = operand.getFieldIdentifier(useEntityIds); // Quote field names in OData filters per FileMaker OData API requirements @@ -317,6 +322,59 @@ export function endsWith(column: Column, value return new FilterExpression("endsWith", [column, value]); } +/** + * Matches pattern operator - checks if a string column matches a regex pattern. + * + * @example + * matchesPattern(users.name, "^A.*e$") // name matches regex pattern + */ +export function matchesPattern( + column: Column, + pattern: string, +): FilterExpression { + return new FilterExpression("matchesPattern", [column, pattern]); +} + +// ============================================================================ +// String Transform Functions +// ============================================================================ + +/** + * Wraps a column with OData `tolower()` for case-insensitive comparisons. + * + * @example + * eq(tolower(users.name), "john") // tolower(name) eq 'john' + */ +export function tolower( + column: Column, +): ColumnFunction { + return new ColumnFunction("tolower", column); +} + +/** + * Wraps a column with OData `toupper()` for case-insensitive comparisons. + * + * @example + * eq(toupper(users.name), "JOHN") // toupper(name) eq 'JOHN' + */ +export function toupper( + column: Column, +): ColumnFunction { + return new ColumnFunction("toupper", column); +} + +/** + * Wraps a column with OData `trim()` to remove leading/trailing whitespace. + * + * @example + * eq(trim(users.name), "John") // trim(name) eq 'John' + */ +export function trim( + column: Column, +): ColumnFunction { + return new ColumnFunction("trim", column); +} + // ============================================================================ // Array Operators // ============================================================================ diff --git a/packages/fmodata/tests/filters.test.ts b/packages/fmodata/tests/filters.test.ts index 9073e820..9ed45811 100644 --- a/packages/fmodata/tests/filters.test.ts +++ b/packages/fmodata/tests/filters.test.ts @@ -25,11 +25,15 @@ import { isNull, lt, lte, + matchesPattern, ne, notInArray, or, startsWith, textField, + tolower, + toupper, + trim, } from "@proofkit/fmodata"; import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; @@ -469,4 +473,58 @@ describe("Filter Tests", () => { expect(queryStringNotInArray).toContain("$filter"); expect(queryStringNotInArray).toContain("static-value"); }); + + it("should support matchesPattern operator", () => { + const query = db.from(contacts).list().where(matchesPattern(contacts.name, "^A.*e$")); + expect(query.getQueryString()).toContain("matchesPattern(name, '^A.*e$')"); + }); + + it("should support tolower transform with eq", () => { + const query = db.from(contacts).list().where(eq(tolower(contacts.name), "john")); + expect(query.getQueryString()).toContain("tolower(name) eq 'john'"); + }); + + it("should support toupper transform with eq", () => { + const query = db.from(contacts).list().where(eq(toupper(contacts.name), "JOHN")); + expect(query.getQueryString()).toContain("toupper(name) eq 'JOHN'"); + }); + + it("should support trim transform with eq", () => { + const query = db.from(contacts).list().where(eq(trim(contacts.name), "John")); + expect(query.getQueryString()).toContain("trim(name) eq 'John'"); + }); + + it("should support nested transforms", () => { + const query = db.from(contacts).list().where(eq(tolower(trim(contacts.name)), "john")); + expect(query.getQueryString()).toContain("tolower(trim(name)) eq 'john'"); + }); + + it("should quote field names inside transforms", () => { + const weirdTable = fmTableOccurrence( + "weird_table", + { + id: textField().primaryKey(), + "name with spaces": textField(), + }, + { defaultSelect: "all" }, + ); + const query = db.from(weirdTable).list().where(eq(tolower(weirdTable["name with spaces"]), "john")); + expect(query.getQueryString()).toContain('tolower("name with spaces") eq \'john\''); + }); + + it("should support transforms with other operators", () => { + const containsQuery = db.from(contacts).list().where(contains(tolower(contacts.name), "john")); + expect(containsQuery.getQueryString()).toContain("contains(tolower(name), 'john')"); + + const startsQuery = db.from(contacts).list().where(startsWith(toupper(contacts.name), "J")); + expect(startsQuery.getQueryString()).toContain("startswith(toupper(name), 'J')"); + + const neQuery = db.from(contacts).list().where(ne(trim(contacts.name), "John")); + expect(neQuery.getQueryString()).toContain("trim(name) ne 'John'"); + }); + + it("should support transforms with entity IDs", () => { + const query = db.from(usersTOWithIds).list().where(eq(tolower(usersTOWithIds.name), "john")); + expect(query.getQueryString()).toContain("tolower(FMFID:6) eq 'john'"); + }); }); diff --git a/packages/fmodata/tests/orm-api.test.ts b/packages/fmodata/tests/orm-api.test.ts index 5a9e0510..926b8347 100644 --- a/packages/fmodata/tests/orm-api.test.ts +++ b/packages/fmodata/tests/orm-api.test.ts @@ -7,10 +7,15 @@ import { fmTableOccurrence, gt, isColumn, + isColumnFunction, + matchesPattern, numberField, or, textField, timestampField, + tolower, + toupper, + trim, } from "@proofkit/fmodata"; import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; @@ -245,6 +250,35 @@ describe("ORM API", () => { const expr = eq(users.name, "O'Brien"); expect(expr.toODataFilter(false)).toBe("name eq 'O''Brien'"); }); + + it("should create matchesPattern operator", () => { + const expr = matchesPattern(users.name, "^A.*e$"); + expect(expr.operator).toBe("matchesPattern"); + expect(expr.toODataFilter(false)).toBe("matchesPattern(name, '^A.*e$')"); + }); + + it("should create tolower column function", () => { + const col = tolower(users.name); + expect(isColumnFunction(col)).toBe(true); + expect(isColumn(col)).toBe(true); + expect(col.toFilterString(false)).toBe("tolower(name)"); + + const expr = eq(col, "john"); + expect(expr.toODataFilter(false)).toBe("tolower(name) eq 'john'"); + }); + + it("should serialize nested column functions", () => { + const col = tolower(trim(users.name)); + expect(col.toFilterString(false)).toBe("tolower(trim(name))"); + + const expr = eq(col, "john"); + expect(expr.toODataFilter(false)).toBe("tolower(trim(name)) eq 'john'"); + }); + + it("should use entity IDs in column functions", () => { + const col = toupper(users.name); + expect(col.toFilterString(true)).toBe("toupper(FMFID:2)"); + }); }); describe("Type Safety", () => { diff --git a/skills/proofkit-fmodata/references/fmodata-api.md b/skills/proofkit-fmodata/references/fmodata-api.md index 6809c0b6..8b2c9b01 100644 --- a/skills/proofkit-fmodata/references/fmodata-api.md +++ b/skills/proofkit-fmodata/references/fmodata-api.md @@ -164,11 +164,32 @@ import { eq, ne, gt, gte, lt, lte } from "@proofkit/fmodata"; ### String ```typescript -import { contains, startsWith, endsWith } from "@proofkit/fmodata"; +import { contains, startsWith, endsWith, matchesPattern } from "@proofkit/fmodata"; .where(contains(Users.email, "@example.com")) .where(startsWith(Users.name, "John")) .where(endsWith(Users.email, ".com")) +.where(matchesPattern(Users.name, "^A.*e$")) +``` + +### String Transforms + +```typescript +import { tolower, toupper, trim } from "@proofkit/fmodata"; + +// Case-insensitive comparison +.where(eq(tolower(Users.name), "john")) +.where(eq(toupper(Users.name), "JOHN")) + +// Trim whitespace +.where(eq(trim(Users.name), "John")) + +// Nest transforms +.where(eq(tolower(trim(Users.name)), "john")) + +// Use with any operator +.where(contains(tolower(Users.name), "john")) +.where(startsWith(toupper(Users.email), "ADMIN")) ``` ### Array