Skip to content

Commit 3d8cd82

Browse files
committed
Fix fmodata mutation Prefer header merging
1 parent 175b0bd commit 3d8cd82

8 files changed

Lines changed: 293 additions & 58 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@proofkit/fmodata": patch
3+
---
4+
5+
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.

packages/fmodata/src/client/builders/mutation-helpers.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,37 @@ export interface FilterQueryBuilder {
1717
export function mergeMutationExecuteOptions(
1818
options: (RequestInit & FFetchOptions & ExecuteOptions) | undefined,
1919
databaseUseEntityIds: boolean,
20-
): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
20+
databaseIncludeSpecialColumns: boolean,
21+
): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean } {
2122
return {
2223
...options,
2324
useEntityIds: options?.useEntityIds ?? databaseUseEntityIds,
25+
includeSpecialColumns: options?.includeSpecialColumns ?? databaseIncludeSpecialColumns,
2426
};
2527
}
2628

29+
export function mergePreferHeaderValues(...values: Array<string | undefined>): string | undefined {
30+
const merged: string[] = [];
31+
const seen = new Set<string>();
32+
33+
for (const value of values) {
34+
if (!value) {
35+
continue;
36+
}
37+
38+
for (const part of value.split(",")) {
39+
const normalized = part.trim();
40+
if (!normalized || seen.has(normalized)) {
41+
continue;
42+
}
43+
seen.add(normalized);
44+
merged.push(normalized);
45+
}
46+
}
47+
48+
return merged.length > 0 ? merged.join(", ") : undefined;
49+
}
50+
2751
export function resolveMutationTableId(
2852
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
2953
table: FMTable<any, any> | undefined,

packages/fmodata/src/client/delete-builder.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,11 @@ export class ExecutableDeleteBuilder<Occ extends FMTable<any, any>>
101101
}
102102

103103
execute(options?: ExecuteMethodOptions<ExecuteOptions>): Promise<Result<{ deletedCount: number }>> {
104-
const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds);
104+
const mergedOptions = mergeMutationExecuteOptions(
105+
options,
106+
this.config.useEntityIds,
107+
this.config.includeSpecialColumns,
108+
);
105109
// biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields
106110
const { method: _method, body: _body, ...requestOptions } = mergedOptions as any;
107111
const useEntityIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;

packages/fmodata/src/client/entity-set.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,19 +230,25 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
230230
}
231231

232232
// Overload: when returnFullRecord is false
233-
insert(data: InsertDataFromFMTable<Occ>, options: { returnFullRecord: false }): InsertBuilder<Occ, "minimal">;
233+
insert(
234+
data: InsertDataFromFMTable<Occ>,
235+
options: { returnFullRecord: false },
236+
): InsertBuilder<Occ, "minimal", DatabaseIncludeSpecialColumns>;
234237

235238
// Overload: when returnFullRecord is true or omitted (default)
236-
insert(data: InsertDataFromFMTable<Occ>, options?: { returnFullRecord?: true }): InsertBuilder<Occ, "representation">;
239+
insert(
240+
data: InsertDataFromFMTable<Occ>,
241+
options?: { returnFullRecord?: true },
242+
): InsertBuilder<Occ, "representation", DatabaseIncludeSpecialColumns>;
237243

238244
// Implementation
239245
insert(
240246
data: InsertDataFromFMTable<Occ>,
241247
options?: { returnFullRecord?: boolean },
242-
): InsertBuilder<Occ, "minimal" | "representation"> {
248+
): InsertBuilder<Occ, "minimal" | "representation", DatabaseIncludeSpecialColumns> {
243249
const returnPreference = options?.returnFullRecord === false ? "minimal" : "representation";
244250

245-
return new InsertBuilder<Occ, typeof returnPreference>({
251+
return new InsertBuilder<Occ, typeof returnPreference, DatabaseIncludeSpecialColumns>({
246252
occurrence: this.occurrence,
247253
layer: this.layer,
248254
// biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime
@@ -253,19 +259,25 @@ export class EntitySet<Occ extends FMTable<any, any>, DatabaseIncludeSpecialColu
253259
}
254260

255261
// Overload: when returnFullRecord is explicitly true
256-
update(data: UpdateDataFromFMTable<Occ>, options: { returnFullRecord: true }): UpdateBuilder<Occ, "representation">;
262+
update(
263+
data: UpdateDataFromFMTable<Occ>,
264+
options: { returnFullRecord: true },
265+
): UpdateBuilder<Occ, "representation", DatabaseIncludeSpecialColumns>;
257266

258267
// Overload: when returnFullRecord is false or omitted (default)
259-
update(data: UpdateDataFromFMTable<Occ>, options?: { returnFullRecord?: false }): UpdateBuilder<Occ, "minimal">;
268+
update(
269+
data: UpdateDataFromFMTable<Occ>,
270+
options?: { returnFullRecord?: false },
271+
): UpdateBuilder<Occ, "minimal", DatabaseIncludeSpecialColumns>;
260272

261273
// Implementation
262274
update(
263275
data: UpdateDataFromFMTable<Occ>,
264276
options?: { returnFullRecord?: boolean },
265-
): UpdateBuilder<Occ, "minimal" | "representation"> {
277+
): UpdateBuilder<Occ, "minimal" | "representation", DatabaseIncludeSpecialColumns> {
266278
const returnPreference = options?.returnFullRecord === true ? "representation" : "minimal";
267279

268-
return new UpdateBuilder<Occ, typeof returnPreference>({
280+
return new UpdateBuilder<Occ, typeof returnPreference, DatabaseIncludeSpecialColumns>({
269281
occurrence: this.occurrence,
270282
layer: this.layer,
271283
// biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime

packages/fmodata/src/client/insert-builder.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@ import type { FMODataLayer, ODataConfig } from "../services";
99
import { transformFieldNamesToIds, transformResponseFields } from "../transform";
1010
import type {
1111
ConditionallyWithODataAnnotations,
12+
ConditionallyWithSpecialColumns,
1213
ExecutableBuilder,
1314
ExecuteMethodOptions,
1415
ExecuteOptions,
16+
NormalizeIncludeSpecialColumns,
1517
Result,
1618
} from "../types";
1719
import { getAcceptHeader } from "../types";
1820
import { validateAndTransformInput, validateSingleResponse } from "../validation";
1921
import {
2022
getLocationHeader,
2123
mergeMutationExecuteOptions,
24+
mergePreferHeaderValues,
2225
parseRowIdFromLocationHeader,
2326
resolveMutationTableId,
2427
} from "./builders/mutation-helpers";
@@ -36,9 +39,16 @@ export class InsertBuilder<
3639
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
3740
Occ extends FMTable<any, any> | undefined = undefined,
3841
ReturnPreference extends "minimal" | "representation" = "representation",
42+
DatabaseIncludeSpecialColumns extends boolean = false,
3943
> implements
4044
ExecutableBuilder<
41-
ReturnPreference extends "minimal" ? { ROWID: number } : InferSchemaOutputFromFMTable<NonNullable<Occ>>
45+
ReturnPreference extends "minimal"
46+
? { ROWID: number }
47+
: ConditionallyWithSpecialColumns<
48+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
49+
DatabaseIncludeSpecialColumns,
50+
false
51+
>
4252
>
4353
{
4454
private readonly table?: Occ;
@@ -67,8 +77,8 @@ export class InsertBuilder<
6777
*/
6878
private mergeExecuteOptions(
6979
options?: RequestInit & FFetchOptions & ExecuteOptions,
70-
): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
71-
return mergeMutationExecuteOptions(options, this.config.useEntityIds);
80+
): RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean } {
81+
return mergeMutationExecuteOptions(options, this.config.useEntityIds, this.config.includeSpecialColumns);
7282
}
7383

7484
/**
@@ -117,7 +127,11 @@ export class InsertBuilder<
117127
ReturnPreference extends "minimal"
118128
? { ROWID: number }
119129
: ConditionallyWithODataAnnotations<
120-
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
130+
ConditionallyWithSpecialColumns<
131+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
132+
NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
133+
false
134+
>,
121135
EO["includeODataAnnotations"] extends true ? true : false
122136
>
123137
>
@@ -128,8 +142,14 @@ export class InsertBuilder<
128142
const { method: _method, headers: callerHeaders, body: _body, ...requestOptions } = mergedOptions as any;
129143
const tableId = this.getTableId(mergedOptions.useEntityIds);
130144
const url = `/${this.config.databaseName}/${tableId}`;
131-
const shouldUseIds = mergedOptions.useEntityIds ?? false;
132-
const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation";
145+
const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;
146+
const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns;
147+
const preferHeader = mergePreferHeaderValues(
148+
new Headers(callerHeaders).get("Prefer") ?? undefined,
149+
this.returnPreference === "minimal" ? "return=minimal" : "return=representation",
150+
shouldUseIds ? "fmodata.entity-ids" : undefined,
151+
includeSpecialColumns ? "fmodata.include-specialcolumns" : undefined,
152+
);
133153

134154
const pipeline = Effect.gen(this, function* () {
135155
// Step 1: Validate input
@@ -157,7 +177,7 @@ export class InsertBuilder<
157177
headers: {
158178
...(callerHeaders || {}),
159179
"Content-Type": "application/json",
160-
Prefer: preferHeader,
180+
...(preferHeader ? { Prefer: preferHeader } : {}),
161181
},
162182
body: JSON.stringify(transformedData),
163183
});
@@ -191,6 +211,7 @@ export class InsertBuilder<
191211
undefined,
192212
undefined,
193213
"exact",
214+
includeSpecialColumns,
194215
),
195216
);
196217

@@ -216,7 +237,11 @@ export class InsertBuilder<
216237
ReturnPreference extends "minimal"
217238
? { ROWID: number }
218239
: ConditionallyWithODataAnnotations<
219-
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
240+
ConditionallyWithSpecialColumns<
241+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
242+
NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
243+
false
244+
>,
220245
EO["includeODataAnnotations"] extends true ? true : false
221246
>
222247
>
@@ -241,16 +266,20 @@ export class InsertBuilder<
241266
toRequest(baseUrl: string, options?: ExecuteOptions): Request {
242267
const config = this.getRequestConfig();
243268
const fullUrl = `${baseUrl}${config.url}`;
244-
245-
// Set Prefer header based on return preference
246-
const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation";
269+
const preferHeader = mergePreferHeaderValues(
270+
this.returnPreference === "minimal" ? "return=minimal" : "return=representation",
271+
(options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined,
272+
(options?.includeSpecialColumns ?? this.config.includeSpecialColumns)
273+
? "fmodata.include-specialcolumns"
274+
: undefined,
275+
);
247276

248277
return new Request(fullUrl, {
249278
method: config.method,
250279
headers: {
251280
"Content-Type": "application/json",
252281
Accept: getAcceptHeader(options?.includeODataAnnotations),
253-
Prefer: preferHeader,
282+
...(preferHeader ? { Prefer: preferHeader } : {}),
254283
},
255284
body: config.body,
256285
});
@@ -260,7 +289,15 @@ export class InsertBuilder<
260289
response: Response,
261290
options?: ExecuteOptions,
262291
): Promise<
263-
Result<ReturnPreference extends "minimal" ? { ROWID: number } : InferSchemaOutputFromFMTable<NonNullable<Occ>>>
292+
Result<
293+
ReturnPreference extends "minimal"
294+
? { ROWID: number }
295+
: ConditionallyWithSpecialColumns<
296+
InferSchemaOutputFromFMTable<NonNullable<Occ>>,
297+
DatabaseIncludeSpecialColumns,
298+
false
299+
>
300+
>
264301
> {
265302
// Check for error responses (important for batch operations)
266303
if (!response.ok) {
@@ -345,6 +382,7 @@ export class InsertBuilder<
345382

346383
// Transform response field IDs back to names if using entity IDs
347384
const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds;
385+
const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns;
348386

349387
let transformedResponse = rawResponse;
350388
if (this.table && shouldUseIds) {
@@ -376,6 +414,7 @@ export class InsertBuilder<
376414
undefined, // No selected fields for insert
377415
undefined, // No expand configs
378416
"exact", // Expect exactly one record
417+
includeSpecialColumns,
379418
);
380419

381420
if (!validation.valid) {

0 commit comments

Comments
 (0)