@proofkit/fmodata 0.1.0-alpha.0

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 (54) hide show
  1. package/README.md +37 -0
  2. package/dist/esm/client/base-table.d.ts +13 -0
  3. package/dist/esm/client/base-table.js +19 -0
  4. package/dist/esm/client/base-table.js.map +1 -0
  5. package/dist/esm/client/database.d.ts +49 -0
  6. package/dist/esm/client/database.js +90 -0
  7. package/dist/esm/client/database.js.map +1 -0
  8. package/dist/esm/client/delete-builder.d.ts +61 -0
  9. package/dist/esm/client/delete-builder.js +121 -0
  10. package/dist/esm/client/delete-builder.js.map +1 -0
  11. package/dist/esm/client/entity-set.d.ts +43 -0
  12. package/dist/esm/client/entity-set.js +120 -0
  13. package/dist/esm/client/entity-set.js.map +1 -0
  14. package/dist/esm/client/filemaker-odata.d.ts +26 -0
  15. package/dist/esm/client/filemaker-odata.js +85 -0
  16. package/dist/esm/client/filemaker-odata.js.map +1 -0
  17. package/dist/esm/client/insert-builder.d.ts +23 -0
  18. package/dist/esm/client/insert-builder.js +69 -0
  19. package/dist/esm/client/insert-builder.js.map +1 -0
  20. package/dist/esm/client/query-builder.d.ts +94 -0
  21. package/dist/esm/client/query-builder.js +649 -0
  22. package/dist/esm/client/query-builder.js.map +1 -0
  23. package/dist/esm/client/record-builder.d.ts +43 -0
  24. package/dist/esm/client/record-builder.js +121 -0
  25. package/dist/esm/client/record-builder.js.map +1 -0
  26. package/dist/esm/client/table-occurrence.d.ts +25 -0
  27. package/dist/esm/client/table-occurrence.js +47 -0
  28. package/dist/esm/client/table-occurrence.js.map +1 -0
  29. package/dist/esm/client/update-builder.d.ts +69 -0
  30. package/dist/esm/client/update-builder.js +134 -0
  31. package/dist/esm/client/update-builder.js.map +1 -0
  32. package/dist/esm/filter-types.d.ts +76 -0
  33. package/dist/esm/index.d.ts +4 -0
  34. package/dist/esm/index.js +10 -0
  35. package/dist/esm/index.js.map +1 -0
  36. package/dist/esm/types.d.ts +67 -0
  37. package/dist/esm/validation.d.ts +41 -0
  38. package/dist/esm/validation.js +270 -0
  39. package/dist/esm/validation.js.map +1 -0
  40. package/package.json +68 -0
  41. package/src/client/base-table.ts +25 -0
  42. package/src/client/database.ts +177 -0
  43. package/src/client/delete-builder.ts +193 -0
  44. package/src/client/entity-set.ts +310 -0
  45. package/src/client/filemaker-odata.ts +119 -0
  46. package/src/client/insert-builder.ts +93 -0
  47. package/src/client/query-builder.ts +1076 -0
  48. package/src/client/record-builder.ts +240 -0
  49. package/src/client/table-occurrence.ts +100 -0
  50. package/src/client/update-builder.ts +212 -0
  51. package/src/filter-types.ts +97 -0
  52. package/src/index.ts +17 -0
  53. package/src/types.ts +123 -0
  54. package/src/validation.ts +397 -0
@@ -0,0 +1,1076 @@
1
+ import { QueryOptions } from "odata-query";
2
+ import buildQuery from "odata-query";
3
+ import type {
4
+ ExecutionContext,
5
+ ExecutableBuilder,
6
+ WithSystemFields,
7
+ ODataRecordMetadata,
8
+ Result,
9
+ InferSchemaType,
10
+ ExecuteOptions,
11
+ ConditionallyWithODataAnnotations,
12
+ ExtractSchemaFromOccurrence,
13
+ } from "../types";
14
+ import type { Filter } from "../filter-types";
15
+ import type { TableOccurrence } from "./table-occurrence";
16
+ import type { BaseTable } from "./base-table";
17
+ import { validateListResponse, validateSingleResponse } from "../validation";
18
+ import { type FFetchOptions } from "@fetchkit/ffetch";
19
+ import { z } from "zod/v4";
20
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
21
+
22
+ // Helper type to extract navigation relation names from an occurrence
23
+ type ExtractNavigationNames<
24
+ O extends TableOccurrence<any, any, any, any> | undefined,
25
+ > =
26
+ O extends TableOccurrence<any, any, infer Nav, any>
27
+ ? Nav extends Record<string, any>
28
+ ? keyof Nav & string
29
+ : never
30
+ : never;
31
+
32
+ // Helper type to resolve a navigation item (handles both direct and lazy-loaded)
33
+ type ResolveNavigationItem<T> = T extends () => infer R ? R : T;
34
+
35
+ // Helper type to find target occurrence by relation name
36
+ type FindNavigationTarget<
37
+ O extends TableOccurrence<any, any, any, any> | undefined,
38
+ Name extends string,
39
+ > =
40
+ O extends TableOccurrence<any, any, infer Nav, any>
41
+ ? Nav extends Record<string, any>
42
+ ? Name extends keyof Nav
43
+ ? ResolveNavigationItem<Nav[Name]>
44
+ : TableOccurrence<
45
+ BaseTable<Record<string, z.ZodTypeAny>, any>,
46
+ any,
47
+ any,
48
+ any
49
+ >
50
+ : TableOccurrence<
51
+ BaseTable<Record<string, z.ZodTypeAny>, any>,
52
+ any,
53
+ any,
54
+ any
55
+ >
56
+ : TableOccurrence<
57
+ BaseTable<Record<string, z.ZodTypeAny>, any>,
58
+ any,
59
+ any,
60
+ any
61
+ >;
62
+
63
+ // Helper type to get the inferred schema type from a target occurrence
64
+ type GetTargetSchemaType<
65
+ O extends TableOccurrence<any, any, any, any> | undefined,
66
+ Rel extends string,
67
+ > = [FindNavigationTarget<O, Rel>] extends [
68
+ TableOccurrence<infer BT, any, any, any>,
69
+ ]
70
+ ? [BT] extends [BaseTable<infer S, any>]
71
+ ? [S] extends [Record<string, StandardSchemaV1>]
72
+ ? InferSchemaType<S>
73
+ : Record<string, any>
74
+ : Record<string, any>
75
+ : Record<string, any>;
76
+
77
+ // Internal type for expand configuration
78
+ type ExpandConfig = {
79
+ relation: string;
80
+ options?: Partial<QueryOptions<any>>;
81
+ };
82
+
83
+ // Type to represent expanded relations
84
+ type ExpandedRelations = Record<string, { schema: any; selected: any }>;
85
+
86
+ export class QueryBuilder<
87
+ T extends Record<string, any>,
88
+ Selected extends keyof T = keyof T,
89
+ SingleMode extends "exact" | "maybe" | false = false,
90
+ IsCount extends boolean = false,
91
+ Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
92
+ Expands extends ExpandedRelations = {},
93
+ > {
94
+ private queryOptions: Partial<QueryOptions<T>> = {};
95
+ private expandConfigs: ExpandConfig[] = [];
96
+ private singleMode: SingleMode = false as SingleMode;
97
+ private isCountMode = false as IsCount;
98
+ private occurrence?: Occ;
99
+ private tableName: string;
100
+ private databaseName: string;
101
+ private context: ExecutionContext;
102
+ private isNavigate?: boolean;
103
+ private navigateRecordId?: string | number;
104
+ private navigateRelation?: string;
105
+ private navigateSourceTableName?: string;
106
+ private navigateBaseRelation?: string;
107
+ constructor(config: {
108
+ occurrence?: Occ;
109
+ tableName: string;
110
+ databaseName: string;
111
+ context: ExecutionContext;
112
+ }) {
113
+ this.occurrence = config.occurrence;
114
+ this.tableName = config.tableName;
115
+ this.databaseName = config.databaseName;
116
+ this.context = config.context;
117
+ }
118
+
119
+ /**
120
+ * Helper to conditionally strip OData annotations based on options
121
+ */
122
+ private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
123
+ data: T,
124
+ options?: ExecuteOptions,
125
+ ): T {
126
+ // Only include annotations if explicitly requested
127
+ if (options?.includeODataAnnotations === true) {
128
+ return data;
129
+ }
130
+
131
+ // Strip OData annotations
132
+ const { "@id": _id, "@editLink": _editLink, ...rest } = data;
133
+ return rest as T;
134
+ }
135
+
136
+ select<K extends keyof T>(
137
+ ...fields: K[]
138
+ ): QueryBuilder<T, K, SingleMode, IsCount, Occ, Expands> {
139
+ const uniqueFields = [...new Set(fields)];
140
+ const newBuilder = new QueryBuilder<
141
+ T,
142
+ K,
143
+ SingleMode,
144
+ IsCount,
145
+ Occ,
146
+ Expands
147
+ >({
148
+ occurrence: this.occurrence,
149
+ tableName: this.tableName,
150
+ databaseName: this.databaseName,
151
+ context: this.context,
152
+ });
153
+ newBuilder.queryOptions = {
154
+ ...this.queryOptions,
155
+ select: uniqueFields as string[],
156
+ };
157
+ newBuilder.expandConfigs = [...this.expandConfigs];
158
+ newBuilder.singleMode = this.singleMode;
159
+ newBuilder.isCountMode = this.isCountMode;
160
+ // Preserve navigation metadata
161
+ newBuilder.isNavigate = this.isNavigate;
162
+ newBuilder.navigateRecordId = this.navigateRecordId;
163
+ newBuilder.navigateRelation = this.navigateRelation;
164
+ newBuilder.navigateSourceTableName = this.navigateSourceTableName;
165
+ newBuilder.navigateBaseRelation = this.navigateBaseRelation;
166
+ return newBuilder;
167
+ }
168
+
169
+ /**
170
+ * Transforms our filter format to odata-query's expected format
171
+ * - Arrays of operators are converted to AND conditions
172
+ * - Single operator objects pass through as-is
173
+ * - Shorthand values are handled by odata-query
174
+ */
175
+ private transformFilter(
176
+ filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
177
+ ): QueryOptions<T>["filter"] {
178
+ if (typeof filter === "string") {
179
+ // Raw string filters pass through
180
+ return filter;
181
+ }
182
+
183
+ if (Array.isArray(filter)) {
184
+ // Array of filters - odata-query handles this as implicit AND
185
+ return filter.map((f) => this.transformFilter(f as any)) as any;
186
+ }
187
+
188
+ // Check if it's a logical filter (and/or/not)
189
+ if ("and" in filter || "or" in filter || "not" in filter) {
190
+ const result: any = {};
191
+ if ("and" in filter && Array.isArray(filter.and)) {
192
+ result.and = filter.and.map((f: any) => this.transformFilter(f));
193
+ }
194
+ if ("or" in filter && Array.isArray(filter.or)) {
195
+ result.or = filter.or.map((f: any) => this.transformFilter(f));
196
+ }
197
+ if ("not" in filter && filter.not) {
198
+ result.not = this.transformFilter(filter.not as any);
199
+ }
200
+ return result;
201
+ }
202
+
203
+ // Transform field filters
204
+ const result: any = {};
205
+ const andConditions: any[] = [];
206
+
207
+ for (const [field, value] of Object.entries(filter)) {
208
+ if (Array.isArray(value)) {
209
+ // Array of operators - convert to AND conditions
210
+ if (value.length === 1) {
211
+ // Single operator in array - unwrap it
212
+ result[field] = value[0];
213
+ } else {
214
+ // Multiple operators - combine with AND
215
+ // Create separate conditions for each operator
216
+ for (const op of value) {
217
+ andConditions.push({ [field]: op });
218
+ }
219
+ }
220
+ } else if (
221
+ value &&
222
+ typeof value === "object" &&
223
+ !(value instanceof Date) &&
224
+ !Array.isArray(value)
225
+ ) {
226
+ // Check if it's an operator object (has operator keys like eq, gt, etc.)
227
+ const operatorKeys = [
228
+ "eq",
229
+ "ne",
230
+ "gt",
231
+ "ge",
232
+ "lt",
233
+ "le",
234
+ "contains",
235
+ "startswith",
236
+ "endswith",
237
+ "in",
238
+ ];
239
+ const isOperatorObject = operatorKeys.some((key) => key in value);
240
+
241
+ if (isOperatorObject) {
242
+ // Single operator object - pass through
243
+ result[field] = value;
244
+ } else {
245
+ // Regular object - might be nested filter, pass through
246
+ result[field] = value;
247
+ }
248
+ } else {
249
+ // Primitive value (shorthand) - pass through
250
+ result[field] = value;
251
+ }
252
+ }
253
+
254
+ // If we have AND conditions from arrays, combine them
255
+ if (andConditions.length > 0) {
256
+ if (Object.keys(result).length > 0) {
257
+ // We have both regular fields and array-derived AND conditions
258
+ // Combine everything with AND
259
+ return { and: [...andConditions, result] };
260
+ } else {
261
+ // Only array-derived AND conditions
262
+ return { and: andConditions };
263
+ }
264
+ }
265
+
266
+ return result;
267
+ }
268
+
269
+ filter(
270
+ filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
271
+ ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
272
+ // Transform our filter format to odata-query's expected format
273
+ this.queryOptions.filter = this.transformFilter(filter) as any;
274
+ return this;
275
+ }
276
+
277
+ orderBy(
278
+ orderBy: QueryOptions<T>["orderBy"],
279
+ ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
280
+ this.queryOptions.orderBy = orderBy;
281
+ return this;
282
+ }
283
+
284
+ top(
285
+ count: number,
286
+ ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
287
+ this.queryOptions.top = count;
288
+ return this;
289
+ }
290
+
291
+ skip(
292
+ count: number,
293
+ ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
294
+ this.queryOptions.skip = count;
295
+ return this;
296
+ }
297
+
298
+ /**
299
+ * Formats select fields for use in query strings.
300
+ * - Wraps "id" fields in double quotes
301
+ * - URL-encodes special characters but preserves spaces
302
+ */
303
+ private formatSelectFields(select: QueryOptions<any>["select"]): string {
304
+ if (!select) return "";
305
+ const selectFieldsArray = Array.isArray(select) ? select : [select];
306
+ return selectFieldsArray
307
+ .map((field) => {
308
+ if (field === "id") return `"id"`;
309
+ const encodedField = encodeURIComponent(String(field));
310
+ return encodedField.replace(/%20/g, " ");
311
+ })
312
+ .join(",");
313
+ }
314
+
315
+ /**
316
+ * Builds expand validation configs from internal expand configurations.
317
+ * These are used to validate expanded navigation properties.
318
+ */
319
+ private buildExpandValidationConfigs(
320
+ configs: ExpandConfig[],
321
+ ): import("../validation").ExpandValidationConfig[] {
322
+ return configs.map((config) => {
323
+ // Look up target occurrence from navigation
324
+ const targetOccurrence = this.occurrence?.navigation[config.relation];
325
+ const targetSchema = targetOccurrence?.baseTable?.schema;
326
+
327
+ // Extract selected fields from options
328
+ const selectedFields = config.options?.select
329
+ ? Array.isArray(config.options.select)
330
+ ? config.options.select.map((f) => String(f))
331
+ : [String(config.options.select)]
332
+ : undefined;
333
+
334
+ return {
335
+ relation: config.relation,
336
+ targetSchema: targetSchema,
337
+ targetOccurrence: targetOccurrence,
338
+ selectedFields: selectedFields,
339
+ nestedExpands: undefined, // TODO: Handle nested expands if needed
340
+ };
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Builds OData expand query string from expand configurations.
346
+ * Handles nested expands recursively.
347
+ */
348
+ private buildExpandString(configs: ExpandConfig[]): string {
349
+ if (configs.length === 0) {
350
+ return "";
351
+ }
352
+
353
+ return configs
354
+ .map((config) => {
355
+ if (!config.options || Object.keys(config.options).length === 0) {
356
+ // Simple expand without options
357
+ return config.relation;
358
+ }
359
+
360
+ // Build query options for this expand
361
+ const parts: string[] = [];
362
+
363
+ if (config.options.select) {
364
+ const selectFields = this.formatSelectFields(config.options.select);
365
+ parts.push(`$select=${selectFields}`);
366
+ }
367
+
368
+ if (config.options.filter) {
369
+ // Use odata-query to build filter string
370
+ const filterQuery = buildQuery({ filter: config.options.filter });
371
+ const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
372
+ if (filterMatch) {
373
+ parts.push(`$filter=${filterMatch[1]}`);
374
+ }
375
+ }
376
+
377
+ if (config.options.orderBy) {
378
+ const orderByValue = Array.isArray(config.options.orderBy)
379
+ ? config.options.orderBy.join(",")
380
+ : config.options.orderBy;
381
+ parts.push(`$orderby=${String(orderByValue)}`);
382
+ }
383
+
384
+ if (config.options.top !== undefined) {
385
+ parts.push(`$top=${config.options.top}`);
386
+ }
387
+
388
+ if (config.options.skip !== undefined) {
389
+ parts.push(`$skip=${config.options.skip}`);
390
+ }
391
+
392
+ // Handle nested expands (from expand configs)
393
+ if (config.options.expand) {
394
+ // If expand is a string, it's already been built
395
+ if (typeof config.options.expand === "string") {
396
+ parts.push(`$expand=${config.options.expand}`);
397
+ }
398
+ }
399
+
400
+ if (parts.length === 0) {
401
+ return config.relation;
402
+ }
403
+
404
+ return `${config.relation}(${parts.join(";")})`;
405
+ })
406
+ .join(",");
407
+ }
408
+
409
+ expand<
410
+ Rel extends ExtractNavigationNames<Occ> | (string & {}),
411
+ TargetOcc extends FindNavigationTarget<Occ, Rel> = FindNavigationTarget<
412
+ Occ,
413
+ Rel
414
+ >,
415
+ TargetSchema extends GetTargetSchemaType<Occ, Rel> = GetTargetSchemaType<
416
+ Occ,
417
+ Rel
418
+ >,
419
+ TargetSelected extends keyof TargetSchema = keyof TargetSchema,
420
+ >(
421
+ relation: Rel,
422
+ callback?: (
423
+ builder: QueryBuilder<
424
+ TargetSchema,
425
+ keyof TargetSchema,
426
+ false,
427
+ false,
428
+ TargetOcc extends TableOccurrence<any, any, any, any>
429
+ ? TargetOcc
430
+ : undefined
431
+ >,
432
+ ) => QueryBuilder<
433
+ WithSystemFields<TargetSchema>,
434
+ TargetSelected,
435
+ any,
436
+ any,
437
+ any
438
+ >,
439
+ ): QueryBuilder<
440
+ T,
441
+ Selected,
442
+ SingleMode,
443
+ IsCount,
444
+ Occ,
445
+ Expands & {
446
+ [K in Rel]: { schema: TargetSchema; selected: TargetSelected };
447
+ }
448
+ > {
449
+ // Look up target occurrence from navigation
450
+ const targetOccurrence = this.occurrence?.navigation[relation as string];
451
+
452
+ if (callback) {
453
+ // Create a new QueryBuilder for the target occurrence
454
+ const targetBuilder = new QueryBuilder<any>({
455
+ occurrence: targetOccurrence,
456
+ tableName: targetOccurrence?.name ?? (relation as string),
457
+ databaseName: this.databaseName,
458
+ context: this.context,
459
+ });
460
+
461
+ // Cast to the expected type for the callback
462
+ // At runtime, the builder is untyped (any), but at compile-time we enforce proper types
463
+ const typedBuilder = targetBuilder as QueryBuilder<
464
+ TargetSchema,
465
+ keyof TargetSchema,
466
+ false,
467
+ false,
468
+ TargetOcc extends TableOccurrence<any, any, any, any>
469
+ ? TargetOcc
470
+ : undefined
471
+ >;
472
+
473
+ // Pass to callback and get configured builder
474
+ const configuredBuilder = callback(typedBuilder);
475
+
476
+ // Extract the builder's query options
477
+ const expandOptions: Partial<QueryOptions<any>> = {
478
+ ...configuredBuilder.queryOptions,
479
+ };
480
+
481
+ // If the configured builder has nested expands, we need to include them
482
+ if (configuredBuilder.expandConfigs.length > 0) {
483
+ // Build nested expand string from the configured builder's expand configs
484
+ const nestedExpandString = this.buildExpandString(
485
+ configuredBuilder.expandConfigs,
486
+ );
487
+ if (nestedExpandString) {
488
+ // Add nested expand to options
489
+ expandOptions.expand = nestedExpandString as any;
490
+ }
491
+ }
492
+
493
+ const expandConfig: ExpandConfig = {
494
+ relation: relation as string,
495
+ options: expandOptions,
496
+ };
497
+
498
+ this.expandConfigs.push(expandConfig);
499
+ } else {
500
+ // Simple expand without callback
501
+ this.expandConfigs.push({ relation: relation as string });
502
+ }
503
+
504
+ return this as any;
505
+ }
506
+
507
+ single(): QueryBuilder<T, Selected, "exact", IsCount, Occ, Expands> {
508
+ const newBuilder = new QueryBuilder<
509
+ T,
510
+ Selected,
511
+ "exact",
512
+ IsCount,
513
+ Occ,
514
+ Expands
515
+ >({
516
+ occurrence: this.occurrence,
517
+ tableName: this.tableName,
518
+ databaseName: this.databaseName,
519
+ context: this.context,
520
+ });
521
+ newBuilder.queryOptions = { ...this.queryOptions };
522
+ newBuilder.expandConfigs = [...this.expandConfigs];
523
+ newBuilder.singleMode = "exact";
524
+ newBuilder.isCountMode = this.isCountMode;
525
+ // Preserve navigation metadata
526
+ newBuilder.isNavigate = this.isNavigate;
527
+ newBuilder.navigateRecordId = this.navigateRecordId;
528
+ newBuilder.navigateRelation = this.navigateRelation;
529
+ newBuilder.navigateSourceTableName = this.navigateSourceTableName;
530
+ newBuilder.navigateBaseRelation = this.navigateBaseRelation;
531
+ return newBuilder;
532
+ }
533
+
534
+ maybeSingle(): QueryBuilder<T, Selected, "maybe", IsCount, Occ, Expands> {
535
+ const newBuilder = new QueryBuilder<
536
+ T,
537
+ Selected,
538
+ "maybe",
539
+ IsCount,
540
+ Occ,
541
+ Expands
542
+ >({
543
+ occurrence: this.occurrence,
544
+ tableName: this.tableName,
545
+ databaseName: this.databaseName,
546
+ context: this.context,
547
+ });
548
+ newBuilder.queryOptions = { ...this.queryOptions };
549
+ newBuilder.expandConfigs = [...this.expandConfigs];
550
+ newBuilder.singleMode = "maybe";
551
+ newBuilder.isCountMode = this.isCountMode;
552
+ // Preserve navigation metadata
553
+ newBuilder.isNavigate = this.isNavigate;
554
+ newBuilder.navigateRecordId = this.navigateRecordId;
555
+ newBuilder.navigateRelation = this.navigateRelation;
556
+ newBuilder.navigateSourceTableName = this.navigateSourceTableName;
557
+ newBuilder.navigateBaseRelation = this.navigateBaseRelation;
558
+ return newBuilder;
559
+ }
560
+
561
+ count(): QueryBuilder<T, Selected, SingleMode, true, Occ, Expands> {
562
+ const newBuilder = new QueryBuilder<
563
+ T,
564
+ Selected,
565
+ SingleMode,
566
+ true,
567
+ Occ,
568
+ Expands
569
+ >({
570
+ occurrence: this.occurrence,
571
+ tableName: this.tableName,
572
+ databaseName: this.databaseName,
573
+ context: this.context,
574
+ });
575
+ newBuilder.queryOptions = { ...this.queryOptions, count: true };
576
+ newBuilder.expandConfigs = [...this.expandConfigs];
577
+ newBuilder.singleMode = this.singleMode;
578
+ newBuilder.isCountMode = true as true;
579
+ // Preserve navigation metadata
580
+ newBuilder.isNavigate = this.isNavigate;
581
+ newBuilder.navigateRecordId = this.navigateRecordId;
582
+ newBuilder.navigateRelation = this.navigateRelation;
583
+ newBuilder.navigateSourceTableName = this.navigateSourceTableName;
584
+ newBuilder.navigateBaseRelation = this.navigateBaseRelation;
585
+ return newBuilder;
586
+ }
587
+
588
+ async execute<EO extends ExecuteOptions>(
589
+ options?: RequestInit & FFetchOptions & EO,
590
+ ): Promise<
591
+ Result<
592
+ IsCount extends true
593
+ ? number
594
+ : SingleMode extends "exact"
595
+ ? ConditionallyWithODataAnnotations<
596
+ Pick<T, Selected> & {
597
+ [K in keyof Expands]: Pick<
598
+ Expands[K]["schema"],
599
+ Expands[K]["selected"]
600
+ >[];
601
+ },
602
+ EO["includeODataAnnotations"] extends true ? true : false
603
+ >
604
+ : SingleMode extends "maybe"
605
+ ? ConditionallyWithODataAnnotations<
606
+ Pick<T, Selected> & {
607
+ [K in keyof Expands]: Pick<
608
+ Expands[K]["schema"],
609
+ Expands[K]["selected"]
610
+ >[];
611
+ },
612
+ EO["includeODataAnnotations"] extends true ? true : false
613
+ > | null
614
+ : ConditionallyWithODataAnnotations<
615
+ Pick<T, Selected> & {
616
+ [K in keyof Expands]: Pick<
617
+ Expands[K]["schema"],
618
+ Expands[K]["selected"]
619
+ >[];
620
+ },
621
+ EO["includeODataAnnotations"] extends true ? true : false
622
+ >[]
623
+ >
624
+ > {
625
+ try {
626
+ // Build query without expand (we'll add it manually)
627
+ const queryOptionsWithoutExpand = { ...this.queryOptions };
628
+ delete queryOptionsWithoutExpand.expand;
629
+
630
+ // Format select fields before building query
631
+ if (queryOptionsWithoutExpand.select) {
632
+ queryOptionsWithoutExpand.select = this.formatSelectFields(
633
+ queryOptionsWithoutExpand.select,
634
+ ) as any;
635
+ }
636
+
637
+ let queryString = buildQuery(queryOptionsWithoutExpand);
638
+
639
+ // Build custom expand string
640
+ const expandString = this.buildExpandString(this.expandConfigs);
641
+ if (expandString) {
642
+ const separator = queryString.includes("?") ? "&" : "?";
643
+ queryString = `${queryString}${separator}$expand=${expandString}`;
644
+ }
645
+
646
+ // Handle navigation from RecordBuilder
647
+ if (
648
+ this.isNavigate &&
649
+ this.navigateRecordId &&
650
+ this.navigateRelation &&
651
+ this.navigateSourceTableName
652
+ ) {
653
+ let url: string;
654
+ if (this.navigateBaseRelation) {
655
+ // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
656
+ url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
657
+ } else {
658
+ // Normal navigation: /sourceTable('recordId')/relation
659
+ url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
660
+ }
661
+ const response = await this.context._makeRequest(url, options);
662
+
663
+ // Skip validation if requested
664
+ if (options?.skipValidation === true) {
665
+ const resp = response as any;
666
+ if (this.singleMode !== false) {
667
+ const records = resp.value ?? [resp];
668
+ const count = Array.isArray(records) ? records.length : 1;
669
+
670
+ if (count > 1) {
671
+ return {
672
+ data: undefined,
673
+ error: new Error(
674
+ `Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
675
+ ),
676
+ };
677
+ }
678
+
679
+ if (count === 0) {
680
+ if (this.singleMode === "exact") {
681
+ return {
682
+ data: undefined,
683
+ error: new Error(
684
+ "Expected exactly one record, but received none",
685
+ ),
686
+ };
687
+ }
688
+ return { data: null as any, error: undefined };
689
+ }
690
+
691
+ const record = Array.isArray(records) ? records[0] : records;
692
+ const stripped = this.stripODataAnnotationsIfNeeded(
693
+ record,
694
+ options,
695
+ );
696
+ return { data: stripped as any, error: undefined };
697
+ } else {
698
+ const records = resp.value ?? [];
699
+ const stripped = records.map((record: any) =>
700
+ this.stripODataAnnotationsIfNeeded(record, options),
701
+ );
702
+ return { data: stripped as any, error: undefined };
703
+ }
704
+ }
705
+
706
+ // Get schema from occurrence if available
707
+ const schema = this.occurrence?.baseTable?.schema;
708
+ const selectedFields = this.queryOptions.select as
709
+ | (keyof T)[]
710
+ | undefined;
711
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
712
+ this.expandConfigs,
713
+ );
714
+
715
+ if (this.singleMode !== false) {
716
+ const validation = await validateSingleResponse<T>(
717
+ response,
718
+ schema,
719
+ selectedFields,
720
+ expandValidationConfigs,
721
+ this.singleMode,
722
+ );
723
+ if (!validation.valid) {
724
+ return { data: undefined, error: validation.error };
725
+ }
726
+ const stripped = validation.data
727
+ ? this.stripODataAnnotationsIfNeeded(validation.data, options)
728
+ : null;
729
+ return { data: stripped as any, error: undefined };
730
+ } else {
731
+ const validation = await validateListResponse<T>(
732
+ response,
733
+ schema,
734
+ selectedFields,
735
+ expandValidationConfigs,
736
+ );
737
+ if (!validation.valid) {
738
+ return { data: undefined, error: validation.error };
739
+ }
740
+ const stripped = validation.data.map((record) =>
741
+ this.stripODataAnnotationsIfNeeded(record, options),
742
+ );
743
+ return { data: stripped as any, error: undefined };
744
+ }
745
+ }
746
+
747
+ // Handle navigation from EntitySet (without record ID)
748
+ if (
749
+ this.isNavigate &&
750
+ !this.navigateRecordId &&
751
+ this.navigateRelation &&
752
+ this.navigateSourceTableName
753
+ ) {
754
+ const response = await this.context._makeRequest(
755
+ `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`,
756
+ options,
757
+ );
758
+
759
+ // Skip validation if requested
760
+ if (options?.skipValidation === true) {
761
+ const resp = response as any;
762
+ if (this.singleMode !== false) {
763
+ const records = resp.value ?? [resp];
764
+ const count = Array.isArray(records) ? records.length : 1;
765
+
766
+ if (count > 1) {
767
+ return {
768
+ data: undefined,
769
+ error: new Error(
770
+ `Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
771
+ ),
772
+ };
773
+ }
774
+
775
+ if (count === 0) {
776
+ if (this.singleMode === "exact") {
777
+ return {
778
+ data: undefined,
779
+ error: new Error(
780
+ "Expected exactly one record, but received none",
781
+ ),
782
+ };
783
+ }
784
+ return { data: null as any, error: undefined };
785
+ }
786
+
787
+ const record = Array.isArray(records) ? records[0] : records;
788
+ const stripped = this.stripODataAnnotationsIfNeeded(
789
+ record,
790
+ options,
791
+ );
792
+ return { data: stripped as any, error: undefined };
793
+ } else {
794
+ const records = resp.value ?? [];
795
+ const stripped = records.map((record: any) =>
796
+ this.stripODataAnnotationsIfNeeded(record, options),
797
+ );
798
+ return { data: stripped as any, error: undefined };
799
+ }
800
+ }
801
+
802
+ // Get schema from occurrence if available
803
+ const schema = this.occurrence?.baseTable?.schema;
804
+ const selectedFields = this.queryOptions.select as
805
+ | (keyof T)[]
806
+ | undefined;
807
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
808
+ this.expandConfigs,
809
+ );
810
+
811
+ if (this.singleMode !== false) {
812
+ const validation = await validateSingleResponse<T>(
813
+ response,
814
+ schema,
815
+ selectedFields,
816
+ expandValidationConfigs,
817
+ this.singleMode,
818
+ );
819
+ if (!validation.valid) {
820
+ return { data: undefined, error: validation.error };
821
+ }
822
+ const stripped = validation.data
823
+ ? this.stripODataAnnotationsIfNeeded(validation.data, options)
824
+ : null;
825
+ return { data: stripped as any, error: undefined };
826
+ } else {
827
+ const validation = await validateListResponse<T>(
828
+ response,
829
+ schema,
830
+ selectedFields,
831
+ expandValidationConfigs,
832
+ );
833
+ if (!validation.valid) {
834
+ return { data: undefined, error: validation.error };
835
+ }
836
+ const stripped = validation.data.map((record) =>
837
+ this.stripODataAnnotationsIfNeeded(record, options),
838
+ );
839
+ return { data: stripped as any, error: undefined };
840
+ }
841
+ }
842
+
843
+ // Handle $count endpoint
844
+ if (this.isCountMode) {
845
+ const result = await this.context._makeRequest(
846
+ `/${this.databaseName}/${this.tableName}/$count${queryString}`,
847
+ options,
848
+ );
849
+ // OData returns count as a string, convert to number
850
+ const count = typeof result === "string" ? Number(result) : result;
851
+ return { data: count as number, error: undefined } as any;
852
+ }
853
+
854
+ const response = await this.context._makeRequest(
855
+ `/${this.databaseName}/${this.tableName}${queryString}`,
856
+ options,
857
+ );
858
+
859
+ // Skip validation if requested
860
+ if (options?.skipValidation === true) {
861
+ const resp = response as any;
862
+ if (this.singleMode !== false) {
863
+ const records = resp.value ?? [resp];
864
+ const count = Array.isArray(records) ? records.length : 1;
865
+
866
+ if (count > 1) {
867
+ return {
868
+ data: undefined,
869
+ error: new Error(
870
+ `Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
871
+ ),
872
+ };
873
+ }
874
+
875
+ if (count === 0) {
876
+ if (this.singleMode === "exact") {
877
+ return {
878
+ data: undefined,
879
+ error: new Error(
880
+ "Expected exactly one record, but received none",
881
+ ),
882
+ };
883
+ }
884
+ return { data: null as any, error: undefined };
885
+ }
886
+
887
+ const record = Array.isArray(records) ? records[0] : records;
888
+ const stripped = this.stripODataAnnotationsIfNeeded(record, options);
889
+ return { data: stripped as any, error: undefined };
890
+ } else {
891
+ // Handle list response structure
892
+ const records = resp.value ?? [];
893
+ const stripped = records.map((record: any) =>
894
+ this.stripODataAnnotationsIfNeeded(record, options),
895
+ );
896
+ return { data: stripped as any, error: undefined };
897
+ }
898
+ }
899
+
900
+ // Get schema from occurrence if available
901
+ const schema = this.occurrence?.baseTable?.schema;
902
+ const selectedFields = this.queryOptions.select as
903
+ | (keyof T)[]
904
+ | undefined;
905
+ const expandValidationConfigs = this.buildExpandValidationConfigs(
906
+ this.expandConfigs,
907
+ );
908
+
909
+ if (this.singleMode !== false) {
910
+ const validation = await validateSingleResponse<T>(
911
+ response,
912
+ schema,
913
+ selectedFields,
914
+ expandValidationConfigs,
915
+ this.singleMode,
916
+ );
917
+ if (!validation.valid) {
918
+ return { data: undefined, error: validation.error };
919
+ }
920
+ const stripped = validation.data
921
+ ? this.stripODataAnnotationsIfNeeded(validation.data, options)
922
+ : null;
923
+ return {
924
+ data: stripped as any,
925
+ error: undefined,
926
+ };
927
+ } else {
928
+ const validation = await validateListResponse<T>(
929
+ response,
930
+ schema,
931
+ selectedFields,
932
+ expandValidationConfigs,
933
+ );
934
+ if (!validation.valid) {
935
+ return { data: undefined, error: validation.error };
936
+ }
937
+ const stripped = validation.data.map((record) =>
938
+ this.stripODataAnnotationsIfNeeded(record, options),
939
+ );
940
+ return {
941
+ data: stripped as any,
942
+ error: undefined,
943
+ };
944
+ }
945
+ } catch (error) {
946
+ return {
947
+ data: undefined,
948
+ error: error instanceof Error ? error : new Error(String(error)),
949
+ };
950
+ }
951
+ }
952
+
953
+ getQueryString(): string {
954
+ // Build query without expand (we'll add it manually)
955
+ const queryOptionsWithoutExpand = { ...this.queryOptions };
956
+ delete queryOptionsWithoutExpand.expand;
957
+
958
+ // Format select fields before building query - buildQuery treats & as separator,
959
+ // so we need to pre-encode special characters. buildQuery preserves encoded values.
960
+ if (queryOptionsWithoutExpand.select) {
961
+ queryOptionsWithoutExpand.select = this.formatSelectFields(
962
+ queryOptionsWithoutExpand.select,
963
+ ) as any;
964
+ }
965
+
966
+ let queryParams = buildQuery(queryOptionsWithoutExpand);
967
+
968
+ // Post-process: buildQuery encodes spaces as %20, but we want to preserve spaces
969
+ // Replace %20 with spaces in the $select part
970
+ if (this.queryOptions.select) {
971
+ queryParams = queryParams.replace(
972
+ /\$select=([^&]*)/,
973
+ (match, selectValue) => {
974
+ return `$select=${selectValue.replace(/%20/g, " ")}`;
975
+ },
976
+ );
977
+ }
978
+ const expandString = this.buildExpandString(this.expandConfigs);
979
+ if (expandString) {
980
+ const separator = queryParams.includes("?") ? "&" : "?";
981
+ queryParams = `${queryParams}${separator}$expand=${expandString}`;
982
+ }
983
+
984
+ // Handle navigation from RecordBuilder (with record ID)
985
+ if (
986
+ this.isNavigate &&
987
+ this.navigateRecordId &&
988
+ this.navigateRelation &&
989
+ this.navigateSourceTableName
990
+ ) {
991
+ let path: string;
992
+ if (this.navigateBaseRelation) {
993
+ // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
994
+ path = `/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}`;
995
+ } else {
996
+ // Normal navigation: /sourceTableName('recordId')/relationName
997
+ path = `/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}`;
998
+ }
999
+ // Append query params if any exist
1000
+ return queryParams ? `${path}${queryParams}` : path;
1001
+ }
1002
+
1003
+ // Handle navigation from EntitySet (without record ID)
1004
+ if (
1005
+ this.isNavigate &&
1006
+ !this.navigateRecordId &&
1007
+ this.navigateRelation &&
1008
+ this.navigateSourceTableName
1009
+ ) {
1010
+ // Return the path portion: /sourceTableName/relationName
1011
+ const path = `/${this.navigateSourceTableName}/${this.navigateRelation}`;
1012
+ // Append query params if any exist
1013
+ return queryParams ? `${path}${queryParams}` : path;
1014
+ }
1015
+
1016
+ // Default case: return table name with query params
1017
+ return `/${this.tableName}${queryParams}`;
1018
+ }
1019
+
1020
+ getRequestConfig(): { method: string; url: string; body?: any } {
1021
+ // Build query without expand (we'll add it manually)
1022
+ const queryOptionsWithoutExpand = { ...this.queryOptions };
1023
+ delete queryOptionsWithoutExpand.expand;
1024
+
1025
+ // Format select fields before building query
1026
+ if (queryOptionsWithoutExpand.select) {
1027
+ queryOptionsWithoutExpand.select = this.formatSelectFields(
1028
+ queryOptionsWithoutExpand.select,
1029
+ ) as any;
1030
+ }
1031
+
1032
+ let queryString = buildQuery(queryOptionsWithoutExpand);
1033
+
1034
+ // Build custom expand string
1035
+ const expandString = this.buildExpandString(this.expandConfigs);
1036
+ if (expandString) {
1037
+ const separator = queryString.includes("?") ? "&" : "?";
1038
+ queryString = `${queryString}${separator}$expand=${expandString}`;
1039
+ }
1040
+
1041
+ let url: string;
1042
+
1043
+ // Handle navigation from RecordBuilder (with record ID)
1044
+ if (
1045
+ this.isNavigate &&
1046
+ this.navigateRecordId &&
1047
+ this.navigateRelation &&
1048
+ this.navigateSourceTableName
1049
+ ) {
1050
+ if (this.navigateBaseRelation) {
1051
+ // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
1052
+ url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
1053
+ } else {
1054
+ // Normal navigation: /sourceTable('recordId')/relation
1055
+ url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
1056
+ }
1057
+ } else if (
1058
+ this.isNavigate &&
1059
+ !this.navigateRecordId &&
1060
+ this.navigateRelation &&
1061
+ this.navigateSourceTableName
1062
+ ) {
1063
+ // Handle navigation from EntitySet (without record ID)
1064
+ url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`;
1065
+ } else if (this.isCountMode) {
1066
+ url = `/${this.databaseName}/${this.tableName}/$count${queryString}`;
1067
+ } else {
1068
+ url = `/${this.databaseName}/${this.tableName}${queryString}`;
1069
+ }
1070
+
1071
+ return {
1072
+ method: "GET",
1073
+ url,
1074
+ };
1075
+ }
1076
+ }