From 7906ee8d49a2899d27156d41c32ce99fc062b6d7 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:02:30 -0700 Subject: [PATCH] Add ROWID record locators to fmodata --- .changeset/fresh-falcons-grow.md | 9 ++ apps/docs/content/docs/fmodata/crud.mdx | 14 +++ .../content/docs/fmodata/extra-properties.mdx | 6 ++ apps/docs/content/docs/fmodata/methods.mdx | 2 + apps/docs/src/app/(home)/page.tsx | 81 ++++++------------ apps/docs/src/app/docs/(docs)/layout.tsx | 7 +- apps/docs/src/app/docs/templates/layout.tsx | 7 +- packages/fmodata/README.md | 29 +++++++ .../fmodata/skills/fmodata-client/SKILL.md | 20 +++++ .../src/client/builders/mutation-helpers.ts | 36 ++++++-- packages/fmodata/src/client/delete-builder.ts | 25 ++++-- packages/fmodata/src/client/entity-set.ts | 15 +++- .../fmodata/src/client/query/url-builder.ts | 36 +++++--- packages/fmodata/src/client/record-builder.ts | 85 ++++++------------- packages/fmodata/src/client/update-builder.ts | 27 ++++-- packages/fmodata/tests/delete.test.ts | 19 +++++ .../fmodata/tests/mutation-helpers.test.ts | 20 ++++- packages/fmodata/tests/navigate.test.ts | 7 ++ .../record-builder-select-expand.test.ts | 7 ++ packages/fmodata/tests/typescript.test.ts | 3 + packages/fmodata/tests/update.test.ts | 20 +++++ 21 files changed, 318 insertions(+), 157 deletions(-) create mode 100644 .changeset/fresh-falcons-grow.md diff --git a/.changeset/fresh-falcons-grow.md b/.changeset/fresh-falcons-grow.md new file mode 100644 index 00000000..72d788dc --- /dev/null +++ b/.changeset/fresh-falcons-grow.md @@ -0,0 +1,9 @@ +--- +"@proofkit/fmodata": minor +--- + +Add `ROWID` record locator support to `fmodata` single-record APIs. + +- Allow `db.from(table).get({ ROWID: 2 })` +- Add `update(data).byRowId(2)` +- Add `delete().byRowId(2)` diff --git a/apps/docs/content/docs/fmodata/crud.mdx b/apps/docs/content/docs/fmodata/crud.mdx index 3af5096e..1186708d 100644 --- a/apps/docs/content/docs/fmodata/crud.mdx +++ b/apps/docs/content/docs/fmodata/crud.mdx @@ -64,6 +64,13 @@ if (result.data) { console.log(`Updated ${result.data.updatedCount} record(s)`); } +// Update by ROWID +const byRowId = await db + .from(users) + .update({ username: "newname" }) + .byRowId(2) + .execute(); + // Update by filter (using ORM API) import { lt, and, eq } from "@proofkit/fmodata"; @@ -85,6 +92,10 @@ const result = await db All fields are optional for updates (except read-only fields which are automatically excluded). TypeScript will enforce that you can only update fields that aren't marked as read-only. + +For webhook hydrate flows, use `db.from(table).get({ ROWID })` when you only have FileMaker `ROWID` metadata. + + ## Delete Delete records by ID or filter: @@ -97,6 +108,9 @@ if (result.data) { console.log(`Deleted ${result.data.deletedCount} record(s)`); } +// Delete by ROWID +const byRowId = await db.from(users).delete().byRowId(2).execute(); + // Delete by filter (using ORM API) import { eq, and, lt } from "@proofkit/fmodata"; diff --git a/apps/docs/content/docs/fmodata/extra-properties.mdx b/apps/docs/content/docs/fmodata/extra-properties.mdx index 03f9ca62..dadf66e5 100644 --- a/apps/docs/content/docs/fmodata/extra-properties.mdx +++ b/apps/docs/content/docs/fmodata/extra-properties.mdx @@ -34,6 +34,12 @@ const result = await db.from(users).list().execute({ }); ``` +Use `ROWID` to hydrate a record when a webhook payload only gives you system columns: + +```typescript +const result = await db.from(users).get({ ROWID: 2 }).execute(); +``` + Special columns are only included when no `$select` query is applied (per OData specification). When using `.select()`, special columns are excluded even if `includeSpecialColumns` is enabled. diff --git a/apps/docs/content/docs/fmodata/methods.mdx b/apps/docs/content/docs/fmodata/methods.mdx index 3aa0c5d3..d2e757bf 100644 --- a/apps/docs/content/docs/fmodata/methods.mdx +++ b/apps/docs/content/docs/fmodata/methods.mdx @@ -13,6 +13,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`. |--------|-------------|---------| | `list()` | Retrieve multiple records | `db.from(users).list().execute()` | | `get(id)` | Get a single record by ID | `db.from(users).get("user-123").execute()` | +| `get({ ROWID })` | Get a single record by FileMaker `ROWID` | `db.from(users).get({ ROWID: 2 }).execute()` | | `getSingleField(column)` | Get a single field value | `db.from(users).get("user-123").getSingleField(users.email).execute()` | | `single()` | Ensure exactly one record | `db.from(users).list().where(eq(...)).single().execute()` | | `maybeSingle()` | Get at most one record (returns null if none) | `db.from(users).list().where(eq(...)).maybeSingle().execute()` | @@ -24,6 +25,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`. | `insert(data)` | Insert a new record | `db.from(users).insert({ username: "john" }).execute()` | | `update(data)` | Update records | `db.from(users).update({ active: true }).byId("user-123").execute()` | | `delete()` | Delete records | `db.from(users).delete().byId("user-123").execute()` | +| `byRowId(rowId)` | Target a single record by FileMaker `ROWID` | `db.from(users).update({ active: true }).byRowId(2).execute()` | ## Query Modifiers diff --git a/apps/docs/src/app/(home)/page.tsx b/apps/docs/src/app/(home)/page.tsx index 4a0a14f4..c6adc8c4 100644 --- a/apps/docs/src/app/(home)/page.tsx +++ b/apps/docs/src/app/(home)/page.tsx @@ -26,51 +26,34 @@ export default function HomePage() {
- ProofKit Logo + ProofKit Logo
-

- A collection of tools for FileMaker-aware TypeScript applications -

+

A collection of tools for FileMaker-aware TypeScript applications

- For new and experienced developers alike, the ProofKit toolset is - the best way to build web apps connected to FileMaker data, or rich, - interactive interfaces in a FileMaker webviewer. + For new and experienced developers alike, the ProofKit toolset is the best way to build web apps connected + to FileMaker data, or rich, interactive interfaces in a FileMaker webviewer.

} title="ProofKit CLI"> - A command line tool to start a new project, or easily apply - templates and common patterns with{" "} - no JavaScript experience{" "} - required. + A command line tool to start a new project, or easily apply templates and common patterns with{" "} + no JavaScript experience required. } title={"Typegen"}> - Automatically generate runtime validators and TypeScript files - from your own FileMaker layouts or table occurrences. + Automatically generate runtime validators and TypeScript files from your own FileMaker layouts or table + occurrences. - } - title="Filemaker Data API" - > - A type-safe API for your FileMaker layouts. Easily connect without - worrying about token management. + } title="Filemaker Data API"> + A type-safe API for your FileMaker layouts. Easily connect without worrying about token management. } > - A strongly-typed OData API client with full TypeScript inference, - runtime validation, and a fluent query builder. + A strongly-typed OData API client with full TypeScript inference, runtime validation, and a fluent query + builder. - } - title="FileMaker Webviewer" - > - Use async functions in WebViewer code to execute and get the - result of a FileMaker script. + } title="FileMaker Webviewer"> + Use async functions in WebViewer code to execute and get the result of a FileMaker script. } > - Own your authentication with FileMaker and the extensible - Better-Auth framework. + Own your authentication with FileMaker and the extensible Better-Auth framework. @@ -118,8 +95,7 @@ export default function HomePage() {

Quick Start

- Use the ProofKit CLI to launch a full-featured Next.js app in - minutes—no prior experience required. + Use the ProofKit CLI to launch a full-featured Next.js app in minutes—no prior experience required.

@@ -152,11 +128,9 @@ export default function HomePage() { Built for AI Agents

- Every ProofKit package ships with agent skills — built from - decades of combined FileMaker integration experience at Proof — - that give AI coding tools like Claude Code and Cursor the - context they need to write correct, production-ready FileMaker - code from day one. + Every ProofKit package ships with agent skills — built from decades of combined FileMaker integration + experience at Proof — that give AI coding tools like Claude Code and Cursor the context they need to + write correct, production-ready FileMaker code from day one.

@@ -167,9 +141,8 @@ export default function HomePage() { Expert knowledge built in

- Agent skills cover API patterns, edge cases, and common - mistakes so your AI agent avoids the pitfalls that trip up - even experienced developers. + Agent skills cover API patterns, edge cases, and common mistakes so your AI agent avoids the pitfalls + that trip up even experienced developers.

@@ -178,9 +151,8 @@ export default function HomePage() { Type-safe by default

- Schemas generated from your FileMaker field names plus runtime - validators catch bugs early — whether code is written by you - or your AI agent. + Schemas generated from your FileMaker field names plus runtime validators catch bugs early — whether + code is written by you or your AI agent.

@@ -189,9 +161,8 @@ export default function HomePage() { Works with any agent

- Skills are bundled with each package — just install and your - AI coding tool picks them up automatically. Compatible with - Claude Code, Cursor, Windsurf, and more. + Skills are bundled with each package — just install and your AI coding tool picks them up + automatically. Compatible with Claude Code, Cursor, Windsurf, and more.

diff --git a/apps/docs/src/app/docs/(docs)/layout.tsx b/apps/docs/src/app/docs/(docs)/layout.tsx index d2ff4c32..3dd42bbc 100644 --- a/apps/docs/src/app/docs/(docs)/layout.tsx +++ b/apps/docs/src/app/docs/(docs)/layout.tsx @@ -16,12 +16,7 @@ export default function Layout({ children }: { children: ReactNode }) {

Made with ❤️ by{" "} - + Proof

diff --git a/apps/docs/src/app/docs/templates/layout.tsx b/apps/docs/src/app/docs/templates/layout.tsx index 6d61877e..43537437 100644 --- a/apps/docs/src/app/docs/templates/layout.tsx +++ b/apps/docs/src/app/docs/templates/layout.tsx @@ -86,12 +86,7 @@ export default async function Layout({ children }: { children: ReactNode }) {

Made with ❤️ by{" "} - + Proof

diff --git a/packages/fmodata/README.md b/packages/fmodata/README.md index 78930b36..b5ecd2e1 100644 --- a/packages/fmodata/README.md +++ b/packages/fmodata/README.md @@ -258,6 +258,16 @@ if (result.data) { } ``` +Get a specific record by `ROWID`: + +```typescript +const result = await db.from(users).get({ ROWID: 2 }).execute(); + +if (result.data) { + console.log(result.data.username); +} +``` + Get a single field value: ```typescript @@ -531,6 +541,13 @@ if (result.data) { console.log(`Updated ${result.data.updatedCount} record(s)`); } +// Update by ROWID +const byRowId = await db + .from(users) + .update({ username: "newname" }) + .byRowId(2) + .execute(); + // Update by filter (using new ORM API) import { lt, and, eq } from "@proofkit/fmodata"; @@ -567,6 +584,9 @@ if (result.data) { console.log(`Deleted ${result.data.deletedCount} record(s)`); } +// Delete by ROWID +const byRowId = await db.from(users).delete().byRowId(2).execute(); + // Delete by filter (using new ORM API) import { eq, and, lt } from "@proofkit/fmodata"; @@ -1471,6 +1491,15 @@ const result = await db.from(users).list().execute({ }); ``` +Use `ROWID` to hydrate a single record when you only have webhook metadata: + +```typescript +const result = await db + .from(users) + .get({ ROWID: 2 }) + .execute(); +``` + **Important:** Special columns are only included when no `$select` query is applied (per OData specification). When using `.select()`, special columns are excluded even if `includeSpecialColumns` is enabled. ### Error Handling diff --git a/packages/fmodata/skills/fmodata-client/SKILL.md b/packages/fmodata/skills/fmodata-client/SKILL.md index 620531f5..1c1299c2 100644 --- a/packages/fmodata/skills/fmodata-client/SKILL.md +++ b/packages/fmodata/skills/fmodata-client/SKILL.md @@ -129,6 +129,9 @@ if (result.data) { // Get single record by ID const one = await db.from(contacts).get("abc-123").execute(); +// Get single record by FileMaker ROWID +const byRowId = await db.from(contacts).get({ ROWID: 2 }).execute(); + // single() -- error if != 1 result; maybeSingle() -- null if 0, error if > 1 const exact = await db .from(contacts) @@ -171,6 +174,13 @@ const updated = await db .byId("abc-123") .execute(); +// Update by ROWID +const updatedByRowId = await db + .from(contacts) + .update({ phone: "+1-555-0100" }) + .byRowId(2) + .execute(); + // Update by filter const bulk = await db .from(contacts) @@ -181,6 +191,9 @@ const bulk = await db // Delete by ID const deleted = await db.from(contacts).delete().byId("abc-123").execute(); +// Delete by ROWID +const deletedByRowId = await db.from(contacts).delete().byRowId(2).execute(); + // Delete by filter const bulkDel = await db .from(contacts) @@ -230,6 +243,13 @@ const orders = await db .navigate(invoices) .execute(); +// Navigate starting from a ROWID-located record +const ordersByRowId = await db + .from(contacts) + .get({ ROWID: 2 }) + .navigate(invoices) + .execute(); + // expand -- includes related records inline const withInvoices = await db .from(contacts) diff --git a/packages/fmodata/src/client/builders/mutation-helpers.ts b/packages/fmodata/src/client/builders/mutation-helpers.ts index b1572705..a275e946 100644 --- a/packages/fmodata/src/client/builders/mutation-helpers.ts +++ b/packages/fmodata/src/client/builders/mutation-helpers.ts @@ -10,10 +10,36 @@ const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/; type MutationMode = "byId" | "byFilter"; +export interface RowIdRecordLocator { + ROWID: number; +} + +export type RecordLocator = string | number | RowIdRecordLocator; + export interface FilterQueryBuilder { getQueryString(options?: { useEntityIds?: boolean }): string; } +function isRowIdRecordLocator(recordLocator: RecordLocator): recordLocator is RowIdRecordLocator { + return typeof recordLocator === "object" && recordLocator !== null && "ROWID" in recordLocator; +} + +function escapeODataStringLiteral(value: string): string { + return value.replaceAll("'", "''"); +} + +export function buildRecordLocatorSegment(recordLocator: RecordLocator): string { + if (isRowIdRecordLocator(recordLocator)) { + return `(ROWID=${recordLocator.ROWID})`; + } + + return `('${escapeODataStringLiteral(String(recordLocator))}')`; +} + +export function buildRecordPath(pathPrefix: string, recordLocator: RecordLocator): string { + return `${pathPrefix}${buildRecordLocatorSegment(recordLocator)}`; +} + export function mergeMutationExecuteOptions( options: (RequestInit & FFetchOptions & ExecuteOptions) | undefined, databaseUseEntityIds: boolean, @@ -41,18 +67,18 @@ export function buildMutationUrl(config: { tableId: string; tableName: string; mode: MutationMode; - recordId?: string | number; + recordLocator?: RecordLocator; queryBuilder?: FilterQueryBuilder; useEntityIds?: boolean; builderName: string; }): string { - const { databaseName, tableId, tableName, mode, recordId, queryBuilder, useEntityIds, builderName } = config; + const { databaseName, tableId, tableName, mode, recordLocator, queryBuilder, useEntityIds, builderName } = config; if (mode === "byId") { - if (recordId === undefined || recordId === null || recordId === "") { - throw new BuilderInvariantError(builderName, "recordId is required for byId mode"); + if (recordLocator === undefined || recordLocator === null || recordLocator === "") { + throw new BuilderInvariantError(builderName, "recordLocator is required for byId mode"); } - return `/${databaseName}/${tableId}('${recordId}')`; + return `/${databaseName}/${buildRecordPath(tableId, recordLocator)}`; } if (!queryBuilder) { diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts index 8da749b4..9ed757f1 100644 --- a/packages/fmodata/src/client/delete-builder.ts +++ b/packages/fmodata/src/client/delete-builder.ts @@ -9,6 +9,7 @@ import { buildMutationUrl, extractAffectedRows, mergeMutationExecuteOptions, + type RecordLocator, resolveMutationTableId, } from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; @@ -43,7 +44,19 @@ export class DeleteBuilder> { occurrence: this.table, layer: this.layer, mode: "byId", - recordId: id, + recordLocator: id, + }); + } + + /** + * Delete a single record by ROWID + */ + byRowId(rowId: number): ExecutableDeleteBuilder { + return new ExecutableDeleteBuilder({ + occurrence: this.table, + layer: this.layer, + mode: "byId", + recordLocator: { ROWID: rowId }, }); } @@ -80,7 +93,7 @@ export class ExecutableDeleteBuilder> { private readonly table: Occ; private readonly mode: "byId" | "byFilter"; - private readonly recordId?: string | number; + private readonly recordLocator?: RecordLocator; private readonly queryBuilder?: QueryBuilder; private readonly layer: FMODataLayer; private readonly config: ODataConfig; @@ -89,13 +102,13 @@ export class ExecutableDeleteBuilder> occurrence: Occ; layer: FMODataLayer; mode: "byId" | "byFilter"; - recordId?: string | number; + recordLocator?: RecordLocator; queryBuilder?: QueryBuilder; }) { this.table = config.occurrence; this.layer = config.layer; this.mode = config.mode; - this.recordId = config.recordId; + this.recordLocator = config.recordLocator; this.queryBuilder = config.queryBuilder; this.config = createClientRuntime(this.layer).config; } @@ -111,7 +124,7 @@ export class ExecutableDeleteBuilder> tableId, tableName: getTableName(this.table), mode: this.mode, - recordId: this.recordId, + recordLocator: this.recordLocator, queryBuilder: this.queryBuilder, useEntityIds, builderName: "ExecutableDeleteBuilder", @@ -141,7 +154,7 @@ export class ExecutableDeleteBuilder> tableId, tableName: getTableName(this.table), mode: this.mode, - recordId: this.recordId, + recordLocator: this.recordLocator, queryBuilder: this.queryBuilder, useEntityIds: this.config.useEntityIds, builderName: "ExecutableDeleteBuilder", diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts index 40d0e6ca..6ccea1ee 100644 --- a/packages/fmodata/src/client/entity-set.ts +++ b/packages/fmodata/src/client/entity-set.ts @@ -17,6 +17,7 @@ import { isUsingEntityIds, } from "../orm/table"; import type { FMODataLayer, ODataConfig } from "../services"; +import type { RowIdRecordLocator } from "./builders/mutation-helpers"; import { resolveTableId } from "./builders/table-utils"; import type { Database } from "./database"; import { DeleteBuilder } from "./delete-builder"; @@ -173,9 +174,15 @@ export class EntitySet, DatabaseIncludeSpecialColu } get( - id: string | number, - // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default - ): RecordBuilder, {}, DatabaseIncludeSpecialColumns> { + locator: RowIdRecordLocator | string | number, + ): RecordBuilder< + Occ, + false, + undefined, + keyof InferSchemaOutputFromFMTable, + Record, + DatabaseIncludeSpecialColumns + > { const builder = new RecordBuilder< Occ, false, @@ -187,7 +194,7 @@ export class EntitySet, DatabaseIncludeSpecialColu >({ occurrence: this.occurrence, layer: this.layer, - recordId: id, + recordLocator: locator, }); // Apply defaultSelect if occurrence exists diff --git a/packages/fmodata/src/client/query/url-builder.ts b/packages/fmodata/src/client/query/url-builder.ts index e3f5c9e3..8d9dc54b 100644 --- a/packages/fmodata/src/client/query/url-builder.ts +++ b/packages/fmodata/src/client/query/url-builder.ts @@ -1,12 +1,13 @@ import type { FMTable } from "../../orm/table"; import { getTableName } from "../../orm/table"; +import { buildRecordPath, type RecordLocator } from "../builders/mutation-helpers"; import { resolveTableId } from "../builders/table-utils"; /** * Configuration for navigation from RecordBuilder or EntitySet */ export interface NavigationConfig { - recordId?: string | number; + recordLocator?: RecordLocator; relation: string; relationEntityId?: string; sourceTableName: string; @@ -56,7 +57,7 @@ export class QueryUrlBuilder { const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds); const navigation = options.navigation; - if (navigation?.recordId && navigation?.relation) { + if (navigation?.recordLocator !== undefined && navigation?.relation) { return this.buildRecordNavigation(queryString, tableId, navigation, effectiveUseEntityIds); } if (navigation?.relation) { @@ -85,8 +86,13 @@ export class QueryUrlBuilder { ? (navigation.baseRelationEntityId ?? navigation.baseRelation) : navigation.baseRelation; const relation = useEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation; - const { recordId } = navigation; - const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`; + const { recordLocator } = navigation; + if (recordLocator === undefined) { + throw new Error("recordLocator is required for record navigation"); + } + const base = baseRelation + ? buildRecordPath(`${sourceTable}/${baseRelation}`, recordLocator) + : buildRecordPath(sourceTable, recordLocator); return `/${this.databaseName}/${base}/${relation}${queryString}`; } @@ -118,7 +124,7 @@ export class QueryUrlBuilder { const navigation = options?.navigation; const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds); - if (navigation?.recordId && navigation?.relation) { + if (navigation?.recordLocator !== undefined && navigation?.relation) { const sourceTable = effectiveUseEntityIds ? (navigation.sourceTableEntityId ?? navigation.sourceTableName) : navigation.sourceTableName; @@ -128,8 +134,13 @@ export class QueryUrlBuilder { const relation = effectiveUseEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation; - const { recordId } = navigation; - const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`; + const { recordLocator } = navigation; + if (recordLocator === undefined) { + throw new Error("recordLocator is required for record navigation"); + } + const base = baseRelation + ? buildRecordPath(`${sourceTable}/${baseRelation}`, recordLocator) + : buildRecordPath(sourceTable, recordLocator); return queryString ? `/${base}/${relation}${queryString}` : `/${base}/${relation}`; } if (navigation?.relation) { @@ -152,12 +163,12 @@ export class QueryUrlBuilder { * Build URL for record operations (single record by ID). * Used by RecordBuilder to build URLs like /database/table('id'). * - * @param recordId - The record ID + * @param recordLocator - The record locator * @param queryString - The OData query string (e.g., "?$select=...") * @param options - Options including operation type and useEntityIds override */ buildRecordUrl( - recordId: string | number, + recordLocator: RecordLocator, queryString: string, options?: { operation?: "getSingleField"; @@ -175,10 +186,13 @@ export class QueryUrlBuilder { let url: string; if (options?.isNavigateFromEntitySet && options.navigateSourceTableName && options.navigateRelation) { // From navigated EntitySet: /sourceTable/relation('recordId') - url = `/${this.databaseName}/${options.navigateSourceTableName}/${options.navigateRelation}('${recordId}')`; + url = `/${this.databaseName}/${buildRecordPath( + `${options.navigateSourceTableName}/${options.navigateRelation}`, + recordLocator, + )}`; } else { // Normal record: /tableName('recordId') - use FMTID if configured - url = `/${this.databaseName}/${tableId}('${recordId}')`; + url = `/${this.databaseName}/${buildRecordPath(tableId, recordLocator)}`; } if (options?.operation === "getSingleField" && options.operationParam) { diff --git a/packages/fmodata/src/client/record-builder.ts b/packages/fmodata/src/client/record-builder.ts index 4ceeb278..99febbcf 100644 --- a/packages/fmodata/src/client/record-builder.ts +++ b/packages/fmodata/src/client/record-builder.ts @@ -31,6 +31,7 @@ import { processSelectWithRenames, resolveTableId, } from "./builders/index"; +import { buildRecordPath, type RecordLocator } from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; import type { ResolveExpandedRelations, SystemColumnsFromOption, SystemColumnsOption } from "./query/types"; import { QueryBuilder } from "./query-builder"; @@ -110,7 +111,7 @@ export class RecordBuilder< > { private readonly table: Occ; - private readonly recordId: string | number; + private readonly recordLocator: RecordLocator; private readonly operation?: "getSingleField" | "navigate"; private readonly operationParam?: string; // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration @@ -171,10 +172,10 @@ export class RecordBuilder< constructor(config: { occurrence: Occ; layer: FMODataLayer; - recordId: string | number; + recordLocator: RecordLocator; }) { this.table = config.occurrence; - this.recordId = config.recordId; + this.recordLocator = config.recordLocator; const runtime = createClientRuntime(config.layer); this.layer = runtime.layer; this.config = runtime.config; @@ -233,7 +234,7 @@ export class RecordBuilder< >({ occurrence: this.table, layer: this.layer, - recordId: this.recordId, + recordLocator: this.recordLocator, }); // Use type assertion to allow assignment to readonly properties on new instance @@ -285,7 +286,7 @@ export class RecordBuilder< >({ occurrence: this.table, layer: this.layer, - recordId: this.recordId, + recordLocator: this.recordLocator, }); // Use type assertion to allow assignment to readonly properties on new instance @@ -429,7 +430,7 @@ export class RecordBuilder< const newBuilder = new RecordBuilder({ occurrence: this.table, layer: this.layer, - recordId: this.recordId, + recordLocator: this.recordLocator, }); // Use type assertion to allow assignment to readonly properties on new instance @@ -533,7 +534,7 @@ export class RecordBuilder< // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (builder as any).navigation = { - recordId: this.recordId, + recordLocator: this.recordLocator, relation: relationName, relationEntityId, sourceTableName, @@ -564,6 +565,21 @@ export class RecordBuilder< }); } + private buildRecordResourcePath(useEntityIds: boolean): string { + if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { + const sourceSegment = useEntityIds + ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) + : this.navigateSourceTableName; + const relationSegment = useEntityIds + ? (this.navigateRelationEntityId ?? this.navigateRelation) + : this.navigateRelation; + return `/${buildRecordPath(`${sourceSegment}/${relationSegment}`, this.recordLocator)}`; + } + + const tableId = this.getTableId(useEntityIds); + return `/${buildRecordPath(tableId, this.recordLocator)}`; + } + execute( options?: ExecuteMethodOptions, ): Promise< @@ -595,23 +611,9 @@ export class RecordBuilder< > > { const mergedOptions = this.mergeExecuteOptions(options); - let url: string; - - // Build the base URL depending on whether this came from a navigated EntitySet - if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { - const sourceSegment = mergedOptions.useEntityIds - ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) - : this.navigateSourceTableName; - const relationSegment = mergedOptions.useEntityIds - ? (this.navigateRelationEntityId ?? this.navigateRelation) - : this.navigateRelation; - // From navigated EntitySet: /sourceTable/relation('recordId') - url = `/${this.config.databaseName}/${sourceSegment}/${relationSegment}('${this.recordId}')`; - } else { - // Normal record: /tableName('recordId') - use FMTID if configured - const tableId = this.getTableId(mergedOptions.useEntityIds); - url = `/${this.config.databaseName}/${tableId}('${this.recordId}')`; - } + let url = `/${this.config.databaseName}${this.buildRecordResourcePath( + mergedOptions.useEntityIds ?? this.config.useEntityIds, + )}`; if (this.operation === "getSingleField" && this.operationColumn) { url += `/${this.operationColumn.getFieldIdentifier(mergedOptions.useEntityIds)}`; @@ -671,23 +673,7 @@ export class RecordBuilder< // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { - let url: string; - - // Build the base URL depending on whether this came from a navigated EntitySet - if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { - const sourceSegment = this.config.useEntityIds - ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) - : this.navigateSourceTableName; - const relationSegment = this.config.useEntityIds - ? (this.navigateRelationEntityId ?? this.navigateRelation) - : this.navigateRelation; - // From navigated EntitySet: /sourceTable/relation('recordId') - url = `/${this.config.databaseName}/${sourceSegment}/${relationSegment}('${this.recordId}')`; - } else { - // For batch operations, use database-level setting (no per-request override available here) - const tableId = this.getTableId(this.config.useEntityIds); - url = `/${this.config.databaseName}/${tableId}('${this.recordId}')`; - } + let url = `/${this.config.databaseName}${this.buildRecordResourcePath(this.config.useEntityIds)}`; if (this.operation === "getSingleField" && this.operationColumn) { // Use the column's getFieldIdentifier to support entity IDs @@ -712,22 +698,7 @@ export class RecordBuilder< */ getQueryString(options?: { useEntityIds?: boolean }): string { const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds; - let path: string; - - // Build the path depending on navigation context - if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { - const sourceSegment = useEntityIds - ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) - : this.navigateSourceTableName; - const relationSegment = useEntityIds - ? (this.navigateRelationEntityId ?? this.navigateRelation) - : this.navigateRelation; - path = `/${sourceSegment}/${relationSegment}('${this.recordId}')`; - } else { - // Use getTableId to respect entity ID settings (same as getRequestConfig) - const tableId = this.getTableId(useEntityIds); - path = `/${tableId}('${this.recordId}')`; - } + const path = this.buildRecordResourcePath(useEntityIds); if (this.operation === "getSingleField" && this.operationColumn) { return `${path}/${this.operationColumn.getFieldIdentifier(useEntityIds)}`; diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts index fb614e81..b1d0e9dd 100644 --- a/packages/fmodata/src/client/update-builder.ts +++ b/packages/fmodata/src/client/update-builder.ts @@ -13,6 +13,7 @@ import { buildMutationUrl, extractAffectedRows, mergeMutationExecuteOptions, + type RecordLocator, resolveMutationTableId, } from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; @@ -58,7 +59,21 @@ export class UpdateBuilder< layer: this.layer, data: this.data, mode: "byId", - recordId: id, + recordLocator: id, + returnPreference: this.returnPreference, + }); + } + + /** + * Update a single record by ROWID + */ + byRowId(rowId: number): ExecutableUpdateBuilder { + return new ExecutableUpdateBuilder({ + occurrence: this.table, + layer: this.layer, + data: this.data, + mode: "byId", + recordLocator: { ROWID: rowId }, returnPreference: this.returnPreference, }); } @@ -105,7 +120,7 @@ export class ExecutableUpdateBuilder< private readonly table: Occ; private readonly data: Partial>; private readonly mode: "byId" | "byFilter"; - private readonly recordId?: string | number; + private readonly recordLocator?: RecordLocator; private readonly queryBuilder?: QueryBuilder; private readonly returnPreference: ReturnPreference; private readonly layer: FMODataLayer; @@ -116,7 +131,7 @@ export class ExecutableUpdateBuilder< layer: FMODataLayer; data: Partial>; mode: "byId" | "byFilter"; - recordId?: string | number; + recordLocator?: RecordLocator; queryBuilder?: QueryBuilder; returnPreference: ReturnPreference; }) { @@ -124,7 +139,7 @@ export class ExecutableUpdateBuilder< this.layer = config.layer; this.data = config.data; this.mode = config.mode; - this.recordId = config.recordId; + this.recordLocator = config.recordLocator; this.queryBuilder = config.queryBuilder; this.returnPreference = config.returnPreference; const runtime = createClientRuntime(this.layer); @@ -146,7 +161,7 @@ export class ExecutableUpdateBuilder< tableId, tableName: getTableName(this.table), mode: this.mode, - recordId: this.recordId, + recordLocator: this.recordLocator, queryBuilder: this.queryBuilder, useEntityIds: shouldUseIds, builderName: "ExecutableUpdateBuilder", @@ -217,7 +232,7 @@ export class ExecutableUpdateBuilder< tableId, tableName: getTableName(this.table), mode: this.mode, - recordId: this.recordId, + recordLocator: this.recordLocator, queryBuilder: this.queryBuilder, useEntityIds: this.config.useEntityIds, builderName: "ExecutableUpdateBuilder", diff --git a/packages/fmodata/tests/delete.test.ts b/packages/fmodata/tests/delete.test.ts index 56ac18a6..871a91b2 100644 --- a/packages/fmodata/tests/delete.test.ts +++ b/packages/fmodata/tests/delete.test.ts @@ -49,6 +49,14 @@ describe("delete method", () => { expect(result).toBeInstanceOf(ExecutableDeleteBuilder); }); + it("should return ExecutableDeleteBuilder after byRowId()", () => { + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); + + const result = db.from(usersTO).delete().byRowId(2); + expect(result).toBeInstanceOf(ExecutableDeleteBuilder); + }); + it("should return ExecutableDeleteBuilder after where()", () => { const mock = new MockFMServerConnection(); const db = mock.database("test_db"); @@ -90,6 +98,17 @@ describe("delete method", () => { db.from(usersTO).delete().byId("user-123"); }); + it("should generate correct URL for delete by ROWID", () => { + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); + + const deleteBuilder = db.from(usersTO).delete().byRowId(2); + const config = deleteBuilder.getRequestConfig(); + + expect(config.method).toBe("DELETE"); + expect(config.url).toBe("/test_db/users(ROWID=2)"); + }); + it("should execute delete by ID and return count", async () => { const mock = new MockFMServerConnection(); mock.addRoute({ diff --git a/packages/fmodata/tests/mutation-helpers.test.ts b/packages/fmodata/tests/mutation-helpers.test.ts index 200be0ff..9000d3e0 100644 --- a/packages/fmodata/tests/mutation-helpers.test.ts +++ b/packages/fmodata/tests/mutation-helpers.test.ts @@ -1,5 +1,6 @@ import { buildMutationUrl, + buildRecordLocatorSegment, extractAffectedRows, parseRowIdFromLocationHeader, stripTablePathPrefix, @@ -14,13 +15,30 @@ describe("mutation helpers", () => { tableId: "users", tableName: "users", mode: "byId", - recordId: "abc-123", + recordLocator: "abc-123", builderName: "TestBuilder", }); expect(url).toBe("/test_db/users('abc-123')"); }); + it("builds byId mutation URLs with ROWID locator", () => { + const url = buildMutationUrl({ + databaseName: "test_db", + tableId: "users", + tableName: "users", + mode: "byId", + recordLocator: { ROWID: 2 }, + builderName: "TestBuilder", + }); + + expect(url).toBe("/test_db/users(ROWID=2)"); + }); + + it("escapes string record locators for OData", () => { + expect(buildRecordLocatorSegment("abc'def")).toBe("('abc''def')"); + }); + it("builds byFilter mutation URLs and rewrites table prefix", () => { const queryBuilder = { getQueryString: () => "/users?$filter=name eq 'John'&$top=10", diff --git a/packages/fmodata/tests/navigate.test.ts b/packages/fmodata/tests/navigate.test.ts index cad75add..bd8a6c2c 100644 --- a/packages/fmodata/tests/navigate.test.ts +++ b/packages/fmodata/tests/navigate.test.ts @@ -90,6 +90,13 @@ describe("navigate", () => { ); }); + it("should support navigation from ROWID record locators", () => { + const db = client.database("test_db"); + const queryBuilder = db.from(contacts).get({ ROWID: 2 }).navigate(users); + + expect(queryBuilder.select({ name: users.name }).getQueryString()).toBe("/contacts(ROWID=2)/users?$select=name"); + }); + it("should navigate w/o needing to get a record first", () => { const db = client.database("test_db"); const queryBuilder = db.from(contacts).navigate(users).list(); diff --git a/packages/fmodata/tests/record-builder-select-expand.test.ts b/packages/fmodata/tests/record-builder-select-expand.test.ts index f76f44a9..ae204aa2 100644 --- a/packages/fmodata/tests/record-builder-select-expand.test.ts +++ b/packages/fmodata/tests/record-builder-select-expand.test.ts @@ -165,6 +165,13 @@ describe("RecordBuilder Select/Expand", () => { expect(queryString).not.toContain("$select="); }); + it("should support ROWID locators in get()", () => { + const queryString = db.from(contacts).get({ ROWID: 2 }).getQueryString(); + + expect(queryString).toBe("/contacts(ROWID=2)"); + expect(queryString).not.toContain("$select="); + }); + it("should override defaultSelect when explicit select() is called", () => { const queryString = db .from(contactsWithSchemaSelect) diff --git a/packages/fmodata/tests/typescript.test.ts b/packages/fmodata/tests/typescript.test.ts index 49d1bc48..efab92a5 100644 --- a/packages/fmodata/tests/typescript.test.ts +++ b/packages/fmodata/tests/typescript.test.ts @@ -49,9 +49,12 @@ describe("fmodata", () => { it("should support get() for single record retrieval", () => { const table = db.from(contacts); const getBuilder = table.get("my-uuid"); + const rowIdBuilder = table.get({ ROWID: 2 }); expect(getBuilder).toBeDefined(); expect(getBuilder.getRequestConfig).toBeDefined(); + expect(rowIdBuilder).toBeDefined(); + expect(rowIdBuilder.getRequestConfig).toBeDefined(); }); it("should support getSingleField() API", () => { diff --git a/packages/fmodata/tests/update.test.ts b/packages/fmodata/tests/update.test.ts index 0ac9c89c..0015d938 100644 --- a/packages/fmodata/tests/update.test.ts +++ b/packages/fmodata/tests/update.test.ts @@ -153,6 +153,14 @@ describe("insert and update methods", () => { expect(result).toBeInstanceOf(ExecutableUpdateBuilder); }); + it("should return ExecutableUpdateBuilder after byRowId()", () => { + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); + + const result = db.from(users).update({ username: "newname" }).byRowId(2); + expect(result).toBeInstanceOf(ExecutableUpdateBuilder); + }); + it("should return ExecutableUpdateBuilder after where()", () => { const mock = new MockFMServerConnection(); const db = mock.database("test_db"); @@ -188,6 +196,18 @@ describe("insert and update methods", () => { expectTypeOf(updateBuilder.execute).returns.resolves.toEqualTypeOf>(); }); + it("should generate correct URL for update by ROWID", () => { + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); + + const updateBuilder = db.from(users).update({ username: "newname" }).byRowId(2); + const config = updateBuilder.getRequestConfig(); + + expect(config.method).toBe("PATCH"); + expect(config.url).toBe("/test_db/users(ROWID=2)"); + expect(config.body).toBe(JSON.stringify({ username: "newname" })); + }); + it("should execute update by ID and return count", async () => { const mock = new MockFMServerConnection(); mock.addRoute({