@proofkit/fmodata 0.1.0-alpha.0 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1624 -18
  2. package/dist/esm/client/base-table.d.ts +117 -5
  3. package/dist/esm/client/base-table.js +43 -5
  4. package/dist/esm/client/base-table.js.map +1 -1
  5. package/dist/esm/client/batch-builder.d.ts +54 -0
  6. package/dist/esm/client/batch-builder.js +179 -0
  7. package/dist/esm/client/batch-builder.js.map +1 -0
  8. package/dist/esm/client/batch-request.d.ts +61 -0
  9. package/dist/esm/client/batch-request.js +252 -0
  10. package/dist/esm/client/batch-request.js.map +1 -0
  11. package/dist/esm/client/database.d.ts +55 -6
  12. package/dist/esm/client/database.js +118 -15
  13. package/dist/esm/client/database.js.map +1 -1
  14. package/dist/esm/client/delete-builder.d.ts +21 -2
  15. package/dist/esm/client/delete-builder.js +96 -32
  16. package/dist/esm/client/delete-builder.js.map +1 -1
  17. package/dist/esm/client/entity-set.d.ts +25 -11
  18. package/dist/esm/client/entity-set.js +31 -11
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +23 -4
  21. package/dist/esm/client/filemaker-odata.js +124 -29
  22. package/dist/esm/client/filemaker-odata.js.map +1 -1
  23. package/dist/esm/client/insert-builder.d.ts +38 -3
  24. package/dist/esm/client/insert-builder.js +231 -34
  25. package/dist/esm/client/insert-builder.js.map +1 -1
  26. package/dist/esm/client/query-builder.d.ts +27 -6
  27. package/dist/esm/client/query-builder.js +457 -210
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +96 -9
  30. package/dist/esm/client/record-builder.js +378 -39
  31. package/dist/esm/client/record-builder.js.map +1 -1
  32. package/dist/esm/client/response-processor.d.ts +38 -0
  33. package/dist/esm/client/schema-manager.d.ts +57 -0
  34. package/dist/esm/client/schema-manager.js +132 -0
  35. package/dist/esm/client/schema-manager.js.map +1 -0
  36. package/dist/esm/client/table-occurrence.d.ts +48 -1
  37. package/dist/esm/client/table-occurrence.js +29 -2
  38. package/dist/esm/client/table-occurrence.js.map +1 -1
  39. package/dist/esm/client/update-builder.d.ts +34 -11
  40. package/dist/esm/client/update-builder.js +135 -31
  41. package/dist/esm/client/update-builder.js.map +1 -1
  42. package/dist/esm/errors.d.ts +73 -0
  43. package/dist/esm/errors.js +148 -0
  44. package/dist/esm/errors.js.map +1 -0
  45. package/dist/esm/index.d.ts +10 -3
  46. package/dist/esm/index.js +28 -5
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/transform.d.ts +65 -0
  49. package/dist/esm/transform.js +114 -0
  50. package/dist/esm/transform.js.map +1 -0
  51. package/dist/esm/types.d.ts +89 -5
  52. package/dist/esm/validation.d.ts +6 -3
  53. package/dist/esm/validation.js +104 -33
  54. package/dist/esm/validation.js.map +1 -1
  55. package/package.json +10 -1
  56. package/src/client/base-table.ts +158 -8
  57. package/src/client/batch-builder.ts +265 -0
  58. package/src/client/batch-request.ts +485 -0
  59. package/src/client/database.ts +175 -18
  60. package/src/client/delete-builder.ts +149 -48
  61. package/src/client/entity-set.ts +114 -23
  62. package/src/client/filemaker-odata.ts +179 -35
  63. package/src/client/insert-builder.ts +350 -40
  64. package/src/client/query-builder.ts +616 -237
  65. package/src/client/query-builder.ts.bak +1457 -0
  66. package/src/client/record-builder.ts +692 -65
  67. package/src/client/response-processor.ts +103 -0
  68. package/src/client/schema-manager.ts +246 -0
  69. package/src/client/table-occurrence.ts +78 -3
  70. package/src/client/update-builder.ts +235 -49
  71. package/src/errors.ts +217 -0
  72. package/src/index.ts +59 -2
  73. package/src/transform.ts +249 -0
  74. package/src/types.ts +201 -35
  75. package/src/validation.ts +120 -36
@@ -5,18 +5,29 @@ import type {
5
5
  ODataRecordMetadata,
6
6
  ODataFieldResponse,
7
7
  InferSchemaType,
8
+ ExecuteOptions,
9
+ WithSystemFields,
10
+ ConditionallyWithODataAnnotations,
8
11
  } from "../types";
9
12
  import type { TableOccurrence } from "./table-occurrence";
10
13
  import type { BaseTable } from "./base-table";
14
+ import {
15
+ transformTableName,
16
+ transformResponseFields,
17
+ getTableIdentifiers,
18
+ transformFieldNamesArray,
19
+ } from "../transform";
11
20
  import { QueryBuilder } from "./query-builder";
12
- import { validateSingleResponse } from "../validation";
21
+ import { validateSingleResponse, type ExpandValidationConfig } from "../validation";
13
22
  import { type FFetchOptions } from "@fetchkit/ffetch";
14
- import { z } from "zod/v4";
23
+ import { StandardSchemaV1 } from "@standard-schema/spec";
24
+ import { QueryOptions } from "odata-query";
25
+ import buildQuery from "odata-query";
15
26
 
16
27
  // Helper type to extract schema from a TableOccurrence
17
28
  type ExtractSchemaFromOccurrence<O> =
18
29
  O extends TableOccurrence<infer BT, any, any, any>
19
- ? BT extends BaseTable<infer S, any>
30
+ ? BT extends BaseTable<infer S, any, any, any>
20
31
  ? S
21
32
  : never
22
33
  : never;
@@ -27,7 +38,7 @@ type ExtractNavigationNames<
27
38
  > =
28
39
  O extends TableOccurrence<any, any, infer Nav, any>
29
40
  ? Nav extends Record<string, any>
30
- ? keyof Nav
41
+ ? keyof Nav & string
31
42
  : never
32
43
  : never;
33
44
 
@@ -40,10 +51,66 @@ type FindNavigationTarget<
40
51
  Name extends string,
41
52
  > =
42
53
  O extends TableOccurrence<any, any, infer Nav, any>
43
- ? Name extends keyof Nav
44
- ? ResolveNavigationItem<Nav[Name]>
45
- : never
46
- : never;
54
+ ? Nav extends Record<string, any>
55
+ ? Name extends keyof Nav
56
+ ? ResolveNavigationItem<Nav[Name]>
57
+ : TableOccurrence<
58
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
59
+ any,
60
+ any,
61
+ any
62
+ >
63
+ : TableOccurrence<
64
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
65
+ any,
66
+ any,
67
+ any
68
+ >
69
+ : TableOccurrence<
70
+ BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
71
+ any,
72
+ any,
73
+ any
74
+ >;
75
+
76
+ // Helper type to get the inferred schema type from a target occurrence
77
+ type GetTargetSchemaType<
78
+ O extends TableOccurrence<any, any, any, any> | undefined,
79
+ Rel extends string,
80
+ > = [FindNavigationTarget<O, Rel>] extends [
81
+ TableOccurrence<infer BT, any, any, any>,
82
+ ]
83
+ ? [BT] extends [BaseTable<infer S, any, any, any>]
84
+ ? [S] extends [Record<string, StandardSchemaV1>]
85
+ ? InferSchemaType<S>
86
+ : Record<string, any>
87
+ : Record<string, any>
88
+ : Record<string, any>;
89
+
90
+ // Internal type for expand configuration
91
+ type ExpandConfig = {
92
+ relation: string;
93
+ options?: Partial<QueryOptions<any>>;
94
+ };
95
+
96
+ // Type to represent expanded relations
97
+ export type ExpandedRelations = Record<string, { schema: any; selected: any }>;
98
+
99
+ // Return type for RecordBuilder execute
100
+ export type RecordReturnType<
101
+ T extends Record<string, any>,
102
+ IsSingleField extends boolean,
103
+ FieldKey extends keyof T,
104
+ Selected extends keyof T,
105
+ Expands extends ExpandedRelations,
106
+ > = IsSingleField extends true
107
+ ? T[FieldKey]
108
+ : Pick<T, Selected> & {
109
+ [K in keyof Expands]: Pick<
110
+ Expands[K]["schema"],
111
+ Expands[K]["selected"]
112
+ >[];
113
+ };
47
114
 
48
115
  export class RecordBuilder<
49
116
  T extends Record<string, any>,
@@ -52,9 +119,11 @@ export class RecordBuilder<
52
119
  Occ extends TableOccurrence<any, any, any, any> | undefined =
53
120
  | TableOccurrence<any, any, any, any>
54
121
  | undefined,
122
+ Selected extends keyof T = keyof T,
123
+ Expands extends ExpandedRelations = {},
55
124
  > implements
56
125
  ExecutableBuilder<
57
- IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata
126
+ RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>
58
127
  >
59
128
  {
60
129
  private occurrence?: Occ;
@@ -68,27 +137,76 @@ export class RecordBuilder<
68
137
  private navigateRelation?: string;
69
138
  private navigateSourceTableName?: string;
70
139
 
140
+ private databaseUseEntityIds: boolean;
141
+
142
+ // New properties for select/expand support
143
+ private selectedFields?: string[];
144
+ private expandConfigs: ExpandConfig[] = [];
145
+
71
146
  constructor(config: {
72
147
  occurrence?: Occ;
73
148
  tableName: string;
74
149
  databaseName: string;
75
150
  context: ExecutionContext;
76
151
  recordId: string | number;
152
+ databaseUseEntityIds?: boolean;
77
153
  }) {
78
154
  this.occurrence = config.occurrence;
79
155
  this.tableName = config.tableName;
80
156
  this.databaseName = config.databaseName;
81
157
  this.context = config.context;
82
158
  this.recordId = config.recordId;
159
+ this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
160
+ }
161
+
162
+ /**
163
+ * Helper to merge database-level useEntityIds with per-request options
164
+ */
165
+ private mergeExecuteOptions(
166
+ options?: RequestInit & FFetchOptions & ExecuteOptions,
167
+ ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
168
+ // If useEntityIds is not set in options, use the database-level setting
169
+ return {
170
+ ...options,
171
+ useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
177
+ * @param useEntityIds - Optional override for entity ID usage
178
+ */
179
+ private getTableId(useEntityIds?: boolean): string {
180
+ if (!this.occurrence) {
181
+ return this.tableName;
182
+ }
183
+
184
+ const contextDefault = this.context._getUseEntityIds?.() ?? false;
185
+ const shouldUseIds = useEntityIds ?? contextDefault;
186
+
187
+ if (shouldUseIds) {
188
+ const identifiers = getTableIdentifiers(this.occurrence);
189
+ if (!identifiers.id) {
190
+ throw new Error(
191
+ `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
192
+ );
193
+ }
194
+ return identifiers.id;
195
+ }
196
+
197
+ return this.occurrence.getTableName();
83
198
  }
84
199
 
85
- getSingleField<K extends keyof T>(field: K): RecordBuilder<T, true, K, Occ> {
86
- const newBuilder = new RecordBuilder<T, true, K, Occ>({
200
+ getSingleField<K extends keyof T>(
201
+ field: K,
202
+ ): RecordBuilder<T, true, K, Occ, keyof T, {}> {
203
+ const newBuilder = new RecordBuilder<T, true, K, Occ, keyof T, {}>({
87
204
  occurrence: this.occurrence,
88
205
  tableName: this.tableName,
89
206
  databaseName: this.databaseName,
90
207
  context: this.context,
91
208
  recordId: this.recordId,
209
+ databaseUseEntityIds: this.databaseUseEntityIds,
92
210
  });
93
211
  newBuilder.operation = "getSingleField";
94
212
  newBuilder.operationParam = field.toString();
@@ -99,13 +217,184 @@ export class RecordBuilder<
99
217
  return newBuilder;
100
218
  }
101
219
 
220
+ /**
221
+ * Select specific fields to retrieve from the record.
222
+ * Only the selected fields will be returned in the response.
223
+ *
224
+ * @example
225
+ * ```typescript
226
+ * const contact = await db.from("contacts").get("uuid").select("name", "email").execute();
227
+ * // contact.data has type { name: string; email: string }
228
+ * ```
229
+ */
230
+ select<K extends keyof T>(
231
+ ...fields: K[]
232
+ ): RecordBuilder<T, false, FieldKey, Occ, K, Expands> {
233
+ const uniqueFields = [...new Set(fields)];
234
+ const newBuilder = new RecordBuilder<T, false, FieldKey, Occ, K, Expands>({
235
+ occurrence: this.occurrence,
236
+ tableName: this.tableName,
237
+ databaseName: this.databaseName,
238
+ context: this.context,
239
+ recordId: this.recordId,
240
+ databaseUseEntityIds: this.databaseUseEntityIds,
241
+ });
242
+ newBuilder.selectedFields = uniqueFields.map((f) => String(f));
243
+ newBuilder.expandConfigs = [...this.expandConfigs];
244
+ // Preserve navigation context
245
+ newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
246
+ newBuilder.navigateRelation = this.navigateRelation;
247
+ newBuilder.navigateSourceTableName = this.navigateSourceTableName;
248
+ return newBuilder;
249
+ }
250
+
251
+ /**
252
+ * Expand a navigation property to include related records.
253
+ * Supports nested select, filter, orderBy, and expand operations.
254
+ *
255
+ * @example
256
+ * ```typescript
257
+ * // Simple expand
258
+ * const contact = await db.from("contacts").get("uuid").expand("users").execute();
259
+ *
260
+ * // Expand with select
261
+ * const contact = await db.from("contacts").get("uuid")
262
+ * .expand("users", b => b.select("username", "email"))
263
+ * .execute();
264
+ * ```
265
+ */
266
+ expand<
267
+ Rel extends ExtractNavigationNames<Occ> | (string & {}),
268
+ TargetOcc extends FindNavigationTarget<Occ, Rel> = FindNavigationTarget<
269
+ Occ,
270
+ Rel
271
+ >,
272
+ TargetSchema extends GetTargetSchemaType<Occ, Rel> = GetTargetSchemaType<
273
+ Occ,
274
+ Rel
275
+ >,
276
+ TargetSelected extends keyof TargetSchema = keyof TargetSchema,
277
+ >(
278
+ relation: Rel,
279
+ callback?: (
280
+ builder: QueryBuilder<
281
+ TargetSchema,
282
+ keyof TargetSchema,
283
+ false,
284
+ false,
285
+ TargetOcc extends TableOccurrence<any, any, any, any>
286
+ ? TargetOcc
287
+ : undefined
288
+ >,
289
+ ) => QueryBuilder<
290
+ WithSystemFields<TargetSchema>,
291
+ TargetSelected,
292
+ any,
293
+ any,
294
+ any
295
+ >,
296
+ ): RecordBuilder<
297
+ T,
298
+ false,
299
+ FieldKey,
300
+ Occ,
301
+ Selected,
302
+ Expands & {
303
+ [K in Rel]: { schema: TargetSchema; selected: TargetSelected };
304
+ }
305
+ > {
306
+ // Look up target occurrence from navigation
307
+ const targetOccurrence = this.occurrence?.navigation[relation as string];
308
+
309
+ // Create new builder with updated types
310
+ const newBuilder = new RecordBuilder<
311
+ T,
312
+ false,
313
+ FieldKey,
314
+ Occ,
315
+ Selected,
316
+ Expands & {
317
+ [K in Rel]: { schema: TargetSchema; selected: TargetSelected };
318
+ }
319
+ >({
320
+ occurrence: this.occurrence,
321
+ tableName: this.tableName,
322
+ databaseName: this.databaseName,
323
+ context: this.context,
324
+ recordId: this.recordId,
325
+ databaseUseEntityIds: this.databaseUseEntityIds,
326
+ });
327
+
328
+ // Copy existing state
329
+ newBuilder.selectedFields = this.selectedFields;
330
+ newBuilder.expandConfigs = [...this.expandConfigs];
331
+ newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
332
+ newBuilder.navigateRelation = this.navigateRelation;
333
+ newBuilder.navigateSourceTableName = this.navigateSourceTableName;
334
+
335
+ if (callback) {
336
+ // Create a new QueryBuilder for the target occurrence
337
+ const targetBuilder = new QueryBuilder<any>({
338
+ occurrence: targetOccurrence,
339
+ tableName: targetOccurrence?.name ?? (relation as string),
340
+ databaseName: this.databaseName,
341
+ context: this.context,
342
+ databaseUseEntityIds: this.databaseUseEntityIds,
343
+ });
344
+
345
+ // Cast to the expected type for the callback
346
+ // At runtime, the builder is untyped (any), but at compile-time we enforce proper types
347
+ const typedBuilder = targetBuilder as QueryBuilder<
348
+ TargetSchema,
349
+ keyof TargetSchema,
350
+ false,
351
+ false,
352
+ TargetOcc extends TableOccurrence<any, any, any, any>
353
+ ? TargetOcc
354
+ : undefined
355
+ >;
356
+
357
+ // Pass to callback and get configured builder
358
+ const configuredBuilder = callback(typedBuilder);
359
+
360
+ // Extract the builder's query options
361
+ const expandOptions: Partial<QueryOptions<any>> = {
362
+ ...(configuredBuilder as any).queryOptions,
363
+ };
364
+
365
+ // If the configured builder has nested expands, we need to include them
366
+ if ((configuredBuilder as any).expandConfigs?.length > 0) {
367
+ // Build nested expand string from the configured builder's expand configs
368
+ const nestedExpandString = this.buildExpandString(
369
+ (configuredBuilder as any).expandConfigs,
370
+ );
371
+ if (nestedExpandString) {
372
+ // Add nested expand to options
373
+ expandOptions.expand = nestedExpandString as any;
374
+ }
375
+ }
376
+
377
+ const expandConfig: ExpandConfig = {
378
+ relation: relation as string,
379
+ options: expandOptions,
380
+ };
381
+
382
+ newBuilder.expandConfigs.push(expandConfig);
383
+ } else {
384
+ // Simple expand without callback
385
+ newBuilder.expandConfigs.push({ relation: relation as string });
386
+ }
387
+
388
+ return newBuilder;
389
+ }
390
+
102
391
  // Overload for valid relation names - returns typed QueryBuilder
103
392
  navigate<RelationName extends ExtractNavigationNames<Occ>>(
104
393
  relationName: RelationName,
105
394
  ): QueryBuilder<
106
395
  ExtractSchemaFromOccurrence<
107
396
  FindNavigationTarget<Occ, RelationName>
108
- > extends Record<string, z.ZodType>
397
+ > extends Record<string, StandardSchemaV1>
109
398
  ? InferSchemaType<
110
399
  ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
111
400
  >
@@ -127,9 +416,14 @@ export class RecordBuilder<
127
416
  context: this.context,
128
417
  });
129
418
  // Store the navigation info - we'll use it in execute
419
+ // Transform relation name to FMTID if using entity IDs
420
+ const relationId = targetOccurrence
421
+ ? transformTableName(targetOccurrence)
422
+ : relationName;
423
+
130
424
  (builder as any).isNavigate = true;
131
425
  (builder as any).navigateRecordId = this.recordId;
132
- (builder as any).navigateRelation = relationName;
426
+ (builder as any).navigateRelation = relationId;
133
427
 
134
428
  // If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path
135
429
  if (
@@ -142,74 +436,300 @@ export class RecordBuilder<
142
436
  (builder as any).navigateBaseRelation = this.navigateRelation;
143
437
  } else {
144
438
  // Normal record navigation: /tableName('recordId')/relation
145
- (builder as any).navigateSourceTableName = this.tableName;
439
+ // Transform source table name to FMTID if using entity IDs
440
+ const sourceTableId = this.occurrence
441
+ ? transformTableName(this.occurrence)
442
+ : this.tableName;
443
+ (builder as any).navigateSourceTableName = sourceTableId;
146
444
  }
147
445
 
148
446
  return builder;
149
447
  }
150
448
 
151
- async execute(
152
- options?: RequestInit & FFetchOptions,
449
+ /**
450
+ * Formats select fields for use in query strings.
451
+ * - Transforms field names to FMFIDs if using entity IDs
452
+ * - Wraps "id" fields in double quotes
453
+ * - URL-encodes special characters but preserves spaces
454
+ */
455
+ private formatSelectFields(
456
+ select: string[] | undefined,
457
+ baseTable?: BaseTable<any, any, any, any>,
458
+ ): string {
459
+ if (!select || select.length === 0) return "";
460
+
461
+ // Transform to field IDs if using entity IDs
462
+ const transformedFields = baseTable
463
+ ? transformFieldNamesArray(select, baseTable)
464
+ : select;
465
+
466
+ return transformedFields
467
+ .map((field) => {
468
+ if (field === "id") return `"id"`;
469
+ const encodedField = encodeURIComponent(String(field));
470
+ return encodedField.replace(/%20/g, " ");
471
+ })
472
+ .join(",");
473
+ }
474
+
475
+ /**
476
+ * Builds expand validation configs from internal expand configurations.
477
+ * These are used to validate expanded navigation properties.
478
+ */
479
+ private buildExpandValidationConfigs(
480
+ configs: ExpandConfig[],
481
+ ): ExpandValidationConfig[] {
482
+ return configs.map((config) => {
483
+ // Look up target occurrence from navigation
484
+ const targetOccurrence = this.occurrence?.navigation[config.relation];
485
+ const targetSchema = targetOccurrence?.baseTable?.schema;
486
+
487
+ // Extract selected fields from options
488
+ const selectedFields = config.options?.select
489
+ ? Array.isArray(config.options.select)
490
+ ? config.options.select.map((f) => String(f))
491
+ : [String(config.options.select)]
492
+ : undefined;
493
+
494
+ return {
495
+ relation: config.relation,
496
+ targetSchema: targetSchema,
497
+ targetOccurrence: targetOccurrence,
498
+ targetBaseTable: targetOccurrence?.baseTable,
499
+ occurrence: targetOccurrence, // For transformation
500
+ selectedFields: selectedFields,
501
+ nestedExpands: undefined, // TODO: Handle nested expands if needed
502
+ };
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Builds OData expand query string from expand configurations.
508
+ * Handles nested expands recursively.
509
+ * Transforms relation names to FMTIDs if using entity IDs.
510
+ */
511
+ private buildExpandString(configs: ExpandConfig[]): string {
512
+ if (configs.length === 0) {
513
+ return "";
514
+ }
515
+
516
+ return configs
517
+ .map((config) => {
518
+ // Get target occurrence for this relation
519
+ const targetOccurrence = this.occurrence?.navigation[config.relation];
520
+
521
+ // When using entity IDs, use the target table's FMTID in the expand parameter
522
+ // FileMaker expects FMTID in $expand when Prefer header is set
523
+ const relationName =
524
+ targetOccurrence && targetOccurrence.isUsingTableId?.()
525
+ ? targetOccurrence.getTableId()
526
+ : config.relation;
527
+
528
+ if (!config.options || Object.keys(config.options).length === 0) {
529
+ // Simple expand without options
530
+ return relationName;
531
+ }
532
+
533
+ // Build query options for this expand
534
+ const parts: string[] = [];
535
+
536
+ if (config.options.select) {
537
+ // Pass target base table for field transformation
538
+ const selectFields = this.formatSelectFields(
539
+ Array.isArray(config.options.select)
540
+ ? config.options.select.map((f) => String(f))
541
+ : [String(config.options.select)],
542
+ targetOccurrence?.baseTable,
543
+ );
544
+ parts.push(`$select=${selectFields}`);
545
+ }
546
+
547
+ if (config.options.filter) {
548
+ // Filter should already be transformed by the nested builder
549
+ // Use odata-query to build filter string
550
+ const filterQuery = buildQuery({ filter: config.options.filter });
551
+ const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
552
+ if (filterMatch) {
553
+ parts.push(`$filter=${filterMatch[1]}`);
554
+ }
555
+ }
556
+
557
+ if (config.options.orderBy) {
558
+ const orderByQuery = buildQuery({ orderBy: config.options.orderBy });
559
+ const orderByMatch = orderByQuery.match(/\$orderby=([^&]+)/);
560
+ if (orderByMatch) {
561
+ parts.push(`$orderby=${orderByMatch[1]}`);
562
+ }
563
+ }
564
+
565
+ if (config.options.top !== undefined) {
566
+ parts.push(`$top=${config.options.top}`);
567
+ }
568
+
569
+ if (config.options.skip !== undefined) {
570
+ parts.push(`$skip=${config.options.skip}`);
571
+ }
572
+
573
+ // Handle nested expand
574
+ if (config.options.expand) {
575
+ // Nested expand is already a string from buildExpandString
576
+ parts.push(`$expand=${String(config.options.expand)}`);
577
+ }
578
+
579
+ if (parts.length === 0) {
580
+ return relationName;
581
+ }
582
+
583
+ return `${relationName}(${parts.join(";")})`;
584
+ })
585
+ .join(",");
586
+ }
587
+
588
+ /**
589
+ * Builds the complete query string including $select and $expand parameters.
590
+ */
591
+ private buildQueryString(): string {
592
+ const parts: string[] = [];
593
+
594
+ // Build $select
595
+ if (this.selectedFields && this.selectedFields.length > 0) {
596
+ const selectString = this.formatSelectFields(
597
+ this.selectedFields,
598
+ this.occurrence?.baseTable,
599
+ );
600
+ if (selectString) {
601
+ parts.push(`$select=${selectString}`);
602
+ }
603
+ }
604
+
605
+ // Build $expand
606
+ const expandString = this.buildExpandString(this.expandConfigs);
607
+ if (expandString) {
608
+ parts.push(`$expand=${expandString}`);
609
+ }
610
+
611
+ if (parts.length === 0) {
612
+ return "";
613
+ }
614
+
615
+ return `?${parts.join("&")}`;
616
+ }
617
+
618
+ /**
619
+ * Helper to conditionally strip OData annotations based on options
620
+ */
621
+ private stripODataAnnotationsIfNeeded<R extends Record<string, any>>(
622
+ data: R,
623
+ options?: ExecuteOptions,
624
+ ): R {
625
+ // Only include annotations if explicitly requested
626
+ if (options?.includeODataAnnotations === true) {
627
+ return data;
628
+ }
629
+
630
+ // Strip OData annotations
631
+ const { "@id": _id, "@editLink": _editLink, ...rest } = data;
632
+ return rest as R;
633
+ }
634
+
635
+ async execute<EO extends ExecuteOptions>(
636
+ options?: RequestInit & FFetchOptions & EO,
153
637
  ): Promise<
154
- Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
638
+ Result<
639
+ ConditionallyWithODataAnnotations<
640
+ RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>,
641
+ EO["includeODataAnnotations"] extends true ? true : false
642
+ >
643
+ >
155
644
  > {
156
- try {
157
- let url: string;
158
-
159
- // Build the base URL depending on whether this came from a navigated EntitySet
160
- if (
161
- this.isNavigateFromEntitySet &&
162
- this.navigateSourceTableName &&
163
- this.navigateRelation
164
- ) {
165
- // From navigated EntitySet: /sourceTable/relation('recordId')
166
- url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
167
- } else {
168
- // Normal record: /tableName('recordId')
169
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
170
- }
645
+ let url: string;
171
646
 
172
- if (this.operation === "getSingleField" && this.operationParam) {
173
- url += `/${this.operationParam}`;
174
- }
647
+ // Build the base URL depending on whether this came from a navigated EntitySet
648
+ if (
649
+ this.isNavigateFromEntitySet &&
650
+ this.navigateSourceTableName &&
651
+ this.navigateRelation
652
+ ) {
653
+ // From navigated EntitySet: /sourceTable/relation('recordId')
654
+ url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
655
+ } else {
656
+ // Normal record: /tableName('recordId') - use FMTID if configured
657
+ const tableId = this.getTableId(
658
+ options?.useEntityIds ?? this.databaseUseEntityIds,
659
+ );
660
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
661
+ }
175
662
 
176
- const response = await this.context._makeRequest(url, options);
663
+ if (this.operation === "getSingleField" && this.operationParam) {
664
+ url += `/${this.operationParam}`;
665
+ } else {
666
+ // Add query string for select/expand (only when not getting a single field)
667
+ const queryString = this.buildQueryString();
668
+ url += queryString;
669
+ }
177
670
 
178
- // Handle single field operation
179
- if (this.operation === "getSingleField") {
180
- // Single field returns a JSON object with @context and value
181
- const fieldResponse = response as ODataFieldResponse<T>;
182
- return { data: fieldResponse.value as any, error: undefined };
183
- }
671
+ const mergedOptions = this.mergeExecuteOptions(options);
672
+ const result = await this.context._makeRequest(url, mergedOptions);
673
+
674
+ if (result.error) {
675
+ return { data: undefined, error: result.error };
676
+ }
184
677
 
185
- // Get schema from occurrence if available
186
- const schema = this.occurrence?.baseTable?.schema;
678
+ let response = result.data;
187
679
 
188
- // Validate the single record response
189
- const validation = await validateSingleResponse<any>(
680
+ // Handle single field operation
681
+ if (this.operation === "getSingleField") {
682
+ // Single field returns a JSON object with @context and value
683
+ const fieldResponse = response as ODataFieldResponse<T>;
684
+ return { data: fieldResponse.value as any, error: undefined };
685
+ }
686
+
687
+ // Transform response field IDs back to names if using entity IDs
688
+ // Only transform if useEntityIds resolves to true (respects per-request override)
689
+ const shouldUseIds = mergedOptions.useEntityIds ?? false;
690
+
691
+ // Build expand validation configs for transformation and validation
692
+ const expandValidationConfigs =
693
+ this.expandConfigs.length > 0
694
+ ? this.buildExpandValidationConfigs(this.expandConfigs)
695
+ : undefined;
696
+
697
+ if (this.occurrence?.baseTable && shouldUseIds) {
698
+ response = transformResponseFields(
190
699
  response,
191
- schema,
192
- undefined, // No selected fields for record.get()
193
- undefined, // No expand configs
194
- "exact", // Expect exactly one record
700
+ this.occurrence.baseTable,
701
+ expandValidationConfigs,
195
702
  );
703
+ }
196
704
 
197
- if (!validation.valid) {
198
- return { data: undefined, error: validation.error };
199
- }
705
+ // Get schema from occurrence if available
706
+ const schema = this.occurrence?.baseTable?.schema;
200
707
 
201
- // Handle null response
202
- if (validation.data === null) {
203
- return { data: null as any, error: undefined };
204
- }
708
+ // Validate the single record response
709
+ const validation = await validateSingleResponse<any>(
710
+ response,
711
+ schema,
712
+ this.selectedFields as (keyof T)[] | undefined,
713
+ expandValidationConfigs,
714
+ "exact", // Expect exactly one record
715
+ );
205
716
 
206
- return { data: validation.data, error: undefined };
207
- } catch (error) {
208
- return {
209
- data: undefined,
210
- error: error instanceof Error ? error : new Error(String(error)),
211
- };
717
+ if (!validation.valid) {
718
+ return { data: undefined, error: validation.error };
719
+ }
720
+
721
+ // Handle null response
722
+ if (validation.data === null) {
723
+ return { data: null as any, error: undefined };
212
724
  }
725
+
726
+ // Strip OData annotations if not requested
727
+ const stripped = this.stripODataAnnotationsIfNeeded(
728
+ validation.data,
729
+ options,
730
+ );
731
+
732
+ return { data: stripped as any, error: undefined };
213
733
  }
214
734
 
215
735
  getRequestConfig(): { method: string; url: string; body?: any } {
@@ -224,12 +744,17 @@ export class RecordBuilder<
224
744
  // From navigated EntitySet: /sourceTable/relation('recordId')
225
745
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
226
746
  } else {
227
- // Normal record: /tableName('recordId')
228
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
747
+ // For batch operations, use database-level setting (no per-request override available here)
748
+ const tableId = this.getTableId(this.databaseUseEntityIds);
749
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
229
750
  }
230
751
 
231
752
  if (this.operation === "getSingleField" && this.operationParam) {
232
753
  url += `/${this.operationParam}`;
754
+ } else {
755
+ // Add query string for select/expand (only when not getting a single field)
756
+ const queryString = this.buildQueryString();
757
+ url += queryString;
233
758
  }
234
759
 
235
760
  return {
@@ -237,4 +762,106 @@ export class RecordBuilder<
237
762
  url,
238
763
  };
239
764
  }
765
+
766
+ /**
767
+ * Returns the query string for this record builder (for testing purposes).
768
+ */
769
+ getQueryString(): string {
770
+ let path: string;
771
+
772
+ // Build the path depending on navigation context
773
+ if (
774
+ this.isNavigateFromEntitySet &&
775
+ this.navigateSourceTableName &&
776
+ this.navigateRelation
777
+ ) {
778
+ path = `/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
779
+ } else {
780
+ path = `/${this.tableName}('${this.recordId}')`;
781
+ }
782
+
783
+ if (this.operation === "getSingleField" && this.operationParam) {
784
+ return `${path}/${this.operationParam}`;
785
+ }
786
+
787
+ const queryString = this.buildQueryString();
788
+ return `${path}${queryString}`;
789
+ }
790
+
791
+ toRequest(baseUrl: string): Request {
792
+ const config = this.getRequestConfig();
793
+ const fullUrl = `${baseUrl}${config.url}`;
794
+
795
+ return new Request(fullUrl, {
796
+ method: config.method,
797
+ headers: {
798
+ "Content-Type": "application/json",
799
+ Accept: "application/json",
800
+ },
801
+ });
802
+ }
803
+
804
+ async processResponse(
805
+ response: Response,
806
+ options?: ExecuteOptions,
807
+ ): Promise<
808
+ Result<RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>>
809
+ > {
810
+ const rawResponse = await response.json();
811
+
812
+ // Handle single field operation
813
+ if (this.operation === "getSingleField") {
814
+ // Single field returns a JSON object with @context and value
815
+ const fieldResponse = rawResponse as ODataFieldResponse<T>;
816
+ return { data: fieldResponse.value as any, error: undefined };
817
+ }
818
+
819
+ // Transform response field IDs back to names if using entity IDs
820
+ // Only transform if useEntityIds resolves to true (respects per-request override)
821
+ const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
822
+
823
+ // Build expand validation configs for transformation and validation
824
+ const expandValidationConfigs =
825
+ this.expandConfigs.length > 0
826
+ ? this.buildExpandValidationConfigs(this.expandConfigs)
827
+ : undefined;
828
+
829
+ let transformedResponse = rawResponse;
830
+ if (this.occurrence?.baseTable && shouldUseIds) {
831
+ transformedResponse = transformResponseFields(
832
+ rawResponse,
833
+ this.occurrence.baseTable,
834
+ expandValidationConfigs,
835
+ );
836
+ }
837
+
838
+ // Get schema from occurrence if available
839
+ const schema = this.occurrence?.baseTable?.schema;
840
+
841
+ // Validate the single record response
842
+ const validation = await validateSingleResponse<any>(
843
+ transformedResponse,
844
+ schema,
845
+ this.selectedFields as (keyof T)[] | undefined,
846
+ expandValidationConfigs,
847
+ "exact", // Expect exactly one record
848
+ );
849
+
850
+ if (!validation.valid) {
851
+ return { data: undefined, error: validation.error };
852
+ }
853
+
854
+ // Handle null response
855
+ if (validation.data === null) {
856
+ return { data: null as any, error: undefined };
857
+ }
858
+
859
+ // Strip OData annotations if not requested
860
+ const stripped = this.stripODataAnnotationsIfNeeded(
861
+ validation.data,
862
+ options,
863
+ );
864
+
865
+ return { data: stripped as any, error: undefined };
866
+ }
240
867
  }