@proofkit/fmodata 0.1.0-alpha.9 → 0.1.0-beta.24
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/LICENSE.md +21 -0
- package/README.md +655 -453
- package/dist/esm/client/batch-builder.d.ts +10 -9
- package/dist/esm/client/batch-builder.js +119 -56
- package/dist/esm/client/batch-builder.js.map +1 -1
- package/dist/esm/client/batch-request.js +16 -21
- package/dist/esm/client/batch-request.js.map +1 -1
- package/dist/esm/client/builders/default-select.d.ts +10 -0
- package/dist/esm/client/builders/default-select.js +41 -0
- package/dist/esm/client/builders/default-select.js.map +1 -0
- package/dist/esm/client/builders/expand-builder.d.ts +45 -0
- package/dist/esm/client/builders/expand-builder.js +185 -0
- package/dist/esm/client/builders/expand-builder.js.map +1 -0
- package/dist/esm/client/builders/index.d.ts +9 -0
- package/dist/esm/client/builders/query-string-builder.d.ts +18 -0
- package/dist/esm/client/builders/query-string-builder.js +21 -0
- package/dist/esm/client/builders/query-string-builder.js.map +1 -0
- package/dist/esm/client/builders/response-processor.d.ts +43 -0
- package/dist/esm/client/builders/response-processor.js +175 -0
- package/dist/esm/client/builders/response-processor.js.map +1 -0
- package/dist/esm/client/builders/select-mixin.d.ts +25 -0
- package/dist/esm/client/builders/select-mixin.js +28 -0
- package/dist/esm/client/builders/select-mixin.js.map +1 -0
- package/dist/esm/client/builders/select-utils.d.ts +18 -0
- package/dist/esm/client/builders/select-utils.js +30 -0
- package/dist/esm/client/builders/select-utils.js.map +1 -0
- package/dist/esm/client/builders/shared-types.d.ts +40 -0
- package/dist/esm/client/builders/table-utils.d.ts +35 -0
- package/dist/esm/client/builders/table-utils.js +44 -0
- package/dist/esm/client/builders/table-utils.js.map +1 -0
- package/dist/esm/client/database.d.ts +34 -22
- package/dist/esm/client/database.js +48 -84
- package/dist/esm/client/database.js.map +1 -1
- package/dist/esm/client/delete-builder.d.ts +25 -30
- package/dist/esm/client/delete-builder.js +45 -30
- package/dist/esm/client/delete-builder.js.map +1 -1
- package/dist/esm/client/entity-set.d.ts +35 -43
- package/dist/esm/client/entity-set.js +126 -52
- package/dist/esm/client/entity-set.js.map +1 -1
- package/dist/esm/client/error-parser.d.ts +12 -0
- package/dist/esm/client/error-parser.js +25 -0
- package/dist/esm/client/error-parser.js.map +1 -0
- package/dist/esm/client/filemaker-odata.d.ts +26 -7
- package/dist/esm/client/filemaker-odata.js +65 -42
- package/dist/esm/client/filemaker-odata.js.map +1 -1
- package/dist/esm/client/insert-builder.d.ts +19 -24
- package/dist/esm/client/insert-builder.js +94 -58
- package/dist/esm/client/insert-builder.js.map +1 -1
- package/dist/esm/client/query/expand-builder.d.ts +35 -0
- package/dist/esm/client/query/index.d.ts +4 -0
- package/dist/esm/client/query/query-builder.d.ts +132 -0
- package/dist/esm/client/query/query-builder.js +456 -0
- package/dist/esm/client/query/query-builder.js.map +1 -0
- package/dist/esm/client/query/response-processor.d.ts +25 -0
- package/dist/esm/client/query/types.d.ts +77 -0
- package/dist/esm/client/query/url-builder.d.ts +71 -0
- package/dist/esm/client/query/url-builder.js +100 -0
- package/dist/esm/client/query/url-builder.js.map +1 -0
- package/dist/esm/client/query-builder.d.ts +2 -115
- package/dist/esm/client/record-builder.d.ts +108 -36
- package/dist/esm/client/record-builder.js +284 -119
- package/dist/esm/client/record-builder.js.map +1 -1
- package/dist/esm/client/response-processor.d.ts +4 -9
- package/dist/esm/client/sanitize-json.d.ts +35 -0
- package/dist/esm/client/sanitize-json.js +27 -0
- package/dist/esm/client/sanitize-json.js.map +1 -0
- package/dist/esm/client/schema-manager.d.ts +5 -5
- package/dist/esm/client/schema-manager.js +45 -31
- package/dist/esm/client/schema-manager.js.map +1 -1
- package/dist/esm/client/update-builder.d.ts +34 -40
- package/dist/esm/client/update-builder.js +99 -58
- package/dist/esm/client/update-builder.js.map +1 -1
- package/dist/esm/client/webhook-builder.d.ts +126 -0
- package/dist/esm/client/webhook-builder.js +189 -0
- package/dist/esm/client/webhook-builder.js.map +1 -0
- package/dist/esm/errors.d.ts +19 -2
- package/dist/esm/errors.js +39 -4
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +10 -8
- package/dist/esm/index.js +40 -10
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/logger.d.ts +47 -0
- package/dist/esm/logger.js +69 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/logger.test.d.ts +1 -0
- package/dist/esm/orm/column.d.ts +62 -0
- package/dist/esm/orm/column.js +63 -0
- package/dist/esm/orm/column.js.map +1 -0
- package/dist/esm/orm/field-builders.d.ts +164 -0
- package/dist/esm/orm/field-builders.js +158 -0
- package/dist/esm/orm/field-builders.js.map +1 -0
- package/dist/esm/orm/index.d.ts +5 -0
- package/dist/esm/orm/operators.d.ts +173 -0
- package/dist/esm/orm/operators.js +260 -0
- package/dist/esm/orm/operators.js.map +1 -0
- package/dist/esm/orm/table.d.ts +355 -0
- package/dist/esm/orm/table.js +202 -0
- package/dist/esm/orm/table.js.map +1 -0
- package/dist/esm/transform.d.ts +20 -21
- package/dist/esm/transform.js +44 -45
- package/dist/esm/transform.js.map +1 -1
- package/dist/esm/types.d.ts +96 -30
- package/dist/esm/types.js +7 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/validation.d.ts +22 -12
- package/dist/esm/validation.js +132 -85
- package/dist/esm/validation.js.map +1 -1
- package/package.json +34 -29
- package/src/client/batch-builder.ts +153 -89
- package/src/client/batch-request.ts +25 -41
- package/src/client/builders/default-select.ts +75 -0
- package/src/client/builders/expand-builder.ts +246 -0
- package/src/client/builders/index.ts +11 -0
- package/src/client/builders/query-string-builder.ts +46 -0
- package/src/client/builders/response-processor.ts +279 -0
- package/src/client/builders/select-mixin.ts +65 -0
- package/src/client/builders/select-utils.ts +59 -0
- package/src/client/builders/shared-types.ts +45 -0
- package/src/client/builders/table-utils.ts +83 -0
- package/src/client/database.ts +89 -183
- package/src/client/delete-builder.ts +74 -84
- package/src/client/entity-set.ts +286 -293
- package/src/client/error-parser.ts +41 -0
- package/src/client/filemaker-odata.ts +98 -66
- package/src/client/insert-builder.ts +157 -118
- package/src/client/query/expand-builder.ts +160 -0
- package/src/client/query/index.ts +14 -0
- package/src/client/query/query-builder.ts +729 -0
- package/src/client/query/response-processor.ts +226 -0
- package/src/client/query/types.ts +126 -0
- package/src/client/query/url-builder.ts +151 -0
- package/src/client/query-builder.ts +10 -1455
- package/src/client/record-builder.ts +575 -240
- package/src/client/response-processor.ts +15 -42
- package/src/client/sanitize-json.ts +64 -0
- package/src/client/schema-manager.ts +61 -76
- package/src/client/update-builder.ts +161 -143
- package/src/client/webhook-builder.ts +265 -0
- package/src/errors.ts +49 -16
- package/src/index.ts +99 -54
- package/src/logger.test.ts +34 -0
- package/src/logger.ts +116 -0
- package/src/orm/column.ts +106 -0
- package/src/orm/field-builders.ts +250 -0
- package/src/orm/index.ts +61 -0
- package/src/orm/operators.ts +473 -0
- package/src/orm/table.ts +741 -0
- package/src/transform.ts +90 -70
- package/src/types.ts +154 -113
- package/src/validation.ts +200 -115
- package/dist/esm/client/base-table.d.ts +0 -125
- package/dist/esm/client/base-table.js +0 -57
- package/dist/esm/client/base-table.js.map +0 -1
- package/dist/esm/client/query-builder.js +0 -896
- package/dist/esm/client/query-builder.js.map +0 -1
- package/dist/esm/client/table-occurrence.d.ts +0 -72
- package/dist/esm/client/table-occurrence.js +0 -74
- package/dist/esm/client/table-occurrence.js.map +0 -1
- package/dist/esm/filter-types.d.ts +0 -76
- package/src/client/base-table.ts +0 -175
- package/src/client/query-builder.ts.bak +0 -1457
- package/src/client/table-occurrence.ts +0 -175
- package/src/filter-types.ts +0 -97
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { QueryOptions } from "odata-query";
|
|
3
|
+
import { RecordCountMismatchError } from "../../errors";
|
|
4
|
+
import type { InternalLogger } from "../../logger";
|
|
5
|
+
import type { FMTable } from "../../orm/table";
|
|
6
|
+
import { getTableSchema } from "../../orm/table";
|
|
7
|
+
import { transformResponseFields } from "../../transform";
|
|
8
|
+
import type { Result } from "../../types";
|
|
9
|
+
import type { ExpandValidationConfig } from "../../validation";
|
|
10
|
+
import { validateListResponse, validateSingleResponse } from "../../validation";
|
|
11
|
+
import type { ExpandConfig } from "./expand-builder";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Configuration for processing query responses
|
|
15
|
+
*/
|
|
16
|
+
export interface ProcessQueryResponseConfig<T> {
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
|
|
18
|
+
occurrence?: FMTable<any, any>;
|
|
19
|
+
singleMode: "exact" | "maybe" | false;
|
|
20
|
+
queryOptions: Partial<QueryOptions<T>>;
|
|
21
|
+
expandConfigs: ExpandConfig[];
|
|
22
|
+
skipValidation?: boolean;
|
|
23
|
+
useEntityIds?: boolean;
|
|
24
|
+
includeSpecialColumns?: boolean;
|
|
25
|
+
// Mapping from field names to output keys (for renamed fields in select)
|
|
26
|
+
fieldMapping?: Record<string, string>;
|
|
27
|
+
logger: InternalLogger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds expand validation configs from internal expand configurations.
|
|
32
|
+
* These are used to validate expanded navigation properties.
|
|
33
|
+
*/
|
|
34
|
+
function buildExpandValidationConfigs(configs: ExpandConfig[]): ExpandValidationConfig[] {
|
|
35
|
+
return configs.map((config) => {
|
|
36
|
+
// Get target table/occurrence from config (stored during expand call)
|
|
37
|
+
const targetTable = config.targetTable;
|
|
38
|
+
|
|
39
|
+
// Extract schema from target table/occurrence
|
|
40
|
+
// Schema is stored directly as Partial<Record<keyof TFields, StandardSchemaV1>>
|
|
41
|
+
const targetSchema = targetTable
|
|
42
|
+
? (getTableSchema(targetTable) as Record<string, StandardSchemaV1> | undefined)
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
// Extract selected fields from options
|
|
46
|
+
let selectedFields: string[] | undefined;
|
|
47
|
+
if (config.options?.select) {
|
|
48
|
+
selectedFields = Array.isArray(config.options.select)
|
|
49
|
+
? config.options.select.map((f) => String(f))
|
|
50
|
+
: [String(config.options.select)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
relation: config.relation,
|
|
55
|
+
targetSchema,
|
|
56
|
+
targetTable,
|
|
57
|
+
table: targetTable, // For transformation
|
|
58
|
+
selectedFields,
|
|
59
|
+
nestedExpands: undefined, // TODO: Handle nested expands if needed
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extracts records from response data without validation.
|
|
66
|
+
* Handles both single and list responses.
|
|
67
|
+
*/
|
|
68
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API, generic return type
|
|
69
|
+
function extractRecords(data: any, singleMode: "exact" | "maybe" | false): Result<any> {
|
|
70
|
+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for response structure
|
|
71
|
+
const resp = data as any;
|
|
72
|
+
if (singleMode !== false) {
|
|
73
|
+
const records = resp.value ?? [resp];
|
|
74
|
+
const count = Array.isArray(records) ? records.length : 1;
|
|
75
|
+
|
|
76
|
+
if (count > 1) {
|
|
77
|
+
return {
|
|
78
|
+
data: undefined,
|
|
79
|
+
error: new RecordCountMismatchError(singleMode === "exact" ? "one" : "at-most-one", count),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (count === 0) {
|
|
84
|
+
if (singleMode === "exact") {
|
|
85
|
+
return {
|
|
86
|
+
data: undefined,
|
|
87
|
+
error: new RecordCountMismatchError("one", 0),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
|
|
91
|
+
return { data: null as any, error: undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const record = Array.isArray(records) ? records[0] : records;
|
|
95
|
+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
|
|
96
|
+
return { data: record as any, error: undefined };
|
|
97
|
+
}
|
|
98
|
+
// Handle list response structure
|
|
99
|
+
const records = resp.value ?? [];
|
|
100
|
+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
|
|
101
|
+
return { data: records as any, error: undefined };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Renames fields in response data according to the field mapping.
|
|
106
|
+
* Used when select() is called with renamed fields (e.g., { userEmail: users.email }).
|
|
107
|
+
*/
|
|
108
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic response data transformation
|
|
109
|
+
function renameFieldsInResponse(data: any, fieldMapping: Record<string, string>): any {
|
|
110
|
+
if (!data || typeof data !== "object") {
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle array responses
|
|
115
|
+
if (Array.isArray(data)) {
|
|
116
|
+
return data.map((item) => renameFieldsInResponse(item, fieldMapping));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Handle OData list response structure
|
|
120
|
+
if ("value" in data && Array.isArray(data.value)) {
|
|
121
|
+
return {
|
|
122
|
+
...data,
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic record transformation
|
|
124
|
+
value: data.value.map((item: any) => renameFieldsInResponse(item, fieldMapping)),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle single record
|
|
129
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic field transformation
|
|
130
|
+
const renamed: Record<string, any> = {};
|
|
131
|
+
for (const [key, value] of Object.entries(data)) {
|
|
132
|
+
// Check if this field should be renamed
|
|
133
|
+
const outputKey = fieldMapping[key];
|
|
134
|
+
if (outputKey) {
|
|
135
|
+
renamed[outputKey] = value;
|
|
136
|
+
} else {
|
|
137
|
+
renamed[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return renamed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Processes a query response by transforming field IDs and validating the data.
|
|
145
|
+
* This function consolidates the response processing logic that was duplicated
|
|
146
|
+
* across multiple navigation branches in QueryBuilder.execute().
|
|
147
|
+
*/
|
|
148
|
+
export async function processQueryResponse<T>(
|
|
149
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
|
|
150
|
+
response: any,
|
|
151
|
+
config: ProcessQueryResponseConfig<T>,
|
|
152
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic return type for interface compliance
|
|
153
|
+
): Promise<Result<any>> {
|
|
154
|
+
const { occurrence, singleMode, skipValidation, useEntityIds, fieldMapping } = config;
|
|
155
|
+
|
|
156
|
+
// Transform response if needed
|
|
157
|
+
let data = response;
|
|
158
|
+
if (occurrence && useEntityIds) {
|
|
159
|
+
const expandValidationConfigs = buildExpandValidationConfigs(config.expandConfigs);
|
|
160
|
+
data = transformResponseFields(response, occurrence, expandValidationConfigs);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Skip validation path
|
|
164
|
+
if (skipValidation) {
|
|
165
|
+
const result = extractRecords(data, singleMode);
|
|
166
|
+
// Rename fields AFTER extraction (but before returning)
|
|
167
|
+
if (result.data && fieldMapping && Object.keys(fieldMapping).length > 0) {
|
|
168
|
+
return {
|
|
169
|
+
...result,
|
|
170
|
+
data: renameFieldsInResponse(result.data, fieldMapping),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Validation path
|
|
177
|
+
// Get schema from occurrence if available
|
|
178
|
+
// Schema is stored directly as Partial<Record<keyof TFields, StandardSchemaV1>>
|
|
179
|
+
const schema = occurrence ? getTableSchema(occurrence) : undefined;
|
|
180
|
+
|
|
181
|
+
const selectedFields = config.queryOptions.select
|
|
182
|
+
? ((Array.isArray(config.queryOptions.select)
|
|
183
|
+
? config.queryOptions.select.map((f) => String(f))
|
|
184
|
+
: [String(config.queryOptions.select)]) as (keyof T)[])
|
|
185
|
+
: undefined;
|
|
186
|
+
const expandValidationConfigs = buildExpandValidationConfigs(config.expandConfigs);
|
|
187
|
+
|
|
188
|
+
// Validate with original field names
|
|
189
|
+
// Special columns are excluded when using single() method (per OData spec behavior)
|
|
190
|
+
// Note: While FileMaker may return special columns in single mode if requested via header,
|
|
191
|
+
// we exclude them here to maintain OData spec compliance. The types will also not include
|
|
192
|
+
// special columns for single mode to match this runtime behavior.
|
|
193
|
+
const shouldIncludeSpecialColumns = singleMode === false ? (config.includeSpecialColumns ?? false) : false;
|
|
194
|
+
const validationResult =
|
|
195
|
+
singleMode !== false
|
|
196
|
+
? await validateSingleResponse(
|
|
197
|
+
data,
|
|
198
|
+
schema,
|
|
199
|
+
selectedFields as string[] | undefined,
|
|
200
|
+
expandValidationConfigs,
|
|
201
|
+
singleMode,
|
|
202
|
+
shouldIncludeSpecialColumns,
|
|
203
|
+
)
|
|
204
|
+
: await validateListResponse(
|
|
205
|
+
data,
|
|
206
|
+
schema,
|
|
207
|
+
selectedFields as string[] | undefined,
|
|
208
|
+
expandValidationConfigs,
|
|
209
|
+
shouldIncludeSpecialColumns,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (!validationResult.valid) {
|
|
213
|
+
return { data: undefined, error: validationResult.error };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Rename fields AFTER validation completes
|
|
217
|
+
if (fieldMapping && Object.keys(fieldMapping).length > 0) {
|
|
218
|
+
return {
|
|
219
|
+
data: renameFieldsInResponse(validationResult.data, fieldMapping),
|
|
220
|
+
error: undefined,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
|
|
225
|
+
return { data: validationResult.data as any, error: undefined };
|
|
226
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Column } from "../../orm/column";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type-safe orderBy type that provides better DX than odata-query's default.
|
|
5
|
+
*
|
|
6
|
+
* Supported forms:
|
|
7
|
+
* - `keyof T` - single field name (defaults to ascending)
|
|
8
|
+
* - `[keyof T, 'asc' | 'desc']` - single field with explicit direction
|
|
9
|
+
* - `Array<[keyof T, 'asc' | 'desc']>` - multiple fields with directions
|
|
10
|
+
*
|
|
11
|
+
* This type intentionally EXCLUDES `Array<keyof T>` to avoid ambiguity
|
|
12
|
+
* between [field1, field2] and [field, direction].
|
|
13
|
+
*/
|
|
14
|
+
export type TypeSafeOrderBy<T> =
|
|
15
|
+
| (keyof T & string) // Single field name
|
|
16
|
+
| [keyof T & string, "asc" | "desc"] // Single field with direction
|
|
17
|
+
| [keyof T & string, "asc" | "desc"][]; // Multiple fields with directions
|
|
18
|
+
|
|
19
|
+
// Internal type for expand configuration
|
|
20
|
+
export interface ExpandConfig {
|
|
21
|
+
relation: string;
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryOptions configuration
|
|
23
|
+
options?: Partial<import("odata-query").QueryOptions<any>>;
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
|
|
25
|
+
targetTable?: import("../../orm/table").FMTable<any, any>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Type to represent expanded relations
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic schema and selected types from user input
|
|
30
|
+
export type ExpandedRelations = Record<string, { schema: any; selected: any; nested?: ExpandedRelations }>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract the value type from a Column.
|
|
34
|
+
* This uses the phantom type stored in Column to get the actual value type (output type for reading).
|
|
35
|
+
*/
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
|
|
37
|
+
type ExtractColumnType<C> = C extends Column<infer T, any, any, any> ? T : never;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map a select object to its return type.
|
|
41
|
+
* For each key in the select object, extract the type from the corresponding Column.
|
|
42
|
+
*/
|
|
43
|
+
type MapSelectToReturnType<
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
|
|
45
|
+
TSelect extends Record<string, Column<any, any, any, any>>,
|
|
46
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any schema shape
|
|
47
|
+
_TSchema extends Record<string, any>,
|
|
48
|
+
> = {
|
|
49
|
+
[K in keyof TSelect]: ExtractColumnType<TSelect[K]>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Helper: Resolve a single expand's return type, including nested expands
|
|
54
|
+
*/
|
|
55
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic schema and selected types from user input
|
|
56
|
+
export type ResolveExpandType<Exp extends { schema: any; selected: any; nested?: ExpandedRelations }> = // Handle the selected fields
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
|
|
58
|
+
(Exp["selected"] extends Record<string, Column<any, any, any, any>>
|
|
59
|
+
? MapSelectToReturnType<Exp["selected"], Exp["schema"]>
|
|
60
|
+
: Exp["selected"] extends keyof Exp["schema"]
|
|
61
|
+
? Pick<Exp["schema"], Exp["selected"]>
|
|
62
|
+
: Exp["schema"]) &
|
|
63
|
+
// Recursively handle nested expands
|
|
64
|
+
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no nested expands
|
|
65
|
+
(Exp["nested"] extends ExpandedRelations ? ResolveExpandedRelations<Exp["nested"]> : {});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Helper: Resolve all expanded relations recursively
|
|
69
|
+
*/
|
|
70
|
+
export type ResolveExpandedRelations<Exps extends ExpandedRelations> = {
|
|
71
|
+
[K in keyof Exps]: ResolveExpandType<Exps[K]>[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* System columns option for select() method.
|
|
76
|
+
* Allows explicitly requesting ROWID and/or ROWMODID when using select().
|
|
77
|
+
*/
|
|
78
|
+
export interface SystemColumnsOption {
|
|
79
|
+
ROWID?: boolean;
|
|
80
|
+
ROWMODID?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract system columns type from SystemColumnsOption.
|
|
85
|
+
* Returns an object type with ROWID and/or ROWMODID properties when set to true.
|
|
86
|
+
*/
|
|
87
|
+
export type SystemColumnsFromOption<T extends SystemColumnsOption | undefined> = (T extends { ROWID: true }
|
|
88
|
+
? { ROWID: number }
|
|
89
|
+
: // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no ROWID field
|
|
90
|
+
{}) &
|
|
91
|
+
// biome-ignore lint/complexity/noBannedTypes: Empty object type represents no ROWMODID field
|
|
92
|
+
(T extends { ROWMODID: true } ? { ROWMODID: number } : {});
|
|
93
|
+
|
|
94
|
+
export type QueryReturnType<
|
|
95
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any schema shape
|
|
96
|
+
T extends Record<string, any>,
|
|
97
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
|
|
98
|
+
Selected extends keyof T | Record<string, Column<any, any, any, any>>,
|
|
99
|
+
SingleMode extends "exact" | "maybe" | false,
|
|
100
|
+
IsCount extends boolean,
|
|
101
|
+
Expands extends ExpandedRelations,
|
|
102
|
+
SystemCols extends SystemColumnsOption | undefined = undefined,
|
|
103
|
+
> = IsCount extends true
|
|
104
|
+
? number
|
|
105
|
+
: // Use tuple wrapping [Selected] extends [...] to prevent distribution over unions
|
|
106
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
|
|
107
|
+
[Selected] extends [Record<string, Column<any, any, any, any>>]
|
|
108
|
+
? SingleMode extends "exact"
|
|
109
|
+
? MapSelectToReturnType<Selected, T> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>
|
|
110
|
+
: SingleMode extends "maybe"
|
|
111
|
+
?
|
|
112
|
+
| (MapSelectToReturnType<Selected, T> &
|
|
113
|
+
ResolveExpandedRelations<Expands> &
|
|
114
|
+
SystemColumnsFromOption<SystemCols>)
|
|
115
|
+
| null
|
|
116
|
+
: (MapSelectToReturnType<Selected, T> &
|
|
117
|
+
ResolveExpandedRelations<Expands> &
|
|
118
|
+
SystemColumnsFromOption<SystemCols>)[]
|
|
119
|
+
: // Use tuple wrapping to prevent distribution over union of keys
|
|
120
|
+
[Selected] extends [keyof T]
|
|
121
|
+
? SingleMode extends "exact"
|
|
122
|
+
? Pick<T, Selected> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>
|
|
123
|
+
: SingleMode extends "maybe"
|
|
124
|
+
? (Pick<T, Selected> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>) | null
|
|
125
|
+
: (Pick<T, Selected> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>)[]
|
|
126
|
+
: never;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { FMTable } from "../../orm/table";
|
|
2
|
+
import { getTableName } from "../../orm/table";
|
|
3
|
+
import type { ExecutionContext } from "../../types";
|
|
4
|
+
import { resolveTableId } from "../builders/table-utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration for navigation from RecordBuilder or EntitySet
|
|
8
|
+
*/
|
|
9
|
+
export interface NavigationConfig {
|
|
10
|
+
recordId?: string | number;
|
|
11
|
+
relation: string;
|
|
12
|
+
sourceTableName: string;
|
|
13
|
+
baseRelation?: string; // For chained navigations from navigated EntitySets
|
|
14
|
+
basePath?: string; // Full base path for chained entity set navigations
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds OData query URLs for different navigation modes.
|
|
19
|
+
* Handles:
|
|
20
|
+
* - Record navigation: /database/sourceTable('recordId')/relation
|
|
21
|
+
* - Entity set navigation: /database/sourceTable/relation
|
|
22
|
+
* - Count endpoint: /database/tableId/$count
|
|
23
|
+
* - Standard queries: /database/tableId
|
|
24
|
+
*/
|
|
25
|
+
export class QueryUrlBuilder {
|
|
26
|
+
private readonly databaseName: string;
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
|
|
28
|
+
private readonly occurrence: FMTable<any, any>;
|
|
29
|
+
private readonly context: ExecutionContext;
|
|
30
|
+
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
|
|
32
|
+
constructor(databaseName: string, occurrence: FMTable<any, any>, context: ExecutionContext) {
|
|
33
|
+
this.databaseName = databaseName;
|
|
34
|
+
this.occurrence = occurrence;
|
|
35
|
+
this.context = context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the full URL for a query request.
|
|
40
|
+
*
|
|
41
|
+
* @param queryString - The OData query string (e.g., "?$filter=...&$select=...")
|
|
42
|
+
* @param options - Options including whether this is a count query, useEntityIds override, and navigation config
|
|
43
|
+
*/
|
|
44
|
+
build(
|
|
45
|
+
queryString: string,
|
|
46
|
+
options: {
|
|
47
|
+
isCount?: boolean;
|
|
48
|
+
useEntityIds?: boolean;
|
|
49
|
+
navigation?: NavigationConfig;
|
|
50
|
+
},
|
|
51
|
+
): string {
|
|
52
|
+
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), this.context, options.useEntityIds);
|
|
53
|
+
|
|
54
|
+
const navigation = options.navigation;
|
|
55
|
+
if (navigation?.recordId && navigation?.relation) {
|
|
56
|
+
return this.buildRecordNavigation(queryString, tableId, navigation);
|
|
57
|
+
}
|
|
58
|
+
if (navigation?.relation) {
|
|
59
|
+
return this.buildEntitySetNavigation(queryString, tableId, navigation);
|
|
60
|
+
}
|
|
61
|
+
if (options.isCount) {
|
|
62
|
+
return `/${this.databaseName}/${tableId}/$count${queryString}`;
|
|
63
|
+
}
|
|
64
|
+
return `/${this.databaseName}/${tableId}${queryString}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds URL for record navigation: /database/sourceTable('recordId')/relation
|
|
69
|
+
* or /database/sourceTable/baseRelation('recordId')/relation for chained navigations
|
|
70
|
+
*/
|
|
71
|
+
private buildRecordNavigation(queryString: string, _tableId: string, navigation: NavigationConfig): string {
|
|
72
|
+
const { sourceTableName, baseRelation, recordId, relation } = navigation;
|
|
73
|
+
const base = baseRelation
|
|
74
|
+
? `${sourceTableName}/${baseRelation}('${recordId}')`
|
|
75
|
+
: `${sourceTableName}('${recordId}')`;
|
|
76
|
+
return `/${this.databaseName}/${base}/${relation}${queryString}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds URL for entity set navigation: /database/sourceTable/relation
|
|
81
|
+
* or /database/basePath/relation for chained navigations
|
|
82
|
+
*/
|
|
83
|
+
private buildEntitySetNavigation(queryString: string, _tableId: string, navigation: NavigationConfig): string {
|
|
84
|
+
const { sourceTableName, basePath, relation } = navigation;
|
|
85
|
+
const base = basePath || sourceTableName;
|
|
86
|
+
return `/${this.databaseName}/${base}/${relation}${queryString}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds a query string path (without database prefix) for getQueryString().
|
|
91
|
+
* Used when the full URL is not needed.
|
|
92
|
+
*/
|
|
93
|
+
buildPath(queryString: string, options?: { useEntityIds?: boolean; navigation?: NavigationConfig }): string {
|
|
94
|
+
const useEntityIds = options?.useEntityIds;
|
|
95
|
+
const navigation = options?.navigation;
|
|
96
|
+
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), this.context, useEntityIds);
|
|
97
|
+
|
|
98
|
+
if (navigation?.recordId && navigation?.relation) {
|
|
99
|
+
const { sourceTableName, baseRelation, recordId, relation } = navigation;
|
|
100
|
+
const base = baseRelation
|
|
101
|
+
? `${sourceTableName}/${baseRelation}('${recordId}')`
|
|
102
|
+
: `${sourceTableName}('${recordId}')`;
|
|
103
|
+
return queryString ? `/${base}/${relation}${queryString}` : `/${base}/${relation}`;
|
|
104
|
+
}
|
|
105
|
+
if (navigation?.relation) {
|
|
106
|
+
const { sourceTableName, basePath, relation } = navigation;
|
|
107
|
+
const base = basePath || sourceTableName;
|
|
108
|
+
return queryString ? `/${base}/${relation}${queryString}` : `/${base}/${relation}`;
|
|
109
|
+
}
|
|
110
|
+
return queryString ? `/${tableId}${queryString}` : `/${tableId}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build URL for record operations (single record by ID).
|
|
115
|
+
* Used by RecordBuilder to build URLs like /database/table('id').
|
|
116
|
+
*
|
|
117
|
+
* @param recordId - The record ID
|
|
118
|
+
* @param queryString - The OData query string (e.g., "?$select=...")
|
|
119
|
+
* @param options - Options including operation type and useEntityIds override
|
|
120
|
+
*/
|
|
121
|
+
buildRecordUrl(
|
|
122
|
+
recordId: string | number,
|
|
123
|
+
queryString: string,
|
|
124
|
+
options?: {
|
|
125
|
+
operation?: "getSingleField";
|
|
126
|
+
operationParam?: string;
|
|
127
|
+
useEntityIds?: boolean;
|
|
128
|
+
isNavigateFromEntitySet?: boolean;
|
|
129
|
+
navigateSourceTableName?: string;
|
|
130
|
+
navigateRelation?: string;
|
|
131
|
+
},
|
|
132
|
+
): string {
|
|
133
|
+
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), this.context, options?.useEntityIds);
|
|
134
|
+
|
|
135
|
+
// Build the base URL depending on whether this came from a navigated EntitySet
|
|
136
|
+
let url: string;
|
|
137
|
+
if (options?.isNavigateFromEntitySet && options.navigateSourceTableName && options.navigateRelation) {
|
|
138
|
+
// From navigated EntitySet: /sourceTable/relation('recordId')
|
|
139
|
+
url = `/${this.databaseName}/${options.navigateSourceTableName}/${options.navigateRelation}('${recordId}')`;
|
|
140
|
+
} else {
|
|
141
|
+
// Normal record: /tableName('recordId') - use FMTID if configured
|
|
142
|
+
url = `/${this.databaseName}/${tableId}('${recordId}')`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (options?.operation === "getSingleField" && options.operationParam) {
|
|
146
|
+
url += `/${options.operationParam}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return url + queryString;
|
|
150
|
+
}
|
|
151
|
+
}
|