@proofkit/fmodata 0.1.0-alpha.0 → 0.1.0-alpha.10
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.
- package/README.md +1624 -18
- package/dist/esm/client/base-table.d.ts +117 -5
- package/dist/esm/client/base-table.js +43 -5
- package/dist/esm/client/base-table.js.map +1 -1
- package/dist/esm/client/batch-builder.d.ts +54 -0
- package/dist/esm/client/batch-builder.js +179 -0
- package/dist/esm/client/batch-builder.js.map +1 -0
- package/dist/esm/client/batch-request.d.ts +61 -0
- package/dist/esm/client/batch-request.js +252 -0
- package/dist/esm/client/batch-request.js.map +1 -0
- package/dist/esm/client/database.d.ts +55 -6
- package/dist/esm/client/database.js +118 -15
- package/dist/esm/client/database.js.map +1 -1
- package/dist/esm/client/delete-builder.d.ts +21 -2
- package/dist/esm/client/delete-builder.js +96 -32
- package/dist/esm/client/delete-builder.js.map +1 -1
- package/dist/esm/client/entity-set.d.ts +25 -11
- package/dist/esm/client/entity-set.js +31 -11
- package/dist/esm/client/entity-set.js.map +1 -1
- package/dist/esm/client/filemaker-odata.d.ts +23 -4
- package/dist/esm/client/filemaker-odata.js +124 -29
- package/dist/esm/client/filemaker-odata.js.map +1 -1
- package/dist/esm/client/insert-builder.d.ts +38 -3
- package/dist/esm/client/insert-builder.js +231 -34
- package/dist/esm/client/insert-builder.js.map +1 -1
- package/dist/esm/client/query-builder.d.ts +27 -6
- package/dist/esm/client/query-builder.js +457 -210
- package/dist/esm/client/query-builder.js.map +1 -1
- package/dist/esm/client/record-builder.d.ts +96 -9
- package/dist/esm/client/record-builder.js +378 -39
- package/dist/esm/client/record-builder.js.map +1 -1
- package/dist/esm/client/response-processor.d.ts +38 -0
- package/dist/esm/client/schema-manager.d.ts +57 -0
- package/dist/esm/client/schema-manager.js +132 -0
- package/dist/esm/client/schema-manager.js.map +1 -0
- package/dist/esm/client/table-occurrence.d.ts +48 -1
- package/dist/esm/client/table-occurrence.js +29 -2
- package/dist/esm/client/table-occurrence.js.map +1 -1
- package/dist/esm/client/update-builder.d.ts +34 -11
- package/dist/esm/client/update-builder.js +135 -31
- package/dist/esm/client/update-builder.js.map +1 -1
- package/dist/esm/errors.d.ts +73 -0
- package/dist/esm/errors.js +148 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +10 -3
- package/dist/esm/index.js +28 -5
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/transform.d.ts +65 -0
- package/dist/esm/transform.js +114 -0
- package/dist/esm/transform.js.map +1 -0
- package/dist/esm/types.d.ts +89 -5
- package/dist/esm/validation.d.ts +6 -3
- package/dist/esm/validation.js +104 -33
- package/dist/esm/validation.js.map +1 -1
- package/package.json +10 -1
- package/src/client/base-table.ts +158 -8
- package/src/client/batch-builder.ts +265 -0
- package/src/client/batch-request.ts +485 -0
- package/src/client/database.ts +175 -18
- package/src/client/delete-builder.ts +149 -48
- package/src/client/entity-set.ts +114 -23
- package/src/client/filemaker-odata.ts +179 -35
- package/src/client/insert-builder.ts +350 -40
- package/src/client/query-builder.ts +616 -237
- package/src/client/query-builder.ts.bak +1457 -0
- package/src/client/record-builder.ts +692 -65
- package/src/client/response-processor.ts +103 -0
- package/src/client/schema-manager.ts +246 -0
- package/src/client/table-occurrence.ts +78 -3
- package/src/client/update-builder.ts +235 -49
- package/src/errors.ts +217 -0
- package/src/index.ts +59 -2
- package/src/transform.ts +249 -0
- package/src/types.ts +201 -35
- package/src/validation.ts +120 -36
|
@@ -5,18 +5,29 @@ import type {
|
|
|
5
5
|
ODataRecordMetadata,
|
|
6
6
|
ODataFieldResponse,
|
|
7
7
|
InferSchemaType,
|
|
8
|
+
ExecuteOptions,
|
|
9
|
+
WithSystemFields,
|
|
10
|
+
ConditionallyWithODataAnnotations,
|
|
8
11
|
} from "../types";
|
|
9
12
|
import type { TableOccurrence } from "./table-occurrence";
|
|
10
13
|
import type { BaseTable } from "./base-table";
|
|
14
|
+
import {
|
|
15
|
+
transformTableName,
|
|
16
|
+
transformResponseFields,
|
|
17
|
+
getTableIdentifiers,
|
|
18
|
+
transformFieldNamesArray,
|
|
19
|
+
} from "../transform";
|
|
11
20
|
import { QueryBuilder } from "./query-builder";
|
|
12
|
-
import { validateSingleResponse } from "../validation";
|
|
21
|
+
import { validateSingleResponse, type ExpandValidationConfig } from "../validation";
|
|
13
22
|
import { type FFetchOptions } from "@fetchkit/ffetch";
|
|
14
|
-
import {
|
|
23
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
24
|
+
import { QueryOptions } from "odata-query";
|
|
25
|
+
import buildQuery from "odata-query";
|
|
15
26
|
|
|
16
27
|
// Helper type to extract schema from a TableOccurrence
|
|
17
28
|
type ExtractSchemaFromOccurrence<O> =
|
|
18
29
|
O extends TableOccurrence<infer BT, any, any, any>
|
|
19
|
-
? BT extends BaseTable<infer S, any>
|
|
30
|
+
? BT extends BaseTable<infer S, any, any, any>
|
|
20
31
|
? S
|
|
21
32
|
: never
|
|
22
33
|
: never;
|
|
@@ -27,7 +38,7 @@ type ExtractNavigationNames<
|
|
|
27
38
|
> =
|
|
28
39
|
O extends TableOccurrence<any, any, infer Nav, any>
|
|
29
40
|
? Nav extends Record<string, any>
|
|
30
|
-
? keyof Nav
|
|
41
|
+
? keyof Nav & string
|
|
31
42
|
: never
|
|
32
43
|
: never;
|
|
33
44
|
|
|
@@ -40,10 +51,66 @@ type FindNavigationTarget<
|
|
|
40
51
|
Name extends string,
|
|
41
52
|
> =
|
|
42
53
|
O extends TableOccurrence<any, any, infer Nav, any>
|
|
43
|
-
?
|
|
44
|
-
?
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
? Nav extends Record<string, any>
|
|
55
|
+
? Name extends keyof Nav
|
|
56
|
+
? ResolveNavigationItem<Nav[Name]>
|
|
57
|
+
: TableOccurrence<
|
|
58
|
+
BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
|
|
59
|
+
any,
|
|
60
|
+
any,
|
|
61
|
+
any
|
|
62
|
+
>
|
|
63
|
+
: TableOccurrence<
|
|
64
|
+
BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
|
|
65
|
+
any,
|
|
66
|
+
any,
|
|
67
|
+
any
|
|
68
|
+
>
|
|
69
|
+
: TableOccurrence<
|
|
70
|
+
BaseTable<Record<string, StandardSchemaV1>, any, any, any>,
|
|
71
|
+
any,
|
|
72
|
+
any,
|
|
73
|
+
any
|
|
74
|
+
>;
|
|
75
|
+
|
|
76
|
+
// Helper type to get the inferred schema type from a target occurrence
|
|
77
|
+
type GetTargetSchemaType<
|
|
78
|
+
O extends TableOccurrence<any, any, any, any> | undefined,
|
|
79
|
+
Rel extends string,
|
|
80
|
+
> = [FindNavigationTarget<O, Rel>] extends [
|
|
81
|
+
TableOccurrence<infer BT, any, any, any>,
|
|
82
|
+
]
|
|
83
|
+
? [BT] extends [BaseTable<infer S, any, any, any>]
|
|
84
|
+
? [S] extends [Record<string, StandardSchemaV1>]
|
|
85
|
+
? InferSchemaType<S>
|
|
86
|
+
: Record<string, any>
|
|
87
|
+
: Record<string, any>
|
|
88
|
+
: Record<string, any>;
|
|
89
|
+
|
|
90
|
+
// Internal type for expand configuration
|
|
91
|
+
type ExpandConfig = {
|
|
92
|
+
relation: string;
|
|
93
|
+
options?: Partial<QueryOptions<any>>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Type to represent expanded relations
|
|
97
|
+
export type ExpandedRelations = Record<string, { schema: any; selected: any }>;
|
|
98
|
+
|
|
99
|
+
// Return type for RecordBuilder execute
|
|
100
|
+
export type RecordReturnType<
|
|
101
|
+
T extends Record<string, any>,
|
|
102
|
+
IsSingleField extends boolean,
|
|
103
|
+
FieldKey extends keyof T,
|
|
104
|
+
Selected extends keyof T,
|
|
105
|
+
Expands extends ExpandedRelations,
|
|
106
|
+
> = IsSingleField extends true
|
|
107
|
+
? T[FieldKey]
|
|
108
|
+
: Pick<T, Selected> & {
|
|
109
|
+
[K in keyof Expands]: Pick<
|
|
110
|
+
Expands[K]["schema"],
|
|
111
|
+
Expands[K]["selected"]
|
|
112
|
+
>[];
|
|
113
|
+
};
|
|
47
114
|
|
|
48
115
|
export class RecordBuilder<
|
|
49
116
|
T extends Record<string, any>,
|
|
@@ -52,9 +119,11 @@ export class RecordBuilder<
|
|
|
52
119
|
Occ extends TableOccurrence<any, any, any, any> | undefined =
|
|
53
120
|
| TableOccurrence<any, any, any, any>
|
|
54
121
|
| undefined,
|
|
122
|
+
Selected extends keyof T = keyof T,
|
|
123
|
+
Expands extends ExpandedRelations = {},
|
|
55
124
|
> implements
|
|
56
125
|
ExecutableBuilder<
|
|
57
|
-
IsSingleField
|
|
126
|
+
RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>
|
|
58
127
|
>
|
|
59
128
|
{
|
|
60
129
|
private occurrence?: Occ;
|
|
@@ -68,27 +137,76 @@ export class RecordBuilder<
|
|
|
68
137
|
private navigateRelation?: string;
|
|
69
138
|
private navigateSourceTableName?: string;
|
|
70
139
|
|
|
140
|
+
private databaseUseEntityIds: boolean;
|
|
141
|
+
|
|
142
|
+
// New properties for select/expand support
|
|
143
|
+
private selectedFields?: string[];
|
|
144
|
+
private expandConfigs: ExpandConfig[] = [];
|
|
145
|
+
|
|
71
146
|
constructor(config: {
|
|
72
147
|
occurrence?: Occ;
|
|
73
148
|
tableName: string;
|
|
74
149
|
databaseName: string;
|
|
75
150
|
context: ExecutionContext;
|
|
76
151
|
recordId: string | number;
|
|
152
|
+
databaseUseEntityIds?: boolean;
|
|
77
153
|
}) {
|
|
78
154
|
this.occurrence = config.occurrence;
|
|
79
155
|
this.tableName = config.tableName;
|
|
80
156
|
this.databaseName = config.databaseName;
|
|
81
157
|
this.context = config.context;
|
|
82
158
|
this.recordId = config.recordId;
|
|
159
|
+
this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Helper to merge database-level useEntityIds with per-request options
|
|
164
|
+
*/
|
|
165
|
+
private mergeExecuteOptions(
|
|
166
|
+
options?: RequestInit & FFetchOptions & ExecuteOptions,
|
|
167
|
+
): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
|
|
168
|
+
// If useEntityIds is not set in options, use the database-level setting
|
|
169
|
+
return {
|
|
170
|
+
...options,
|
|
171
|
+
useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
|
|
177
|
+
* @param useEntityIds - Optional override for entity ID usage
|
|
178
|
+
*/
|
|
179
|
+
private getTableId(useEntityIds?: boolean): string {
|
|
180
|
+
if (!this.occurrence) {
|
|
181
|
+
return this.tableName;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const contextDefault = this.context._getUseEntityIds?.() ?? false;
|
|
185
|
+
const shouldUseIds = useEntityIds ?? contextDefault;
|
|
186
|
+
|
|
187
|
+
if (shouldUseIds) {
|
|
188
|
+
const identifiers = getTableIdentifiers(this.occurrence);
|
|
189
|
+
if (!identifiers.id) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return identifiers.id;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return this.occurrence.getTableName();
|
|
83
198
|
}
|
|
84
199
|
|
|
85
|
-
getSingleField<K extends keyof T>(
|
|
86
|
-
|
|
200
|
+
getSingleField<K extends keyof T>(
|
|
201
|
+
field: K,
|
|
202
|
+
): RecordBuilder<T, true, K, Occ, keyof T, {}> {
|
|
203
|
+
const newBuilder = new RecordBuilder<T, true, K, Occ, keyof T, {}>({
|
|
87
204
|
occurrence: this.occurrence,
|
|
88
205
|
tableName: this.tableName,
|
|
89
206
|
databaseName: this.databaseName,
|
|
90
207
|
context: this.context,
|
|
91
208
|
recordId: this.recordId,
|
|
209
|
+
databaseUseEntityIds: this.databaseUseEntityIds,
|
|
92
210
|
});
|
|
93
211
|
newBuilder.operation = "getSingleField";
|
|
94
212
|
newBuilder.operationParam = field.toString();
|
|
@@ -99,13 +217,184 @@ export class RecordBuilder<
|
|
|
99
217
|
return newBuilder;
|
|
100
218
|
}
|
|
101
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Select specific fields to retrieve from the record.
|
|
222
|
+
* Only the selected fields will be returned in the response.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```typescript
|
|
226
|
+
* const contact = await db.from("contacts").get("uuid").select("name", "email").execute();
|
|
227
|
+
* // contact.data has type { name: string; email: string }
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
select<K extends keyof T>(
|
|
231
|
+
...fields: K[]
|
|
232
|
+
): RecordBuilder<T, false, FieldKey, Occ, K, Expands> {
|
|
233
|
+
const uniqueFields = [...new Set(fields)];
|
|
234
|
+
const newBuilder = new RecordBuilder<T, false, FieldKey, Occ, K, Expands>({
|
|
235
|
+
occurrence: this.occurrence,
|
|
236
|
+
tableName: this.tableName,
|
|
237
|
+
databaseName: this.databaseName,
|
|
238
|
+
context: this.context,
|
|
239
|
+
recordId: this.recordId,
|
|
240
|
+
databaseUseEntityIds: this.databaseUseEntityIds,
|
|
241
|
+
});
|
|
242
|
+
newBuilder.selectedFields = uniqueFields.map((f) => String(f));
|
|
243
|
+
newBuilder.expandConfigs = [...this.expandConfigs];
|
|
244
|
+
// Preserve navigation context
|
|
245
|
+
newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
|
|
246
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
247
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
248
|
+
return newBuilder;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Expand a navigation property to include related records.
|
|
253
|
+
* Supports nested select, filter, orderBy, and expand operations.
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```typescript
|
|
257
|
+
* // Simple expand
|
|
258
|
+
* const contact = await db.from("contacts").get("uuid").expand("users").execute();
|
|
259
|
+
*
|
|
260
|
+
* // Expand with select
|
|
261
|
+
* const contact = await db.from("contacts").get("uuid")
|
|
262
|
+
* .expand("users", b => b.select("username", "email"))
|
|
263
|
+
* .execute();
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
expand<
|
|
267
|
+
Rel extends ExtractNavigationNames<Occ> | (string & {}),
|
|
268
|
+
TargetOcc extends FindNavigationTarget<Occ, Rel> = FindNavigationTarget<
|
|
269
|
+
Occ,
|
|
270
|
+
Rel
|
|
271
|
+
>,
|
|
272
|
+
TargetSchema extends GetTargetSchemaType<Occ, Rel> = GetTargetSchemaType<
|
|
273
|
+
Occ,
|
|
274
|
+
Rel
|
|
275
|
+
>,
|
|
276
|
+
TargetSelected extends keyof TargetSchema = keyof TargetSchema,
|
|
277
|
+
>(
|
|
278
|
+
relation: Rel,
|
|
279
|
+
callback?: (
|
|
280
|
+
builder: QueryBuilder<
|
|
281
|
+
TargetSchema,
|
|
282
|
+
keyof TargetSchema,
|
|
283
|
+
false,
|
|
284
|
+
false,
|
|
285
|
+
TargetOcc extends TableOccurrence<any, any, any, any>
|
|
286
|
+
? TargetOcc
|
|
287
|
+
: undefined
|
|
288
|
+
>,
|
|
289
|
+
) => QueryBuilder<
|
|
290
|
+
WithSystemFields<TargetSchema>,
|
|
291
|
+
TargetSelected,
|
|
292
|
+
any,
|
|
293
|
+
any,
|
|
294
|
+
any
|
|
295
|
+
>,
|
|
296
|
+
): RecordBuilder<
|
|
297
|
+
T,
|
|
298
|
+
false,
|
|
299
|
+
FieldKey,
|
|
300
|
+
Occ,
|
|
301
|
+
Selected,
|
|
302
|
+
Expands & {
|
|
303
|
+
[K in Rel]: { schema: TargetSchema; selected: TargetSelected };
|
|
304
|
+
}
|
|
305
|
+
> {
|
|
306
|
+
// Look up target occurrence from navigation
|
|
307
|
+
const targetOccurrence = this.occurrence?.navigation[relation as string];
|
|
308
|
+
|
|
309
|
+
// Create new builder with updated types
|
|
310
|
+
const newBuilder = new RecordBuilder<
|
|
311
|
+
T,
|
|
312
|
+
false,
|
|
313
|
+
FieldKey,
|
|
314
|
+
Occ,
|
|
315
|
+
Selected,
|
|
316
|
+
Expands & {
|
|
317
|
+
[K in Rel]: { schema: TargetSchema; selected: TargetSelected };
|
|
318
|
+
}
|
|
319
|
+
>({
|
|
320
|
+
occurrence: this.occurrence,
|
|
321
|
+
tableName: this.tableName,
|
|
322
|
+
databaseName: this.databaseName,
|
|
323
|
+
context: this.context,
|
|
324
|
+
recordId: this.recordId,
|
|
325
|
+
databaseUseEntityIds: this.databaseUseEntityIds,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Copy existing state
|
|
329
|
+
newBuilder.selectedFields = this.selectedFields;
|
|
330
|
+
newBuilder.expandConfigs = [...this.expandConfigs];
|
|
331
|
+
newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
|
|
332
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
333
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
334
|
+
|
|
335
|
+
if (callback) {
|
|
336
|
+
// Create a new QueryBuilder for the target occurrence
|
|
337
|
+
const targetBuilder = new QueryBuilder<any>({
|
|
338
|
+
occurrence: targetOccurrence,
|
|
339
|
+
tableName: targetOccurrence?.name ?? (relation as string),
|
|
340
|
+
databaseName: this.databaseName,
|
|
341
|
+
context: this.context,
|
|
342
|
+
databaseUseEntityIds: this.databaseUseEntityIds,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Cast to the expected type for the callback
|
|
346
|
+
// At runtime, the builder is untyped (any), but at compile-time we enforce proper types
|
|
347
|
+
const typedBuilder = targetBuilder as QueryBuilder<
|
|
348
|
+
TargetSchema,
|
|
349
|
+
keyof TargetSchema,
|
|
350
|
+
false,
|
|
351
|
+
false,
|
|
352
|
+
TargetOcc extends TableOccurrence<any, any, any, any>
|
|
353
|
+
? TargetOcc
|
|
354
|
+
: undefined
|
|
355
|
+
>;
|
|
356
|
+
|
|
357
|
+
// Pass to callback and get configured builder
|
|
358
|
+
const configuredBuilder = callback(typedBuilder);
|
|
359
|
+
|
|
360
|
+
// Extract the builder's query options
|
|
361
|
+
const expandOptions: Partial<QueryOptions<any>> = {
|
|
362
|
+
...(configuredBuilder as any).queryOptions,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// If the configured builder has nested expands, we need to include them
|
|
366
|
+
if ((configuredBuilder as any).expandConfigs?.length > 0) {
|
|
367
|
+
// Build nested expand string from the configured builder's expand configs
|
|
368
|
+
const nestedExpandString = this.buildExpandString(
|
|
369
|
+
(configuredBuilder as any).expandConfigs,
|
|
370
|
+
);
|
|
371
|
+
if (nestedExpandString) {
|
|
372
|
+
// Add nested expand to options
|
|
373
|
+
expandOptions.expand = nestedExpandString as any;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const expandConfig: ExpandConfig = {
|
|
378
|
+
relation: relation as string,
|
|
379
|
+
options: expandOptions,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
newBuilder.expandConfigs.push(expandConfig);
|
|
383
|
+
} else {
|
|
384
|
+
// Simple expand without callback
|
|
385
|
+
newBuilder.expandConfigs.push({ relation: relation as string });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return newBuilder;
|
|
389
|
+
}
|
|
390
|
+
|
|
102
391
|
// Overload for valid relation names - returns typed QueryBuilder
|
|
103
392
|
navigate<RelationName extends ExtractNavigationNames<Occ>>(
|
|
104
393
|
relationName: RelationName,
|
|
105
394
|
): QueryBuilder<
|
|
106
395
|
ExtractSchemaFromOccurrence<
|
|
107
396
|
FindNavigationTarget<Occ, RelationName>
|
|
108
|
-
> extends Record<string,
|
|
397
|
+
> extends Record<string, StandardSchemaV1>
|
|
109
398
|
? InferSchemaType<
|
|
110
399
|
ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
|
|
111
400
|
>
|
|
@@ -127,9 +416,14 @@ export class RecordBuilder<
|
|
|
127
416
|
context: this.context,
|
|
128
417
|
});
|
|
129
418
|
// Store the navigation info - we'll use it in execute
|
|
419
|
+
// Transform relation name to FMTID if using entity IDs
|
|
420
|
+
const relationId = targetOccurrence
|
|
421
|
+
? transformTableName(targetOccurrence)
|
|
422
|
+
: relationName;
|
|
423
|
+
|
|
130
424
|
(builder as any).isNavigate = true;
|
|
131
425
|
(builder as any).navigateRecordId = this.recordId;
|
|
132
|
-
(builder as any).navigateRelation =
|
|
426
|
+
(builder as any).navigateRelation = relationId;
|
|
133
427
|
|
|
134
428
|
// If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path
|
|
135
429
|
if (
|
|
@@ -142,74 +436,300 @@ export class RecordBuilder<
|
|
|
142
436
|
(builder as any).navigateBaseRelation = this.navigateRelation;
|
|
143
437
|
} else {
|
|
144
438
|
// Normal record navigation: /tableName('recordId')/relation
|
|
145
|
-
|
|
439
|
+
// Transform source table name to FMTID if using entity IDs
|
|
440
|
+
const sourceTableId = this.occurrence
|
|
441
|
+
? transformTableName(this.occurrence)
|
|
442
|
+
: this.tableName;
|
|
443
|
+
(builder as any).navigateSourceTableName = sourceTableId;
|
|
146
444
|
}
|
|
147
445
|
|
|
148
446
|
return builder;
|
|
149
447
|
}
|
|
150
448
|
|
|
151
|
-
|
|
152
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Formats select fields for use in query strings.
|
|
451
|
+
* - Transforms field names to FMFIDs if using entity IDs
|
|
452
|
+
* - Wraps "id" fields in double quotes
|
|
453
|
+
* - URL-encodes special characters but preserves spaces
|
|
454
|
+
*/
|
|
455
|
+
private formatSelectFields(
|
|
456
|
+
select: string[] | undefined,
|
|
457
|
+
baseTable?: BaseTable<any, any, any, any>,
|
|
458
|
+
): string {
|
|
459
|
+
if (!select || select.length === 0) return "";
|
|
460
|
+
|
|
461
|
+
// Transform to field IDs if using entity IDs
|
|
462
|
+
const transformedFields = baseTable
|
|
463
|
+
? transformFieldNamesArray(select, baseTable)
|
|
464
|
+
: select;
|
|
465
|
+
|
|
466
|
+
return transformedFields
|
|
467
|
+
.map((field) => {
|
|
468
|
+
if (field === "id") return `"id"`;
|
|
469
|
+
const encodedField = encodeURIComponent(String(field));
|
|
470
|
+
return encodedField.replace(/%20/g, " ");
|
|
471
|
+
})
|
|
472
|
+
.join(",");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Builds expand validation configs from internal expand configurations.
|
|
477
|
+
* These are used to validate expanded navigation properties.
|
|
478
|
+
*/
|
|
479
|
+
private buildExpandValidationConfigs(
|
|
480
|
+
configs: ExpandConfig[],
|
|
481
|
+
): ExpandValidationConfig[] {
|
|
482
|
+
return configs.map((config) => {
|
|
483
|
+
// Look up target occurrence from navigation
|
|
484
|
+
const targetOccurrence = this.occurrence?.navigation[config.relation];
|
|
485
|
+
const targetSchema = targetOccurrence?.baseTable?.schema;
|
|
486
|
+
|
|
487
|
+
// Extract selected fields from options
|
|
488
|
+
const selectedFields = config.options?.select
|
|
489
|
+
? Array.isArray(config.options.select)
|
|
490
|
+
? config.options.select.map((f) => String(f))
|
|
491
|
+
: [String(config.options.select)]
|
|
492
|
+
: undefined;
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
relation: config.relation,
|
|
496
|
+
targetSchema: targetSchema,
|
|
497
|
+
targetOccurrence: targetOccurrence,
|
|
498
|
+
targetBaseTable: targetOccurrence?.baseTable,
|
|
499
|
+
occurrence: targetOccurrence, // For transformation
|
|
500
|
+
selectedFields: selectedFields,
|
|
501
|
+
nestedExpands: undefined, // TODO: Handle nested expands if needed
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Builds OData expand query string from expand configurations.
|
|
508
|
+
* Handles nested expands recursively.
|
|
509
|
+
* Transforms relation names to FMTIDs if using entity IDs.
|
|
510
|
+
*/
|
|
511
|
+
private buildExpandString(configs: ExpandConfig[]): string {
|
|
512
|
+
if (configs.length === 0) {
|
|
513
|
+
return "";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return configs
|
|
517
|
+
.map((config) => {
|
|
518
|
+
// Get target occurrence for this relation
|
|
519
|
+
const targetOccurrence = this.occurrence?.navigation[config.relation];
|
|
520
|
+
|
|
521
|
+
// When using entity IDs, use the target table's FMTID in the expand parameter
|
|
522
|
+
// FileMaker expects FMTID in $expand when Prefer header is set
|
|
523
|
+
const relationName =
|
|
524
|
+
targetOccurrence && targetOccurrence.isUsingTableId?.()
|
|
525
|
+
? targetOccurrence.getTableId()
|
|
526
|
+
: config.relation;
|
|
527
|
+
|
|
528
|
+
if (!config.options || Object.keys(config.options).length === 0) {
|
|
529
|
+
// Simple expand without options
|
|
530
|
+
return relationName;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Build query options for this expand
|
|
534
|
+
const parts: string[] = [];
|
|
535
|
+
|
|
536
|
+
if (config.options.select) {
|
|
537
|
+
// Pass target base table for field transformation
|
|
538
|
+
const selectFields = this.formatSelectFields(
|
|
539
|
+
Array.isArray(config.options.select)
|
|
540
|
+
? config.options.select.map((f) => String(f))
|
|
541
|
+
: [String(config.options.select)],
|
|
542
|
+
targetOccurrence?.baseTable,
|
|
543
|
+
);
|
|
544
|
+
parts.push(`$select=${selectFields}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (config.options.filter) {
|
|
548
|
+
// Filter should already be transformed by the nested builder
|
|
549
|
+
// Use odata-query to build filter string
|
|
550
|
+
const filterQuery = buildQuery({ filter: config.options.filter });
|
|
551
|
+
const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
|
|
552
|
+
if (filterMatch) {
|
|
553
|
+
parts.push(`$filter=${filterMatch[1]}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (config.options.orderBy) {
|
|
558
|
+
const orderByQuery = buildQuery({ orderBy: config.options.orderBy });
|
|
559
|
+
const orderByMatch = orderByQuery.match(/\$orderby=([^&]+)/);
|
|
560
|
+
if (orderByMatch) {
|
|
561
|
+
parts.push(`$orderby=${orderByMatch[1]}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (config.options.top !== undefined) {
|
|
566
|
+
parts.push(`$top=${config.options.top}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (config.options.skip !== undefined) {
|
|
570
|
+
parts.push(`$skip=${config.options.skip}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Handle nested expand
|
|
574
|
+
if (config.options.expand) {
|
|
575
|
+
// Nested expand is already a string from buildExpandString
|
|
576
|
+
parts.push(`$expand=${String(config.options.expand)}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (parts.length === 0) {
|
|
580
|
+
return relationName;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return `${relationName}(${parts.join(";")})`;
|
|
584
|
+
})
|
|
585
|
+
.join(",");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Builds the complete query string including $select and $expand parameters.
|
|
590
|
+
*/
|
|
591
|
+
private buildQueryString(): string {
|
|
592
|
+
const parts: string[] = [];
|
|
593
|
+
|
|
594
|
+
// Build $select
|
|
595
|
+
if (this.selectedFields && this.selectedFields.length > 0) {
|
|
596
|
+
const selectString = this.formatSelectFields(
|
|
597
|
+
this.selectedFields,
|
|
598
|
+
this.occurrence?.baseTable,
|
|
599
|
+
);
|
|
600
|
+
if (selectString) {
|
|
601
|
+
parts.push(`$select=${selectString}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Build $expand
|
|
606
|
+
const expandString = this.buildExpandString(this.expandConfigs);
|
|
607
|
+
if (expandString) {
|
|
608
|
+
parts.push(`$expand=${expandString}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (parts.length === 0) {
|
|
612
|
+
return "";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return `?${parts.join("&")}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Helper to conditionally strip OData annotations based on options
|
|
620
|
+
*/
|
|
621
|
+
private stripODataAnnotationsIfNeeded<R extends Record<string, any>>(
|
|
622
|
+
data: R,
|
|
623
|
+
options?: ExecuteOptions,
|
|
624
|
+
): R {
|
|
625
|
+
// Only include annotations if explicitly requested
|
|
626
|
+
if (options?.includeODataAnnotations === true) {
|
|
627
|
+
return data;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Strip OData annotations
|
|
631
|
+
const { "@id": _id, "@editLink": _editLink, ...rest } = data;
|
|
632
|
+
return rest as R;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async execute<EO extends ExecuteOptions>(
|
|
636
|
+
options?: RequestInit & FFetchOptions & EO,
|
|
153
637
|
): Promise<
|
|
154
|
-
Result<
|
|
638
|
+
Result<
|
|
639
|
+
ConditionallyWithODataAnnotations<
|
|
640
|
+
RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>,
|
|
641
|
+
EO["includeODataAnnotations"] extends true ? true : false
|
|
642
|
+
>
|
|
643
|
+
>
|
|
155
644
|
> {
|
|
156
|
-
|
|
157
|
-
let url: string;
|
|
158
|
-
|
|
159
|
-
// Build the base URL depending on whether this came from a navigated EntitySet
|
|
160
|
-
if (
|
|
161
|
-
this.isNavigateFromEntitySet &&
|
|
162
|
-
this.navigateSourceTableName &&
|
|
163
|
-
this.navigateRelation
|
|
164
|
-
) {
|
|
165
|
-
// From navigated EntitySet: /sourceTable/relation('recordId')
|
|
166
|
-
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
|
|
167
|
-
} else {
|
|
168
|
-
// Normal record: /tableName('recordId')
|
|
169
|
-
url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
|
|
170
|
-
}
|
|
645
|
+
let url: string;
|
|
171
646
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
647
|
+
// Build the base URL depending on whether this came from a navigated EntitySet
|
|
648
|
+
if (
|
|
649
|
+
this.isNavigateFromEntitySet &&
|
|
650
|
+
this.navigateSourceTableName &&
|
|
651
|
+
this.navigateRelation
|
|
652
|
+
) {
|
|
653
|
+
// From navigated EntitySet: /sourceTable/relation('recordId')
|
|
654
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
|
|
655
|
+
} else {
|
|
656
|
+
// Normal record: /tableName('recordId') - use FMTID if configured
|
|
657
|
+
const tableId = this.getTableId(
|
|
658
|
+
options?.useEntityIds ?? this.databaseUseEntityIds,
|
|
659
|
+
);
|
|
660
|
+
url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
|
|
661
|
+
}
|
|
175
662
|
|
|
176
|
-
|
|
663
|
+
if (this.operation === "getSingleField" && this.operationParam) {
|
|
664
|
+
url += `/${this.operationParam}`;
|
|
665
|
+
} else {
|
|
666
|
+
// Add query string for select/expand (only when not getting a single field)
|
|
667
|
+
const queryString = this.buildQueryString();
|
|
668
|
+
url += queryString;
|
|
669
|
+
}
|
|
177
670
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
671
|
+
const mergedOptions = this.mergeExecuteOptions(options);
|
|
672
|
+
const result = await this.context._makeRequest(url, mergedOptions);
|
|
673
|
+
|
|
674
|
+
if (result.error) {
|
|
675
|
+
return { data: undefined, error: result.error };
|
|
676
|
+
}
|
|
184
677
|
|
|
185
|
-
|
|
186
|
-
const schema = this.occurrence?.baseTable?.schema;
|
|
678
|
+
let response = result.data;
|
|
187
679
|
|
|
188
|
-
|
|
189
|
-
|
|
680
|
+
// Handle single field operation
|
|
681
|
+
if (this.operation === "getSingleField") {
|
|
682
|
+
// Single field returns a JSON object with @context and value
|
|
683
|
+
const fieldResponse = response as ODataFieldResponse<T>;
|
|
684
|
+
return { data: fieldResponse.value as any, error: undefined };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Transform response field IDs back to names if using entity IDs
|
|
688
|
+
// Only transform if useEntityIds resolves to true (respects per-request override)
|
|
689
|
+
const shouldUseIds = mergedOptions.useEntityIds ?? false;
|
|
690
|
+
|
|
691
|
+
// Build expand validation configs for transformation and validation
|
|
692
|
+
const expandValidationConfigs =
|
|
693
|
+
this.expandConfigs.length > 0
|
|
694
|
+
? this.buildExpandValidationConfigs(this.expandConfigs)
|
|
695
|
+
: undefined;
|
|
696
|
+
|
|
697
|
+
if (this.occurrence?.baseTable && shouldUseIds) {
|
|
698
|
+
response = transformResponseFields(
|
|
190
699
|
response,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
undefined, // No expand configs
|
|
194
|
-
"exact", // Expect exactly one record
|
|
700
|
+
this.occurrence.baseTable,
|
|
701
|
+
expandValidationConfigs,
|
|
195
702
|
);
|
|
703
|
+
}
|
|
196
704
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
705
|
+
// Get schema from occurrence if available
|
|
706
|
+
const schema = this.occurrence?.baseTable?.schema;
|
|
200
707
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
708
|
+
// Validate the single record response
|
|
709
|
+
const validation = await validateSingleResponse<any>(
|
|
710
|
+
response,
|
|
711
|
+
schema,
|
|
712
|
+
this.selectedFields as (keyof T)[] | undefined,
|
|
713
|
+
expandValidationConfigs,
|
|
714
|
+
"exact", // Expect exactly one record
|
|
715
|
+
);
|
|
205
716
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
717
|
+
if (!validation.valid) {
|
|
718
|
+
return { data: undefined, error: validation.error };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Handle null response
|
|
722
|
+
if (validation.data === null) {
|
|
723
|
+
return { data: null as any, error: undefined };
|
|
212
724
|
}
|
|
725
|
+
|
|
726
|
+
// Strip OData annotations if not requested
|
|
727
|
+
const stripped = this.stripODataAnnotationsIfNeeded(
|
|
728
|
+
validation.data,
|
|
729
|
+
options,
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
return { data: stripped as any, error: undefined };
|
|
213
733
|
}
|
|
214
734
|
|
|
215
735
|
getRequestConfig(): { method: string; url: string; body?: any } {
|
|
@@ -224,12 +744,17 @@ export class RecordBuilder<
|
|
|
224
744
|
// From navigated EntitySet: /sourceTable/relation('recordId')
|
|
225
745
|
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
|
|
226
746
|
} else {
|
|
227
|
-
//
|
|
228
|
-
|
|
747
|
+
// For batch operations, use database-level setting (no per-request override available here)
|
|
748
|
+
const tableId = this.getTableId(this.databaseUseEntityIds);
|
|
749
|
+
url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
|
|
229
750
|
}
|
|
230
751
|
|
|
231
752
|
if (this.operation === "getSingleField" && this.operationParam) {
|
|
232
753
|
url += `/${this.operationParam}`;
|
|
754
|
+
} else {
|
|
755
|
+
// Add query string for select/expand (only when not getting a single field)
|
|
756
|
+
const queryString = this.buildQueryString();
|
|
757
|
+
url += queryString;
|
|
233
758
|
}
|
|
234
759
|
|
|
235
760
|
return {
|
|
@@ -237,4 +762,106 @@ export class RecordBuilder<
|
|
|
237
762
|
url,
|
|
238
763
|
};
|
|
239
764
|
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Returns the query string for this record builder (for testing purposes).
|
|
768
|
+
*/
|
|
769
|
+
getQueryString(): string {
|
|
770
|
+
let path: string;
|
|
771
|
+
|
|
772
|
+
// Build the path depending on navigation context
|
|
773
|
+
if (
|
|
774
|
+
this.isNavigateFromEntitySet &&
|
|
775
|
+
this.navigateSourceTableName &&
|
|
776
|
+
this.navigateRelation
|
|
777
|
+
) {
|
|
778
|
+
path = `/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
|
|
779
|
+
} else {
|
|
780
|
+
path = `/${this.tableName}('${this.recordId}')`;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (this.operation === "getSingleField" && this.operationParam) {
|
|
784
|
+
return `${path}/${this.operationParam}`;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const queryString = this.buildQueryString();
|
|
788
|
+
return `${path}${queryString}`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
toRequest(baseUrl: string): Request {
|
|
792
|
+
const config = this.getRequestConfig();
|
|
793
|
+
const fullUrl = `${baseUrl}${config.url}`;
|
|
794
|
+
|
|
795
|
+
return new Request(fullUrl, {
|
|
796
|
+
method: config.method,
|
|
797
|
+
headers: {
|
|
798
|
+
"Content-Type": "application/json",
|
|
799
|
+
Accept: "application/json",
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async processResponse(
|
|
805
|
+
response: Response,
|
|
806
|
+
options?: ExecuteOptions,
|
|
807
|
+
): Promise<
|
|
808
|
+
Result<RecordReturnType<T, IsSingleField, FieldKey, Selected, Expands>>
|
|
809
|
+
> {
|
|
810
|
+
const rawResponse = await response.json();
|
|
811
|
+
|
|
812
|
+
// Handle single field operation
|
|
813
|
+
if (this.operation === "getSingleField") {
|
|
814
|
+
// Single field returns a JSON object with @context and value
|
|
815
|
+
const fieldResponse = rawResponse as ODataFieldResponse<T>;
|
|
816
|
+
return { data: fieldResponse.value as any, error: undefined };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Transform response field IDs back to names if using entity IDs
|
|
820
|
+
// Only transform if useEntityIds resolves to true (respects per-request override)
|
|
821
|
+
const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
|
|
822
|
+
|
|
823
|
+
// Build expand validation configs for transformation and validation
|
|
824
|
+
const expandValidationConfigs =
|
|
825
|
+
this.expandConfigs.length > 0
|
|
826
|
+
? this.buildExpandValidationConfigs(this.expandConfigs)
|
|
827
|
+
: undefined;
|
|
828
|
+
|
|
829
|
+
let transformedResponse = rawResponse;
|
|
830
|
+
if (this.occurrence?.baseTable && shouldUseIds) {
|
|
831
|
+
transformedResponse = transformResponseFields(
|
|
832
|
+
rawResponse,
|
|
833
|
+
this.occurrence.baseTable,
|
|
834
|
+
expandValidationConfigs,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Get schema from occurrence if available
|
|
839
|
+
const schema = this.occurrence?.baseTable?.schema;
|
|
840
|
+
|
|
841
|
+
// Validate the single record response
|
|
842
|
+
const validation = await validateSingleResponse<any>(
|
|
843
|
+
transformedResponse,
|
|
844
|
+
schema,
|
|
845
|
+
this.selectedFields as (keyof T)[] | undefined,
|
|
846
|
+
expandValidationConfigs,
|
|
847
|
+
"exact", // Expect exactly one record
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
if (!validation.valid) {
|
|
851
|
+
return { data: undefined, error: validation.error };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Handle null response
|
|
855
|
+
if (validation.data === null) {
|
|
856
|
+
return { data: null as any, error: undefined };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Strip OData annotations if not requested
|
|
860
|
+
const stripped = this.stripODataAnnotationsIfNeeded(
|
|
861
|
+
validation.data,
|
|
862
|
+
options,
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
return { data: stripped as any, error: undefined };
|
|
866
|
+
}
|
|
240
867
|
}
|