@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,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
 
@@ -6,53 +6,62 @@ import type {
6
6
  InferSchemaType,
7
7
  ExecuteOptions,
8
8
  ConditionallyWithODataAnnotations,
9
+ ExecuteMethodOptions,
9
10
  } from "../types";
10
11
  import { getAcceptHeader } from "../types";
11
- import type { TableOccurrence } from "./table-occurrence";
12
- import { validateSingleResponse } from "../validation";
12
+ import type { FMTable } from "../orm/table";
13
+ import {
14
+ getBaseTableConfig,
15
+ getTableName,
16
+ getTableId as getTableIdHelper,
17
+ isUsingEntityIds,
18
+ } from "../orm/table";
19
+ import {
20
+ validateSingleResponse,
21
+ validateAndTransformInput,
22
+ } from "../validation";
13
23
  import { type FFetchOptions } from "@fetchkit/ffetch";
14
24
  import {
15
25
  transformFieldNamesToIds,
16
- transformTableName,
17
26
  transformResponseFields,
18
- getTableIdentifiers,
19
27
  } from "../transform";
20
28
  import { InvalidLocationHeaderError } from "../errors";
21
29
  import { safeJsonParse } from "./sanitize-json";
30
+ import { parseErrorResponse } from "./error-parser";
22
31
 
23
32
  export type InsertOptions = {
24
33
  return?: "minimal" | "representation";
25
34
  };
26
35
 
36
+ import type { InferSchemaOutputFromFMTable } from "../orm/table";
37
+
27
38
  export class InsertBuilder<
28
- T extends Record<string, any>,
29
- Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
39
+ Occ extends FMTable<any, any> | undefined = undefined,
30
40
  ReturnPreference extends "minimal" | "representation" = "representation",
31
41
  > implements
32
42
  ExecutableBuilder<
33
- ReturnPreference extends "minimal" ? { ROWID: number } : T
43
+ ReturnPreference extends "minimal"
44
+ ? { ROWID: number }
45
+ : InferSchemaOutputFromFMTable<NonNullable<Occ>>
34
46
  >
35
47
  {
36
- private occurrence?: Occ;
37
- private tableName: string;
48
+ private table?: Occ;
38
49
  private databaseName: string;
39
50
  private context: ExecutionContext;
40
- private data: Partial<T>;
51
+ private data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
41
52
  private returnPreference: ReturnPreference;
42
53
 
43
54
  private databaseUseEntityIds: boolean;
44
55
 
45
56
  constructor(config: {
46
57
  occurrence?: Occ;
47
- tableName: string;
48
58
  databaseName: string;
49
59
  context: ExecutionContext;
50
- data: Partial<T>;
60
+ data: Partial<InferSchemaOutputFromFMTable<NonNullable<Occ>>>;
51
61
  returnPreference?: ReturnPreference;
52
62
  databaseUseEntityIds?: boolean;
53
63
  }) {
54
- this.occurrence = config.occurrence;
55
- this.tableName = config.tableName;
64
+ this.table = config.occurrence;
56
65
  this.databaseName = config.databaseName;
57
66
  this.context = config.context;
58
67
  this.data = config.data;
@@ -74,7 +83,6 @@ export class InsertBuilder<
74
83
  };
75
84
  }
76
85
 
77
-
78
86
  /**
79
87
  * Parse ROWID from Location header
80
88
  * Expected formats:
@@ -115,52 +123,69 @@ export class InsertBuilder<
115
123
  * @param useEntityIds - Optional override for entity ID usage
116
124
  */
117
125
  private getTableId(useEntityIds?: boolean): string {
118
- if (!this.occurrence) {
119
- return this.tableName;
126
+ if (!this.table) {
127
+ throw new Error("Table occurrence is required");
120
128
  }
121
129
 
122
130
  const contextDefault = this.context._getUseEntityIds?.() ?? false;
123
131
  const shouldUseIds = useEntityIds ?? contextDefault;
124
132
 
125
133
  if (shouldUseIds) {
126
- const identifiers = getTableIdentifiers(this.occurrence);
127
- if (!identifiers.id) {
134
+ if (!isUsingEntityIds(this.table)) {
128
135
  throw new Error(
129
- `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
136
+ `useEntityIds is true but table "${getTableName(this.table)}" does not have entity IDs configured`,
130
137
  );
131
138
  }
132
- return identifiers.id;
139
+ return getTableIdHelper(this.table);
133
140
  }
134
141
 
135
- return this.occurrence.getTableName();
142
+ return getTableName(this.table);
136
143
  }
137
144
 
138
145
  async execute<EO extends ExecuteOptions>(
139
- options?: RequestInit & FFetchOptions & EO,
146
+ options?: ExecuteMethodOptions<EO>,
140
147
  ): Promise<
141
148
  Result<
142
149
  ReturnPreference extends "minimal"
143
150
  ? { ROWID: number }
144
151
  : ConditionallyWithODataAnnotations<
145
- T,
152
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>,
146
153
  EO["includeODataAnnotations"] extends true ? true : false
147
154
  >
148
155
  >
149
156
  > {
150
157
  // Merge database-level useEntityIds with per-request options
151
158
  const mergedOptions = this.mergeExecuteOptions(options);
152
-
159
+
153
160
  // Get table identifier with override support
154
161
  const tableId = this.getTableId(mergedOptions.useEntityIds);
155
162
  const url = `/${this.databaseName}/${tableId}`;
156
163
 
164
+ // Validate and transform input data using input validators (writeValidators)
165
+ let validatedData = this.data;
166
+ if (this.table) {
167
+ const baseTableConfig = getBaseTableConfig(this.table);
168
+ const inputSchema = baseTableConfig.inputSchema;
169
+
170
+ try {
171
+ validatedData = await validateAndTransformInput(this.data, inputSchema);
172
+ } catch (error) {
173
+ // If validation fails, return error immediately
174
+ return {
175
+ data: undefined,
176
+ error: error instanceof Error ? error : new Error(String(error)),
177
+ } as any;
178
+ }
179
+ }
180
+
157
181
  // Transform field names to FMFIDs if using entity IDs
158
182
  // Only transform if useEntityIds resolves to true (respects per-request override)
159
183
  const shouldUseIds = mergedOptions.useEntityIds ?? false;
160
-
161
- const transformedData = this.occurrence?.baseTable && shouldUseIds
162
- ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
163
- : this.data;
184
+
185
+ const transformedData =
186
+ this.table && shouldUseIds
187
+ ? transformFieldNamesToIds(validatedData, this.table)
188
+ : validatedData;
164
189
 
165
190
  // Set Prefer header based on return preference
166
191
  const preferHeader =
@@ -204,19 +229,31 @@ export class InsertBuilder<
204
229
 
205
230
  // Transform response field IDs back to names if using entity IDs
206
231
  // Only transform if useEntityIds resolves to true (respects per-request override)
207
- if (this.occurrence?.baseTable && shouldUseIds) {
232
+ if (this.table && shouldUseIds) {
208
233
  response = transformResponseFields(
209
234
  response,
210
- this.occurrence.baseTable,
235
+ this.table,
211
236
  undefined, // No expand configs for insert
212
237
  );
213
238
  }
214
239
 
215
- // Get schema from occurrence if available
216
- const schema = this.occurrence?.baseTable?.schema;
240
+ // Get schema from table if available, excluding container fields
241
+ let schema: Record<string, any> | undefined;
242
+ if (this.table) {
243
+ const baseTableConfig = getBaseTableConfig(this.table);
244
+ const containerFields = baseTableConfig.containerFields || [];
245
+
246
+ // Filter out container fields from schema
247
+ schema = { ...baseTableConfig.schema };
248
+ for (const containerField of containerFields) {
249
+ delete schema[containerField as string];
250
+ }
251
+ }
217
252
 
218
253
  // Validate the response (FileMaker returns the created record)
219
- const validation = await validateSingleResponse<T>(
254
+ const validation = await validateSingleResponse<
255
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>
256
+ >(
220
257
  response,
221
258
  schema,
222
259
  undefined, // No selected fields for insert
@@ -241,12 +278,14 @@ export class InsertBuilder<
241
278
 
242
279
  getRequestConfig(): { method: string; url: string; body?: any } {
243
280
  // For batch operations, use database-level setting (no per-request override available here)
281
+ // Note: Input validation happens in execute() and processResponse() for batch operations
244
282
  const tableId = this.getTableId(this.databaseUseEntityIds);
245
283
 
246
284
  // 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;
285
+ const transformedData =
286
+ this.table && this.databaseUseEntityIds
287
+ ? transformFieldNamesToIds(this.data, this.table)
288
+ : this.data;
250
289
 
251
290
  return {
252
291
  method: "POST",
@@ -280,8 +319,22 @@ export class InsertBuilder<
280
319
  response: Response,
281
320
  options?: ExecuteOptions,
282
321
  ): Promise<
283
- Result<ReturnPreference extends "minimal" ? { ROWID: number } : T>
322
+ Result<
323
+ ReturnPreference extends "minimal"
324
+ ? { ROWID: number }
325
+ : InferSchemaOutputFromFMTable<NonNullable<Occ>>
326
+ >
284
327
  > {
328
+ // Check for error responses (important for batch operations)
329
+ if (!response.ok) {
330
+ const tableName = this.table ? getTableName(this.table) : "unknown";
331
+ const error = await parseErrorResponse(
332
+ response,
333
+ response.url || `/${this.databaseName}/${tableName}`,
334
+ );
335
+ return { data: undefined, error };
336
+ }
337
+
285
338
  // Handle 204 No Content (common in batch/changeset operations)
286
339
  // FileMaker uses return=minimal for changeset operations regardless of Prefer header
287
340
  if (response.status === 204) {
@@ -336,24 +389,53 @@ export class InsertBuilder<
336
389
  };
337
390
  }
338
391
 
392
+ // Validate and transform input data using input validators (writeValidators)
393
+ // This is needed for processResponse because it's called from batch operations
394
+ // where the data hasn't been validated yet
395
+ let validatedData = this.data;
396
+ if (this.table) {
397
+ const baseTableConfig = getBaseTableConfig(this.table);
398
+ const inputSchema = baseTableConfig.inputSchema;
399
+ try {
400
+ validatedData = await validateAndTransformInput(this.data, inputSchema);
401
+ } catch (error) {
402
+ return {
403
+ data: undefined,
404
+ error: error instanceof Error ? error : new Error(String(error)),
405
+ } as any;
406
+ }
407
+ }
408
+
339
409
  // Transform response field IDs back to names if using entity IDs
340
410
  // Only transform if useEntityIds resolves to true (respects per-request override)
341
411
  const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
342
-
412
+
343
413
  let transformedResponse = rawResponse;
344
- if (this.occurrence?.baseTable && shouldUseIds) {
414
+ if (this.table && shouldUseIds) {
345
415
  transformedResponse = transformResponseFields(
346
416
  rawResponse,
347
- this.occurrence.baseTable,
417
+ this.table,
348
418
  undefined, // No expand configs for insert
349
419
  );
350
420
  }
351
421
 
352
- // Get schema from occurrence if available
353
- const schema = this.occurrence?.baseTable?.schema;
422
+ // Get schema from table if available, excluding container fields
423
+ let schema: Record<string, any> | undefined;
424
+ if (this.table) {
425
+ const baseTableConfig = getBaseTableConfig(this.table);
426
+ const containerFields = baseTableConfig.containerFields || [];
427
+
428
+ // Filter out container fields from schema
429
+ schema = { ...baseTableConfig.schema };
430
+ for (const containerField of containerFields) {
431
+ delete schema[containerField as string];
432
+ }
433
+ }
354
434
 
355
435
  // Validate the response (FileMaker returns the created record)
356
- const validation = await validateSingleResponse<T>(
436
+ const validation = await validateSingleResponse<
437
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>
438
+ >(
357
439
  transformedResponse,
358
440
  schema,
359
441
  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
+