@proofkit/fmodata 0.1.0-alpha.12 → 0.1.0-alpha.14

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