diff --git a/.changeset/fix-fmodata-mutation-special-columns.md b/.changeset/fix-fmodata-mutation-special-columns.md new file mode 100644 index 00000000..a7b897a2 --- /dev/null +++ b/.changeset/fix-fmodata-mutation-special-columns.md @@ -0,0 +1,5 @@ +--- +"@proofkit/fmodata": patch +--- + +Fix `insert()` and `update(..., { returnFullRecord: true })` to preserve merged `Prefer` headers for `fmodata.include-specialcolumns` and `fmodata.entity-ids`, and return special columns in typed full-record mutation responses. diff --git a/packages/fmodata/src/client/builders/mutation-helpers.ts b/packages/fmodata/src/client/builders/mutation-helpers.ts index b1572705..89353f3f 100644 --- a/packages/fmodata/src/client/builders/mutation-helpers.ts +++ b/packages/fmodata/src/client/builders/mutation-helpers.ts @@ -17,13 +17,37 @@ export interface FilterQueryBuilder { export function mergeMutationExecuteOptions( options: (RequestInit & FFetchOptions & ExecuteOptions) | undefined, databaseUseEntityIds: boolean, -): RequestInit & FFetchOptions & { useEntityIds?: boolean } { + databaseIncludeSpecialColumns: boolean, +): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean } { return { ...options, useEntityIds: options?.useEntityIds ?? databaseUseEntityIds, + includeSpecialColumns: options?.includeSpecialColumns ?? databaseIncludeSpecialColumns, }; } +export function mergePreferHeaderValues(...values: Array): string | undefined { + const merged: string[] = []; + const seen = new Set(); + + for (const value of values) { + if (!value) { + continue; + } + + for (const part of value.split(",")) { + const normalized = part.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + merged.push(normalized); + } + } + + return merged.length > 0 ? merged.join(", ") : undefined; +} + export function resolveMutationTableId( // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration table: FMTable | undefined, diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts index 8da749b4..bd66a4da 100644 --- a/packages/fmodata/src/client/delete-builder.ts +++ b/packages/fmodata/src/client/delete-builder.ts @@ -101,7 +101,11 @@ export class ExecutableDeleteBuilder> } execute(options?: ExecuteMethodOptions): Promise> { - const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds); + const mergedOptions = mergeMutationExecuteOptions( + options, + this.config.useEntityIds, + this.config.includeSpecialColumns, + ); // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields const { method: _method, body: _body, ...requestOptions } = mergedOptions as any; const useEntityIds = mergedOptions.useEntityIds ?? this.config.useEntityIds; diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts index 40d0e6ca..2aef9af9 100644 --- a/packages/fmodata/src/client/entity-set.ts +++ b/packages/fmodata/src/client/entity-set.ts @@ -230,19 +230,25 @@ export class EntitySet, DatabaseIncludeSpecialColu } // Overload: when returnFullRecord is false - insert(data: InsertDataFromFMTable, options: { returnFullRecord: false }): InsertBuilder; + insert( + data: InsertDataFromFMTable, + options: { returnFullRecord: false }, + ): InsertBuilder; // Overload: when returnFullRecord is true or omitted (default) - insert(data: InsertDataFromFMTable, options?: { returnFullRecord?: true }): InsertBuilder; + insert( + data: InsertDataFromFMTable, + options?: { returnFullRecord?: true }, + ): InsertBuilder; // Implementation insert( data: InsertDataFromFMTable, options?: { returnFullRecord?: boolean }, - ): InsertBuilder { + ): InsertBuilder { const returnPreference = options?.returnFullRecord === false ? "minimal" : "representation"; - return new InsertBuilder({ + return new InsertBuilder({ occurrence: this.occurrence, layer: this.layer, // biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime @@ -253,19 +259,25 @@ export class EntitySet, DatabaseIncludeSpecialColu } // Overload: when returnFullRecord is explicitly true - update(data: UpdateDataFromFMTable, options: { returnFullRecord: true }): UpdateBuilder; + update( + data: UpdateDataFromFMTable, + options: { returnFullRecord: true }, + ): UpdateBuilder; // Overload: when returnFullRecord is false or omitted (default) - update(data: UpdateDataFromFMTable, options?: { returnFullRecord?: false }): UpdateBuilder; + update( + data: UpdateDataFromFMTable, + options?: { returnFullRecord?: false }, + ): UpdateBuilder; // Implementation update( data: UpdateDataFromFMTable, options?: { returnFullRecord?: boolean }, - ): UpdateBuilder { + ): UpdateBuilder { const returnPreference = options?.returnFullRecord === true ? "representation" : "minimal"; - return new UpdateBuilder({ + return new UpdateBuilder({ occurrence: this.occurrence, layer: this.layer, // biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime diff --git a/packages/fmodata/src/client/filemaker-odata.ts b/packages/fmodata/src/client/filemaker-odata.ts index ac722bf6..a70addd4 100644 --- a/packages/fmodata/src/client/filemaker-odata.ts +++ b/packages/fmodata/src/client/filemaker-odata.ts @@ -15,6 +15,7 @@ import { createLogger, type InternalLogger, type Logger } from "../logger"; import { type FMODataLayer, HttpClient, ODataConfig, ODataLogger } from "../services"; import type { Auth, ExecutionContext, Result } from "../types"; import { getAcceptHeader } from "../types"; +import { mergePreferHeaderValues } from "./builders/mutation-helpers"; import { Database } from "./database"; import { safeJsonParse } from "./sanitize-json"; @@ -219,19 +220,27 @@ export class FMServerConnection implements ExecutionContext { preferValues.push("fmodata.include-specialcolumns"); } - const headers = { - Authorization: - "apiKey" in this.auth - ? `Bearer ${this.auth.apiKey}` - : `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`, - "Content-Type": "application/json", - Accept: getAcceptHeader(includeODataAnnotations), - ...(preferValues.length > 0 ? { Prefer: preferValues.join(", ") } : {}), - ...(options?.headers || {}), - }; + const headers = new Headers(options?.headers); + headers.set( + "Authorization", + "apiKey" in this.auth + ? `Bearer ${this.auth.apiKey}` + : `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`, + ); + headers.set("Content-Type", "application/json"); + headers.set("Accept", getAcceptHeader(includeODataAnnotations)); + const mergedPrefer = mergePreferHeaderValues( + preferValues.length > 0 ? preferValues.join(", ") : undefined, + headers.get("Prefer") ?? undefined, + ); + if (mergedPrefer) { + headers.set("Prefer", mergedPrefer); + } else { + headers.delete("Prefer"); + } // Prepare loggableHeaders by omitting the Authorization key - const { Authorization, ...loggableHeaders } = headers; + const { authorization: _authorization, ...loggableHeaders } = Object.fromEntries(headers.entries()); logger.debug("Request headers:", loggableHeaders); // TEMPORARY WORKAROUND: Hopefully this feature will be fixed in the ffetch library diff --git a/packages/fmodata/src/client/insert-builder.ts b/packages/fmodata/src/client/insert-builder.ts index 475865ca..27129054 100644 --- a/packages/fmodata/src/client/insert-builder.ts +++ b/packages/fmodata/src/client/insert-builder.ts @@ -9,9 +9,11 @@ import type { FMODataLayer, ODataConfig } from "../services"; import { transformFieldNamesToIds, transformResponseFields } from "../transform"; import type { ConditionallyWithODataAnnotations, + ConditionallyWithSpecialColumns, ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, + NormalizeIncludeSpecialColumns, Result, } from "../types"; import { getAcceptHeader } from "../types"; @@ -19,6 +21,7 @@ import { validateAndTransformInput, validateSingleResponse } from "../validation import { getLocationHeader, mergeMutationExecuteOptions, + mergePreferHeaderValues, parseRowIdFromLocationHeader, resolveMutationTableId, } from "./builders/mutation-helpers"; @@ -36,9 +39,16 @@ export class InsertBuilder< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable | undefined = undefined, ReturnPreference extends "minimal" | "representation" = "representation", + DatabaseIncludeSpecialColumns extends boolean = false, > implements ExecutableBuilder< - ReturnPreference extends "minimal" ? { ROWID: number } : InferSchemaOutputFromFMTable> + ReturnPreference extends "minimal" + ? { ROWID: number } + : ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable>, + DatabaseIncludeSpecialColumns, + false + > > { private readonly table?: Occ; @@ -67,8 +77,8 @@ export class InsertBuilder< */ private mergeExecuteOptions( options?: RequestInit & FFetchOptions & ExecuteOptions, - ): RequestInit & FFetchOptions & { useEntityIds?: boolean } { - return mergeMutationExecuteOptions(options, this.config.useEntityIds); + ): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean } { + return mergeMutationExecuteOptions(options, this.config.useEntityIds, this.config.includeSpecialColumns); } /** @@ -117,7 +127,11 @@ export class InsertBuilder< ReturnPreference extends "minimal" ? { ROWID: number } : ConditionallyWithODataAnnotations< - InferSchemaOutputFromFMTable>, + ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable>, + NormalizeIncludeSpecialColumns, + false + >, EO["includeODataAnnotations"] extends true ? true : false > > @@ -128,8 +142,21 @@ export class InsertBuilder< const { method: _method, headers: callerHeaders, body: _body, ...requestOptions } = mergedOptions as any; const tableId = this.getTableId(mergedOptions.useEntityIds); const url = `/${this.config.databaseName}/${tableId}`; - const shouldUseIds = mergedOptions.useEntityIds ?? false; - const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation"; + const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds; + const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns; + const canonicalHeaders = new Headers(callerHeaders || {}); + const preferHeader = mergePreferHeaderValues( + this.returnPreference === "minimal" ? "return=minimal" : "return=representation", + shouldUseIds ? "fmodata.entity-ids" : undefined, + includeSpecialColumns ? "fmodata.include-specialcolumns" : undefined, + canonicalHeaders.get("Prefer") ?? undefined, + ); + canonicalHeaders.set("Content-Type", "application/json"); + if (preferHeader) { + canonicalHeaders.set("Prefer", preferHeader); + } else { + canonicalHeaders.delete("Prefer"); + } const pipeline = Effect.gen(this, function* () { // Step 1: Validate input @@ -154,11 +181,7 @@ export class InsertBuilder< const responseData = yield* requestFromService(url, { ...requestOptions, method: "POST", - headers: { - ...(callerHeaders || {}), - "Content-Type": "application/json", - Prefer: preferHeader, - }, + headers: canonicalHeaders, body: JSON.stringify(transformedData), }); @@ -191,6 +214,7 @@ export class InsertBuilder< undefined, undefined, "exact", + includeSpecialColumns, ), ); @@ -216,7 +240,11 @@ export class InsertBuilder< ReturnPreference extends "minimal" ? { ROWID: number } : ConditionallyWithODataAnnotations< - InferSchemaOutputFromFMTable>, + ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable>, + NormalizeIncludeSpecialColumns, + false + >, EO["includeODataAnnotations"] extends true ? true : false > > @@ -241,26 +269,41 @@ export class InsertBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); const fullUrl = `${baseUrl}${config.url}`; - - // Set Prefer header based on return preference - const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation"; + const preferHeader = mergePreferHeaderValues( + this.returnPreference === "minimal" ? "return=minimal" : "return=representation", + (options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined, + (options?.includeSpecialColumns ?? this.config.includeSpecialColumns) + ? "fmodata.include-specialcolumns" + : undefined, + ); return new Request(fullUrl, { method: config.method, headers: { "Content-Type": "application/json", Accept: getAcceptHeader(options?.includeODataAnnotations), - Prefer: preferHeader, + ...(preferHeader ? { Prefer: preferHeader } : {}), }, body: config.body, }); } - async processResponse( + async processResponse( response: Response, - options?: ExecuteOptions, + options?: EO, ): Promise< - Result>> + Result< + ReturnPreference extends "minimal" + ? { ROWID: number } + : ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable>, + NormalizeIncludeSpecialColumns< + EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined, + DatabaseIncludeSpecialColumns + >, + false + > + > > { // Check for error responses (important for batch operations) if (!response.ok) { @@ -345,6 +388,7 @@ export class InsertBuilder< // Transform response field IDs back to names if using entity IDs const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds; + const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns; let transformedResponse = rawResponse; if (this.table && shouldUseIds) { @@ -376,6 +420,7 @@ export class InsertBuilder< undefined, // No selected fields for insert undefined, // No expand configs "exact", // Expect exactly one record + includeSpecialColumns, ); if (!validation.valid) { diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts index fb614e81..4417cd4b 100644 --- a/packages/fmodata/src/client/update-builder.ts +++ b/packages/fmodata/src/client/update-builder.ts @@ -5,14 +5,22 @@ import { BuilderInvariantError } from "../errors"; import type { FMTable, InferSchemaOutputFromFMTable } from "../orm/table"; import { getBaseTableConfig, getTableName } from "../orm/table"; import type { FMODataLayer, ODataConfig } from "../services"; -import { transformFieldNamesToIds } from "../transform"; -import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, Result } from "../types"; +import { transformFieldNamesToIds, transformResponseFields } from "../transform"; +import type { + ConditionallyWithSpecialColumns, + ExecutableBuilder, + ExecuteMethodOptions, + ExecuteOptions, + NormalizeIncludeSpecialColumns, + Result, +} from "../types"; import { getAcceptHeader } from "../types"; -import { validateAndTransformInput } from "../validation"; +import { validateAndTransformInput, validateSingleResponse } from "../validation"; import { buildMutationUrl, extractAffectedRows, mergeMutationExecuteOptions, + mergePreferHeaderValues, resolveMutationTableId, } from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; @@ -27,6 +35,7 @@ export class UpdateBuilder< // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration Occ extends FMTable, ReturnPreference extends "minimal" | "representation" = "minimal", + DatabaseIncludeSpecialColumns extends boolean = false, > { private readonly table: Occ; private readonly data: Partial>; @@ -52,8 +61,8 @@ export class UpdateBuilder< * Update a single record by ID * Returns updated count by default, or full record if returnFullRecord was set to true */ - byId(id: string | number): ExecutableUpdateBuilder { - return new ExecutableUpdateBuilder({ + byId(id: string | number): ExecutableUpdateBuilder { + return new ExecutableUpdateBuilder({ occurrence: this.table, layer: this.layer, data: this.data, @@ -68,7 +77,9 @@ export class UpdateBuilder< * Returns updated count by default, or full record if returnFullRecord was set to true * @param fn Callback that receives a QueryBuilder for building the filter */ - where(fn: (q: QueryBuilder) => QueryBuilder): ExecutableUpdateBuilder { + where( + fn: (q: QueryBuilder) => QueryBuilder, + ): ExecutableUpdateBuilder { // Create a QueryBuilder for the user to configure const queryBuilder = new QueryBuilder({ occurrence: this.table, @@ -78,7 +89,7 @@ export class UpdateBuilder< // Let the user configure it const configuredBuilder = fn(queryBuilder); - return new ExecutableUpdateBuilder({ + return new ExecutableUpdateBuilder({ occurrence: this.table, layer: this.layer, data: this.data, @@ -99,8 +110,13 @@ export class ExecutableUpdateBuilder< Occ extends FMTable, _IsByFilter extends boolean, ReturnPreference extends "minimal" | "representation" = "minimal", + DatabaseIncludeSpecialColumns extends boolean = false, > implements - ExecutableBuilder> + ExecutableBuilder< + ReturnPreference extends "minimal" + ? { updatedCount: number } + : ConditionallyWithSpecialColumns, DatabaseIncludeSpecialColumns, false> + > { private readonly table: Occ; private readonly data: Partial>; @@ -131,15 +147,28 @@ export class ExecutableUpdateBuilder< this.config = runtime.config; } - execute( - options?: ExecuteMethodOptions, + execute( + options?: ExecuteMethodOptions, ): Promise< - Result> + Result< + ReturnPreference extends "minimal" + ? { updatedCount: number } + : ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable, + NormalizeIncludeSpecialColumns, + false + > + > > { - const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds); + const mergedOptions = mergeMutationExecuteOptions( + options, + this.config.useEntityIds, + this.config.includeSpecialColumns, + ); // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields const { method: _method, body: _body, headers: callerHeaders, ...requestOptions } = mergedOptions as any; const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds; + const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns; const tableId = resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder"); const url = buildMutationUrl({ databaseName: this.config.databaseName, @@ -152,9 +181,18 @@ export class ExecutableUpdateBuilder< builderName: "ExecutableUpdateBuilder", }); - const headers: Record = { "Content-Type": "application/json" }; - if (this.returnPreference === "representation") { - headers.Prefer = "return=representation"; + const requestHeaders = new Headers(callerHeaders || {}); + const preferHeader = mergePreferHeaderValues( + this.returnPreference === "representation" ? "return=representation" : undefined, + shouldUseIds ? "fmodata.entity-ids" : undefined, + includeSpecialColumns ? "fmodata.include-specialcolumns" : undefined, + requestHeaders.get("Prefer") ?? undefined, + ); + requestHeaders.set("Content-Type", "application/json"); + if (preferHeader) { + requestHeaders.set("Prefer", preferHeader); + } else { + requestHeaders.delete("Prefer"); } const pipeline = Effect.gen(this, function* () { @@ -176,11 +214,6 @@ export class ExecutableUpdateBuilder< this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData; // Step 3: Make PATCH request via DI - const requestHeaders = new Headers(callerHeaders); - for (const [key, value] of Object.entries(headers)) { - requestHeaders.set(key, value); - } - const response = yield* requestFromService(url, { ...requestOptions, method: "PATCH", @@ -200,7 +233,15 @@ export class ExecutableUpdateBuilder< return runLayerResult(this.layer, pipeline, "fmodata.update", { "fmodata.table": getTableName(this.table), }) as Promise< - Result> + Result< + ReturnPreference extends "minimal" + ? { updatedCount: number } + : ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable, + NormalizeIncludeSpecialColumns, + false + > + > >; } @@ -233,23 +274,53 @@ export class ExecutableUpdateBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); const fullUrl = `${baseUrl}${config.url}`; + const preferHeader = mergePreferHeaderValues( + this.returnPreference === "representation" ? "return=representation" : undefined, + (options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined, + (options?.includeSpecialColumns ?? this.config.includeSpecialColumns) + ? "fmodata.include-specialcolumns" + : undefined, + ); return new Request(fullUrl, { method: config.method, headers: { "Content-Type": "application/json", Accept: getAcceptHeader(options?.includeODataAnnotations), + ...(preferHeader ? { Prefer: preferHeader } : {}), }, body: config.body, }); } - async processResponse( + async processResponse( response: Response, - _options?: ExecuteOptions, + options?: EO, ): Promise< - Result> + Result< + ReturnPreference extends "minimal" + ? { updatedCount: number } + : ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable, + NormalizeIncludeSpecialColumns< + EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined, + DatabaseIncludeSpecialColumns + >, + false + > + > > { + type UpdateResponse = ReturnPreference extends "minimal" + ? { updatedCount: number } + : ConditionallyWithSpecialColumns< + InferSchemaOutputFromFMTable, + NormalizeIncludeSpecialColumns< + EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined, + DatabaseIncludeSpecialColumns + >, + false + >; + // Check for error responses (important for batch operations) if (!response.ok) { const tableName = getTableName(this.table); @@ -262,9 +333,7 @@ export class ExecutableUpdateBuilder< if (!text || text.trim() === "") { const updatedCount = extractAffectedRows(undefined, response.headers, 1, "updatedCount"); return { - data: { updatedCount } as ReturnPreference extends "minimal" - ? { updatedCount: number } - : InferSchemaOutputFromFMTable, + data: { updatedCount } as unknown as UpdateResponse, error: undefined, }; } @@ -294,11 +363,39 @@ export class ExecutableUpdateBuilder< // Handle based on return preference if (this.returnPreference === "representation") { - // Return the full updated record + const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds; + const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns; + + let transformedResponse = rawResponse; + if (this.table && shouldUseIds) { + transformedResponse = transformResponseFields(rawResponse, this.table, undefined); + } + + const validation = await validateSingleResponse>( + transformedResponse, + getBaseTableConfig(this.table).schema, + undefined, + undefined, + "exact", + includeSpecialColumns, + ); + + if (!validation.valid) { + return { data: undefined, error: validation.error }; + } + + if (validation.data === null) { + return { + data: undefined, + error: new BuilderInvariantError( + "ExecutableUpdateBuilder.processResponse", + "update operation returned null response", + ), + }; + } + return { - data: rawResponse as ReturnPreference extends "minimal" - ? { updatedCount: number } - : InferSchemaOutputFromFMTable, + data: validation.data as unknown as UpdateResponse, error: undefined, }; } @@ -306,9 +403,7 @@ export class ExecutableUpdateBuilder< const updatedCount = extractAffectedRows(rawResponse, response.headers, 0, "updatedCount"); return { - data: { updatedCount } as ReturnPreference extends "minimal" - ? { updatedCount: number } - : InferSchemaOutputFromFMTable, + data: { updatedCount } as unknown as UpdateResponse, error: undefined, }; } diff --git a/packages/fmodata/tests/include-special-columns.test.ts b/packages/fmodata/tests/include-special-columns.test.ts index 51855d1f..d86b5fc8 100644 --- a/packages/fmodata/tests/include-special-columns.test.ts +++ b/packages/fmodata/tests/include-special-columns.test.ts @@ -344,6 +344,35 @@ describe("includeSpecialColumns feature", () => { expect(preferHeader3).toBeNull(); }); + it("should merge caller Prefer with database-level special columns header", async () => { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }] }, + status: 200, + }); + const db = mock.database("TestDB", { + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + await db + .from(contactsTO) + .list() + .execute({ + headers: { + Prefer: "handling=lenient, fmodata.include-specialcolumns", + }, + hooks: { + before: (req) => { + preferHeader = req.headers.get("Prefer"); + }, + }, + }); + + expect(preferHeader).toBe("fmodata.include-specialcolumns, handling=lenient"); + }); + it("should combine includeSpecialColumns with useEntityIds in Prefer header", async () => { const contactsTOWithEntityIds = fmTableOccurrence( "contacts", @@ -650,4 +679,108 @@ describe("includeSpecialColumns feature", () => { expect(firstRecord).toHaveProperty("ROWID"); expect(firstRecord).toHaveProperty("ROWMODID"); }); + + it("should include special columns for insert full-record responses", async () => { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { id: "1", name: "John", ROWID: 123, ROWMODID: 456 }, + status: 200, + }); + const db = mock.database("TestDB", { + includeSpecialColumns: true, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .insert({ name: "John" }) + .execute({ + hooks: { + before: (req) => { + preferHeader = req.headers.get("Prefer"); + }, + }, + }); + + expect(preferHeader).toBe("fmodata.include-specialcolumns, return=representation"); + assert(data, "data is undefined"); + expectTypeOf(data).toHaveProperty("ROWID"); + expectTypeOf(data).toHaveProperty("ROWMODID"); + data.ROWID; + data.ROWMODID; + expect(data).toHaveProperty("ROWID", 123); + expect(data).toHaveProperty("ROWMODID", 456); + + let mergedPreferHeader: string | null = null; + await db + .from(contactsTO) + .insert({ name: "John" }) + .execute({ + headers: { + Prefer: "handling=lenient, fmodata.include-specialcolumns", + }, + hooks: { + before: (req) => { + mergedPreferHeader = req.headers.get("Prefer"); + }, + }, + }); + + expect(mergedPreferHeader).toBe("fmodata.include-specialcolumns, return=representation, handling=lenient"); + }); + + it("should include special columns for update returnFullRecord responses", async () => { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { id: "1", name: "John", ROWID: 123, ROWMODID: 456 }, + status: 200, + }); + const db = mock.database("TestDB", { + includeSpecialColumns: false, + }); + + let preferHeader: string | null = null; + const { data } = await db + .from(contactsTO) + .update({ name: "John" }, { returnFullRecord: true }) + .byId("1") + .execute({ + includeSpecialColumns: true, + hooks: { + before: (req) => { + preferHeader = req.headers.get("Prefer"); + }, + }, + }); + + expect(preferHeader).toBe("fmodata.include-specialcolumns, return=representation"); + assert(data, "data is undefined"); + expectTypeOf(data).toHaveProperty("ROWID"); + expectTypeOf(data).toHaveProperty("ROWMODID"); + data.ROWID; + data.ROWMODID; + expect(data).toHaveProperty("ROWID", 123); + expect(data).toHaveProperty("ROWMODID", 456); + + let mergedPreferHeader: string | null = null; + await db + .from(contactsTO) + .update({ name: "John" }, { returnFullRecord: true }) + .byId("1") + .execute({ + includeSpecialColumns: true, + headers: { + Prefer: "handling=lenient, return=representation", + }, + hooks: { + before: (req) => { + mergedPreferHeader = req.headers.get("Prefer"); + }, + }, + }); + + expect(mergedPreferHeader).toBe("fmodata.include-specialcolumns, return=representation, handling=lenient"); + }); }); diff --git a/packages/fmodata/tests/use-entity-ids-override.test.ts b/packages/fmodata/tests/use-entity-ids-override.test.ts index cbb2313a..0d298651 100644 --- a/packages/fmodata/tests/use-entity-ids-override.test.ts +++ b/packages/fmodata/tests/use-entity-ids-override.test.ts @@ -106,20 +106,20 @@ describe("Per-request useEntityIds override", () => { }); const db = mock.database("TestDB", { useEntityIds: true }); - // Insert with entity IDs enabled — verify via URL (uses FMTID) - // Note: The insert builder sets its own Prefer header ("return=representation") - // which overwrites the entity-ids Prefer value. Entity ID usage is verified via URL. + // Insert with entity IDs enabled — verify via URL and merged Prefer header await db.from(localContactsTO).insert({ name: "Test" }).execute(); const call0 = mock.spy?.calls[0]; expect(call0?.url).toContain("FMTID:100"); + expect(call0?.headers?.prefer).toBe("fmodata.entity-ids, return=representation"); - // Insert with entity IDs disabled — URL should use table name + // Insert with entity IDs disabled — URL should use table name, Prefer keeps return preference only await db.from(localContactsTO).insert({ name: "Test" }).execute({ useEntityIds: false }); const call1 = mock.spy?.calls[1]; expect(call1?.url).toContain("/contacts"); expect(call1?.url).not.toContain("FMTID:"); + expect(call1?.headers?.prefer).toBe("return=representation"); }); it("should work with update operations", async () => {