From e3469d5920c379d448e966c4da443303f3ebe0d3 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:14:01 -0600 Subject: [PATCH] fix(fmodata): navigate() includes parent table when defaultSelect is schema Fix #107: navigation context wasn't propagated when defaultSelect was "schema" or object because list() returned early before reaching the navigation propagation code. Co-Authored-By: Claude Opus 4.5 --- .../fix-navigate-defaultselect-schema.md | 5 ++ packages/fmodata/scripts/test-crossjoin.ts | 14 +++-- packages/fmodata/src/client/entity-set.ts | 24 ++++++++- packages/fmodata/tests/navigate.test.ts | 53 +++++++++++++++++++ 4 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-navigate-defaultselect-schema.md diff --git a/.changeset/fix-navigate-defaultselect-schema.md b/.changeset/fix-navigate-defaultselect-schema.md new file mode 100644 index 00000000..b59c070d --- /dev/null +++ b/.changeset/fix-navigate-defaultselect-schema.md @@ -0,0 +1,5 @@ +--- +"@proofkit/fmodata": patch +--- + +Fix navigate() not including parent table in URL when defaultSelect is "schema" or object (#107) diff --git a/packages/fmodata/scripts/test-crossjoin.ts b/packages/fmodata/scripts/test-crossjoin.ts index d139ef7d..44907c11 100644 --- a/packages/fmodata/scripts/test-crossjoin.ts +++ b/packages/fmodata/scripts/test-crossjoin.ts @@ -27,16 +27,20 @@ console.log(` FMODATA_SERVER_URL: ${serverUrl ? "✓ set" : "✗ missing"}`); console.log(` FMODATA_API_KEY: ${apiKey ? "✓ set" : "✗ missing"}`); console.log(` FMODATA_DATABASE: ${database ? "✓ set" : "✗ missing"}`); -if (!serverUrl || !apiKey || !database) { +if (!(serverUrl && apiKey && database)) { console.error("Missing required env vars: FMODATA_SERVER_URL, FMODATA_API_KEY, FMODATA_DATABASE"); process.exit(1); } const ffetch = createClient(); +const trailingSlashRegex = /\/+$/; async function makeRequest(path: string): Promise<{ status: number; data: unknown }> { - const cleanBaseUrl = serverUrl!.replace(/\/+$/, ""); - const basePath = `${cleanBaseUrl}/otto/fmi/odata/v4/${encodeURIComponent(database!)}`; + if (!(serverUrl && database)) { + throw new Error("serverUrl and database must be set"); + } + const cleanBaseUrl = serverUrl.replace(trailingSlashRegex, ""); + const basePath = `${cleanBaseUrl}/otto/fmi/odata/v4/${encodeURIComponent(database)}`; const fullUrl = `${basePath}${path.startsWith("/") ? path : `/${path}`}`; console.log(`\n${"=".repeat(80)}`); @@ -58,12 +62,12 @@ async function makeRequest(path: string): Promise<{ status: number; data: unknow } console.log(`Status: ${response.status}`); - console.log(`Response:`); + console.log("Response:"); console.log(JSON.stringify(data, null, 2)); return { status: response.status, data }; } catch (error) { - console.error(`Error:`, error); + console.error("Error:", error); return { status: 0, data: null }; } } diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts index 4f5f59f2..63273a7a 100644 --- a/packages/fmodata/src/client/entity-set.ts +++ b/packages/fmodata/src/client/entity-set.ts @@ -120,7 +120,17 @@ export class EntitySet, DatabaseIncludeSpecialColu // Include special columns if enabled at database level const systemColumns = this.databaseIncludeSpecialColumns ? { ROWID: true, ROWMODID: true } : undefined; - return builder.select(allColumns, systemColumns).top(1000) as QueryBuilder< + const selectedBuilder = builder.select(allColumns, systemColumns).top(1000); + // Propagate navigation context if present + if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (selectedBuilder as any).navigation = { + relation: this.navigateRelation, + sourceTableName: this.navigateSourceTableName, + basePath: this.navigateBasePath, + }; + } + return selectedBuilder as QueryBuilder< Occ, keyof InferSchemaOutputFromFMTable, false, @@ -134,7 +144,17 @@ export class EntitySet, DatabaseIncludeSpecialColu if (typeof defaultSelectValue === "object") { // defaultSelectValue is a select object (Record) // Cast to the declared return type - runtime behavior handles the actual selection - return builder.select(defaultSelectValue as ExtractColumnsFromOcc).top(1000) as QueryBuilder< + const selectedBuilder = builder.select(defaultSelectValue as ExtractColumnsFromOcc).top(1000); + // Propagate navigation context if present + if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (selectedBuilder as any).navigation = { + relation: this.navigateRelation, + sourceTableName: this.navigateSourceTableName, + basePath: this.navigateBasePath, + }; + } + return selectedBuilder as QueryBuilder< Occ, keyof InferSchemaOutputFromFMTable, false, diff --git a/packages/fmodata/tests/navigate.test.ts b/packages/fmodata/tests/navigate.test.ts index 21e66422..a038d8ad 100644 --- a/packages/fmodata/tests/navigate.test.ts +++ b/packages/fmodata/tests/navigate.test.ts @@ -5,9 +5,38 @@ * This validates that navigation properties can be accessed from record instances. */ +import { dateField, fmTableOccurrence, textField } from "@proofkit/fmodata"; import { describe, expect, expectTypeOf, it } from "vitest"; import { arbitraryTable, contacts, createMockClient, invoices, lineItems, users } from "./utils/test-setup"; +const contactsUsersPathRegex = /^\/contacts\/users/; + +// Tables with defaultSelect: "schema" to test issue #107 +const contactsWithSchema = fmTableOccurrence( + "contacts", + { + PrimaryKey: textField().primaryKey(), + name: textField(), + closedDate: dateField(), + }, + { + defaultSelect: "schema", + navigationPaths: ["users"], + }, +); + +const usersWithSchema = fmTableOccurrence( + "users", + { + id: textField().primaryKey(), + name: textField(), + }, + { + defaultSelect: "schema", + navigationPaths: ["contacts"], + }, +); + describe("navigate", () => { const client = createMockClient(); @@ -149,4 +178,28 @@ describe("navigate", () => { // fetchHandler: simpleMock({ status: 200, body: {} }), // }); }); + + // Issue #107: navigate() doesn't include parent table in URL path + // when defaultSelect is "schema" or an object + describe("with defaultSelect='schema' (#107)", () => { + it("should include parent table in URL path", () => { + const db = client.database("test_db"); + const query = db + .from(contactsWithSchema) + .navigate(usersWithSchema) + .list() + .where("contacts/closedDate eq null") + .select({ name: usersWithSchema.name }) + .top(10); + + expect(query.getQueryString()).toContain("/contacts/users"); + }); + + it("should include parent table in URL path without filter", () => { + const db = client.database("test_db"); + const query = db.from(contactsWithSchema).navigate(usersWithSchema).list(); + + expect(query.getQueryString()).toMatch(contactsUsersPathRegex); + }); + }); });