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

Fix navigate() not including parent table in URL when defaultSelect is "schema" or object (#107)
14 changes: 9 additions & 5 deletions packages/fmodata/scripts/test-crossjoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand All @@ -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 };
}
}
Expand Down
24 changes: 22 additions & 2 deletions packages/fmodata/src/client/entity-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,17 @@ export class EntitySet<Occ extends FMTable<any, any>, 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<Occ>,
false,
Expand All @@ -134,7 +144,17 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
if (typeof defaultSelectValue === "object") {
// defaultSelectValue is a select object (Record<string, Column>)
// Cast to the declared return type - runtime behavior handles the actual selection
return builder.select(defaultSelectValue as ExtractColumnsFromOcc<Occ>).top(1000) as QueryBuilder<
const selectedBuilder = builder.select(defaultSelectValue as ExtractColumnsFromOcc<Occ>).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<Occ>,
false,
Expand Down
53 changes: 53 additions & 0 deletions packages/fmodata/tests/navigate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
});
});
});
Loading