@proofkit/fmodata 0.1.0-alpha.0
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 +37 -0
- package/dist/esm/client/base-table.d.ts +13 -0
- package/dist/esm/client/base-table.js +19 -0
- package/dist/esm/client/base-table.js.map +1 -0
- package/dist/esm/client/database.d.ts +49 -0
- package/dist/esm/client/database.js +90 -0
- package/dist/esm/client/database.js.map +1 -0
- package/dist/esm/client/delete-builder.d.ts +61 -0
- package/dist/esm/client/delete-builder.js +121 -0
- package/dist/esm/client/delete-builder.js.map +1 -0
- package/dist/esm/client/entity-set.d.ts +43 -0
- package/dist/esm/client/entity-set.js +120 -0
- package/dist/esm/client/entity-set.js.map +1 -0
- package/dist/esm/client/filemaker-odata.d.ts +26 -0
- package/dist/esm/client/filemaker-odata.js +85 -0
- package/dist/esm/client/filemaker-odata.js.map +1 -0
- package/dist/esm/client/insert-builder.d.ts +23 -0
- package/dist/esm/client/insert-builder.js +69 -0
- package/dist/esm/client/insert-builder.js.map +1 -0
- package/dist/esm/client/query-builder.d.ts +94 -0
- package/dist/esm/client/query-builder.js +649 -0
- package/dist/esm/client/query-builder.js.map +1 -0
- package/dist/esm/client/record-builder.d.ts +43 -0
- package/dist/esm/client/record-builder.js +121 -0
- package/dist/esm/client/record-builder.js.map +1 -0
- package/dist/esm/client/table-occurrence.d.ts +25 -0
- package/dist/esm/client/table-occurrence.js +47 -0
- package/dist/esm/client/table-occurrence.js.map +1 -0
- package/dist/esm/client/update-builder.d.ts +69 -0
- package/dist/esm/client/update-builder.js +134 -0
- package/dist/esm/client/update-builder.js.map +1 -0
- package/dist/esm/filter-types.d.ts +76 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/types.d.ts +67 -0
- package/dist/esm/validation.d.ts +41 -0
- package/dist/esm/validation.js +270 -0
- package/dist/esm/validation.js.map +1 -0
- package/package.json +68 -0
- package/src/client/base-table.ts +25 -0
- package/src/client/database.ts +177 -0
- package/src/client/delete-builder.ts +193 -0
- package/src/client/entity-set.ts +310 -0
- package/src/client/filemaker-odata.ts +119 -0
- package/src/client/insert-builder.ts +93 -0
- package/src/client/query-builder.ts +1076 -0
- package/src/client/record-builder.ts +240 -0
- package/src/client/table-occurrence.ts +100 -0
- package/src/client/update-builder.ts +212 -0
- package/src/filter-types.ts +97 -0
- package/src/index.ts +17 -0
- package/src/types.ts +123 -0
- package/src/validation.ts +397 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import { QueryOptions } from "odata-query";
|
|
2
|
+
import buildQuery from "odata-query";
|
|
3
|
+
import type {
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
ExecutableBuilder,
|
|
6
|
+
WithSystemFields,
|
|
7
|
+
ODataRecordMetadata,
|
|
8
|
+
Result,
|
|
9
|
+
InferSchemaType,
|
|
10
|
+
ExecuteOptions,
|
|
11
|
+
ConditionallyWithODataAnnotations,
|
|
12
|
+
ExtractSchemaFromOccurrence,
|
|
13
|
+
} from "../types";
|
|
14
|
+
import type { Filter } from "../filter-types";
|
|
15
|
+
import type { TableOccurrence } from "./table-occurrence";
|
|
16
|
+
import type { BaseTable } from "./base-table";
|
|
17
|
+
import { validateListResponse, validateSingleResponse } from "../validation";
|
|
18
|
+
import { type FFetchOptions } from "@fetchkit/ffetch";
|
|
19
|
+
import { z } from "zod/v4";
|
|
20
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
21
|
+
|
|
22
|
+
// Helper type to extract navigation relation names from an occurrence
|
|
23
|
+
type ExtractNavigationNames<
|
|
24
|
+
O extends TableOccurrence<any, any, any, any> | undefined,
|
|
25
|
+
> =
|
|
26
|
+
O extends TableOccurrence<any, any, infer Nav, any>
|
|
27
|
+
? Nav extends Record<string, any>
|
|
28
|
+
? keyof Nav & string
|
|
29
|
+
: never
|
|
30
|
+
: never;
|
|
31
|
+
|
|
32
|
+
// Helper type to resolve a navigation item (handles both direct and lazy-loaded)
|
|
33
|
+
type ResolveNavigationItem<T> = T extends () => infer R ? R : T;
|
|
34
|
+
|
|
35
|
+
// Helper type to find target occurrence by relation name
|
|
36
|
+
type FindNavigationTarget<
|
|
37
|
+
O extends TableOccurrence<any, any, any, any> | undefined,
|
|
38
|
+
Name extends string,
|
|
39
|
+
> =
|
|
40
|
+
O extends TableOccurrence<any, any, infer Nav, any>
|
|
41
|
+
? Nav extends Record<string, any>
|
|
42
|
+
? Name extends keyof Nav
|
|
43
|
+
? ResolveNavigationItem<Nav[Name]>
|
|
44
|
+
: TableOccurrence<
|
|
45
|
+
BaseTable<Record<string, z.ZodTypeAny>, any>,
|
|
46
|
+
any,
|
|
47
|
+
any,
|
|
48
|
+
any
|
|
49
|
+
>
|
|
50
|
+
: TableOccurrence<
|
|
51
|
+
BaseTable<Record<string, z.ZodTypeAny>, any>,
|
|
52
|
+
any,
|
|
53
|
+
any,
|
|
54
|
+
any
|
|
55
|
+
>
|
|
56
|
+
: TableOccurrence<
|
|
57
|
+
BaseTable<Record<string, z.ZodTypeAny>, any>,
|
|
58
|
+
any,
|
|
59
|
+
any,
|
|
60
|
+
any
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
// Helper type to get the inferred schema type from a target occurrence
|
|
64
|
+
type GetTargetSchemaType<
|
|
65
|
+
O extends TableOccurrence<any, any, any, any> | undefined,
|
|
66
|
+
Rel extends string,
|
|
67
|
+
> = [FindNavigationTarget<O, Rel>] extends [
|
|
68
|
+
TableOccurrence<infer BT, any, any, any>,
|
|
69
|
+
]
|
|
70
|
+
? [BT] extends [BaseTable<infer S, any>]
|
|
71
|
+
? [S] extends [Record<string, StandardSchemaV1>]
|
|
72
|
+
? InferSchemaType<S>
|
|
73
|
+
: Record<string, any>
|
|
74
|
+
: Record<string, any>
|
|
75
|
+
: Record<string, any>;
|
|
76
|
+
|
|
77
|
+
// Internal type for expand configuration
|
|
78
|
+
type ExpandConfig = {
|
|
79
|
+
relation: string;
|
|
80
|
+
options?: Partial<QueryOptions<any>>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Type to represent expanded relations
|
|
84
|
+
type ExpandedRelations = Record<string, { schema: any; selected: any }>;
|
|
85
|
+
|
|
86
|
+
export class QueryBuilder<
|
|
87
|
+
T extends Record<string, any>,
|
|
88
|
+
Selected extends keyof T = keyof T,
|
|
89
|
+
SingleMode extends "exact" | "maybe" | false = false,
|
|
90
|
+
IsCount extends boolean = false,
|
|
91
|
+
Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
|
|
92
|
+
Expands extends ExpandedRelations = {},
|
|
93
|
+
> {
|
|
94
|
+
private queryOptions: Partial<QueryOptions<T>> = {};
|
|
95
|
+
private expandConfigs: ExpandConfig[] = [];
|
|
96
|
+
private singleMode: SingleMode = false as SingleMode;
|
|
97
|
+
private isCountMode = false as IsCount;
|
|
98
|
+
private occurrence?: Occ;
|
|
99
|
+
private tableName: string;
|
|
100
|
+
private databaseName: string;
|
|
101
|
+
private context: ExecutionContext;
|
|
102
|
+
private isNavigate?: boolean;
|
|
103
|
+
private navigateRecordId?: string | number;
|
|
104
|
+
private navigateRelation?: string;
|
|
105
|
+
private navigateSourceTableName?: string;
|
|
106
|
+
private navigateBaseRelation?: string;
|
|
107
|
+
constructor(config: {
|
|
108
|
+
occurrence?: Occ;
|
|
109
|
+
tableName: string;
|
|
110
|
+
databaseName: string;
|
|
111
|
+
context: ExecutionContext;
|
|
112
|
+
}) {
|
|
113
|
+
this.occurrence = config.occurrence;
|
|
114
|
+
this.tableName = config.tableName;
|
|
115
|
+
this.databaseName = config.databaseName;
|
|
116
|
+
this.context = config.context;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Helper to conditionally strip OData annotations based on options
|
|
121
|
+
*/
|
|
122
|
+
private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
|
|
123
|
+
data: T,
|
|
124
|
+
options?: ExecuteOptions,
|
|
125
|
+
): T {
|
|
126
|
+
// Only include annotations if explicitly requested
|
|
127
|
+
if (options?.includeODataAnnotations === true) {
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Strip OData annotations
|
|
132
|
+
const { "@id": _id, "@editLink": _editLink, ...rest } = data;
|
|
133
|
+
return rest as T;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
select<K extends keyof T>(
|
|
137
|
+
...fields: K[]
|
|
138
|
+
): QueryBuilder<T, K, SingleMode, IsCount, Occ, Expands> {
|
|
139
|
+
const uniqueFields = [...new Set(fields)];
|
|
140
|
+
const newBuilder = new QueryBuilder<
|
|
141
|
+
T,
|
|
142
|
+
K,
|
|
143
|
+
SingleMode,
|
|
144
|
+
IsCount,
|
|
145
|
+
Occ,
|
|
146
|
+
Expands
|
|
147
|
+
>({
|
|
148
|
+
occurrence: this.occurrence,
|
|
149
|
+
tableName: this.tableName,
|
|
150
|
+
databaseName: this.databaseName,
|
|
151
|
+
context: this.context,
|
|
152
|
+
});
|
|
153
|
+
newBuilder.queryOptions = {
|
|
154
|
+
...this.queryOptions,
|
|
155
|
+
select: uniqueFields as string[],
|
|
156
|
+
};
|
|
157
|
+
newBuilder.expandConfigs = [...this.expandConfigs];
|
|
158
|
+
newBuilder.singleMode = this.singleMode;
|
|
159
|
+
newBuilder.isCountMode = this.isCountMode;
|
|
160
|
+
// Preserve navigation metadata
|
|
161
|
+
newBuilder.isNavigate = this.isNavigate;
|
|
162
|
+
newBuilder.navigateRecordId = this.navigateRecordId;
|
|
163
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
164
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
165
|
+
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
|
|
166
|
+
return newBuilder;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Transforms our filter format to odata-query's expected format
|
|
171
|
+
* - Arrays of operators are converted to AND conditions
|
|
172
|
+
* - Single operator objects pass through as-is
|
|
173
|
+
* - Shorthand values are handled by odata-query
|
|
174
|
+
*/
|
|
175
|
+
private transformFilter(
|
|
176
|
+
filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
|
|
177
|
+
): QueryOptions<T>["filter"] {
|
|
178
|
+
if (typeof filter === "string") {
|
|
179
|
+
// Raw string filters pass through
|
|
180
|
+
return filter;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Array.isArray(filter)) {
|
|
184
|
+
// Array of filters - odata-query handles this as implicit AND
|
|
185
|
+
return filter.map((f) => this.transformFilter(f as any)) as any;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check if it's a logical filter (and/or/not)
|
|
189
|
+
if ("and" in filter || "or" in filter || "not" in filter) {
|
|
190
|
+
const result: any = {};
|
|
191
|
+
if ("and" in filter && Array.isArray(filter.and)) {
|
|
192
|
+
result.and = filter.and.map((f: any) => this.transformFilter(f));
|
|
193
|
+
}
|
|
194
|
+
if ("or" in filter && Array.isArray(filter.or)) {
|
|
195
|
+
result.or = filter.or.map((f: any) => this.transformFilter(f));
|
|
196
|
+
}
|
|
197
|
+
if ("not" in filter && filter.not) {
|
|
198
|
+
result.not = this.transformFilter(filter.not as any);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Transform field filters
|
|
204
|
+
const result: any = {};
|
|
205
|
+
const andConditions: any[] = [];
|
|
206
|
+
|
|
207
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
// Array of operators - convert to AND conditions
|
|
210
|
+
if (value.length === 1) {
|
|
211
|
+
// Single operator in array - unwrap it
|
|
212
|
+
result[field] = value[0];
|
|
213
|
+
} else {
|
|
214
|
+
// Multiple operators - combine with AND
|
|
215
|
+
// Create separate conditions for each operator
|
|
216
|
+
for (const op of value) {
|
|
217
|
+
andConditions.push({ [field]: op });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else if (
|
|
221
|
+
value &&
|
|
222
|
+
typeof value === "object" &&
|
|
223
|
+
!(value instanceof Date) &&
|
|
224
|
+
!Array.isArray(value)
|
|
225
|
+
) {
|
|
226
|
+
// Check if it's an operator object (has operator keys like eq, gt, etc.)
|
|
227
|
+
const operatorKeys = [
|
|
228
|
+
"eq",
|
|
229
|
+
"ne",
|
|
230
|
+
"gt",
|
|
231
|
+
"ge",
|
|
232
|
+
"lt",
|
|
233
|
+
"le",
|
|
234
|
+
"contains",
|
|
235
|
+
"startswith",
|
|
236
|
+
"endswith",
|
|
237
|
+
"in",
|
|
238
|
+
];
|
|
239
|
+
const isOperatorObject = operatorKeys.some((key) => key in value);
|
|
240
|
+
|
|
241
|
+
if (isOperatorObject) {
|
|
242
|
+
// Single operator object - pass through
|
|
243
|
+
result[field] = value;
|
|
244
|
+
} else {
|
|
245
|
+
// Regular object - might be nested filter, pass through
|
|
246
|
+
result[field] = value;
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
// Primitive value (shorthand) - pass through
|
|
250
|
+
result[field] = value;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// If we have AND conditions from arrays, combine them
|
|
255
|
+
if (andConditions.length > 0) {
|
|
256
|
+
if (Object.keys(result).length > 0) {
|
|
257
|
+
// We have both regular fields and array-derived AND conditions
|
|
258
|
+
// Combine everything with AND
|
|
259
|
+
return { and: [...andConditions, result] };
|
|
260
|
+
} else {
|
|
261
|
+
// Only array-derived AND conditions
|
|
262
|
+
return { and: andConditions };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
filter(
|
|
270
|
+
filter: Filter<ExtractSchemaFromOccurrence<Occ>>,
|
|
271
|
+
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
|
|
272
|
+
// Transform our filter format to odata-query's expected format
|
|
273
|
+
this.queryOptions.filter = this.transformFilter(filter) as any;
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
orderBy(
|
|
278
|
+
orderBy: QueryOptions<T>["orderBy"],
|
|
279
|
+
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
|
|
280
|
+
this.queryOptions.orderBy = orderBy;
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
top(
|
|
285
|
+
count: number,
|
|
286
|
+
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
|
|
287
|
+
this.queryOptions.top = count;
|
|
288
|
+
return this;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
skip(
|
|
292
|
+
count: number,
|
|
293
|
+
): QueryBuilder<T, Selected, SingleMode, IsCount, Occ, Expands> {
|
|
294
|
+
this.queryOptions.skip = count;
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Formats select fields for use in query strings.
|
|
300
|
+
* - Wraps "id" fields in double quotes
|
|
301
|
+
* - URL-encodes special characters but preserves spaces
|
|
302
|
+
*/
|
|
303
|
+
private formatSelectFields(select: QueryOptions<any>["select"]): string {
|
|
304
|
+
if (!select) return "";
|
|
305
|
+
const selectFieldsArray = Array.isArray(select) ? select : [select];
|
|
306
|
+
return selectFieldsArray
|
|
307
|
+
.map((field) => {
|
|
308
|
+
if (field === "id") return `"id"`;
|
|
309
|
+
const encodedField = encodeURIComponent(String(field));
|
|
310
|
+
return encodedField.replace(/%20/g, " ");
|
|
311
|
+
})
|
|
312
|
+
.join(",");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Builds expand validation configs from internal expand configurations.
|
|
317
|
+
* These are used to validate expanded navigation properties.
|
|
318
|
+
*/
|
|
319
|
+
private buildExpandValidationConfigs(
|
|
320
|
+
configs: ExpandConfig[],
|
|
321
|
+
): import("../validation").ExpandValidationConfig[] {
|
|
322
|
+
return configs.map((config) => {
|
|
323
|
+
// Look up target occurrence from navigation
|
|
324
|
+
const targetOccurrence = this.occurrence?.navigation[config.relation];
|
|
325
|
+
const targetSchema = targetOccurrence?.baseTable?.schema;
|
|
326
|
+
|
|
327
|
+
// Extract selected fields from options
|
|
328
|
+
const selectedFields = config.options?.select
|
|
329
|
+
? Array.isArray(config.options.select)
|
|
330
|
+
? config.options.select.map((f) => String(f))
|
|
331
|
+
: [String(config.options.select)]
|
|
332
|
+
: undefined;
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
relation: config.relation,
|
|
336
|
+
targetSchema: targetSchema,
|
|
337
|
+
targetOccurrence: targetOccurrence,
|
|
338
|
+
selectedFields: selectedFields,
|
|
339
|
+
nestedExpands: undefined, // TODO: Handle nested expands if needed
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Builds OData expand query string from expand configurations.
|
|
346
|
+
* Handles nested expands recursively.
|
|
347
|
+
*/
|
|
348
|
+
private buildExpandString(configs: ExpandConfig[]): string {
|
|
349
|
+
if (configs.length === 0) {
|
|
350
|
+
return "";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return configs
|
|
354
|
+
.map((config) => {
|
|
355
|
+
if (!config.options || Object.keys(config.options).length === 0) {
|
|
356
|
+
// Simple expand without options
|
|
357
|
+
return config.relation;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Build query options for this expand
|
|
361
|
+
const parts: string[] = [];
|
|
362
|
+
|
|
363
|
+
if (config.options.select) {
|
|
364
|
+
const selectFields = this.formatSelectFields(config.options.select);
|
|
365
|
+
parts.push(`$select=${selectFields}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (config.options.filter) {
|
|
369
|
+
// Use odata-query to build filter string
|
|
370
|
+
const filterQuery = buildQuery({ filter: config.options.filter });
|
|
371
|
+
const filterMatch = filterQuery.match(/\$filter=([^&]+)/);
|
|
372
|
+
if (filterMatch) {
|
|
373
|
+
parts.push(`$filter=${filterMatch[1]}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (config.options.orderBy) {
|
|
378
|
+
const orderByValue = Array.isArray(config.options.orderBy)
|
|
379
|
+
? config.options.orderBy.join(",")
|
|
380
|
+
: config.options.orderBy;
|
|
381
|
+
parts.push(`$orderby=${String(orderByValue)}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (config.options.top !== undefined) {
|
|
385
|
+
parts.push(`$top=${config.options.top}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (config.options.skip !== undefined) {
|
|
389
|
+
parts.push(`$skip=${config.options.skip}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Handle nested expands (from expand configs)
|
|
393
|
+
if (config.options.expand) {
|
|
394
|
+
// If expand is a string, it's already been built
|
|
395
|
+
if (typeof config.options.expand === "string") {
|
|
396
|
+
parts.push(`$expand=${config.options.expand}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (parts.length === 0) {
|
|
401
|
+
return config.relation;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return `${config.relation}(${parts.join(";")})`;
|
|
405
|
+
})
|
|
406
|
+
.join(",");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
expand<
|
|
410
|
+
Rel extends ExtractNavigationNames<Occ> | (string & {}),
|
|
411
|
+
TargetOcc extends FindNavigationTarget<Occ, Rel> = FindNavigationTarget<
|
|
412
|
+
Occ,
|
|
413
|
+
Rel
|
|
414
|
+
>,
|
|
415
|
+
TargetSchema extends GetTargetSchemaType<Occ, Rel> = GetTargetSchemaType<
|
|
416
|
+
Occ,
|
|
417
|
+
Rel
|
|
418
|
+
>,
|
|
419
|
+
TargetSelected extends keyof TargetSchema = keyof TargetSchema,
|
|
420
|
+
>(
|
|
421
|
+
relation: Rel,
|
|
422
|
+
callback?: (
|
|
423
|
+
builder: QueryBuilder<
|
|
424
|
+
TargetSchema,
|
|
425
|
+
keyof TargetSchema,
|
|
426
|
+
false,
|
|
427
|
+
false,
|
|
428
|
+
TargetOcc extends TableOccurrence<any, any, any, any>
|
|
429
|
+
? TargetOcc
|
|
430
|
+
: undefined
|
|
431
|
+
>,
|
|
432
|
+
) => QueryBuilder<
|
|
433
|
+
WithSystemFields<TargetSchema>,
|
|
434
|
+
TargetSelected,
|
|
435
|
+
any,
|
|
436
|
+
any,
|
|
437
|
+
any
|
|
438
|
+
>,
|
|
439
|
+
): QueryBuilder<
|
|
440
|
+
T,
|
|
441
|
+
Selected,
|
|
442
|
+
SingleMode,
|
|
443
|
+
IsCount,
|
|
444
|
+
Occ,
|
|
445
|
+
Expands & {
|
|
446
|
+
[K in Rel]: { schema: TargetSchema; selected: TargetSelected };
|
|
447
|
+
}
|
|
448
|
+
> {
|
|
449
|
+
// Look up target occurrence from navigation
|
|
450
|
+
const targetOccurrence = this.occurrence?.navigation[relation as string];
|
|
451
|
+
|
|
452
|
+
if (callback) {
|
|
453
|
+
// Create a new QueryBuilder for the target occurrence
|
|
454
|
+
const targetBuilder = new QueryBuilder<any>({
|
|
455
|
+
occurrence: targetOccurrence,
|
|
456
|
+
tableName: targetOccurrence?.name ?? (relation as string),
|
|
457
|
+
databaseName: this.databaseName,
|
|
458
|
+
context: this.context,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Cast to the expected type for the callback
|
|
462
|
+
// At runtime, the builder is untyped (any), but at compile-time we enforce proper types
|
|
463
|
+
const typedBuilder = targetBuilder as QueryBuilder<
|
|
464
|
+
TargetSchema,
|
|
465
|
+
keyof TargetSchema,
|
|
466
|
+
false,
|
|
467
|
+
false,
|
|
468
|
+
TargetOcc extends TableOccurrence<any, any, any, any>
|
|
469
|
+
? TargetOcc
|
|
470
|
+
: undefined
|
|
471
|
+
>;
|
|
472
|
+
|
|
473
|
+
// Pass to callback and get configured builder
|
|
474
|
+
const configuredBuilder = callback(typedBuilder);
|
|
475
|
+
|
|
476
|
+
// Extract the builder's query options
|
|
477
|
+
const expandOptions: Partial<QueryOptions<any>> = {
|
|
478
|
+
...configuredBuilder.queryOptions,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// If the configured builder has nested expands, we need to include them
|
|
482
|
+
if (configuredBuilder.expandConfigs.length > 0) {
|
|
483
|
+
// Build nested expand string from the configured builder's expand configs
|
|
484
|
+
const nestedExpandString = this.buildExpandString(
|
|
485
|
+
configuredBuilder.expandConfigs,
|
|
486
|
+
);
|
|
487
|
+
if (nestedExpandString) {
|
|
488
|
+
// Add nested expand to options
|
|
489
|
+
expandOptions.expand = nestedExpandString as any;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const expandConfig: ExpandConfig = {
|
|
494
|
+
relation: relation as string,
|
|
495
|
+
options: expandOptions,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
this.expandConfigs.push(expandConfig);
|
|
499
|
+
} else {
|
|
500
|
+
// Simple expand without callback
|
|
501
|
+
this.expandConfigs.push({ relation: relation as string });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return this as any;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
single(): QueryBuilder<T, Selected, "exact", IsCount, Occ, Expands> {
|
|
508
|
+
const newBuilder = new QueryBuilder<
|
|
509
|
+
T,
|
|
510
|
+
Selected,
|
|
511
|
+
"exact",
|
|
512
|
+
IsCount,
|
|
513
|
+
Occ,
|
|
514
|
+
Expands
|
|
515
|
+
>({
|
|
516
|
+
occurrence: this.occurrence,
|
|
517
|
+
tableName: this.tableName,
|
|
518
|
+
databaseName: this.databaseName,
|
|
519
|
+
context: this.context,
|
|
520
|
+
});
|
|
521
|
+
newBuilder.queryOptions = { ...this.queryOptions };
|
|
522
|
+
newBuilder.expandConfigs = [...this.expandConfigs];
|
|
523
|
+
newBuilder.singleMode = "exact";
|
|
524
|
+
newBuilder.isCountMode = this.isCountMode;
|
|
525
|
+
// Preserve navigation metadata
|
|
526
|
+
newBuilder.isNavigate = this.isNavigate;
|
|
527
|
+
newBuilder.navigateRecordId = this.navigateRecordId;
|
|
528
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
529
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
530
|
+
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
|
|
531
|
+
return newBuilder;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
maybeSingle(): QueryBuilder<T, Selected, "maybe", IsCount, Occ, Expands> {
|
|
535
|
+
const newBuilder = new QueryBuilder<
|
|
536
|
+
T,
|
|
537
|
+
Selected,
|
|
538
|
+
"maybe",
|
|
539
|
+
IsCount,
|
|
540
|
+
Occ,
|
|
541
|
+
Expands
|
|
542
|
+
>({
|
|
543
|
+
occurrence: this.occurrence,
|
|
544
|
+
tableName: this.tableName,
|
|
545
|
+
databaseName: this.databaseName,
|
|
546
|
+
context: this.context,
|
|
547
|
+
});
|
|
548
|
+
newBuilder.queryOptions = { ...this.queryOptions };
|
|
549
|
+
newBuilder.expandConfigs = [...this.expandConfigs];
|
|
550
|
+
newBuilder.singleMode = "maybe";
|
|
551
|
+
newBuilder.isCountMode = this.isCountMode;
|
|
552
|
+
// Preserve navigation metadata
|
|
553
|
+
newBuilder.isNavigate = this.isNavigate;
|
|
554
|
+
newBuilder.navigateRecordId = this.navigateRecordId;
|
|
555
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
556
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
557
|
+
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
|
|
558
|
+
return newBuilder;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
count(): QueryBuilder<T, Selected, SingleMode, true, Occ, Expands> {
|
|
562
|
+
const newBuilder = new QueryBuilder<
|
|
563
|
+
T,
|
|
564
|
+
Selected,
|
|
565
|
+
SingleMode,
|
|
566
|
+
true,
|
|
567
|
+
Occ,
|
|
568
|
+
Expands
|
|
569
|
+
>({
|
|
570
|
+
occurrence: this.occurrence,
|
|
571
|
+
tableName: this.tableName,
|
|
572
|
+
databaseName: this.databaseName,
|
|
573
|
+
context: this.context,
|
|
574
|
+
});
|
|
575
|
+
newBuilder.queryOptions = { ...this.queryOptions, count: true };
|
|
576
|
+
newBuilder.expandConfigs = [...this.expandConfigs];
|
|
577
|
+
newBuilder.singleMode = this.singleMode;
|
|
578
|
+
newBuilder.isCountMode = true as true;
|
|
579
|
+
// Preserve navigation metadata
|
|
580
|
+
newBuilder.isNavigate = this.isNavigate;
|
|
581
|
+
newBuilder.navigateRecordId = this.navigateRecordId;
|
|
582
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
583
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
584
|
+
newBuilder.navigateBaseRelation = this.navigateBaseRelation;
|
|
585
|
+
return newBuilder;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async execute<EO extends ExecuteOptions>(
|
|
589
|
+
options?: RequestInit & FFetchOptions & EO,
|
|
590
|
+
): Promise<
|
|
591
|
+
Result<
|
|
592
|
+
IsCount extends true
|
|
593
|
+
? number
|
|
594
|
+
: SingleMode extends "exact"
|
|
595
|
+
? ConditionallyWithODataAnnotations<
|
|
596
|
+
Pick<T, Selected> & {
|
|
597
|
+
[K in keyof Expands]: Pick<
|
|
598
|
+
Expands[K]["schema"],
|
|
599
|
+
Expands[K]["selected"]
|
|
600
|
+
>[];
|
|
601
|
+
},
|
|
602
|
+
EO["includeODataAnnotations"] extends true ? true : false
|
|
603
|
+
>
|
|
604
|
+
: SingleMode extends "maybe"
|
|
605
|
+
? ConditionallyWithODataAnnotations<
|
|
606
|
+
Pick<T, Selected> & {
|
|
607
|
+
[K in keyof Expands]: Pick<
|
|
608
|
+
Expands[K]["schema"],
|
|
609
|
+
Expands[K]["selected"]
|
|
610
|
+
>[];
|
|
611
|
+
},
|
|
612
|
+
EO["includeODataAnnotations"] extends true ? true : false
|
|
613
|
+
> | null
|
|
614
|
+
: ConditionallyWithODataAnnotations<
|
|
615
|
+
Pick<T, Selected> & {
|
|
616
|
+
[K in keyof Expands]: Pick<
|
|
617
|
+
Expands[K]["schema"],
|
|
618
|
+
Expands[K]["selected"]
|
|
619
|
+
>[];
|
|
620
|
+
},
|
|
621
|
+
EO["includeODataAnnotations"] extends true ? true : false
|
|
622
|
+
>[]
|
|
623
|
+
>
|
|
624
|
+
> {
|
|
625
|
+
try {
|
|
626
|
+
// Build query without expand (we'll add it manually)
|
|
627
|
+
const queryOptionsWithoutExpand = { ...this.queryOptions };
|
|
628
|
+
delete queryOptionsWithoutExpand.expand;
|
|
629
|
+
|
|
630
|
+
// Format select fields before building query
|
|
631
|
+
if (queryOptionsWithoutExpand.select) {
|
|
632
|
+
queryOptionsWithoutExpand.select = this.formatSelectFields(
|
|
633
|
+
queryOptionsWithoutExpand.select,
|
|
634
|
+
) as any;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let queryString = buildQuery(queryOptionsWithoutExpand);
|
|
638
|
+
|
|
639
|
+
// Build custom expand string
|
|
640
|
+
const expandString = this.buildExpandString(this.expandConfigs);
|
|
641
|
+
if (expandString) {
|
|
642
|
+
const separator = queryString.includes("?") ? "&" : "?";
|
|
643
|
+
queryString = `${queryString}${separator}$expand=${expandString}`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Handle navigation from RecordBuilder
|
|
647
|
+
if (
|
|
648
|
+
this.isNavigate &&
|
|
649
|
+
this.navigateRecordId &&
|
|
650
|
+
this.navigateRelation &&
|
|
651
|
+
this.navigateSourceTableName
|
|
652
|
+
) {
|
|
653
|
+
let url: string;
|
|
654
|
+
if (this.navigateBaseRelation) {
|
|
655
|
+
// Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
|
|
656
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
|
|
657
|
+
} else {
|
|
658
|
+
// Normal navigation: /sourceTable('recordId')/relation
|
|
659
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
|
|
660
|
+
}
|
|
661
|
+
const response = await this.context._makeRequest(url, options);
|
|
662
|
+
|
|
663
|
+
// Skip validation if requested
|
|
664
|
+
if (options?.skipValidation === true) {
|
|
665
|
+
const resp = response as any;
|
|
666
|
+
if (this.singleMode !== false) {
|
|
667
|
+
const records = resp.value ?? [resp];
|
|
668
|
+
const count = Array.isArray(records) ? records.length : 1;
|
|
669
|
+
|
|
670
|
+
if (count > 1) {
|
|
671
|
+
return {
|
|
672
|
+
data: undefined,
|
|
673
|
+
error: new Error(
|
|
674
|
+
`Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
|
|
675
|
+
),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (count === 0) {
|
|
680
|
+
if (this.singleMode === "exact") {
|
|
681
|
+
return {
|
|
682
|
+
data: undefined,
|
|
683
|
+
error: new Error(
|
|
684
|
+
"Expected exactly one record, but received none",
|
|
685
|
+
),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return { data: null as any, error: undefined };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const record = Array.isArray(records) ? records[0] : records;
|
|
692
|
+
const stripped = this.stripODataAnnotationsIfNeeded(
|
|
693
|
+
record,
|
|
694
|
+
options,
|
|
695
|
+
);
|
|
696
|
+
return { data: stripped as any, error: undefined };
|
|
697
|
+
} else {
|
|
698
|
+
const records = resp.value ?? [];
|
|
699
|
+
const stripped = records.map((record: any) =>
|
|
700
|
+
this.stripODataAnnotationsIfNeeded(record, options),
|
|
701
|
+
);
|
|
702
|
+
return { data: stripped as any, error: undefined };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Get schema from occurrence if available
|
|
707
|
+
const schema = this.occurrence?.baseTable?.schema;
|
|
708
|
+
const selectedFields = this.queryOptions.select as
|
|
709
|
+
| (keyof T)[]
|
|
710
|
+
| undefined;
|
|
711
|
+
const expandValidationConfigs = this.buildExpandValidationConfigs(
|
|
712
|
+
this.expandConfigs,
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
if (this.singleMode !== false) {
|
|
716
|
+
const validation = await validateSingleResponse<T>(
|
|
717
|
+
response,
|
|
718
|
+
schema,
|
|
719
|
+
selectedFields,
|
|
720
|
+
expandValidationConfigs,
|
|
721
|
+
this.singleMode,
|
|
722
|
+
);
|
|
723
|
+
if (!validation.valid) {
|
|
724
|
+
return { data: undefined, error: validation.error };
|
|
725
|
+
}
|
|
726
|
+
const stripped = validation.data
|
|
727
|
+
? this.stripODataAnnotationsIfNeeded(validation.data, options)
|
|
728
|
+
: null;
|
|
729
|
+
return { data: stripped as any, error: undefined };
|
|
730
|
+
} else {
|
|
731
|
+
const validation = await validateListResponse<T>(
|
|
732
|
+
response,
|
|
733
|
+
schema,
|
|
734
|
+
selectedFields,
|
|
735
|
+
expandValidationConfigs,
|
|
736
|
+
);
|
|
737
|
+
if (!validation.valid) {
|
|
738
|
+
return { data: undefined, error: validation.error };
|
|
739
|
+
}
|
|
740
|
+
const stripped = validation.data.map((record) =>
|
|
741
|
+
this.stripODataAnnotationsIfNeeded(record, options),
|
|
742
|
+
);
|
|
743
|
+
return { data: stripped as any, error: undefined };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Handle navigation from EntitySet (without record ID)
|
|
748
|
+
if (
|
|
749
|
+
this.isNavigate &&
|
|
750
|
+
!this.navigateRecordId &&
|
|
751
|
+
this.navigateRelation &&
|
|
752
|
+
this.navigateSourceTableName
|
|
753
|
+
) {
|
|
754
|
+
const response = await this.context._makeRequest(
|
|
755
|
+
`/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`,
|
|
756
|
+
options,
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
// Skip validation if requested
|
|
760
|
+
if (options?.skipValidation === true) {
|
|
761
|
+
const resp = response as any;
|
|
762
|
+
if (this.singleMode !== false) {
|
|
763
|
+
const records = resp.value ?? [resp];
|
|
764
|
+
const count = Array.isArray(records) ? records.length : 1;
|
|
765
|
+
|
|
766
|
+
if (count > 1) {
|
|
767
|
+
return {
|
|
768
|
+
data: undefined,
|
|
769
|
+
error: new Error(
|
|
770
|
+
`Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
|
|
771
|
+
),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (count === 0) {
|
|
776
|
+
if (this.singleMode === "exact") {
|
|
777
|
+
return {
|
|
778
|
+
data: undefined,
|
|
779
|
+
error: new Error(
|
|
780
|
+
"Expected exactly one record, but received none",
|
|
781
|
+
),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
return { data: null as any, error: undefined };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const record = Array.isArray(records) ? records[0] : records;
|
|
788
|
+
const stripped = this.stripODataAnnotationsIfNeeded(
|
|
789
|
+
record,
|
|
790
|
+
options,
|
|
791
|
+
);
|
|
792
|
+
return { data: stripped as any, error: undefined };
|
|
793
|
+
} else {
|
|
794
|
+
const records = resp.value ?? [];
|
|
795
|
+
const stripped = records.map((record: any) =>
|
|
796
|
+
this.stripODataAnnotationsIfNeeded(record, options),
|
|
797
|
+
);
|
|
798
|
+
return { data: stripped as any, error: undefined };
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Get schema from occurrence if available
|
|
803
|
+
const schema = this.occurrence?.baseTable?.schema;
|
|
804
|
+
const selectedFields = this.queryOptions.select as
|
|
805
|
+
| (keyof T)[]
|
|
806
|
+
| undefined;
|
|
807
|
+
const expandValidationConfigs = this.buildExpandValidationConfigs(
|
|
808
|
+
this.expandConfigs,
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
if (this.singleMode !== false) {
|
|
812
|
+
const validation = await validateSingleResponse<T>(
|
|
813
|
+
response,
|
|
814
|
+
schema,
|
|
815
|
+
selectedFields,
|
|
816
|
+
expandValidationConfigs,
|
|
817
|
+
this.singleMode,
|
|
818
|
+
);
|
|
819
|
+
if (!validation.valid) {
|
|
820
|
+
return { data: undefined, error: validation.error };
|
|
821
|
+
}
|
|
822
|
+
const stripped = validation.data
|
|
823
|
+
? this.stripODataAnnotationsIfNeeded(validation.data, options)
|
|
824
|
+
: null;
|
|
825
|
+
return { data: stripped as any, error: undefined };
|
|
826
|
+
} else {
|
|
827
|
+
const validation = await validateListResponse<T>(
|
|
828
|
+
response,
|
|
829
|
+
schema,
|
|
830
|
+
selectedFields,
|
|
831
|
+
expandValidationConfigs,
|
|
832
|
+
);
|
|
833
|
+
if (!validation.valid) {
|
|
834
|
+
return { data: undefined, error: validation.error };
|
|
835
|
+
}
|
|
836
|
+
const stripped = validation.data.map((record) =>
|
|
837
|
+
this.stripODataAnnotationsIfNeeded(record, options),
|
|
838
|
+
);
|
|
839
|
+
return { data: stripped as any, error: undefined };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Handle $count endpoint
|
|
844
|
+
if (this.isCountMode) {
|
|
845
|
+
const result = await this.context._makeRequest(
|
|
846
|
+
`/${this.databaseName}/${this.tableName}/$count${queryString}`,
|
|
847
|
+
options,
|
|
848
|
+
);
|
|
849
|
+
// OData returns count as a string, convert to number
|
|
850
|
+
const count = typeof result === "string" ? Number(result) : result;
|
|
851
|
+
return { data: count as number, error: undefined } as any;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const response = await this.context._makeRequest(
|
|
855
|
+
`/${this.databaseName}/${this.tableName}${queryString}`,
|
|
856
|
+
options,
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
// Skip validation if requested
|
|
860
|
+
if (options?.skipValidation === true) {
|
|
861
|
+
const resp = response as any;
|
|
862
|
+
if (this.singleMode !== false) {
|
|
863
|
+
const records = resp.value ?? [resp];
|
|
864
|
+
const count = Array.isArray(records) ? records.length : 1;
|
|
865
|
+
|
|
866
|
+
if (count > 1) {
|
|
867
|
+
return {
|
|
868
|
+
data: undefined,
|
|
869
|
+
error: new Error(
|
|
870
|
+
`Expected ${this.singleMode === "exact" ? "exactly one" : "at most one"} record, but received ${count}`,
|
|
871
|
+
),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (count === 0) {
|
|
876
|
+
if (this.singleMode === "exact") {
|
|
877
|
+
return {
|
|
878
|
+
data: undefined,
|
|
879
|
+
error: new Error(
|
|
880
|
+
"Expected exactly one record, but received none",
|
|
881
|
+
),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
return { data: null as any, error: undefined };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const record = Array.isArray(records) ? records[0] : records;
|
|
888
|
+
const stripped = this.stripODataAnnotationsIfNeeded(record, options);
|
|
889
|
+
return { data: stripped as any, error: undefined };
|
|
890
|
+
} else {
|
|
891
|
+
// Handle list response structure
|
|
892
|
+
const records = resp.value ?? [];
|
|
893
|
+
const stripped = records.map((record: any) =>
|
|
894
|
+
this.stripODataAnnotationsIfNeeded(record, options),
|
|
895
|
+
);
|
|
896
|
+
return { data: stripped as any, error: undefined };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Get schema from occurrence if available
|
|
901
|
+
const schema = this.occurrence?.baseTable?.schema;
|
|
902
|
+
const selectedFields = this.queryOptions.select as
|
|
903
|
+
| (keyof T)[]
|
|
904
|
+
| undefined;
|
|
905
|
+
const expandValidationConfigs = this.buildExpandValidationConfigs(
|
|
906
|
+
this.expandConfigs,
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
if (this.singleMode !== false) {
|
|
910
|
+
const validation = await validateSingleResponse<T>(
|
|
911
|
+
response,
|
|
912
|
+
schema,
|
|
913
|
+
selectedFields,
|
|
914
|
+
expandValidationConfigs,
|
|
915
|
+
this.singleMode,
|
|
916
|
+
);
|
|
917
|
+
if (!validation.valid) {
|
|
918
|
+
return { data: undefined, error: validation.error };
|
|
919
|
+
}
|
|
920
|
+
const stripped = validation.data
|
|
921
|
+
? this.stripODataAnnotationsIfNeeded(validation.data, options)
|
|
922
|
+
: null;
|
|
923
|
+
return {
|
|
924
|
+
data: stripped as any,
|
|
925
|
+
error: undefined,
|
|
926
|
+
};
|
|
927
|
+
} else {
|
|
928
|
+
const validation = await validateListResponse<T>(
|
|
929
|
+
response,
|
|
930
|
+
schema,
|
|
931
|
+
selectedFields,
|
|
932
|
+
expandValidationConfigs,
|
|
933
|
+
);
|
|
934
|
+
if (!validation.valid) {
|
|
935
|
+
return { data: undefined, error: validation.error };
|
|
936
|
+
}
|
|
937
|
+
const stripped = validation.data.map((record) =>
|
|
938
|
+
this.stripODataAnnotationsIfNeeded(record, options),
|
|
939
|
+
);
|
|
940
|
+
return {
|
|
941
|
+
data: stripped as any,
|
|
942
|
+
error: undefined,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
} catch (error) {
|
|
946
|
+
return {
|
|
947
|
+
data: undefined,
|
|
948
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
getQueryString(): string {
|
|
954
|
+
// Build query without expand (we'll add it manually)
|
|
955
|
+
const queryOptionsWithoutExpand = { ...this.queryOptions };
|
|
956
|
+
delete queryOptionsWithoutExpand.expand;
|
|
957
|
+
|
|
958
|
+
// Format select fields before building query - buildQuery treats & as separator,
|
|
959
|
+
// so we need to pre-encode special characters. buildQuery preserves encoded values.
|
|
960
|
+
if (queryOptionsWithoutExpand.select) {
|
|
961
|
+
queryOptionsWithoutExpand.select = this.formatSelectFields(
|
|
962
|
+
queryOptionsWithoutExpand.select,
|
|
963
|
+
) as any;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
let queryParams = buildQuery(queryOptionsWithoutExpand);
|
|
967
|
+
|
|
968
|
+
// Post-process: buildQuery encodes spaces as %20, but we want to preserve spaces
|
|
969
|
+
// Replace %20 with spaces in the $select part
|
|
970
|
+
if (this.queryOptions.select) {
|
|
971
|
+
queryParams = queryParams.replace(
|
|
972
|
+
/\$select=([^&]*)/,
|
|
973
|
+
(match, selectValue) => {
|
|
974
|
+
return `$select=${selectValue.replace(/%20/g, " ")}`;
|
|
975
|
+
},
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
const expandString = this.buildExpandString(this.expandConfigs);
|
|
979
|
+
if (expandString) {
|
|
980
|
+
const separator = queryParams.includes("?") ? "&" : "?";
|
|
981
|
+
queryParams = `${queryParams}${separator}$expand=${expandString}`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Handle navigation from RecordBuilder (with record ID)
|
|
985
|
+
if (
|
|
986
|
+
this.isNavigate &&
|
|
987
|
+
this.navigateRecordId &&
|
|
988
|
+
this.navigateRelation &&
|
|
989
|
+
this.navigateSourceTableName
|
|
990
|
+
) {
|
|
991
|
+
let path: string;
|
|
992
|
+
if (this.navigateBaseRelation) {
|
|
993
|
+
// Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
|
|
994
|
+
path = `/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}`;
|
|
995
|
+
} else {
|
|
996
|
+
// Normal navigation: /sourceTableName('recordId')/relationName
|
|
997
|
+
path = `/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}`;
|
|
998
|
+
}
|
|
999
|
+
// Append query params if any exist
|
|
1000
|
+
return queryParams ? `${path}${queryParams}` : path;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Handle navigation from EntitySet (without record ID)
|
|
1004
|
+
if (
|
|
1005
|
+
this.isNavigate &&
|
|
1006
|
+
!this.navigateRecordId &&
|
|
1007
|
+
this.navigateRelation &&
|
|
1008
|
+
this.navigateSourceTableName
|
|
1009
|
+
) {
|
|
1010
|
+
// Return the path portion: /sourceTableName/relationName
|
|
1011
|
+
const path = `/${this.navigateSourceTableName}/${this.navigateRelation}`;
|
|
1012
|
+
// Append query params if any exist
|
|
1013
|
+
return queryParams ? `${path}${queryParams}` : path;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Default case: return table name with query params
|
|
1017
|
+
return `/${this.tableName}${queryParams}`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
getRequestConfig(): { method: string; url: string; body?: any } {
|
|
1021
|
+
// Build query without expand (we'll add it manually)
|
|
1022
|
+
const queryOptionsWithoutExpand = { ...this.queryOptions };
|
|
1023
|
+
delete queryOptionsWithoutExpand.expand;
|
|
1024
|
+
|
|
1025
|
+
// Format select fields before building query
|
|
1026
|
+
if (queryOptionsWithoutExpand.select) {
|
|
1027
|
+
queryOptionsWithoutExpand.select = this.formatSelectFields(
|
|
1028
|
+
queryOptionsWithoutExpand.select,
|
|
1029
|
+
) as any;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
let queryString = buildQuery(queryOptionsWithoutExpand);
|
|
1033
|
+
|
|
1034
|
+
// Build custom expand string
|
|
1035
|
+
const expandString = this.buildExpandString(this.expandConfigs);
|
|
1036
|
+
if (expandString) {
|
|
1037
|
+
const separator = queryString.includes("?") ? "&" : "?";
|
|
1038
|
+
queryString = `${queryString}${separator}$expand=${expandString}`;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
let url: string;
|
|
1042
|
+
|
|
1043
|
+
// Handle navigation from RecordBuilder (with record ID)
|
|
1044
|
+
if (
|
|
1045
|
+
this.isNavigate &&
|
|
1046
|
+
this.navigateRecordId &&
|
|
1047
|
+
this.navigateRelation &&
|
|
1048
|
+
this.navigateSourceTableName
|
|
1049
|
+
) {
|
|
1050
|
+
if (this.navigateBaseRelation) {
|
|
1051
|
+
// Navigation from a navigated EntitySet: /sourceTable/baseRelation('recordId')/relation
|
|
1052
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateBaseRelation}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
|
|
1053
|
+
} else {
|
|
1054
|
+
// Normal navigation: /sourceTable('recordId')/relation
|
|
1055
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}('${this.navigateRecordId}')/${this.navigateRelation}${queryString}`;
|
|
1056
|
+
}
|
|
1057
|
+
} else if (
|
|
1058
|
+
this.isNavigate &&
|
|
1059
|
+
!this.navigateRecordId &&
|
|
1060
|
+
this.navigateRelation &&
|
|
1061
|
+
this.navigateSourceTableName
|
|
1062
|
+
) {
|
|
1063
|
+
// Handle navigation from EntitySet (without record ID)
|
|
1064
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}${queryString}`;
|
|
1065
|
+
} else if (this.isCountMode) {
|
|
1066
|
+
url = `/${this.databaseName}/${this.tableName}/$count${queryString}`;
|
|
1067
|
+
} else {
|
|
1068
|
+
url = `/${this.databaseName}/${this.tableName}${queryString}`;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
method: "GET",
|
|
1073
|
+
url,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
}
|