@proofkit/fmodata 0.1.0-alpha.13 → 0.1.0-alpha.15

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 (143) hide show
  1. package/README.md +489 -334
  2. package/dist/esm/client/batch-builder.d.ts +7 -5
  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 +12 -19
  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 +9 -12
  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 +133 -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 -64
  58. package/dist/esm/client/record-builder.js +158 -297
  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 +17 -25
  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 +73 -12
  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 +22 -17
  92. package/src/client/batch-builder.ts +102 -33
  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 +48 -52
  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 +126 -44
  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 +826 -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 -1454
  115. package/src/client/record-builder.ts +336 -586
  116. package/src/client/response-processor.ts +4 -5
  117. package/src/client/update-builder.ts +113 -75
  118. package/src/errors.ts +22 -1
  119. package/src/index.ts +58 -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 +88 -63
  127. package/src/validation.ts +76 -4
  128. package/LICENSE.md +0 -21
  129. package/dist/esm/client/base-table.d.ts +0 -128
  130. package/dist/esm/client/base-table.js +0 -57
  131. package/dist/esm/client/base-table.js.map +0 -1
  132. package/dist/esm/client/build-occurrences.d.ts +0 -74
  133. package/dist/esm/client/build-occurrences.js +0 -31
  134. package/dist/esm/client/build-occurrences.js.map +0 -1
  135. package/dist/esm/client/query-builder.js +0 -900
  136. package/dist/esm/client/query-builder.js.map +0 -1
  137. package/dist/esm/client/table-occurrence.d.ts +0 -86
  138. package/dist/esm/client/table-occurrence.js +0 -58
  139. package/dist/esm/client/table-occurrence.js.map +0 -1
  140. package/src/client/base-table.ts +0 -178
  141. package/src/client/build-occurrences.ts +0 -155
  142. package/src/client/query-builder.ts.bak +0 -1457
  143. package/src/client/table-occurrence.ts +0 -156
@@ -0,0 +1,826 @@
1
+ import { QueryOptions } from "odata-query";
2
+ import buildQuery from "odata-query";
3
+ import type {
4
+ ExecutionContext,
5
+ ExecutableBuilder,
6
+ Result,
7
+ ExecuteOptions,
8
+ ConditionallyWithODataAnnotations,
9
+ ExtractSchemaFromOccurrence,
10
+ ExecuteMethodOptions,
11
+ } from "../../types";
12
+ import type { Filter } from "../../filter-types";
13
+ import { RecordCountMismatchError } from "../../errors";
14
+ import { type FFetchOptions } from "@fetchkit/ffetch";
15
+ import {
16
+ transformFieldName,
17
+ transformFieldNamesArray,
18
+ transformOrderByField,
19
+ } from "../../transform";
20
+ import { safeJsonParse } from "../sanitize-json";
21
+ import { parseErrorResponse } from "../error-parser";
22
+ import { isColumn, type Column } from "../../orm/column";
23
+ import {
24
+ FilterExpression,
25
+ OrderByExpression,
26
+ isOrderByExpression,
27
+ } from "../../orm/operators";
28
+ import {
29
+ FMTable,
30
+ type InferSchemaOutputFromFMTable,
31
+ type ValidExpandTarget,
32
+ type ExtractTableName,
33
+ type ValidateNoContainerFields,
34
+ getTableName,
35
+ } from "../../orm/table";
36
+ import {
37
+ ExpandBuilder,
38
+ type ExpandConfig,
39
+ type ExpandedRelations,
40
+ resolveTableId,
41
+ mergeExecuteOptions,
42
+ formatSelectFields,
43
+ processQueryResponse,
44
+ processSelectWithRenames,
45
+ buildSelectExpandQueryString,
46
+ createODataRequest,
47
+ } from "../builders/index";
48
+ import { QueryUrlBuilder, type NavigationConfig } from "./url-builder";
49
+ import type { TypeSafeOrderBy, QueryReturnType } from "./types";
50
+
51
+ // Re-export QueryReturnType for backward compatibility
52
+ export type { QueryReturnType };
53
+
54
+ /**
55
+ * Default maximum number of records to return in a list query.
56
+ * This prevents stack overflow issues with large datasets while still
57
+ * allowing substantial data retrieval. Users can override with .top().
58
+ */
59
+ const DEFAULT_TOP = 1000;
60
+
61
+ export type { TypeSafeOrderBy, ExpandedRelations };
62
+
63
+ export class QueryBuilder<
64
+ Occ extends FMTable<any, any>,
65
+ Selected extends
66
+ | keyof InferSchemaOutputFromFMTable<Occ>
67
+ | Record<
68
+ string,
69
+ Column<any, ExtractTableName<Occ>>
70
+ > = keyof InferSchemaOutputFromFMTable<Occ>,
71
+ SingleMode extends "exact" | "maybe" | false = false,
72
+ IsCount extends boolean = false,
73
+ Expands extends ExpandedRelations = {},
74
+ > implements
75
+ ExecutableBuilder<
76
+ QueryReturnType<
77
+ InferSchemaOutputFromFMTable<Occ>,
78
+ Selected,
79
+ SingleMode,
80
+ IsCount,
81
+ Expands
82
+ >
83
+ >
84
+ {
85
+ private queryOptions: Partial<
86
+ QueryOptions<InferSchemaOutputFromFMTable<Occ>>
87
+ > = {};
88
+ private expandConfigs: ExpandConfig[] = [];
89
+ private singleMode: SingleMode = false as SingleMode;
90
+ private isCountMode = false as IsCount;
91
+ private occurrence: Occ;
92
+ private databaseName: string;
93
+ private context: ExecutionContext;
94
+ private navigation?: NavigationConfig;
95
+ private databaseUseEntityIds: boolean;
96
+ private expandBuilder: ExpandBuilder;
97
+ private urlBuilder: QueryUrlBuilder;
98
+ // Mapping from field names to output keys (for renamed fields in select)
99
+ private fieldMapping?: Record<string, string>;
100
+
101
+ constructor(config: {
102
+ occurrence: Occ;
103
+ databaseName: string;
104
+ context: ExecutionContext;
105
+ databaseUseEntityIds?: boolean;
106
+ }) {
107
+ this.occurrence = config.occurrence;
108
+ this.databaseName = config.databaseName;
109
+ this.context = config.context;
110
+ this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
111
+ this.expandBuilder = new ExpandBuilder(this.databaseUseEntityIds);
112
+ this.urlBuilder = new QueryUrlBuilder(
113
+ this.databaseName,
114
+ this.occurrence,
115
+ this.context,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * Helper to merge database-level useEntityIds with per-request options
121
+ */
122
+ private mergeExecuteOptions(
123
+ options?: RequestInit & FFetchOptions & ExecuteOptions,
124
+ ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
125
+ return mergeExecuteOptions(options, this.databaseUseEntityIds);
126
+ }
127
+
128
+ /**
129
+ * Gets the FMTable instance
130
+ */
131
+ private getTable(): FMTable<any, any> | undefined {
132
+ return this.occurrence;
133
+ }
134
+
135
+ /**
136
+ * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
137
+ * @param useEntityIds - Optional override for entity ID usage
138
+ */
139
+ private getTableIdOrName(useEntityIds?: boolean): string {
140
+ return resolveTableId(
141
+ this.occurrence,
142
+ getTableName(this.occurrence),
143
+ this.context,
144
+ useEntityIds,
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Creates a new QueryBuilder with modified configuration.
150
+ * Used by single(), maybeSingle(), count(), and select() to create new instances.
151
+ */
152
+ private cloneWithChanges<
153
+ NewSelected extends
154
+ | keyof InferSchemaOutputFromFMTable<Occ>
155
+ | Record<string, Column<any, ExtractTableName<Occ>>> = Selected,
156
+ NewSingle extends "exact" | "maybe" | false = SingleMode,
157
+ NewCount extends boolean = IsCount,
158
+ >(changes: {
159
+ selectedFields?: NewSelected;
160
+ singleMode?: NewSingle;
161
+ isCountMode?: NewCount;
162
+ queryOptions?: Partial<QueryOptions<InferSchemaOutputFromFMTable<Occ>>>;
163
+ fieldMapping?: Record<string, string>;
164
+ }): QueryBuilder<Occ, NewSelected, NewSingle, NewCount, Expands> {
165
+ const newBuilder = new QueryBuilder<
166
+ Occ,
167
+ NewSelected,
168
+ NewSingle,
169
+ NewCount,
170
+ Expands
171
+ >({
172
+ occurrence: this.occurrence,
173
+ databaseName: this.databaseName,
174
+ context: this.context,
175
+ databaseUseEntityIds: this.databaseUseEntityIds,
176
+ });
177
+ newBuilder.queryOptions = {
178
+ ...this.queryOptions,
179
+ ...changes.queryOptions,
180
+ };
181
+ newBuilder.expandConfigs = [...this.expandConfigs];
182
+ newBuilder.singleMode = (changes.singleMode ?? this.singleMode) as any;
183
+ newBuilder.isCountMode = (changes.isCountMode ?? this.isCountMode) as any;
184
+ newBuilder.fieldMapping = changes.fieldMapping ?? this.fieldMapping;
185
+ // Copy navigation metadata
186
+ newBuilder.navigation = this.navigation;
187
+ newBuilder.urlBuilder = new QueryUrlBuilder(
188
+ this.databaseName,
189
+ this.occurrence,
190
+ this.context,
191
+ );
192
+ return newBuilder;
193
+ }
194
+
195
+ /**
196
+ * Select fields using column references.
197
+ * Allows renaming fields by using different keys in the object.
198
+ * Container fields cannot be selected and will cause a type error.
199
+ *
200
+ * @example
201
+ * db.from(users).list().select({
202
+ * name: users.name,
203
+ * userEmail: users.email // renamed!
204
+ * })
205
+ *
206
+ * @param fields - Object mapping output keys to column references (container fields excluded)
207
+ * @returns QueryBuilder with updated selected fields
208
+ */
209
+ select<
210
+ TSelect extends Record<string, Column<any, ExtractTableName<Occ>, false>>,
211
+ >(fields: TSelect): QueryBuilder<Occ, TSelect, SingleMode, IsCount, Expands> {
212
+ const tableName = getTableName(this.occurrence);
213
+ const { selectedFields, fieldMapping } = processSelectWithRenames(
214
+ fields,
215
+ tableName,
216
+ );
217
+
218
+ return this.cloneWithChanges({
219
+ selectedFields: fields as any,
220
+ queryOptions: {
221
+ select: selectedFields,
222
+ },
223
+ fieldMapping:
224
+ Object.keys(fieldMapping).length > 0 ? fieldMapping : undefined,
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Transforms our filter format to odata-query's expected format
230
+ * - Arrays of operators are converted to AND conditions
231
+ * - Single operator objects pass through as-is
232
+ * - Shorthand values are handled by odata-query
233
+ */
234
+ private transformFilter(
235
+ filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
236
+ ): QueryOptions<InferSchemaOutputFromFMTable<Occ>>["filter"] {
237
+ if (typeof filter === "string") {
238
+ // Raw string filters pass through
239
+ return filter;
240
+ }
241
+
242
+ if (Array.isArray(filter)) {
243
+ // Array of filters - odata-query handles this as implicit AND
244
+ return filter.map((f) => this.transformFilter(f as any)) as any;
245
+ }
246
+
247
+ // Check if it's a logical filter (and/or/not)
248
+ if ("and" in filter || "or" in filter || "not" in filter) {
249
+ const result: any = {};
250
+ if ("and" in filter && Array.isArray(filter.and)) {
251
+ result.and = filter.and.map((f: any) => this.transformFilter(f));
252
+ }
253
+ if ("or" in filter && Array.isArray(filter.or)) {
254
+ result.or = filter.or.map((f: any) => this.transformFilter(f));
255
+ }
256
+ if ("not" in filter && filter.not) {
257
+ result.not = this.transformFilter(filter.not as any);
258
+ }
259
+ return result;
260
+ }
261
+
262
+ // Transform field filters
263
+ const result: any = {};
264
+ const andConditions: any[] = [];
265
+
266
+ for (const [field, value] of Object.entries(filter)) {
267
+ // Transform field name to FMFID if using entity IDs AND the feature is enabled
268
+ const shouldTransform = this.occurrence && this.databaseUseEntityIds;
269
+ const fieldId = shouldTransform
270
+ ? transformFieldName(field, this.occurrence!)
271
+ : field;
272
+
273
+ if (Array.isArray(value)) {
274
+ // Array of operators - convert to AND conditions
275
+ if (value.length === 1) {
276
+ // Single operator in array - unwrap it
277
+ result[fieldId] = value[0];
278
+ } else {
279
+ // Multiple operators - combine with AND
280
+ // Create separate conditions for each operator
281
+ for (const op of value) {
282
+ andConditions.push({ [fieldId]: op });
283
+ }
284
+ }
285
+ } else if (
286
+ value &&
287
+ typeof value === "object" &&
288
+ !(value instanceof Date) &&
289
+ !Array.isArray(value)
290
+ ) {
291
+ // Check if it's an operator object (has operator keys like eq, gt, etc.)
292
+ const operatorKeys = [
293
+ "eq",
294
+ "ne",
295
+ "gt",
296
+ "ge",
297
+ "lt",
298
+ "le",
299
+ "contains",
300
+ "startswith",
301
+ "endswith",
302
+ "in",
303
+ ];
304
+ const isOperatorObject = operatorKeys.some((key) => key in value);
305
+
306
+ if (isOperatorObject) {
307
+ // Single operator object - pass through
308
+ result[fieldId] = value;
309
+ } else {
310
+ // Regular object - might be nested filter, pass through
311
+ result[fieldId] = value;
312
+ }
313
+ } else {
314
+ // Primitive value (shorthand) - pass through
315
+ result[fieldId] = value;
316
+ }
317
+ }
318
+
319
+ // If we have AND conditions from arrays, combine them
320
+ if (andConditions.length > 0) {
321
+ if (Object.keys(result).length > 0) {
322
+ // We have both regular fields and array-derived AND conditions
323
+ // Combine everything with AND
324
+ return { and: [...andConditions, result] };
325
+ } else {
326
+ // Only array-derived AND conditions
327
+ return { and: andConditions };
328
+ }
329
+ }
330
+
331
+ return result;
332
+ }
333
+
334
+ filter(
335
+ filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
336
+ ): QueryBuilder<Occ, Selected, SingleMode, IsCount, Expands> {
337
+ // Transform our filter format to odata-query's expected format
338
+ this.queryOptions.filter = this.transformFilter(filter) as any;
339
+ return this;
340
+ }
341
+
342
+ /**
343
+ * Filter results using operator expressions (new ORM-style API).
344
+ * Supports eq, gt, lt, and, or, etc. operators with Column references.
345
+ *
346
+ * @example
347
+ * .where(eq(users.hobby, "reading"))
348
+ * .where(and(eq(users.active, true), gt(users.age, 18)))
349
+ */
350
+ where(
351
+ expression: FilterExpression,
352
+ ): QueryBuilder<Occ, Selected, SingleMode, IsCount, Expands> {
353
+ // Convert FilterExpression to OData filter string
354
+ const filterString = expression.toODataFilter(this.databaseUseEntityIds);
355
+ this.queryOptions.filter = filterString;
356
+ return this;
357
+ }
358
+
359
+ /**
360
+ * Specify the sort order for query results.
361
+ *
362
+ * @example Single field (ascending by default)
363
+ * ```ts
364
+ * .orderBy("name")
365
+ * .orderBy(users.name) // Column reference
366
+ * .orderBy(asc(users.name)) // Explicit ascending
367
+ * ```
368
+ *
369
+ * @example Single field with explicit direction
370
+ * ```ts
371
+ * .orderBy(["name", "desc"])
372
+ * .orderBy([users.name, "desc"]) // Column reference
373
+ * .orderBy(desc(users.name)) // Explicit descending
374
+ * ```
375
+ *
376
+ * @example Multiple fields with directions
377
+ * ```ts
378
+ * .orderBy([["name", "asc"], ["createdAt", "desc"]])
379
+ * .orderBy([[users.name, "asc"], [users.createdAt, "desc"]]) // Column references
380
+ * .orderBy(users.name, desc(users.age)) // Variadic with helpers
381
+ * ```
382
+ */
383
+ orderBy(
384
+ ...orderByArgs:
385
+ | [
386
+ | TypeSafeOrderBy<InferSchemaOutputFromFMTable<Occ>>
387
+ | Column<any, ExtractTableName<Occ>>
388
+ | OrderByExpression<ExtractTableName<Occ>>,
389
+ ]
390
+ | [
391
+ Column<any, ExtractTableName<Occ>>,
392
+ ...Array<
393
+ | Column<any, ExtractTableName<Occ>>
394
+ | OrderByExpression<ExtractTableName<Occ>>
395
+ >,
396
+ ]
397
+ ): QueryBuilder<Occ, Selected, SingleMode, IsCount, Expands> {
398
+ const tableName = getTableName(this.occurrence);
399
+
400
+ // Handle variadic arguments (multiple fields)
401
+ if (orderByArgs.length > 1) {
402
+ const orderByParts = orderByArgs.map((arg) => {
403
+ if (isOrderByExpression(arg)) {
404
+ // Validate table match
405
+ if (arg.column.tableName !== tableName) {
406
+ console.warn(
407
+ `Column ${arg.column.toString()} is from table "${arg.column.tableName}", but query is for table "${tableName}"`,
408
+ );
409
+ }
410
+ const fieldName = arg.column.fieldName;
411
+ const transformedField = this.occurrence
412
+ ? transformOrderByField(fieldName, this.occurrence)
413
+ : fieldName;
414
+ return `${transformedField} ${arg.direction}`;
415
+ } else if (isColumn(arg)) {
416
+ // Validate table match
417
+ if (arg.tableName !== tableName) {
418
+ console.warn(
419
+ `Column ${arg.toString()} is from table "${arg.tableName}", but query is for table "${tableName}"`,
420
+ );
421
+ }
422
+ const fieldName = arg.fieldName;
423
+ const transformedField = this.occurrence
424
+ ? transformOrderByField(fieldName, this.occurrence)
425
+ : fieldName;
426
+ return transformedField; // Default to ascending
427
+ } else {
428
+ throw new Error(
429
+ "Variadic orderBy() only accepts Column or OrderByExpression arguments",
430
+ );
431
+ }
432
+ });
433
+ this.queryOptions.orderBy = orderByParts;
434
+ return this;
435
+ }
436
+
437
+ // Handle single argument
438
+ const orderBy = orderByArgs[0];
439
+
440
+ // Handle OrderByExpression
441
+ if (isOrderByExpression(orderBy)) {
442
+ // Validate table match
443
+ if (orderBy.column.tableName !== tableName) {
444
+ console.warn(
445
+ `Column ${orderBy.column.toString()} is from table "${orderBy.column.tableName}", but query is for table "${tableName}"`,
446
+ );
447
+ }
448
+ const fieldName = orderBy.column.fieldName;
449
+ const transformedField = this.occurrence
450
+ ? transformOrderByField(fieldName, this.occurrence)
451
+ : fieldName;
452
+ this.queryOptions.orderBy = `${transformedField} ${orderBy.direction}`;
453
+ return this;
454
+ }
455
+
456
+ // Handle Column references
457
+ if (isColumn(orderBy)) {
458
+ // Validate table match
459
+ if (orderBy.tableName !== tableName) {
460
+ console.warn(
461
+ `Column ${orderBy.toString()} is from table "${orderBy.tableName}", but query is for table "${tableName}"`,
462
+ );
463
+ }
464
+ // Single Column reference without direction (defaults to ascending)
465
+ const fieldName = orderBy.fieldName;
466
+ this.queryOptions.orderBy = this.occurrence
467
+ ? transformOrderByField(fieldName, this.occurrence)
468
+ : fieldName;
469
+ return this;
470
+ }
471
+ // Transform field names to FMFIDs if using entity IDs
472
+ if (this.occurrence && orderBy) {
473
+ if (Array.isArray(orderBy)) {
474
+ // Check if it's a single tuple [field, direction] or array of tuples
475
+ if (
476
+ orderBy.length === 2 &&
477
+ (typeof orderBy[0] === "string" || isColumn(orderBy[0])) &&
478
+ (orderBy[1] === "asc" || orderBy[1] === "desc")
479
+ ) {
480
+ // Single tuple: [field, direction] or [column, direction]
481
+ const field = isColumn(orderBy[0])
482
+ ? orderBy[0].fieldName
483
+ : orderBy[0];
484
+ const direction = orderBy[1] as "asc" | "desc";
485
+ this.queryOptions.orderBy = `${transformOrderByField(field, this.occurrence)} ${direction}`;
486
+ } else {
487
+ // Array of tuples: [[field, dir], [field, dir], ...]
488
+ this.queryOptions.orderBy = (
489
+ orderBy as Array<[any, "asc" | "desc"]>
490
+ ).map(([fieldOrCol, direction]) => {
491
+ const field = isColumn(fieldOrCol)
492
+ ? fieldOrCol.fieldName
493
+ : String(fieldOrCol);
494
+ const transformedField = transformOrderByField(
495
+ field,
496
+ this.occurrence!,
497
+ );
498
+ return `${transformedField} ${direction}`;
499
+ });
500
+ }
501
+ } else {
502
+ // Single field name (string)
503
+ this.queryOptions.orderBy = transformOrderByField(
504
+ String(orderBy),
505
+ this.occurrence,
506
+ );
507
+ }
508
+ } else {
509
+ // No occurrence/baseTable - pass through as-is
510
+ if (Array.isArray(orderBy)) {
511
+ if (
512
+ orderBy.length === 2 &&
513
+ (typeof orderBy[0] === "string" || isColumn(orderBy[0])) &&
514
+ (orderBy[1] === "asc" || orderBy[1] === "desc")
515
+ ) {
516
+ // Single tuple: [field, direction] or [column, direction]
517
+ const field = isColumn(orderBy[0])
518
+ ? orderBy[0].fieldName
519
+ : orderBy[0];
520
+ const direction = orderBy[1] as "asc" | "desc";
521
+ this.queryOptions.orderBy = `${field} ${direction}`;
522
+ } else {
523
+ // Array of tuples
524
+ this.queryOptions.orderBy = (
525
+ orderBy as Array<[any, "asc" | "desc"]>
526
+ ).map(([fieldOrCol, direction]) => {
527
+ const field = isColumn(fieldOrCol)
528
+ ? fieldOrCol.fieldName
529
+ : String(fieldOrCol);
530
+ return `${field} ${direction}`;
531
+ });
532
+ }
533
+ } else {
534
+ this.queryOptions.orderBy = orderBy;
535
+ }
536
+ }
537
+ return this;
538
+ }
539
+
540
+ top(
541
+ count: number,
542
+ ): QueryBuilder<Occ, Selected, SingleMode, IsCount, Expands> {
543
+ this.queryOptions.top = count;
544
+ return this;
545
+ }
546
+
547
+ skip(
548
+ count: number,
549
+ ): QueryBuilder<Occ, Selected, SingleMode, IsCount, Expands> {
550
+ this.queryOptions.skip = count;
551
+ return this;
552
+ }
553
+
554
+ expand<TargetTable extends FMTable<any, any>>(
555
+ targetTable: ValidExpandTarget<Occ, TargetTable>,
556
+ callback?: (
557
+ builder: QueryBuilder<
558
+ TargetTable,
559
+ keyof InferSchemaOutputFromFMTable<TargetTable>,
560
+ false,
561
+ false
562
+ >,
563
+ ) => QueryBuilder<TargetTable, any, any, any, any>,
564
+ ): QueryBuilder<
565
+ Occ,
566
+ Selected,
567
+ SingleMode,
568
+ IsCount,
569
+ Expands & {
570
+ [K in ExtractTableName<TargetTable>]: {
571
+ schema: InferSchemaOutputFromFMTable<TargetTable>;
572
+ selected: keyof InferSchemaOutputFromFMTable<TargetTable>;
573
+ };
574
+ }
575
+ > {
576
+ // Use ExpandBuilder.processExpand to handle the expand logic
577
+ type TargetBuilder = QueryBuilder<
578
+ TargetTable,
579
+ keyof InferSchemaOutputFromFMTable<TargetTable>,
580
+ false,
581
+ false
582
+ >;
583
+ const expandConfig = this.expandBuilder.processExpand<
584
+ TargetTable,
585
+ TargetBuilder
586
+ >(
587
+ targetTable,
588
+ this.occurrence,
589
+ callback,
590
+ () =>
591
+ new QueryBuilder<TargetTable>({
592
+ occurrence: targetTable,
593
+ databaseName: this.databaseName,
594
+ context: this.context,
595
+ databaseUseEntityIds: this.databaseUseEntityIds,
596
+ }),
597
+ );
598
+
599
+ this.expandConfigs.push(expandConfig);
600
+ return this as any;
601
+ }
602
+
603
+ single(): QueryBuilder<Occ, Selected, "exact", IsCount, Expands> {
604
+ return this.cloneWithChanges({ singleMode: "exact" as const });
605
+ }
606
+
607
+ maybeSingle(): QueryBuilder<Occ, Selected, "maybe", IsCount, Expands> {
608
+ return this.cloneWithChanges({ singleMode: "maybe" as const });
609
+ }
610
+
611
+ count(): QueryBuilder<Occ, Selected, SingleMode, true, Expands> {
612
+ return this.cloneWithChanges({
613
+ isCountMode: true as const,
614
+ queryOptions: { count: true },
615
+ });
616
+ }
617
+
618
+ /**
619
+ * Builds the OData query string from current query options and expand configs.
620
+ */
621
+ private buildQueryString(): string {
622
+ // Build query without expand and select (we'll add them manually if using entity IDs)
623
+ const queryOptionsWithoutExpandAndSelect = { ...this.queryOptions };
624
+ const originalSelect = queryOptionsWithoutExpandAndSelect.select;
625
+ delete queryOptionsWithoutExpandAndSelect.expand;
626
+ delete queryOptionsWithoutExpandAndSelect.select;
627
+
628
+ let queryString = buildQuery(queryOptionsWithoutExpandAndSelect);
629
+
630
+ // Use shared helper for select/expand portion
631
+ const selectArray = originalSelect
632
+ ? Array.isArray(originalSelect)
633
+ ? originalSelect.map(String)
634
+ : [String(originalSelect)]
635
+ : undefined;
636
+
637
+ const selectExpandString = buildSelectExpandQueryString({
638
+ selectedFields: selectArray,
639
+ expandConfigs: this.expandConfigs,
640
+ table: this.occurrence,
641
+ useEntityIds: this.databaseUseEntityIds,
642
+ });
643
+
644
+ // Append select/expand to existing query string
645
+ if (selectExpandString) {
646
+ // Strip leading ? from helper result and append with appropriate separator
647
+ const params = selectExpandString.startsWith("?")
648
+ ? selectExpandString.slice(1)
649
+ : selectExpandString;
650
+ const separator = queryString.includes("?") ? "&" : "?";
651
+ queryString = `${queryString}${separator}${params}`;
652
+ }
653
+
654
+ return queryString;
655
+ }
656
+
657
+ async execute<EO extends ExecuteOptions>(
658
+ options?: ExecuteMethodOptions<EO>,
659
+ ): Promise<
660
+ Result<
661
+ ConditionallyWithODataAnnotations<
662
+ QueryReturnType<
663
+ InferSchemaOutputFromFMTable<Occ>,
664
+ Selected,
665
+ SingleMode,
666
+ IsCount,
667
+ Expands
668
+ >,
669
+ EO["includeODataAnnotations"] extends true ? true : false
670
+ >
671
+ >
672
+ > {
673
+ const mergedOptions = this.mergeExecuteOptions(options);
674
+ const queryString = this.buildQueryString();
675
+
676
+ // Handle $count endpoint
677
+ if (this.isCountMode) {
678
+ const url = this.urlBuilder.build(queryString, {
679
+ isCount: true,
680
+ useEntityIds: mergedOptions.useEntityIds,
681
+ navigation: this.navigation,
682
+ });
683
+ const result = await this.context._makeRequest(url, mergedOptions);
684
+
685
+ if (result.error) {
686
+ return { data: undefined, error: result.error };
687
+ }
688
+
689
+ // OData returns count as a string, convert to number
690
+ const count =
691
+ typeof result.data === "string" ? Number(result.data) : result.data;
692
+ return { data: count as number, error: undefined } as any;
693
+ }
694
+
695
+ const url = this.urlBuilder.build(queryString, {
696
+ isCount: this.isCountMode,
697
+ useEntityIds: mergedOptions.useEntityIds,
698
+ navigation: this.navigation,
699
+ });
700
+
701
+ const result = await this.context._makeRequest(url, mergedOptions);
702
+
703
+ if (result.error) {
704
+ return { data: undefined, error: result.error };
705
+ }
706
+
707
+ return processQueryResponse(result.data, {
708
+ occurrence: this.occurrence,
709
+ singleMode: this.singleMode,
710
+ queryOptions: this.queryOptions as any,
711
+ expandConfigs: this.expandConfigs,
712
+ skipValidation: options?.skipValidation,
713
+ useEntityIds: mergedOptions.useEntityIds,
714
+ fieldMapping: this.fieldMapping,
715
+ });
716
+ }
717
+
718
+ getQueryString(): string {
719
+ const queryString = this.buildQueryString();
720
+ return this.urlBuilder.buildPath(queryString, {
721
+ useEntityIds: this.databaseUseEntityIds,
722
+ navigation: this.navigation,
723
+ });
724
+ }
725
+
726
+ getRequestConfig(): { method: string; url: string; body?: any } {
727
+ const queryString = this.buildQueryString();
728
+ const url = this.urlBuilder.build(queryString, {
729
+ isCount: this.isCountMode,
730
+ useEntityIds: this.databaseUseEntityIds,
731
+ navigation: this.navigation,
732
+ });
733
+
734
+ return {
735
+ method: "GET",
736
+ url,
737
+ };
738
+ }
739
+
740
+ toRequest(baseUrl: string, options?: ExecuteOptions): Request {
741
+ const config = this.getRequestConfig();
742
+ return createODataRequest(baseUrl, config, options);
743
+ }
744
+
745
+ async processResponse(
746
+ response: Response,
747
+ options?: ExecuteOptions,
748
+ ): Promise<
749
+ Result<
750
+ QueryReturnType<
751
+ InferSchemaOutputFromFMTable<Occ>,
752
+ Selected,
753
+ SingleMode,
754
+ IsCount,
755
+ Expands
756
+ >
757
+ >
758
+ > {
759
+ // Check for error responses (important for batch operations)
760
+ if (!response.ok) {
761
+ const error = await parseErrorResponse(
762
+ response,
763
+ response.url ||
764
+ `/${this.databaseName}/${getTableName(this.occurrence)}`,
765
+ );
766
+ return { data: undefined, error };
767
+ }
768
+
769
+ // Handle 204 No Content (shouldn't happen for queries, but handle it gracefully)
770
+ if (response.status === 204) {
771
+ // Return empty list for list queries, null for single queries
772
+ if (this.singleMode !== false) {
773
+ if (this.singleMode === "maybe") {
774
+ return { data: null as any, error: undefined };
775
+ }
776
+ return {
777
+ data: undefined,
778
+ error: new RecordCountMismatchError("one", 0),
779
+ };
780
+ }
781
+ return { data: [] as any, error: undefined };
782
+ }
783
+
784
+ // Parse the response body (using safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values)
785
+ let rawData;
786
+ try {
787
+ rawData = await safeJsonParse(response);
788
+ } catch (err) {
789
+ // Check if it's an empty body error (common with 204 responses)
790
+ if (err instanceof SyntaxError && response.status === 204) {
791
+ // Handled above, but just in case
792
+ return { data: [] as any, error: undefined };
793
+ }
794
+ return {
795
+ data: undefined,
796
+ error: {
797
+ name: "ResponseParseError",
798
+ message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
799
+ timestamp: new Date(),
800
+ } as any,
801
+ };
802
+ }
803
+
804
+ if (!rawData) {
805
+ return {
806
+ data: undefined,
807
+ error: {
808
+ name: "ResponseError",
809
+ message: "Response body was empty or null",
810
+ timestamp: new Date(),
811
+ } as any,
812
+ };
813
+ }
814
+
815
+ const mergedOptions = this.mergeExecuteOptions(options);
816
+ return processQueryResponse(rawData, {
817
+ occurrence: this.occurrence,
818
+ singleMode: this.singleMode,
819
+ queryOptions: this.queryOptions as any,
820
+ expandConfigs: this.expandConfigs,
821
+ skipValidation: options?.skipValidation,
822
+ useEntityIds: mergedOptions.useEntityIds,
823
+ fieldMapping: this.fieldMapping,
824
+ });
825
+ }
826
+ }