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/fix-date-filter-quoting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/fmodata": patch
---

Fix unquoted date/time/timestamp values in OData filters and fix `Database.from()` mutating shared `_useEntityIds` state
22 changes: 20 additions & 2 deletions .github/workflows/continuous-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ jobs:
test:
if: github.ref != 'refs/heads/beads-sync'
runs-on: ubuntu-latest
needs: [lint, typecheck]
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -73,7 +72,26 @@ jobs:
build:
if: github.ref != 'refs/heads/beads-sync'
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4

- run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

publish:
if: github.ref != 'refs/heads/beads-sync'
runs-on: ubuntu-latest
needs: [lint, typecheck, test, build]
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down
9 changes: 5 additions & 4 deletions packages/fmodata/src/client/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
readonly webhook: WebhookManager;
private readonly databaseName: string;
private readonly context: ExecutionContext;
private _useEntityIds: boolean;
private readonly _useEntityIds: boolean;
private readonly _includeSpecialColumns: IncludeSpecialColumns;

constructor(
Expand Down Expand Up @@ -85,20 +85,21 @@ export class Database<IncludeSpecialColumns extends boolean = false> {

// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
from<T extends FMTable<any, any>>(table: T): EntitySet<T, IncludeSpecialColumns> {
// Only override database-level useEntityIds if table explicitly sets it
// (not if it's undefined, which would override the database setting)
// Resolve useEntityIds per-call without mutating shared Database state
let useEntityIds = this._useEntityIds;
if (Object.hasOwn(table, FMTable.Symbol.UseEntityIds)) {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access
const tableUseEntityIds = (table as any)[FMTable.Symbol.UseEntityIds];
if (typeof tableUseEntityIds === "boolean") {
this._useEntityIds = tableUseEntityIds;
useEntityIds = tableUseEntityIds;
}
}
return new EntitySet<T, IncludeSpecialColumns>({
occurrence: table as T,
databaseName: this.databaseName,
context: this.context,
database: this,
useEntityIds,
});
}

Expand Down
5 changes: 3 additions & 2 deletions packages/fmodata/src/client/entity-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
context: ExecutionContext;
// biome-ignore lint/suspicious/noExplicitAny: Database type is optional and can be any Database instance
database?: any;
useEntityIds?: boolean;
}) {
this.occurrence = config.occurrence;
this.databaseName = config.databaseName;
this.context = config.context;
this.database = config.database;
// Get useEntityIds from database if available, otherwise default to false
this.databaseUseEntityIds = config.database?._getUseEntityIds ?? false;
// Use explicit useEntityIds if provided, otherwise fall back to database setting
this.databaseUseEntityIds = config.useEntityIds ?? config.database?._getUseEntityIds ?? false;
Copy link

Choose a reason for hiding this comment

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

Navigate loses per-table useEntityIds after mutation fix

Medium Severity

The navigate() method creates a new EntitySet without passing useEntityIds, so it always falls back to config.database?._getUseEntityIds (the original database default). Before this PR, from() mutated the shared _useEntityIds on the database, so navigate() would inherit the per-table override via the database getter. Now that the mutation is correctly removed, navigate() silently loses the resolved useEntityIds from the parent EntitySet. For example, db.from(tableWithEntityIds).navigate(otherTable) will revert to the database default instead of preserving the entity ID setting, potentially causing queries to use field names instead of entity IDs.

Additional Locations (1)

Fix in Cursor Fix in Web

// Get includeSpecialColumns from database if available, otherwise default to false
this.databaseIncludeSpecialColumns = (config.database?._getIncludeSpecialColumns ??
false) as DatabaseIncludeSpecialColumns;
Expand Down
14 changes: 7 additions & 7 deletions packages/fmodata/src/orm/column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class Column<
readonly tableEntityId?: `FMTID:${string}`;
// biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
readonly inputValidator?: StandardSchemaV1<TInput, any>;
readonly fieldType?: string;

// Phantom types for TypeScript inference - never actually hold values
readonly _phantomOutput!: TOutput;
Expand All @@ -36,12 +37,14 @@ export class Column<
tableEntityId?: `FMTID:${string}`;
// biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
inputValidator?: StandardSchemaV1<TInput, any>;
fieldType?: string;
}) {
this.fieldName = config.fieldName;
this.entityId = config.entityId;
this.tableName = config.tableName;
this.tableEntityId = config.tableEntityId;
this.inputValidator = config.inputValidator;
this.fieldType = config.fieldType;
}

/**
Expand Down Expand Up @@ -105,16 +108,14 @@ export class ColumnFunction<
readonly fnName: string;
readonly innerColumn: Column<TOutput, TInput, TableName, IsContainer>;

constructor(
fnName: string,
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,
fieldType: innerColumn.fieldType,
});
this.fnName = fnName;
this.innerColumn = innerColumn;
Expand All @@ -125,9 +126,7 @@ export class ColumnFunction<
return `${this.fnName}(${this.innerColumn.toFilterString(useEntityIds)})`;
}
const fieldIdentifier = this.innerColumn.getFieldIdentifier(useEntityIds);
const quoted = needsFieldQuoting(fieldIdentifier)
? `"${fieldIdentifier}"`
: fieldIdentifier;
const quoted = needsFieldQuoting(fieldIdentifier) ? `"${fieldIdentifier}"` : fieldIdentifier;
return `${this.fnName}(${quoted})`;
}
}
Expand All @@ -152,6 +151,7 @@ export function createColumn<TOutput, TInput, TName extends string, IsContainer
tableEntityId?: `FMTID:${string}`;
// biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
inputValidator?: StandardSchemaV1<TInput, any>;
fieldType?: string;
}): Column<TOutput, TInput, TName, IsContainer> {
return new Column(config) as Column<TOutput, TInput, TName, IsContainer>;
}
6 changes: 6 additions & 0 deletions packages/fmodata/src/orm/operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export class FilterExpression {
}
}

// Date/time/timestamp values must be unquoted in OData filters
const ft = column?.fieldType;
if (ft === "date" || ft === "time" || ft === "timestamp") {
return String(value);
}

if (typeof value === "string") {
return `'${value.replace(/'/g, "''")}'`; // Escape single quotes
}
Expand Down
2 changes: 2 additions & 0 deletions packages/fmodata/src/orm/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export function fmTableOccurrence<
tableName: name,
tableEntityId: options?.entityId,
inputValidator: config.inputValidator,
fieldType: config.fieldType,
});
}

Expand Down Expand Up @@ -734,6 +735,7 @@ export function getTableColumns<T extends FMTable<any, any>>(
tableName,
tableEntityId,
inputValidator: config.inputValidator,
fieldType: config.fieldType,
});
}

Expand Down
85 changes: 75 additions & 10 deletions packages/fmodata/tests/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import {
and,
contains,
dateField,
endsWith,
eq,
fmTableOccurrence,
Expand All @@ -31,6 +32,7 @@ import {
or,
startsWith,
textField,
timestampField,
tolower,
toupper,
trim,
Expand Down Expand Up @@ -480,22 +482,34 @@ describe("Filter Tests", () => {
});

it("should support tolower transform with eq", () => {
const query = db.from(contacts).list().where(eq(tolower(contacts.name), "john"));
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"));
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"));
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"));
const query = db
.from(contacts)
.list()
.where(eq(tolower(trim(contacts.name)), "john"));
expect(query.getQueryString()).toContain("tolower(trim(name)) eq 'john'");
});

Expand All @@ -508,23 +522,74 @@ describe("Filter Tests", () => {
},
{ 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\'');
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"));
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"));
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"));
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"));
const query = db
.from(usersTOWithIds)
.list()
.where(eq(tolower(usersTOWithIds.name), "john"));
expect(query.getQueryString()).toContain("tolower(FMFID:6) eq 'john'");
});

it("should not quote date values in filters", () => {
// FileMaker OData API expects date values WITHOUT single quotes:
// invoiceDate gt 2024-01-01 ✅ correct
// invoiceDate gt '2024-01-01' ❌ FileMaker gets confused
//
// Currently dateField() input type is string, so the value hits the
// string branch in _operandToString and gets single-quoted. This test
// documents the desired behavior.
const dateTable = fmTableOccurrence("invoices", {
id: textField().primaryKey(),
invoiceDate: dateField(),
dueDate: dateField(),
createdAt: timestampField(),
});
// Fresh db to avoid state pollution from prior tests mutating useEntityIds
const freshDb = createMockClient().database("test.fmp12");

const gtQuery = freshDb.from(dateTable).list().where(gt(dateTable.invoiceDate, "2024-01-01"));
expect(gtQuery.getQueryString()).toContain("invoiceDate gt 2024-01-01");

const ltQuery = freshDb.from(dateTable).list().where(lt(dateTable.dueDate, "2025-12-31"));
expect(ltQuery.getQueryString()).toContain("dueDate lt 2025-12-31");

const gteQuery = freshDb.from(dateTable).list().where(gte(dateTable.invoiceDate, "2024-06-15"));
expect(gteQuery.getQueryString()).toContain("invoiceDate ge 2024-06-15");

const lteQuery = freshDb.from(dateTable).list().where(lte(dateTable.dueDate, "2024-06-15"));
expect(lteQuery.getQueryString()).toContain("dueDate le 2024-06-15");

const eqQuery = freshDb.from(dateTable).list().where(eq(dateTable.invoiceDate, "2024-01-01"));
expect(eqQuery.getQueryString()).toContain("invoiceDate eq 2024-01-01");

const tsQuery = freshDb.from(dateTable).list().where(gt(dateTable.createdAt, "2024-01-01T00:00:00Z"));
expect(tsQuery.getQueryString()).toContain("createdAt gt 2024-01-01T00:00:00Z");
});
});
Loading