From 222c01b5fa940d74953e25bd363efbd0c387cb9d Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:02:25 -0600 Subject: [PATCH] expands with empty select --- .beads/metadata.json | 2 +- .../src/client/builders/default-select.ts | 4 +- .../src/client/builders/expand-builder.ts | 4 +- packages/fmodata/tests/expands.test.ts | 97 ++++++++++++++++++- .../web/src/components/ui/data-grid.tsx | 1 - 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/.beads/metadata.json b/.beads/metadata.json index c787975e..f581edc0 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,4 +1,4 @@ { "database": "beads.db", "jsonl_export": "issues.jsonl" -} \ No newline at end of file +} diff --git a/packages/fmodata/src/client/builders/default-select.ts b/packages/fmodata/src/client/builders/default-select.ts index 233a6cff..c271bad6 100644 --- a/packages/fmodata/src/client/builders/default-select.ts +++ b/packages/fmodata/src/client/builders/default-select.ts @@ -47,7 +47,9 @@ export function getDefaultSelectFields( fields.push("ROWID", "ROWMODID"); } - return fields; + // Return undefined (meaning "all") when schema has no fields with validators, + // rather than an empty array which would generate an empty $select= + return fields.length > 0 ? fields : undefined; } if (Array.isArray(defaultSelect)) { diff --git a/packages/fmodata/src/client/builders/expand-builder.ts b/packages/fmodata/src/client/builders/expand-builder.ts index de39fd61..cce196c5 100644 --- a/packages/fmodata/src/client/builders/expand-builder.ts +++ b/packages/fmodata/src/client/builders/expand-builder.ts @@ -214,7 +214,9 @@ export class ExpandBuilder { if (opts.select) { const selectArray = Array.isArray(opts.select) ? opts.select.map(String) : [String(opts.select)]; const selectFields = formatSelectFields(selectArray, config.targetTable, this.useEntityIds); - parts.push(`$select=${selectFields}`); + if (selectFields) { + parts.push(`$select=${selectFields}`); + } } if (opts.filter) { diff --git a/packages/fmodata/tests/expands.test.ts b/packages/fmodata/tests/expands.test.ts index 754d7bdb..8e150753 100644 --- a/packages/fmodata/tests/expands.test.ts +++ b/packages/fmodata/tests/expands.test.ts @@ -7,7 +7,7 @@ * DO NOT RUN THESE TESTS YET - they define the API we want to build. */ -import { eq, fmTableOccurrence, numberField, textField } from "@proofkit/fmodata"; +import { eq, FMServerConnection, fmTableOccurrence, numberField, textField } from "@proofkit/fmodata"; import { assert, describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; import { mockResponses } from "./fixtures/responses"; @@ -578,3 +578,98 @@ describe("Expand API Specification", () => { }); }); }); + +/** + * GitHub Issue #109: Nested expand generates empty $select= causing OData parse error + * + * When expanding a table that has no readValidators and uses the default "schema" + * defaultSelect, getDefaultSelectFields returns an empty array. This causes + * buildExpandParts to generate "$select=" (empty) inside the expand, which + * FileMaker OData cannot parse. + * + * @see https://github.com/proofkit/proofkit/issues/109 + */ +const EMPTY_SELECT_CLOSE_PAREN = /\$select=\)/; +const EMPTY_SELECT_SEMICOLON = /\$select=;/; +const EMPTY_SELECT_END = /\$select=$/; +const EXPAND_CAPTURE = /\$expand=([^&]+)/; +const SELECT_CAPTURE = /\$select=([^;)]+)/; + +describe("Issue #109: Empty $select in expand", () => { + // Tables matching the issue reproduction: entity IDs, no defaultSelect (defaults to "schema"), + // no readValidators on any fields + const Parent = fmTableOccurrence( + "Parent", + { + _pk: textField().primaryKey().entityId("FMFID:1"), + }, + { + entityId: "FMTID:1", + navigationPaths: ["Child"], + }, + ); + + const Child = fmTableOccurrence( + "Child", + { + _pk: textField().primaryKey().entityId("FMFID:2"), + data: textField().entityId("FMFID:3"), + }, + { + entityId: "FMTID:2", + navigationPaths: ["Parent"], + }, + ); + + const connection = new FMServerConnection({ + serverUrl: "https://example.com", + auth: { username: "test", password: "test" }, + }); + + const db = connection.database("Test.fmp12", { useEntityIds: true }); + + it("should not generate empty $select= inside expand on .get()", () => { + const queryString = db.from(Parent).get("test-id").expand(Child).getQueryString(); + + // The query should NOT contain an empty $select= (i.e. "$select=)" or "$select=;") + expect(queryString).not.toMatch(EMPTY_SELECT_CLOSE_PAREN); + expect(queryString).not.toMatch(EMPTY_SELECT_SEMICOLON); + expect(queryString).not.toMatch(EMPTY_SELECT_END); + }); + + it("should not generate empty $select= inside expand on .list()", () => { + const queryString = db.from(Parent).list().expand(Child).getQueryString(); + + expect(queryString).not.toMatch(EMPTY_SELECT_CLOSE_PAREN); + expect(queryString).not.toMatch(EMPTY_SELECT_SEMICOLON); + }); + + it("should either omit $select or include all fields in expand without callback", () => { + const queryString = db.from(Parent).get("test-id").expand(Child).getQueryString(); + + // If expand includes $select, it must have actual field names + if (queryString.includes("$expand=")) { + const expandMatch = queryString.match(EXPAND_CAPTURE); + expect(expandMatch).toBeTruthy(); + const expandContent = expandMatch?.[1]; + // If there's a $select inside the expand parens, it must not be empty + if (expandContent?.includes("$select=")) { + const selectMatch = expandContent.match(SELECT_CAPTURE); + expect(selectMatch).toBeTruthy(); + expect(selectMatch?.[1]?.length).toBeGreaterThan(0); + } + } + }); + + it("should not generate empty $select= in nested expand without explicit select", () => { + const queryString = db + .from(Parent) + .get("test-id") + .expand(Child, (b) => b.expand(Parent)) + .getQueryString(); + + // Neither the Child expand nor the nested Parent expand should have empty $select= + expect(queryString).not.toMatch(EMPTY_SELECT_CLOSE_PAREN); + expect(queryString).not.toMatch(EMPTY_SELECT_SEMICOLON); + }); +}); diff --git a/packages/typegen/web/src/components/ui/data-grid.tsx b/packages/typegen/web/src/components/ui/data-grid.tsx index 377cce41..2cdaac35 100644 --- a/packages/typegen/web/src/components/ui/data-grid.tsx +++ b/packages/typegen/web/src/components/ui/data-grid.tsx @@ -5,7 +5,6 @@ import { createContext, type ReactNode, useContext } from "react"; import { cn } from "@/lib/utils"; declare module "@tanstack/react-table" { - // biome-ignore lint/correctness/noUnusedVariables: TValue is required by library interface interface ColumnMeta { headerTitle?: string; headerClassName?: string;