@proofkit/fmodata 0.1.0-alpha.8 → 0.1.0-beta.23

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 (163) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +651 -449
  3. package/dist/esm/client/batch-builder.d.ts +10 -9
  4. package/dist/esm/client/batch-builder.js +119 -56
  5. package/dist/esm/client/batch-builder.js.map +1 -1
  6. package/dist/esm/client/batch-request.js +16 -21
  7. package/dist/esm/client/batch-request.js.map +1 -1
  8. package/dist/esm/client/builders/default-select.d.ts +10 -0
  9. package/dist/esm/client/builders/default-select.js +41 -0
  10. package/dist/esm/client/builders/default-select.js.map +1 -0
  11. package/dist/esm/client/builders/expand-builder.d.ts +45 -0
  12. package/dist/esm/client/builders/expand-builder.js +185 -0
  13. package/dist/esm/client/builders/expand-builder.js.map +1 -0
  14. package/dist/esm/client/builders/index.d.ts +9 -0
  15. package/dist/esm/client/builders/query-string-builder.d.ts +18 -0
  16. package/dist/esm/client/builders/query-string-builder.js +21 -0
  17. package/dist/esm/client/builders/query-string-builder.js.map +1 -0
  18. package/dist/esm/client/builders/response-processor.d.ts +43 -0
  19. package/dist/esm/client/builders/response-processor.js +175 -0
  20. package/dist/esm/client/builders/response-processor.js.map +1 -0
  21. package/dist/esm/client/builders/select-mixin.d.ts +25 -0
  22. package/dist/esm/client/builders/select-mixin.js +28 -0
  23. package/dist/esm/client/builders/select-mixin.js.map +1 -0
  24. package/dist/esm/client/builders/select-utils.d.ts +18 -0
  25. package/dist/esm/client/builders/select-utils.js +30 -0
  26. package/dist/esm/client/builders/select-utils.js.map +1 -0
  27. package/dist/esm/client/builders/shared-types.d.ts +40 -0
  28. package/dist/esm/client/builders/table-utils.d.ts +35 -0
  29. package/dist/esm/client/builders/table-utils.js +44 -0
  30. package/dist/esm/client/builders/table-utils.js.map +1 -0
  31. package/dist/esm/client/database.d.ts +34 -22
  32. package/dist/esm/client/database.js +48 -84
  33. package/dist/esm/client/database.js.map +1 -1
  34. package/dist/esm/client/delete-builder.d.ts +25 -30
  35. package/dist/esm/client/delete-builder.js +45 -30
  36. package/dist/esm/client/delete-builder.js.map +1 -1
  37. package/dist/esm/client/entity-set.d.ts +35 -43
  38. package/dist/esm/client/entity-set.js +110 -52
  39. package/dist/esm/client/entity-set.js.map +1 -1
  40. package/dist/esm/client/error-parser.d.ts +12 -0
  41. package/dist/esm/client/error-parser.js +25 -0
  42. package/dist/esm/client/error-parser.js.map +1 -0
  43. package/dist/esm/client/filemaker-odata.d.ts +26 -7
  44. package/dist/esm/client/filemaker-odata.js +65 -42
  45. package/dist/esm/client/filemaker-odata.js.map +1 -1
  46. package/dist/esm/client/insert-builder.d.ts +19 -24
  47. package/dist/esm/client/insert-builder.js +94 -58
  48. package/dist/esm/client/insert-builder.js.map +1 -1
  49. package/dist/esm/client/query/expand-builder.d.ts +35 -0
  50. package/dist/esm/client/query/index.d.ts +4 -0
  51. package/dist/esm/client/query/query-builder.d.ts +132 -0
  52. package/dist/esm/client/query/query-builder.js +456 -0
  53. package/dist/esm/client/query/query-builder.js.map +1 -0
  54. package/dist/esm/client/query/response-processor.d.ts +25 -0
  55. package/dist/esm/client/query/types.d.ts +77 -0
  56. package/dist/esm/client/query/url-builder.d.ts +71 -0
  57. package/dist/esm/client/query/url-builder.js +100 -0
  58. package/dist/esm/client/query/url-builder.js.map +1 -0
  59. package/dist/esm/client/query-builder.d.ts +2 -115
  60. package/dist/esm/client/record-builder.d.ts +108 -36
  61. package/dist/esm/client/record-builder.js +284 -119
  62. package/dist/esm/client/record-builder.js.map +1 -1
  63. package/dist/esm/client/response-processor.d.ts +4 -9
  64. package/dist/esm/client/sanitize-json.d.ts +35 -0
  65. package/dist/esm/client/sanitize-json.js +27 -0
  66. package/dist/esm/client/sanitize-json.js.map +1 -0
  67. package/dist/esm/client/schema-manager.d.ts +5 -5
  68. package/dist/esm/client/schema-manager.js +45 -31
  69. package/dist/esm/client/schema-manager.js.map +1 -1
  70. package/dist/esm/client/update-builder.d.ts +34 -40
  71. package/dist/esm/client/update-builder.js +99 -58
  72. package/dist/esm/client/update-builder.js.map +1 -1
  73. package/dist/esm/client/webhook-builder.d.ts +126 -0
  74. package/dist/esm/client/webhook-builder.js +189 -0
  75. package/dist/esm/client/webhook-builder.js.map +1 -0
  76. package/dist/esm/errors.d.ts +19 -2
  77. package/dist/esm/errors.js +39 -4
  78. package/dist/esm/errors.js.map +1 -1
  79. package/dist/esm/index.d.ts +10 -8
  80. package/dist/esm/index.js +40 -10
  81. package/dist/esm/index.js.map +1 -1
  82. package/dist/esm/logger.d.ts +47 -0
  83. package/dist/esm/logger.js +69 -0
  84. package/dist/esm/logger.js.map +1 -0
  85. package/dist/esm/logger.test.d.ts +1 -0
  86. package/dist/esm/orm/column.d.ts +62 -0
  87. package/dist/esm/orm/column.js +63 -0
  88. package/dist/esm/orm/column.js.map +1 -0
  89. package/dist/esm/orm/field-builders.d.ts +164 -0
  90. package/dist/esm/orm/field-builders.js +158 -0
  91. package/dist/esm/orm/field-builders.js.map +1 -0
  92. package/dist/esm/orm/index.d.ts +5 -0
  93. package/dist/esm/orm/operators.d.ts +173 -0
  94. package/dist/esm/orm/operators.js +260 -0
  95. package/dist/esm/orm/operators.js.map +1 -0
  96. package/dist/esm/orm/table.d.ts +355 -0
  97. package/dist/esm/orm/table.js +202 -0
  98. package/dist/esm/orm/table.js.map +1 -0
  99. package/dist/esm/transform.d.ts +20 -21
  100. package/dist/esm/transform.js +44 -45
  101. package/dist/esm/transform.js.map +1 -1
  102. package/dist/esm/types.d.ts +96 -30
  103. package/dist/esm/types.js +7 -0
  104. package/dist/esm/types.js.map +1 -0
  105. package/dist/esm/validation.d.ts +22 -12
  106. package/dist/esm/validation.js +132 -85
  107. package/dist/esm/validation.js.map +1 -1
  108. package/package.json +28 -20
  109. package/src/client/batch-builder.ts +153 -89
  110. package/src/client/batch-request.ts +25 -41
  111. package/src/client/builders/default-select.ts +75 -0
  112. package/src/client/builders/expand-builder.ts +246 -0
  113. package/src/client/builders/index.ts +11 -0
  114. package/src/client/builders/query-string-builder.ts +46 -0
  115. package/src/client/builders/response-processor.ts +279 -0
  116. package/src/client/builders/select-mixin.ts +65 -0
  117. package/src/client/builders/select-utils.ts +59 -0
  118. package/src/client/builders/shared-types.ts +45 -0
  119. package/src/client/builders/table-utils.ts +83 -0
  120. package/src/client/database.ts +89 -183
  121. package/src/client/delete-builder.ts +74 -84
  122. package/src/client/entity-set.ts +266 -293
  123. package/src/client/error-parser.ts +41 -0
  124. package/src/client/filemaker-odata.ts +98 -66
  125. package/src/client/insert-builder.ts +157 -118
  126. package/src/client/query/expand-builder.ts +160 -0
  127. package/src/client/query/index.ts +14 -0
  128. package/src/client/query/query-builder.ts +729 -0
  129. package/src/client/query/response-processor.ts +226 -0
  130. package/src/client/query/types.ts +126 -0
  131. package/src/client/query/url-builder.ts +151 -0
  132. package/src/client/query-builder.ts +10 -1455
  133. package/src/client/record-builder.ts +575 -240
  134. package/src/client/response-processor.ts +15 -42
  135. package/src/client/sanitize-json.ts +64 -0
  136. package/src/client/schema-manager.ts +61 -76
  137. package/src/client/update-builder.ts +161 -143
  138. package/src/client/webhook-builder.ts +265 -0
  139. package/src/errors.ts +49 -16
  140. package/src/index.ts +99 -54
  141. package/src/logger.test.ts +34 -0
  142. package/src/logger.ts +116 -0
  143. package/src/orm/column.ts +106 -0
  144. package/src/orm/field-builders.ts +250 -0
  145. package/src/orm/index.ts +61 -0
  146. package/src/orm/operators.ts +473 -0
  147. package/src/orm/table.ts +741 -0
  148. package/src/transform.ts +90 -70
  149. package/src/types.ts +154 -113
  150. package/src/validation.ts +200 -115
  151. package/dist/esm/client/base-table.d.ts +0 -125
  152. package/dist/esm/client/base-table.js +0 -57
  153. package/dist/esm/client/base-table.js.map +0 -1
  154. package/dist/esm/client/query-builder.js +0 -896
  155. package/dist/esm/client/query-builder.js.map +0 -1
  156. package/dist/esm/client/table-occurrence.d.ts +0 -72
  157. package/dist/esm/client/table-occurrence.js +0 -74
  158. package/dist/esm/client/table-occurrence.js.map +0 -1
  159. package/dist/esm/filter-types.d.ts +0 -76
  160. package/src/client/base-table.ts +0 -166
  161. package/src/client/query-builder.ts.bak +0 -1457
  162. package/src/client/table-occurrence.ts +0 -175
  163. package/src/filter-types.ts +0 -97
@@ -1,1457 +0,0 @@
1
- import { QueryOptions } from "odata-query";
2
- import buildQuery from "odata-query";
3
- import type {
4
- ExecutionContext,
5
- ExecutableBuilder,
6
- WithSystemFields,
7
- Result,
8
- InferSchemaType,
9
- ExecuteOptions,
10
- ConditionallyWithODataAnnotations,
11
- ExtractSchemaFromOccurrence,
12
- } from "../types";
13
- import type { Filter } from "../filter-types";
14
- import type { TableOccurrence } from "./table-occurrence";
15
- import type { BaseTable } from "./base-table";
16
- import { validateListResponse, validateSingleResponse } from "../validation";
17
- import { RecordCountMismatchError } from "../errors";
18
- import { type FFetchOptions } from "@fetchkit/ffetch";
19
- import type { StandardSchemaV1 } from "@standard-schema/spec";
20
- import {
21
- transformFieldNamesArray,
22
- transformFieldName,
23
- transformOrderByField,
24
- transformResponseFields,
25
- getTableIdentifiers,
26
- } from "../transform";
27
-
28
-
29
- /**
30
- * Default maximum number of records to return in a list query.
31
- * This prevents stack overflow issues with large datasets while still
32
- * allowing substantial data retrieval. Users can override with .top().
33
- */
34
- const DEFAULT_TOP = 1000;
35
-
36
- // Helper type to extract navigation relation names from an occurrence
37
- type ExtractNavigationNames<
38
- O extends TableOccurrence<any, any, any, any> | undefined,
39
- > =
40
- O extends TableOccurrence<any, any, infer Nav, any>
41
- ? Nav extends Record<string, any>
42
- ? keyof Nav & string
43
- : never
44
- : never;
45
-
46
- // Helper type to resolve a navigation item (handles both direct and lazy-loaded)
47
- type ResolveNavigationItem<T> = T extends () => infer R ? R : T;
48
-
49
- // Helper type to find target occurrence by relation name
50
- type FindNavigationTarget<
51
- O extends TableOccurrence<any, any, any, any> | undefined,
52
- Name extends string,
53
- > =
54
- O extends TableOccurrence<any, any, infer Nav, any>
55
- ? Nav extends Record<string, any>
56
- ? Name extends keyof Nav
57
- ? ResolveNavigationItem<Nav[Name]>
58
- : TableOccurrence<
59
- BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
60
- any,
61
- any,
62
- any
63
- >
64
- : TableOccurrence<
65
- BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
66
- any,
67
- any,
68
- any
69
- >
70
- : TableOccurrence<
71
- BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
72
- any,
73
- any,
74
- any
75
- >;
76
-
77
- // Helper type to get the inferred schema type from a target occurrence
78
- type GetTargetSchemaType<
79
- O extends TableOccurrence<any, any, any, any> | undefined,
80
- Rel extends string,
81
- > = [FindNavigationTarget<O, Rel>] extends [
82
- TableOccurrence<infer BT, any, any, any>,
83
- ]
84
- ? [BT] extends [BaseTable<infer S, any>]
85
- ? [S] extends [Record<string, StandardSchemaV1>]
86
- ? InferSchemaType<S>
87
- : Record<string, any>
88
- : Record<string, any>
89
- : Record<string, any>;
90
-
91
- // Internal type for expand configuration
92
- type ExpandConfig = {
93
- relation: string;
94
- options?: Partial<QueryOptions<any>>;
95
- };
96
-
97
- // Type to represent expanded relations
98
- export type ExpandedRelations = Record<string, { schema: any; selected: any }>;
99
-
100
- export type QueryReturnType<
101
- T extends Record<string, any>,
102
- Selected extends keyof T,
103
- SingleMode extends "exact" | "maybe" | false,
104
- IsCount extends boolean,
105
- Expands extends ExpandedRelations,
106
- > = IsCount extends true
107
- ? number
108
- : SingleMode extends "exact"
109
- ? Pick<T, Selected> & {
110
- [K in keyof Expands]: Pick<
111
- Expands[K]["schema"],
112
- Expands[K]["selected"]
113
- >[];
114
- }
115
- : SingleMode extends "maybe"
116
- ?
117
- | (Pick<T, Selected> & {
118
- [K in keyof Expands]: Pick<
119
- Expands[K]["schema"],
120
- Expands[K]["selected"]
121
- >[];
122
- })
123
- | null
124
- : (Pick<T, Selected> & {
125
- [K in keyof Expands]: Pick<
126
- Expands[K]["schema"],
127
- Expands[K]["selected"]
128
- >[];
129
- })[];
130
-
131
- export class QueryBuilder<
132
- T extends Record<string, any>,
133
- Selected extends keyof T = keyof T,
134
- SingleMode extends "exact" | "maybe" | false = false,
135
- IsCount extends boolean = false,
136
- Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
137
- Expands extends ExpandedRelations = {},
138
- > implements
139
- ExecutableBuilder<
140
- QueryReturnType<T, Selected, SingleMode, IsCount, Expands>
141
- >
142
- {
143
- private queryOptions: Partial<QueryOptions<T>> = {};
144
- private expandConfigs: ExpandConfig[] = [];
145
- private singleMode: SingleMode = false as SingleMode;
146
- private isCountMode = false as IsCount;
147
- private occurrence?: Occ;
148
- private tableName: string;
149
- private databaseName: string;
150
- private context: ExecutionContext;
151
- private isNavigate?: boolean;
152
- private navigateRecordId?: string | number;
153
- private navigateRelation?: string;
154
- private navigateSourceTableName?: string;
155
- private navigateBaseRelation?: string;
156
- private databaseUseEntityIds: boolean;
157
-
158
- constructor(config: {
159
- occurrence?: Occ;
160
- tableName: string;
161
- databaseName: string;
162
- context: ExecutionContext;
163
- databaseUseEntityIds?: boolean;
164
- }) {
165
- this.occurrence = config.occurrence;
166
- this.tableName = config.tableName;
167
- this.databaseName = config.databaseName;
168
- this.context = config.context;
169
- this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
170
- }
171
-
172
- /**
173
- * Helper to merge database-level useEntityIds with per-request options
174
- */
175
- private mergeExecuteOptions(
176
- options?: RequestInit & FFetchOptions & ExecuteOptions,
177
- ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
178
- // If useEntityIds is not set in options, use the database-level setting
179
- return {
180
- ...options,
181
- useEntityIds:
182
- options?.useEntityIds === undefined
183
- ? this.databaseUseEntityIds
184
- : options.useEntityIds,
185
- };
186
- }
187
-
188
- /**
189
- * Helper to conditionally strip OData annotations based on options
190
- */
191
- private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
192
- data: T,
193
- options?: ExecuteOptions,
194
- ): T {
195
- // Only include annotations if explicitly requested
196
- if (options?.includeODataAnnotations === true) {
197
- return data;
198
- }
199
-
200
- // Strip OData annotations
201
- const { "@id": _id, "@editLink": _editLink, ...rest } = data;
202
- return rest as T;
203
- }
204
-
205
- /**
206
- * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
207
- * @param useEntityIds - Optional override for entity ID usage
208
- */
209
- private getTableId(useEntityIds?: boolean): string {
210
- if (!this.occurrence) {
211
- return this.tableName;
212
- }
213
-
214
- const contextDefault = this.context._getUseEntityIds?.() ?? false;
215
- const shouldUseIds = useEntityIds ?? contextDefault;
216
-
217
- if (shouldUseIds) {
218
- const identifiers = getTableIdentifiers(this.occurrence);
219
- if (!identifiers.id) {
220
- throw new Error(
221
- `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`,
222
- );
223
- }
224
- return identifiers.id;
225
- }
226
-
227
- return this.occurrence.getTableName();
228
- }
229
-
230
- select<K extends keyof T>(
231
- ...fields: K[]
232
- ): QueryBuilder<T, K, SingleMode, IsCount, Occ, Expands> {
233
- const uniqueFields = [...new Set(fields)];
234
- const newBuilder = new QueryBuilder<
235
- T,
236
- K,
237
- SingleMode,
238
- IsCount,
239
- Occ,
240
- Expands
241
- >({
242
- occurrence: this.occurrence,
243
- tableName: this.tableName,
244
- databaseName: this.databaseName,
245
- context: this.context,
246
- databaseUseEntityIds: this.databaseUseEntityIds,
247
- });
248
- newBuilder.queryOptions = {
249
- ...this.queryOptions,
250
- select: uniqueFields as string[],
251
- };
252
- newBuilder.expandConfigs = [...this.expandConfigs];
253
- newBuilder.singleMode = this.singleMode;
254
- newBuilder.isCountMode = this.isCountMode;
255
- // Preserve navigation metadata
256
- newBuilder.isNavigate = this.isNavigate;
257
- newBuilder.navigateRecordId = this.navigateRecordId;
258
- newBuilder.navigateRelation = this.navigateRelation;
259
- newBuilder.navigateSourceTableName = this.navigateSourceTableName;
260
- newBuilder.navigateBaseRelation = this.navigateBaseRelation;
261
- return newBuilder;
262
- }
263
-
264
- /**
265
- * Transforms our filter format to odata-query's expected format
266
- * - Arrays of operators are converted to AND conditions
267
- * - Single operator objects pass through as-is
268
- * - Shorthand values are handled by odata-query
269
- */
270
- private transformFilter(
271
- filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
272
- ): QueryOptions<T>["filter"] {
273
- if (typeof filter === "string") {
274
- // Raw string filters pass through
275
- return filter;
276
- }
277
-
278
- if (Array.isArray(filter)) {
279
- // Array of filters - odata-query handles this as implicit AND
280
- return filter.map((f) => this.transformFilter(f as any)) as any;
281
- }
282
-
283
- // Check if it's a logical filter (and/or/not)
284
- if ("and" in filter || "or" in filter || "not" in filter) {
285
- const result: any = {};
286
- if ("and" in filter && Array.isArray(filter.and)) {
287
- result.and = filter.and.map((f: any) => this.transformFilter(f));
288
- }
289
- if ("or" in filter && Array.isArray(filter.or)) {
290
- result.or = filter.or.map((f: any) => this.transformFilter(f));
291
- }
292
- if ("not" in filter && filter.not) {
293
- result.not = this.transformFilter(filter.not as any);
294
- }
295
- return result;
296
- }
297
-
298
- // Transform field filters
299
- const result: any = {};
300
- const andConditions: any[] = [];
301
-
302
- for (const [field, value] of Object.entries(filter)) {
303
- // Transform field name to FMFID if using entity IDs
304
- const fieldId = this.occurrence?.baseTable
305
- ? transformFieldName(field, this.occurrence.baseTable)
306
- : field;
307
-
308
- if (Array.isArray(value)) {
309
- // Array of operators - convert to AND conditions
310
- if (value.length === 1) {
311
- // Single operator in array - unwrap it
312
- result[fieldId] = value[0];
313
- } else {
314
- // Multiple operators - combine with AND
315
- // Create separate conditions for each operator
316
- for (const op of value) {
317
- andConditions.push({ [fieldId]: op });
318
- }
319
- }
320
- } else if (
321
- value &&
322
- typeof value === "object" &&
323
- !(value instanceof Date) &&
324
- !Array.isArray(value)
325
- ) {
326
- // Check if it's an operator object (has operator keys like eq, gt, etc.)
327
- const operatorKeys = [
328
- "eq",
329
- "ne",
330
- "gt",
331
- "ge",
332
- "lt",
333
- "le",
334
- "contains",
335
- "startswith",
336
- "endswith",
337
- "in",
338
- ];
339
- const isOperatorObject = operatorKeys.some((key) => key in value);
340
-
341
- if (isOperatorObject) {
342
- // Single operator object - pass through
343
- result[fieldId] = value;
344
- } else {
345
- // Regular object - might be nested filter, pass through
346
- result[fieldId] = value;
347
- }
348
- } else {
349
- // Primitive value (shorthand) - pass through
350
- result[fieldId] = value;
351
- }
352
- }
353
-
354
- // If we have AND conditions from arrays, combine them
355
- if (andConditions.length > 0) {
356
- if (Object.keys(result).length > 0) {
357
- // We have both regular fields and array-derived AND conditions
358
- // Combine everything with AND
359
- return { and: [...andConditions, result] };
360
- } else {
361
- // Only array-derived AND conditions
362
- return { and: andConditions };
363
- }
364
- }
365
-
366
- return result;
367
- }
368
-
369
- filter(
370
- filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
371
- ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
372
- // Transform our filter format to odata-query's expected format
373
- this.queryOptions.filter = this.transformFilter(filter) as any;
374
- return this;
375
- }
376
-
377
- orderBy(
378
- orderBy: QueryOptions<T>["orderBy"],
379
- ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
380
- // Transform field names to FMFIDs if using entity IDs
381
- if (this.occurrence?.baseTable && orderBy) {
382
- if (Array.isArray(orderBy)) {
383
- this.queryOptions.orderBy = orderBy.map((field) =>
384
- transformOrderByField(String(field), this.occurrence!.baseTable),
385
- );
386
- } else {
387
- this.queryOptions.orderBy = transformOrderByField(
388
- String(orderBy),
389
- this.occurrence.baseTable,
390
- );
391
- }
392
- } else {
393
- this.queryOptions.orderBy = orderBy;
394
- }
395
- return this;
396
- }
397
-
398
- top(
399
- count: number,
400
- ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
401
- this.queryOptions.top = count;
402
- console.log("top method", {
403
- count,
404
- databaseUseEntityIds: this.databaseUseEntityIds,
405
- });
406
- return this;
407
- }
408
-
409
- skip(
410
- count: number,
411
- ): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
412
- this.queryOptions.skip = count;
413
- return this;
414
- }
415
-
416
- /**
417
- * Formats select fields for use in query strings.
418
- * - Transforms field names to FMFIDs if using entity IDs
419
- * - Wraps "id" fields in double quotes
420
- * - URL-encodes special characters but preserves spaces
421
- */
422
- private formatSelectFields(
423
- select: QueryOptions<any>["select"],
424
- baseTable?: BaseTable<any, any, any, any>,
425
- ): string {
426
- if (!select) return "";
427
- const selectFieldsArray = Array.isArray(select) ? select : [select];
428
-
429
- // Transform to field IDs if using entity IDs
430
- const transformedFields = baseTable
431
- ? transformFieldNamesArray(
432
- selectFieldsArray.map((f) => String(f)),
433
- baseTable,
434
- )
435
- : selectFieldsArray.map((f) => String(f));
436
-
437
- return transformedFields
438
- .map((field) => {
439
- if (field === "id") return `"id"`;
440
- const encodedField = encodeURIComponent(String(field));
441
- return encodedField.replace(/%20/g, " ");
442
- })
443
- .join(",");
444
- }
445
-
446
- /**
447
- * Builds expand validation configs from internal expand configurations.
448
- * These are used to validate expanded navigation properties.
449
- */
450
- private buildExpandValidationConfigs(
451
- configs: ExpandConfig[],
452
- ): import("../validation").ExpandValidationConfig[] {
453
- return configs.map((config) => {
454
- // Look up target occurrence from navigation
455
- const targetOccurrence = this.occurrence?.navigation[config.relation];
456
- const targetSchema = targetOccurrence?.baseTable?.schema;
457
-
458
- // Extract selected fields from options
459
- const selectedFields = config.options?.select
460
- ? Array.isArray(config.options.select)
461
- ? config.options.select.map((f) => String(f))
462
- : [String(config.options.select)]
463
- : undefined;
464
-
465
- return {
466
- relation: config.relation,
467
- targetSchema: targetSchema,
468
- targetOccurrence: targetOccurrence,
469
- targetBaseTable: targetOccurrence?.baseTable,
470
- occurrence: targetOccurrence, // Add occurrence for transformation
471
- selectedFields: selectedFields,
472
- nestedExpands: undefined, // TODO: Handle nested expands if needed
473
- };
474
- });
475
- }
476
-
477
- /**
478
- * Builds OData expand query string from expand configurations.
479
- * Handles nested expands recursively.
480
- * Transforms relation names to FMTIDs if using entity IDs.
481
- */
482
- private buildExpandString(configs: ExpandConfig[]): string {
483
- if (configs.length === 0) {
484
- return "";
485
- }
486
-
487
- return configs
488
- .map((config) => {
489
- // Get target occurrence for this relation
490
- const targetOccurrence = this.occurrence?.navigation[config.relation];
491
-
492
- // When using entity IDs, use the target table's FMTID in the expand parameter
493
- // FileMaker expects FMTID in $expand when Prefer header is set
494
- const relationName =
495
- targetOccurrence && targetOccurrence.isUsingTableId()
496
- ? targetOccurrence.getTableId()
497
- : config.relation;
498
-
499
- if (!config.options || Object.keys(config.options).length === 0) {
500
- // Simple expand without options
501
- return relationName;
502
- }
503
-
504
- // Build query options for this expand
505
- const parts: string[] = [];
506
-
507
- if (config.options.select) {
508
- // Pass target base table for field transformation
509
- const selectFields = this.formatSelectFields(
510
- config.options.select,
511
- targetOccurrence?.baseTable,
512
- );
513
- parts.push(`$select=${selectFields}`);
514
- }
515
-
516
- if (config.options.filter) {
517
- // Filter should already be transformed by the nested builder
518
- // Use odata-query to build filter string
519
- const filterQuery = buildQuery({ filter: config.options.filter });
520
- const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
521
- if (filterMatch) {
522
- parts.push(`$filter=${filterMatch[1]}`);
523
- }
524
- }
525
-
526
- if (config.options.orderBy) {
527
- // OrderBy should already be transformed by the nested builder
528
- const orderByValue = Array.isArray(config.options.orderBy)
529
- ? config.options.orderBy.join(",")
530
- : config.options.orderBy;
531
- parts.push(`$orderby=${String(orderByValue)}`);
532
- }
533
-
534
- if (config.options.top !== undefined) {
535
- parts.push(`$top=${config.options.top}`);
536
- }
537
-
538
- if (config.options.skip !== undefined) {
539
- parts.push(`$skip=${config.options.skip}`);
540
- }
541
-
542
- // Handle nested expands (from expand configs)
543
- if (config.options.expand) {
544
- // If expand is a string, it's already been built
545
- if (typeof config.options.expand === "string") {
546
- parts.push(`$expand=${config.options.expand}`);
547
- }
548
- }
549
-
550
- if (parts.length === 0) {
551
- return relationName;
552
- }
553
-
554
- return `${relationName}(${parts.join(";")})`;
555
- })
556
- .join(",");
557
- }
558
-
559
- expand<
560
- Rel extends ExtractNavigationNames<Occ> | (string & {}),
561
- TargetOcc extends FindNavigationTarget<Occ, Rel> = FindNavigationTarget<
562
- Occ,
563
- Rel
564
- >,
565
- TargetSchema extends GetTargetSchemaType<Occ, Rel> = GetTargetSchemaType<
566
- Occ,
567
- Rel
568
- >,
569
- TargetSelected extends keyof TargetSchema = keyof TargetSchema,
570
- >(
571
- relation: Rel,
572
- callback?: (
573
- builder: QueryBuilder<
574
- TargetSchema,
575
- keyof TargetSchema,
576
- false,
577
- false,
578
- TargetOcc extends TableOccurrence<any, any, any, any>
579
- ? TargetOcc
580
- : undefined
581
- >,
582
- ) => QueryBuilder<
583
- WithSystemFields<TargetSchema>,
584
- TargetSelected,
585
- any,
586
- any,
587
- any
588
- >,
589
- ): QueryBuilder<
590
- T,
591
- Selected,
592
- SingleMode,
593
- IsCount,
594
- Occ,
595
- Expands & {
596
- [K in Rel]: { schema: TargetSchema; selected: TargetSelected };
597
- }
598
- > {
599
- // Look up target occurrence from navigation
600
- const targetOccurrence = this.occurrence?.navigation[relation as string];
601
-
602
- if (callback) {
603
- // Create a new QueryBuilder for the target occurrence
604
- const targetBuilder = new QueryBuilder<any>({
605
- occurrence: targetOccurrence,
606
- tableName: targetOccurrence?.name ?? (relation as string),
607
- databaseName: this.databaseName,
608
- context: this.context,
609
- databaseUseEntityIds: this.databaseUseEntityIds,
610
- });
611
-
612
- // Cast to the expected type for the callback
613
- // At runtime, the builder is untyped (any), but at compile-time we enforce proper types
614
- const typedBuilder = targetBuilder as QueryBuilder<
615
- TargetSchema,
616
- keyof TargetSchema,
617
- false,
618
- false,
619
- TargetOcc extends TableOccurrence<any, any, any, any>
620
- ? TargetOcc
621
- : undefined
622
- >;
623
-
624
- // Pass to callback and get configured builder
625
- const configuredBuilder = callback(typedBuilder);
626
-
627
- // Extract the builder's query options
628
- const expandOptions: Partial<QueryOptions<any>> = {
629
- ...configuredBuilder.queryOptions,
630
- };
631
-
632
- // If the configured builder has nested expands, we need to include them
633
- if (configuredBuilder.expandConfigs.length > 0) {
634
- // Build nested expand string from the configured builder's expand configs
635
- const nestedExpandString = this.buildExpandString(
636
- configuredBuilder.expandConfigs,
637
- );
638
- if (nestedExpandString) {
639
- // Add nested expand to options
640
- expandOptions.expand = nestedExpandString as any;
641
- }
642
- }
643
-
644
- const expandConfig: ExpandConfig = {
645
- relation: relation as string,
646
- options: expandOptions,
647
- };
648
-
649
- this.expandConfigs.push(expandConfig);
650
- } else {
651
- // Simple expand without callback
652
- this.expandConfigs.push({ relation: relation as string });
653
- }
654
-
655
- return this as any;
656
- }
657
-
658
- single(): QueryBuilder<T, Selected, "exact", IsCount, Occ, Expands> {
659
- const newBuilder = new QueryBuilder<
660
- T,
661
- Selected,
662
- "exact",
663
- IsCount,
664
- Occ,
665
- Expands
666
- >({
667
- occurrence: this.occurrence,
668
- tableName: this.tableName,
669
- databaseName: this.databaseName,
670
- context: this.context,
671
- databaseUseEntityIds: this.databaseUseEntityIds,
672
- });
673
- newBuilder.queryOptions = { ...this.queryOptions };
674
- newBuilder.expandConfigs = [...this.expandConfigs];
675
- newBuilder.singleMode = "exact";
676
- newBuilder.isCountMode = this.isCountMode;
677
- // Preserve navigation metadata
678
- newBuilder.isNavigate = this.isNavigate;
679
- newBuilder.navigateRecordId = this.navigateRecordId;
680
- newBuilder.navigateRelation = this.navigateRelation;
681
- newBuilder.navigateSourceTableName = this.navigateSourceTableName;
682
- newBuilder.navigateBaseRelation = this.navigateBaseRelation;
683
- return newBuilder;
684
- }
685
-
686
- maybeSingle(): QueryBuilder<T, Selected, "maybe", IsCount, Occ, Expands> {
687
- const newBuilder = new QueryBuilder<
688
- T,
689
- Selected,
690
- "maybe",
691
- IsCount,
692
- Occ,
693
- Expands
694
- >({
695
- occurrence: this.occurrence,
696
- tableName: this.tableName,
697
- databaseName: this.databaseName,
698
- context: this.context,
699
- databaseUseEntityIds: this.databaseUseEntityIds,
700
- });
701
- newBuilder.queryOptions = { ...this.queryOptions };
702
- newBuilder.expandConfigs = [...this.expandConfigs];
703
- newBuilder.singleMode = "maybe";
704
- newBuilder.isCountMode = this.isCountMode;
705
- // Preserve navigation metadata
706
- newBuilder.isNavigate = this.isNavigate;
707
- newBuilder.navigateRecordId = this.navigateRecordId;
708
- newBuilder.navigateRelation = this.navigateRelation;
709
- newBuilder.navigateSourceTableName = this.navigateSourceTableName;
710
- newBuilder.navigateBaseRelation = this.navigateBaseRelation;
711
- return newBuilder;
712
- }
713
-
714
- count(): QueryBuilder<T, Selected, SingleMode, true, Occ, Expands> {
715
- const newBuilder = new QueryBuilder<
716
- T,
717
- Selected,
718
- SingleMode,
719
- true,
720
- Occ,
721
- Expands
722
- >({
723
- occurrence: this.occurrence,
724
- tableName: this.tableName,
725
- databaseName: this.databaseName,
726
- context: this.context,
727
- databaseUseEntityIds: this.databaseUseEntityIds,
728
- });
729
- newBuilder.queryOptions = { ...this.queryOptions, count: true };
730
- newBuilder.expandConfigs = [...this.expandConfigs];
731
- newBuilder.singleMode = this.singleMode;
732
- newBuilder.isCountMode = true as true;
733
- // Preserve navigation metadata
734
- newBuilder.isNavigate = this.isNavigate;
735
- newBuilder.navigateRecordId = this.navigateRecordId;
736
- newBuilder.navigateRelation = this.navigateRelation;
737
- newBuilder.navigateSourceTableName = this.navigateSourceTableName;
738
- newBuilder.navigateBaseRelation = this.navigateBaseRelation;
739
- return newBuilder;
740
- }
741
-
742
- async execute<EO extends ExecuteOptions>(
743
- options?: RequestInit & FFetchOptions & EO,
744
- ): Promise<
745
- Result<
746
- IsCount extends true
747
- ? number
748
- : SingleMode extends "exact"
749
- ? ConditionallyWithODataAnnotations<
750
- Pick<T, Selected> & {
751
- [K in keyof Expands]: Pick<
752
- Expands[K]["schema"],
753
- Expands[K]["selected"]
754
- >[];
755
- },
756
- EO["includeODataAnnotations"] extends true ? true : false
757
- >
758
- : SingleMode extends "maybe"
759
- ? ConditionallyWithODataAnnotations<
760
- Pick<T, Selected> & {
761
- [K in keyof Expands]: Pick<
762
- Expands[K]["schema"],
763
- Expands[K]["selected"]
764
- >[];
765
- },
766
- EO["includeODataAnnotations"] extends true ? true : false
767
- > | null
768
- : ConditionallyWithODataAnnotations<
769
- Pick<T, Selected> & {
770
- [K in keyof Expands]: Pick<
771
- Expands[K]["schema"],
772
- Expands[K]["selected"]
773
- >[];
774
- },
775
- EO["includeODataAnnotations"] extends true ? true : false
776
- >[]
777
- >
778
- > {
779
- // Build query without expand (we'll add it manually)
780
- const queryOptionsWithoutExpand = { ...this.queryOptions };
781
- delete queryOptionsWithoutExpand.expand;
782
-
783
- const mergedOptions = this.mergeExecuteOptions(options);
784
-
785
- // Format select fields before building query
786
- if (queryOptionsWithoutExpand.select) {
787
- queryOptionsWithoutExpand.select = this.formatSelectFields(
788
- queryOptionsWithoutExpand.select,
789
- this.occurrence?.baseTable,
790
- ) as any;
791
- }
792
-
793
- let queryString = buildQuery(queryOptionsWithoutExpand);
794
-
795
- // Build custom expand string
796
- const expandString = this.buildExpandString(this.expandConfigs);
797
- if (expandString) {
798
- const separator = queryString.includes("?") ? "&" : "?";
799
- queryString = `${queryString}${separator}$expand=${expandString}`;
800
- }
801
-
802
- // Handle navigation from RecordBuilder
803
- if (
804
- this.isNavigate &&
805
- this.navigateRecordId &&
806
- this.navigateRelation &&
807
- this.navigateSourceTableName
808
- ) {
809
- let url: string;
810
- if (this.navigateBaseRelation) {
811
- // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
812
- url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
813
- } else {
814
- // Normal navigation: /sourceTable('recordId')/relation
815
- url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
816
- }
817
- const result = await this.context._makeRequest(url, mergedOptions);
818
-
819
- if (result.error) {
820
- return { data: undefined, error: result.error };
821
- }
822
-
823
- let response = result.data;
824
-
825
- // Transform response field IDs back to names if using entity IDs
826
- // Only transform if useEntityIds resolves to true (respects per-request override)
827
- const shouldUseIds = mergedOptions.useEntityIds ?? false;
828
-
829
- if (this.occurrence?.baseTable && shouldUseIds) {
830
- const expandValidationConfigs = this.buildExpandValidationConfigs(
831
- this.expandConfigs,
832
- );
833
- response = transformResponseFields(
834
- response,
835
- this.occurrence.baseTable,
836
- expandValidationConfigs,
837
- );
838
- }
839
-
840
- // Skip validation if requested
841
- if (options?.skipValidation === true) {
842
- const resp = response as any;
843
- if (this.singleMode !== false) {
844
- const records = resp.value ?? [resp];
845
- const count = Array.isArray(records) ? records.length : 1;
846
-
847
- if (count > 1) {
848
- return {
849
- data: undefined,
850
- error: new RecordCountMismatchError(
851
- this.singleMode === "exact" ? "one" : "at-most-one",
852
- count,
853
- ),
854
- };
855
- }
856
-
857
- if (count === 0) {
858
- if (this.singleMode === "exact") {
859
- return {
860
- data: undefined,
861
- error: new RecordCountMismatchError("one", 0),
862
- };
863
- }
864
- return { data: null as any, error: undefined };
865
- }
866
-
867
- const record = Array.isArray(records) ? records[0] : records;
868
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
869
- return { data: stripped as any, error: undefined };
870
- } else {
871
- const records = resp.value ?? [];
872
- const stripped = records.map((record: any) =>
873
- this.stripODataAnnotationsIfNeeded(record, options),
874
- );
875
- return { data: stripped as any, error: undefined };
876
- }
877
- }
878
-
879
- // Get schema from occurrence if available
880
- const schema = this.occurrence?.baseTable?.schema;
881
- const selectedFields = this.queryOptions.select as
882
- | (keyof T)[]
883
- | undefined;
884
- const expandValidationConfigs = this.buildExpandValidationConfigs(
885
- this.expandConfigs,
886
- );
887
-
888
- if (this.singleMode !== false) {
889
- const validation = await validateSingleResponse<T>(
890
- response,
891
- schema,
892
- selectedFields,
893
- expandValidationConfigs,
894
- this.singleMode,
895
- );
896
- if (!validation.valid) {
897
- return { data: undefined, error: validation.error };
898
- }
899
- const stripped = validation.data
900
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
901
- : null;
902
- return { data: stripped as any, error: undefined };
903
- } else {
904
- const validation = await validateListResponse<T>(
905
- response,
906
- schema,
907
- selectedFields,
908
- expandValidationConfigs,
909
- );
910
- if (!validation.valid) {
911
- return { data: undefined, error: validation.error };
912
- }
913
- const stripped = validation.data.map((record) =>
914
- this.stripODataAnnotationsIfNeeded(record, options),
915
- );
916
- return { data: stripped as any, error: undefined };
917
- }
918
- }
919
-
920
- // Handle navigation from EntitySet (without record ID)
921
- if (
922
- this.isNavigate &&
923
- !this.navigateRecordId &&
924
- this.navigateRelation &&
925
- this.navigateSourceTableName
926
- ) {
927
- const result = await this.context._makeRequest(
928
- `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`,
929
- mergedOptions,
930
- );
931
-
932
- if (result.error) {
933
- return { data: undefined, error: result.error };
934
- }
935
-
936
- let response = result.data;
937
-
938
- // Transform response field IDs back to names if using entity IDs
939
- // Only transform if useEntityIds resolves to true (respects per-request override)
940
- const shouldUseIds = mergedOptions.useEntityIds ?? false;
941
-
942
- if (this.occurrence?.baseTable && shouldUseIds) {
943
- const expandValidationConfigs = this.buildExpandValidationConfigs(
944
- this.expandConfigs,
945
- );
946
- response = transformResponseFields(
947
- response,
948
- this.occurrence.baseTable,
949
- expandValidationConfigs,
950
- );
951
- }
952
-
953
- // Skip validation if requested
954
- if (options?.skipValidation === true) {
955
- const resp = response as any;
956
- if (this.singleMode !== false) {
957
- const records = resp.value ?? [resp];
958
- const count = Array.isArray(records) ? records.length : 1;
959
-
960
- if (count > 1) {
961
- return {
962
- data: undefined,
963
- error: new RecordCountMismatchError(
964
- this.singleMode === "exact" ? "one" : "at-most-one",
965
- count,
966
- ),
967
- };
968
- }
969
-
970
- if (count === 0) {
971
- if (this.singleMode === "exact") {
972
- return {
973
- data: undefined,
974
- error: new RecordCountMismatchError("one", 0),
975
- };
976
- }
977
- return { data: null as any, error: undefined };
978
- }
979
-
980
- const record = Array.isArray(records) ? records[0] : records;
981
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
982
- return { data: stripped as any, error: undefined };
983
- } else {
984
- const records = resp.value ?? [];
985
- const stripped = records.map((record: any) =>
986
- this.stripODataAnnotationsIfNeeded(record, options),
987
- );
988
- return { data: stripped as any, error: undefined };
989
- }
990
- }
991
-
992
- // Get schema from occurrence if available
993
- const schema = this.occurrence?.baseTable?.schema;
994
- const selectedFields = this.queryOptions.select as
995
- | (keyof T)[]
996
- | undefined;
997
- const expandValidationConfigs = this.buildExpandValidationConfigs(
998
- this.expandConfigs,
999
- );
1000
-
1001
- if (this.singleMode !== false) {
1002
- const validation = await validateSingleResponse<T>(
1003
- response,
1004
- schema,
1005
- selectedFields,
1006
- expandValidationConfigs,
1007
- this.singleMode,
1008
- );
1009
- if (!validation.valid) {
1010
- return { data: undefined, error: validation.error };
1011
- }
1012
- const stripped = validation.data
1013
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
1014
- : null;
1015
- return { data: stripped as any, error: undefined };
1016
- } else {
1017
- const validation = await validateListResponse<T>(
1018
- response,
1019
- schema,
1020
- selectedFields,
1021
- expandValidationConfigs,
1022
- );
1023
- if (!validation.valid) {
1024
- return { data: undefined, error: validation.error };
1025
- }
1026
- const stripped = validation.data.map((record) =>
1027
- this.stripODataAnnotationsIfNeeded(record, options),
1028
- );
1029
- return { data: stripped as any, error: undefined };
1030
- }
1031
- }
1032
-
1033
- // Handle $count endpoint
1034
- if (this.isCountMode) {
1035
- const tableId = this.getTableId(mergedOptions.useEntityIds);
1036
- const result = await this.context._makeRequest(
1037
- `/${this.databaseName}/${tableId}/$count${queryString}`,
1038
- mergedOptions,
1039
- );
1040
-
1041
- if (result.error) {
1042
- return { data: undefined, error: result.error };
1043
- }
1044
-
1045
- // OData returns count as a string, convert to number
1046
- const count =
1047
- typeof result.data === "string" ? Number(result.data) : result.data;
1048
- return { data: count as number, error: undefined } as any;
1049
- }
1050
-
1051
- const tableId = this.getTableId(mergedOptions.useEntityIds);
1052
- const result = await this.context._makeRequest(
1053
- `/${this.databaseName}/${tableId}${queryString}`,
1054
- mergedOptions,
1055
- );
1056
-
1057
- if (result.error) {
1058
- return { data: undefined, error: result.error };
1059
- }
1060
-
1061
- let response = result.data;
1062
-
1063
- // Transform response field IDs back to names if using entity IDs
1064
- // Only transform if useEntityIds resolves to true (respects per-request override)
1065
- const shouldUseIds = mergedOptions.useEntityIds ?? false;
1066
-
1067
- if (this.occurrence?.baseTable && shouldUseIds) {
1068
- const expandValidationConfigs = this.buildExpandValidationConfigs(
1069
- this.expandConfigs,
1070
- );
1071
- response = transformResponseFields(
1072
- response,
1073
- this.occurrence.baseTable,
1074
- expandValidationConfigs,
1075
- );
1076
- }
1077
-
1078
- // Skip validation if requested
1079
- if (options?.skipValidation === true) {
1080
- const resp = response as any;
1081
- if (this.singleMode !== false) {
1082
- const records = resp.value ?? [resp];
1083
- const count = Array.isArray(records) ? records.length : 1;
1084
-
1085
- if (count > 1) {
1086
- return {
1087
- data: undefined,
1088
- error: new RecordCountMismatchError(
1089
- this.singleMode === "exact" ? "one" : "at-most-one",
1090
- count,
1091
- ),
1092
- };
1093
- }
1094
-
1095
- if (count === 0) {
1096
- if (this.singleMode === "exact") {
1097
- return {
1098
- data: undefined,
1099
- error: new RecordCountMismatchError("one", 0),
1100
- };
1101
- }
1102
- return { data: null as any, error: undefined };
1103
- }
1104
-
1105
- const record = Array.isArray(records) ? records[0] : records;
1106
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
1107
- return { data: stripped as any, error: undefined };
1108
- } else {
1109
- // Handle list response structure
1110
- const records = resp.value ?? [];
1111
- const stripped = records.map((record: any) =>
1112
- this.stripODataAnnotationsIfNeeded(record, options),
1113
- );
1114
- return { data: stripped as any, error: undefined };
1115
- }
1116
- }
1117
-
1118
- // Get schema from occurrence if available
1119
- const schema = this.occurrence?.baseTable?.schema;
1120
- const selectedFields = this.queryOptions.select as (keyof T)[] | undefined;
1121
- const expandValidationConfigs = this.buildExpandValidationConfigs(
1122
- this.expandConfigs,
1123
- );
1124
-
1125
- if (this.singleMode !== false) {
1126
- const validation = await validateSingleResponse<T>(
1127
- response,
1128
- schema,
1129
- selectedFields,
1130
- expandValidationConfigs,
1131
- this.singleMode,
1132
- );
1133
- if (!validation.valid) {
1134
- return { data: undefined, error: validation.error };
1135
- }
1136
- const stripped = validation.data
1137
- ? this.stripODataAnnotationsIfNeeded(validation.data, options)
1138
- : null;
1139
- return {
1140
- data: stripped as any,
1141
- error: undefined,
1142
- };
1143
- } else {
1144
- const validation = await validateListResponse<T>(
1145
- response,
1146
- schema,
1147
- selectedFields,
1148
- expandValidationConfigs,
1149
- );
1150
- if (!validation.valid) {
1151
- return { data: undefined, error: validation.error };
1152
- }
1153
- const stripped = validation.data.map((record) =>
1154
- this.stripODataAnnotationsIfNeeded(record, options),
1155
- );
1156
- return {
1157
- data: stripped as any,
1158
- error: undefined,
1159
- };
1160
- }
1161
- }
1162
-
1163
- getQueryString(): string {
1164
- // Build query without expand (we'll add it manually)
1165
- const queryOptionsWithoutExpand = { ...this.queryOptions };
1166
- delete queryOptionsWithoutExpand.expand;
1167
-
1168
- // Format select fields before building query - buildQuery treats & as separator,
1169
- // so we need to pre-encode special characters. buildQuery preserves encoded values.
1170
- if (queryOptionsWithoutExpand.select) {
1171
- queryOptionsWithoutExpand.select = this.formatSelectFields(
1172
- queryOptionsWithoutExpand.select,
1173
- ) as any;
1174
- }
1175
-
1176
- let queryParams = buildQuery(queryOptionsWithoutExpand);
1177
-
1178
- // Post-process: buildQuery encodes spaces as %20, but we want to preserve spaces
1179
- // Replace %20 with spaces in the $select part
1180
- if (this.queryOptions.select) {
1181
- queryParams = queryParams.replace(
1182
- /\$select=([^&]*)/,
1183
- (match, selectValue) => {
1184
- return `$select=${selectValue.replace(/%20/g, " ")}`;
1185
- },
1186
- );
1187
- }
1188
- const expandString = this.buildExpandString(this.expandConfigs);
1189
- if (expandString) {
1190
- const separator = queryParams.includes("?") ? "&" : "?";
1191
- queryParams = `${queryParams}${separator}$expand=${expandString}`;
1192
- }
1193
-
1194
- // Handle navigation from RecordBuilder (with record ID)
1195
- if (
1196
- this.isNavigate &&
1197
- this.navigateRecordId &&
1198
- this.navigateRelation &&
1199
- this.navigateSourceTableName
1200
- ) {
1201
- let path: string;
1202
- if (this.navigateBaseRelation) {
1203
- // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
1204
- path = `/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}`;
1205
- } else {
1206
- // Normal navigation: /sourceTableName('recordId')/relationName
1207
- path = `/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}`;
1208
- }
1209
- // Append query params if any exist
1210
- return queryParams ? `${path}${queryParams}` : path;
1211
- }
1212
-
1213
- // Handle navigation from EntitySet (without record ID)
1214
- if (
1215
- this.isNavigate &&
1216
- !this.navigateRecordId &&
1217
- this.navigateRelation &&
1218
- this.navigateSourceTableName
1219
- ) {
1220
- // Return the path portion: /sourceTableName/relationName
1221
- const path = `/${this.navigateSourceTableName}/${this.navigateRelation}`;
1222
- // Append query params if any exist
1223
- return queryParams ? `${path}${queryParams}` : path;
1224
- }
1225
-
1226
- // Default case: return table name with query params
1227
- return `/${this.tableName}${queryParams}`;
1228
- }
1229
-
1230
- getRequestConfig(): { method: string; url: string; body?: any } {
1231
- // Build query without expand (we'll add it manually)
1232
- const queryOptionsWithoutExpand = { ...this.queryOptions };
1233
- delete queryOptionsWithoutExpand.expand;
1234
-
1235
- // Format select fields before building query
1236
- if (queryOptionsWithoutExpand.select) {
1237
- queryOptionsWithoutExpand.select = this.formatSelectFields(
1238
- queryOptionsWithoutExpand.select,
1239
- ) as any;
1240
- }
1241
-
1242
- let queryString = buildQuery(queryOptionsWithoutExpand);
1243
-
1244
- // Build custom expand string
1245
- const expandString = this.buildExpandString(this.expandConfigs);
1246
- if (expandString) {
1247
- const separator = queryString.includes("?") ? "&" : "?";
1248
- queryString = `${queryString}${separator}$expand=${expandString}`;
1249
- }
1250
-
1251
- let url: string;
1252
-
1253
- // Handle navigation from RecordBuilder (with record ID)
1254
- if (
1255
- this.isNavigate &&
1256
- this.navigateRecordId &&
1257
- this.navigateRelation &&
1258
- this.navigateSourceTableName
1259
- ) {
1260
- if (this.navigateBaseRelation) {
1261
- // Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
1262
- url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
1263
- } else {
1264
- // Normal navigation: /sourceTable('recordId')/relation
1265
- url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
1266
- }
1267
- } else if (
1268
- this.isNavigate &&
1269
- !this.navigateRecordId &&
1270
- this.navigateRelation &&
1271
- this.navigateSourceTableName
1272
- ) {
1273
- // Handle navigation from EntitySet (without record ID)
1274
- url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`;
1275
- } else if (this.isCountMode) {
1276
- url = `/${this.databaseName}/${this.tableName}/$count${queryString}`;
1277
- } else {
1278
- url = `/${this.databaseName}/${this.tableName}${queryString}`;
1279
- }
1280
-
1281
- return {
1282
- method: "GET",
1283
- url,
1284
- };
1285
- }
1286
-
1287
- toRequest(baseUrl: string): Request {
1288
- const config = this.getRequestConfig();
1289
- const fullUrl = `${baseUrl}${config.url}`;
1290
-
1291
- return new Request(fullUrl, {
1292
- method: config.method,
1293
- headers: {
1294
- "Content-Type": "application/json",
1295
- Accept: "application/json",
1296
- },
1297
- });
1298
- }
1299
-
1300
- async processResponse(
1301
- response: Response,
1302
- options?: ExecuteOptions,
1303
- ): Promise<
1304
- Result<QueryReturnType<T, Selected, SingleMode, IsCount, Expands>>
1305
- > {
1306
- // Handle 204 No Content (shouldn't happen for queries, but handle it gracefully)
1307
- if (response.status === 204) {
1308
- // Return empty list for list queries, null for single queries
1309
- if (this.singleMode !== false) {
1310
- if (this.singleMode === "maybe") {
1311
- return { data: null as any, error: undefined };
1312
- }
1313
- return {
1314
- data: undefined,
1315
- error: new RecordCountMismatchError("one", 0),
1316
- };
1317
- }
1318
- return { data: [] as any, error: undefined };
1319
- }
1320
-
1321
- // Parse the response body
1322
- let rawData;
1323
- try {
1324
- rawData = await response.json();
1325
- } catch (err) {
1326
- // Check if it's an empty body error (common with 204 responses)
1327
- if (err instanceof SyntaxError && response.status === 204) {
1328
- // Handled above, but just in case
1329
- return { data: [] as any, error: undefined };
1330
- }
1331
- return {
1332
- data: undefined,
1333
- error: {
1334
- name: "ResponseParseError",
1335
- message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
1336
- timestamp: new Date(),
1337
- } as any,
1338
- };
1339
- }
1340
-
1341
- if (!rawData) {
1342
- return {
1343
- data: undefined,
1344
- error: {
1345
- name: "ResponseError",
1346
- message: "Response body was empty or null",
1347
- timestamp: new Date(),
1348
- } as any,
1349
- };
1350
- }
1351
-
1352
- // Transform response field IDs back to names if using entity IDs
1353
- // Only transform if useEntityIds resolves to true (respects per-request override)
1354
- const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
1355
-
1356
- let transformedData = rawData;
1357
- if (this.occurrence?.baseTable && shouldUseIds) {
1358
- const expandValidationConfigs = this.buildExpandValidationConfigs(
1359
- this.expandConfigs,
1360
- );
1361
- transformedData = transformResponseFields(
1362
- rawData,
1363
- this.occurrence.baseTable,
1364
- expandValidationConfigs,
1365
- );
1366
- }
1367
-
1368
- // Skip validation if requested
1369
- if (options?.skipValidation === true) {
1370
- const resp = transformedData as any;
1371
- if (this.singleMode !== false) {
1372
- const records = resp.value ?? [resp];
1373
- const count = Array.isArray(records) ? records.length : 1;
1374
-
1375
- if (count > 1) {
1376
- return {
1377
- data: undefined,
1378
- error: new RecordCountMismatchError(
1379
- this.singleMode === "exact" ? "one" : "at-most-one",
1380
- count,
1381
- ),
1382
- };
1383
- }
1384
-
1385
- if (count === 0) {
1386
- if (this.singleMode === "exact") {
1387
- return {
1388
- data: undefined,
1389
- error: new RecordCountMismatchError("one", 0),
1390
- };
1391
- }
1392
- return { data: null as any, error: undefined };
1393
- }
1394
-
1395
- const record = Array.isArray(records) ? records[0] : records;
1396
- const stripped = this.stripODataAnnotationsIfNeeded(record, options);
1397
- return { data: stripped as any, error: undefined };
1398
- } else {
1399
- // Handle list response structure
1400
- const records = resp.value ?? [];
1401
- const stripped = records.map((record: any) =>
1402
- this.stripODataAnnotationsIfNeeded(record, options),
1403
- );
1404
- return { data: stripped as any, error: undefined };
1405
- }
1406
- }
1407
-
1408
- // Get schema from occurrence if available
1409
- const schema = this.occurrence?.baseTable?.schema;
1410
- const selectedFields = this.queryOptions.select as (keyof T)[] | undefined;
1411
- const expandValidationConfigs = this.buildExpandValidationConfigs(
1412
- this.expandConfigs,
1413
- );
1414
-
1415
- if (this.singleMode !== false) {
1416
- // Single mode (one() or oneOrNull())
1417
- const validation = await validateSingleResponse<T>(
1418
- transformedData,
1419
- schema,
1420
- selectedFields,
1421
- expandValidationConfigs,
1422
- this.singleMode,
1423
- );
1424
-
1425
- if (!validation.valid) {
1426
- return { data: undefined, error: validation.error };
1427
- }
1428
-
1429
- if (validation.data === null) {
1430
- return { data: null as any, error: undefined };
1431
- }
1432
-
1433
- const stripped = this.stripODataAnnotationsIfNeeded(
1434
- validation.data,
1435
- options,
1436
- );
1437
- return { data: stripped as any, error: undefined };
1438
- }
1439
-
1440
- // List mode
1441
- const validation = await validateListResponse<T>(
1442
- transformedData,
1443
- schema,
1444
- selectedFields,
1445
- expandValidationConfigs,
1446
- );
1447
-
1448
- if (!validation.valid) {
1449
- return { data: undefined, error: validation.error };
1450
- }
1451
-
1452
- const stripped = validation.data.map((record) =>
1453
- this.stripODataAnnotationsIfNeeded(record, options),
1454
- );
1455
- return { data: stripped as any, error: undefined };
1456
- }
1457
- }