@proofkit/fmodata 0.1.0-alpha.13 → 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 (143) 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 -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 +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 +20 -17
  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 -1454
  115. package/src/client/record-builder.ts +325 -585
  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/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,59 @@
1
+ import {
2
+ HTTPError,
3
+ ODataError,
4
+ SchemaLockedError,
5
+ FMODataErrorType,
6
+ } from "../errors";
7
+ import { safeJsonParse } from "./sanitize-json";
8
+
9
+ /**
10
+ * Parses an error response and returns an appropriate error object.
11
+ * This helper is used by builder processResponse methods to handle error responses
12
+ * consistently, particularly important for batch operations where errors need to be
13
+ * properly parsed from the response body.
14
+ *
15
+ * @param response - The Response object (may be from batch or direct request)
16
+ * @param url - The URL that was requested (for error context)
17
+ * @returns An appropriate error object (ODataError, SchemaLockedError, or HTTPError)
18
+ */
19
+ export async function parseErrorResponse(
20
+ response: Response,
21
+ url: string,
22
+ ): Promise<FMODataErrorType> {
23
+ // Try to parse error body if it's JSON
24
+ let errorBody: { error?: { code?: string | number; message?: string } } | undefined;
25
+
26
+ try {
27
+ if (response.headers.get("content-type")?.includes("application/json")) {
28
+ errorBody = await safeJsonParse<typeof errorBody>(response);
29
+ }
30
+ } catch {
31
+ // Ignore JSON parse errors - we'll fall back to HTTPError
32
+ }
33
+
34
+ // Check if it's an OData error response
35
+ if (errorBody?.error) {
36
+ const errorCode = errorBody.error.code;
37
+ const errorMessage = errorBody.error.message || response.statusText;
38
+
39
+ // Check for schema locked error (code 303)
40
+ if (errorCode === "303" || errorCode === 303) {
41
+ return new SchemaLockedError(url, errorMessage, errorBody.error);
42
+ }
43
+
44
+ return new ODataError(
45
+ url,
46
+ errorMessage,
47
+ String(errorCode),
48
+ errorBody.error,
49
+ );
50
+ }
51
+
52
+ // Fall back to generic HTTPError
53
+ return new HTTPError(url, response.status, response.statusText, errorBody);
54
+ }
55
+
56
+
57
+
58
+
59
+
@@ -15,7 +15,6 @@ import {
15
15
  ResponseParseError,
16
16
  } from "../errors";
17
17
  import { Database } from "./database";
18
- import { TableOccurrence } from "./table-occurrence";
19
18
  import { safeJsonParse } from "./sanitize-json";
20
19
  import { get } from "es-toolkit/compat";
21
20
 
@@ -116,14 +115,7 @@ export class FMServerConnection implements ExecutionContext {
116
115
  headers,
117
116
  };
118
117
 
119
- // For batch requests, use native fetch to avoid any potential serialization issues with ffetch
120
- const resp = url.includes("/$batch")
121
- ? await fetch(fullUrl, {
122
- method: finalOptions.method,
123
- headers: finalOptions.headers,
124
- body: finalOptions.body,
125
- })
126
- : await clientToUse(fullUrl, finalOptions);
118
+ const resp = await clientToUse(fullUrl, finalOptions);
127
119
 
128
120
  // Handle HTTP errors
129
121
  if (!resp.ok) {
@@ -261,15 +253,12 @@ export class FMServerConnection implements ExecutionContext {
261
253
  }
262
254
  }
263
255
 
264
- database<
265
- const Occurrences extends readonly TableOccurrence<any, any, any, any>[],
266
- >(
256
+ database(
267
257
  name: string,
268
258
  config?: {
269
- occurrences?: Occurrences | undefined;
270
259
  useEntityIds?: boolean;
271
260
  },
272
- ): Database<Occurrences> {
261
+ ): Database {
273
262
  return new Database(name, this, config);
274
263
  }
275
264
 
@@ -8,51 +8,59 @@ import type {
8
8
  ConditionallyWithODataAnnotations,
9
9
  } from "../types";
10
10
  import { getAcceptHeader } from "../types";
11
- import type { TableOccurrence } from "./table-occurrence";
12
- import { validateSingleResponse } from "../validation";
11
+ import type { FMTable } from "../orm/table";
12
+ import {
13
+ getBaseTableConfig,
14
+ getTableName,
15
+ getTableId as getTableIdHelper,
16
+ isUsingEntityIds,
17
+ } from "../orm/table";
18
+ import {
19
+ validateSingleResponse,
20
+ validateAndTransformInput,
21
+ } from "../validation";
13
22
  import { type FFetchOptions } from "@fetchkit/ffetch";
14
23
  import {
15
24
  transformFieldNamesToIds,
16
- transformTableName,
17
25
  transformResponseFields,
18
- getTableIdentifiers,
19
26
  } from "../transform";
20
27
  import { InvalidLocationHeaderError } from "../errors";
21
28
  import { safeJsonParse } from "./sanitize-json";
29
+ import { parseErrorResponse } from "./error-parser";
22
30
 
23
31
  export type InsertOptions = {
24
32
  return?: "minimal" | "representation";
25
33
  };
26
34
 
35
+ import type { InferSchemaOutputFromFMTable } from "../orm/table";
36
+
27
37
  export class InsertBuilder<
28
- T extends Record<string, any>,
29
- Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
38
+ Occ extends FMTable<any, any> | undefined = undefined,
30
39
  ReturnPreference extends "minimal" | "representation" = "representation",
31
40
  > implements
32
41
  ExecutableBuilder<
33
- ReturnPreference extends "minimal" ? { ROWID: number } : T
42
+ ReturnPreference extends "minimal"
43
+ ? { ROWID: number }
44
+ : InferSchemaOutputFromFMTable<NonNullable<Occ>>
34
45
  >
35
46
  {
36
- private occurrence?: Occ;
37
- private tableName: string;
47
+ private table?: Occ;
38
48
  private databaseName: string;
39
49
  private context: ExecutionContext;
40
- private data: Partial<T>;
50
+ private data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
41
51
  private returnPreference: ReturnPreference;
42
52
 
43
53
  private databaseUseEntityIds: boolean;
44
54
 
45
55
  constructor(config: {
46
56
  occurrence?: Occ;
47
- tableName: string;
48
57
  databaseName: string;
49
58
  context: ExecutionContext;
50
- data: Partial<T>;
59
+ data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
51
60
  returnPreference?: ReturnPreference;
52
61
  databaseUseEntityIds?: boolean;
53
62
  }) {
54
- this.occurrence = config.occurrence;
55
- this.tableName = config.tableName;
63
+ this.table = config.occurrence;
56
64
  this.databaseName = config.databaseName;
57
65
  this.context = config.context;
58
66
  this.data = config.data;
@@ -74,7 +82,6 @@ export class InsertBuilder<
74
82
  };
75
83
  }
76
84
 
77
-
78
85
  /**
79
86
  * Parse ROWID from Location header
80
87
  * Expected formats:
@@ -115,24 +122,23 @@ export class InsertBuilder<
115
122
  * @param useEntityIds - Optional override for entity ID usage
116
123
  */
117
124
  private getTableId(useEntityIds?: boolean): string {
118
- if (!this.occurrence) {
119
- return this.tableName;
125
+ if (!this.table) {
126
+ throw new Error("Table occurrence is required");
120
127
  }
121
128
 
122
129
  const contextDefault = this.context._getUseEntityIds?.() ?? false;
123
130
  const shouldUseIds = useEntityIds ?? contextDefault;
124
131
 
125
132
  if (shouldUseIds) {
126
- const identifiers = getTableIdentifiers(this.occurrence);
127
- if (!identifiers.id) {
133
+ if (!isUsingEntityIds(this.table)) {
128
134
  throw new Error(
129
- `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
135
+ `useEntityIds is true but table "${getTableName(this.table)}" does not have entity IDs configured`,
130
136
  );
131
137
  }
132
- return identifiers.id;
138
+ return getTableIdHelper(this.table);
133
139
  }
134
140
 
135
- return this.occurrence.getTableName();
141
+ return getTableName(this.table);
136
142
  }
137
143
 
138
144
  async execute<EO extends ExecuteOptions>(
@@ -142,25 +148,43 @@ export class InsertBuilder<
142
148
  ReturnPreference extends "minimal"
143
149
  ? { ROWID: number }
144
150
  : ConditionallyWithODataAnnotations<
145
- T,
151
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>,
146
152
  EO["includeODataAnnotations"] extends true ? true : false
147
153
  >
148
154
  >
149
155
  > {
150
156
  // Merge database-level useEntityIds with per-request options
151
157
  const mergedOptions = this.mergeExecuteOptions(options);
152
-
158
+
153
159
  // Get table identifier with override support
154
160
  const tableId = this.getTableId(mergedOptions.useEntityIds);
155
161
  const url = `/${this.databaseName}/${tableId}`;
156
162
 
163
+ // Validate and transform input data using input validators (writeValidators)
164
+ let validatedData = this.data;
165
+ if (this.table) {
166
+ const baseTableConfig = getBaseTableConfig(this.table);
167
+ const inputSchema = baseTableConfig.inputSchema;
168
+
169
+ try {
170
+ validatedData = await validateAndTransformInput(this.data, inputSchema);
171
+ } catch (error) {
172
+ // If validation fails, return error immediately
173
+ return {
174
+ data: undefined,
175
+ error: error instanceof Error ? error : new Error(String(error)),
176
+ } as any;
177
+ }
178
+ }
179
+
157
180
  // Transform field names to FMFIDs if using entity IDs
158
181
  // Only transform if useEntityIds resolves to true (respects per-request override)
159
182
  const shouldUseIds = mergedOptions.useEntityIds ?? false;
160
-
161
- const transformedData = this.occurrence?.baseTable && shouldUseIds
162
- ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
163
- : this.data;
183
+
184
+ const transformedData =
185
+ this.table && shouldUseIds
186
+ ? transformFieldNamesToIds(validatedData, this.table)
187
+ : validatedData;
164
188
 
165
189
  // Set Prefer header based on return preference
166
190
  const preferHeader =
@@ -204,19 +228,31 @@ export class InsertBuilder<
204
228
 
205
229
  // Transform response field IDs back to names if using entity IDs
206
230
  // Only transform if useEntityIds resolves to true (respects per-request override)
207
- if (this.occurrence?.baseTable && shouldUseIds) {
231
+ if (this.table && shouldUseIds) {
208
232
  response = transformResponseFields(
209
233
  response,
210
- this.occurrence.baseTable,
234
+ this.table,
211
235
  undefined, // No expand configs for insert
212
236
  );
213
237
  }
214
238
 
215
- // Get schema from occurrence if available
216
- const schema = this.occurrence?.baseTable?.schema;
239
+ // Get schema from table if available, excluding container fields
240
+ let schema: Record<string, any> | undefined;
241
+ if (this.table) {
242
+ const baseTableConfig = getBaseTableConfig(this.table);
243
+ const containerFields = baseTableConfig.containerFields || [];
244
+
245
+ // Filter out container fields from schema
246
+ schema = { ...baseTableConfig.schema };
247
+ for (const containerField of containerFields) {
248
+ delete schema[containerField as string];
249
+ }
250
+ }
217
251
 
218
252
  // Validate the response (FileMaker returns the created record)
219
- const validation = await validateSingleResponse<T>(
253
+ const validation = await validateSingleResponse<
254
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>
255
+ >(
220
256
  response,
221
257
  schema,
222
258
  undefined, // No selected fields for insert
@@ -241,12 +277,14 @@ export class InsertBuilder<
241
277
 
242
278
  getRequestConfig(): { method: string; url: string; body?: any } {
243
279
  // For batch operations, use database-level setting (no per-request override available here)
280
+ // Note: Input validation happens in execute() and processResponse() for batch operations
244
281
  const tableId = this.getTableId(this.databaseUseEntityIds);
245
282
 
246
283
  // Transform field names to FMFIDs if using entity IDs
247
- const transformedData = this.occurrence?.baseTable && this.databaseUseEntityIds
248
- ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
249
- : this.data;
284
+ const transformedData =
285
+ this.table && this.databaseUseEntityIds
286
+ ? transformFieldNamesToIds(this.data, this.table)
287
+ : this.data;
250
288
 
251
289
  return {
252
290
  method: "POST",
@@ -280,8 +318,22 @@ export class InsertBuilder<
280
318
  response: Response,
281
319
  options?: ExecuteOptions,
282
320
  ): Promise<
283
- Result<ReturnPreference extends "minimal" ? { ROWID: number } : T>
321
+ Result<
322
+ ReturnPreference extends "minimal"
323
+ ? { ROWID: number }
324
+ : InferSchemaOutputFromFMTable<NonNullable<Occ>>
325
+ >
284
326
  > {
327
+ // Check for error responses (important for batch operations)
328
+ if (!response.ok) {
329
+ const tableName = this.table ? getTableName(this.table) : "unknown";
330
+ const error = await parseErrorResponse(
331
+ response,
332
+ response.url || `/${this.databaseName}/${tableName}`,
333
+ );
334
+ return { data: undefined, error };
335
+ }
336
+
285
337
  // Handle 204 No Content (common in batch/changeset operations)
286
338
  // FileMaker uses return=minimal for changeset operations regardless of Prefer header
287
339
  if (response.status === 204) {
@@ -336,24 +388,53 @@ export class InsertBuilder<
336
388
  };
337
389
  }
338
390
 
391
+ // Validate and transform input data using input validators (writeValidators)
392
+ // This is needed for processResponse because it's called from batch operations
393
+ // where the data hasn't been validated yet
394
+ let validatedData = this.data;
395
+ if (this.table) {
396
+ const baseTableConfig = getBaseTableConfig(this.table);
397
+ const inputSchema = baseTableConfig.inputSchema;
398
+ try {
399
+ validatedData = await validateAndTransformInput(this.data, inputSchema);
400
+ } catch (error) {
401
+ return {
402
+ data: undefined,
403
+ error: error instanceof Error ? error : new Error(String(error)),
404
+ } as any;
405
+ }
406
+ }
407
+
339
408
  // Transform response field IDs back to names if using entity IDs
340
409
  // Only transform if useEntityIds resolves to true (respects per-request override)
341
410
  const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
342
-
411
+
343
412
  let transformedResponse = rawResponse;
344
- if (this.occurrence?.baseTable && shouldUseIds) {
413
+ if (this.table && shouldUseIds) {
345
414
  transformedResponse = transformResponseFields(
346
415
  rawResponse,
347
- this.occurrence.baseTable,
416
+ this.table,
348
417
  undefined, // No expand configs for insert
349
418
  );
350
419
  }
351
420
 
352
- // Get schema from occurrence if available
353
- const schema = this.occurrence?.baseTable?.schema;
421
+ // Get schema from table if available, excluding container fields
422
+ let schema: Record<string, any> | undefined;
423
+ if (this.table) {
424
+ const baseTableConfig = getBaseTableConfig(this.table);
425
+ const containerFields = baseTableConfig.containerFields || [];
426
+
427
+ // Filter out container fields from schema
428
+ schema = { ...baseTableConfig.schema };
429
+ for (const containerField of containerFields) {
430
+ delete schema[containerField as string];
431
+ }
432
+ }
354
433
 
355
434
  // Validate the response (FileMaker returns the created record)
356
- const validation = await validateSingleResponse<T>(
435
+ const validation = await validateSingleResponse<
436
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>
437
+ >(
357
438
  transformedResponse,
358
439
  schema,
359
440
  undefined, // No selected fields for insert
@@ -0,0 +1,164 @@
1
+ import { QueryOptions } from "odata-query";
2
+ import buildQuery from "odata-query";
3
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
4
+ import { FMTable } from "../../orm/table";
5
+ import type { ExpandValidationConfig } from "../../validation";
6
+ import { formatSelectFields } from "../builders/select-utils";
7
+
8
+ /**
9
+ * Internal type for expand configuration
10
+ */
11
+ export type ExpandConfig = {
12
+ relation: string;
13
+ options?: Partial<QueryOptions<any>>;
14
+ targetTable?: FMTable<any, any>;
15
+ };
16
+
17
+ /**
18
+ * Builds OData expand query strings and validation configs.
19
+ * Handles nested expands recursively and transforms relation names to FMTIDs
20
+ * when using entity IDs.
21
+ */
22
+ export class ExpandBuilder {
23
+ constructor(private useEntityIds: boolean) {}
24
+
25
+ /**
26
+ * Builds OData expand query string from expand configurations.
27
+ * Handles nested expands recursively.
28
+ * Transforms relation names to FMTIDs if using entity IDs.
29
+ */
30
+ buildExpandString(configs: ExpandConfig[]): string {
31
+ if (configs.length === 0) {
32
+ return "";
33
+ }
34
+
35
+ return configs.map((config) => this.buildSingleExpand(config)).join(",");
36
+ }
37
+
38
+ /**
39
+ * Builds a single expand string with its options.
40
+ */
41
+ private buildSingleExpand(config: ExpandConfig): string {
42
+ // Get target table/occurrence from config (stored during expand call)
43
+ const targetTable = config.targetTable;
44
+
45
+ // When using entity IDs, use the target table's FMTID in the expand parameter
46
+ // FileMaker expects FMTID in $expand when Prefer header is set
47
+ // Only use FMTID if databaseUseEntityIds is enabled
48
+ let relationName = config.relation;
49
+ if (this.useEntityIds) {
50
+ if (targetTable && FMTable.Symbol.EntityId in targetTable) {
51
+ const tableId = (targetTable as any)[FMTable.Symbol.EntityId] as
52
+ | `FMTID:${string}`
53
+ | undefined;
54
+ if (tableId) {
55
+ relationName = tableId;
56
+ }
57
+ }
58
+ }
59
+
60
+ if (!config.options || Object.keys(config.options).length === 0) {
61
+ // Simple expand without options
62
+ return relationName;
63
+ }
64
+
65
+ // Build query options for this expand
66
+ const parts: string[] = [];
67
+
68
+ if (config.options.select) {
69
+ // Use shared formatSelectFields function for consistent id field quoting
70
+ const selectArray = Array.isArray(config.options.select)
71
+ ? config.options.select.map(String)
72
+ : [String(config.options.select)];
73
+ const selectFields = formatSelectFields(
74
+ selectArray,
75
+ targetTable,
76
+ this.useEntityIds,
77
+ );
78
+ parts.push(`$select=${selectFields}`);
79
+ }
80
+
81
+ if (config.options.filter) {
82
+ // Filter should already be transformed by the nested builder
83
+ // Use odata-query to build filter string
84
+ const filterQuery = buildQuery({ filter: config.options.filter });
85
+ const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
86
+ if (filterMatch) {
87
+ parts.push(`$filter=${filterMatch[1]}`);
88
+ }
89
+ }
90
+
91
+ if (config.options.orderBy) {
92
+ // OrderBy should already be transformed by the nested builder
93
+ const orderByValue = Array.isArray(config.options.orderBy)
94
+ ? config.options.orderBy.join(",")
95
+ : config.options.orderBy;
96
+ parts.push(`$orderby=${String(orderByValue)}`);
97
+ }
98
+
99
+ if (config.options.top !== undefined) {
100
+ parts.push(`$top=${config.options.top}`);
101
+ }
102
+
103
+ if (config.options.skip !== undefined) {
104
+ parts.push(`$skip=${config.options.skip}`);
105
+ }
106
+
107
+ // Handle nested expands (from expand configs)
108
+ if (config.options.expand) {
109
+ // If expand is a string, it's already been built
110
+ if (typeof config.options.expand === "string") {
111
+ parts.push(`$expand=${config.options.expand}`);
112
+ }
113
+ }
114
+
115
+ if (parts.length === 0) {
116
+ return relationName;
117
+ }
118
+
119
+ return `${relationName}(${parts.join(";")})`;
120
+ }
121
+
122
+ /**
123
+ * Builds expand validation configs from internal expand configurations.
124
+ * These are used to validate expanded navigation properties.
125
+ */
126
+ buildValidationConfigs(configs: ExpandConfig[]): ExpandValidationConfig[] {
127
+ return configs.map((config) => {
128
+ // Get target table/occurrence from config (stored during expand call)
129
+ const targetTable = config.targetTable;
130
+
131
+ // Extract schema from target table/occurrence
132
+ let targetSchema: Record<string, StandardSchemaV1> | undefined;
133
+ if (targetTable) {
134
+ const tableSchema = (targetTable as any)[FMTable.Symbol.Schema];
135
+ if (tableSchema) {
136
+ const zodSchema = tableSchema["~standard"]?.schema;
137
+ if (
138
+ zodSchema &&
139
+ typeof zodSchema === "object" &&
140
+ "shape" in zodSchema
141
+ ) {
142
+ targetSchema = zodSchema.shape as Record<string, StandardSchemaV1>;
143
+ }
144
+ }
145
+ }
146
+
147
+ // Extract selected fields from options
148
+ const selectedFields = config.options?.select
149
+ ? Array.isArray(config.options.select)
150
+ ? config.options.select.map((f) => String(f))
151
+ : [String(config.options.select)]
152
+ : undefined;
153
+
154
+ return {
155
+ relation: config.relation,
156
+ targetSchema: targetSchema,
157
+ targetTable: targetTable,
158
+ table: targetTable, // For transformation
159
+ selectedFields: selectedFields,
160
+ nestedExpands: undefined, // TODO: Handle nested expands if needed
161
+ };
162
+ });
163
+ }
164
+ }
@@ -0,0 +1,13 @@
1
+ // Re-export QueryBuilder as the main export
2
+ export { QueryBuilder } from "./query-builder";
3
+
4
+ // Export types
5
+ export type {
6
+ TypeSafeOrderBy,
7
+ ExpandedRelations,
8
+ QueryReturnType,
9
+ } from "./query-builder";
10
+
11
+ // Export ExpandConfig from expand-builder
12
+ export type { ExpandConfig } from "./expand-builder";
13
+