Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odata-string-functions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/fmodata": minor
---

Add OData string functions: `matchesPattern`, `tolower`, `toupper`, `trim`
20 changes: 20 additions & 0 deletions apps/docs/content/docs/fmodata/methods.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/docs/fmodata/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions packages/fmodata/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
asc,
// Column references
type Column,
type ColumnFunction,
calcField,
containerField,
contains,
Expand Down Expand Up @@ -88,10 +89,12 @@ export {
type InferTableSchema,
inArray,
isColumn,
isColumnFunction,
isNotNull,
isNull,
lt,
lte,
matchesPattern,
ne,
not,
notInArray,
Expand All @@ -104,6 +107,9 @@ export {
textField,
timeField,
timestampField,
tolower,
toupper,
trim,
} from "./orm/index";
// Utility types for type annotations
export type {
Expand Down
51 changes: 51 additions & 0 deletions packages/fmodata/src/orm/column.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -89,6 +90,56 @@ export function isColumn(value: any): value is Column<any, any, any, any> {
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<TOutput, TInput, TableName, IsContainer> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ColumnFunction transform silently ignored in orderBy operations

Low Severity

The new ColumnFunction class extends Column, making it type-compatible with asc() and desc() operators. However, the orderBy serialization in query-builder.ts uses column.fieldName directly, which for ColumnFunction returns only the underlying field name without the function wrapper. This means asc(tolower(users.name)) compiles successfully but the tolower transform is silently ignored, producing $orderby=name asc instead of the expected $orderby=tolower(name) asc. Users attempting case-insensitive sorting would get unexpected results.

Fix in Cursor Fix in Web

readonly fnName: string;
readonly innerColumn: Column<TOutput, TInput, TableName, IsContainer>;

constructor(
fnName: string,
innerColumn: Column<TOutput, TInput, TableName, IsContainer>,
) {
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<any, any, any, any> {
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.
Expand Down
6 changes: 5 additions & 1 deletion packages/fmodata/src/orm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,13 +32,17 @@ export {
isOrderByExpression,
lt,
lte,
matchesPattern,
ne,
not,
notInArray,
// OrderBy operators
OrderByExpression,
or,
startsWith,
tolower,
toupper,
trim,
} from "./operators";

// Table definition - fmTableOccurrence function
Expand Down
62 changes: 60 additions & 2 deletions packages/fmodata/src/orm/operators.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -152,6 +153,10 @@ export class FilterExpression {
useEntityIds?: boolean, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
column?: Column<any, any, any, any>,
): 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
Expand Down Expand Up @@ -317,6 +322,59 @@ export function endsWith<TOutput, TInput>(column: Column<TOutput, TInput>, 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<TOutput extends string | null, TInput>(
column: Column<TOutput, TInput>,
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<TOutput extends string | null, TInput, TableName extends string, IsContainer extends boolean>(
column: Column<TOutput, TInput, TableName, IsContainer>,
): ColumnFunction<TOutput, TInput, TableName, IsContainer> {
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<TOutput extends string | null, TInput, TableName extends string, IsContainer extends boolean>(
column: Column<TOutput, TInput, TableName, IsContainer>,
): ColumnFunction<TOutput, TInput, TableName, IsContainer> {
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<TOutput extends string | null, TInput, TableName extends string, IsContainer extends boolean>(
column: Column<TOutput, TInput, TableName, IsContainer>,
): ColumnFunction<TOutput, TInput, TableName, IsContainer> {
return new ColumnFunction("trim", column);
}

// ============================================================================
// Array Operators
// ============================================================================
Expand Down
58 changes: 58 additions & 0 deletions packages/fmodata/tests/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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'");
});
});
34 changes: 34 additions & 0 deletions packages/fmodata/tests/orm-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading