@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.
Files changed (163) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +655 -453
  3. package/dist/esm/client/batch-builder.d.ts +10 -9
  4. package/dist/esm/client/batch-builder.js +119 -56
  5. package/dist/esm/client/batch-builder.js.map +1 -1
  6. package/dist/esm/client/batch-request.js +16 -21
  7. package/dist/esm/client/batch-request.js.map +1 -1
  8. package/dist/esm/client/builders/default-select.d.ts +10 -0
  9. package/dist/esm/client/builders/default-select.js +41 -0
  10. package/dist/esm/client/builders/default-select.js.map +1 -0
  11. package/dist/esm/client/builders/expand-builder.d.ts +45 -0
  12. package/dist/esm/client/builders/expand-builder.js +185 -0
  13. package/dist/esm/client/builders/expand-builder.js.map +1 -0
  14. package/dist/esm/client/builders/index.d.ts +9 -0
  15. package/dist/esm/client/builders/query-string-builder.d.ts +18 -0
  16. package/dist/esm/client/builders/query-string-builder.js +21 -0
  17. package/dist/esm/client/builders/query-string-builder.js.map +1 -0
  18. package/dist/esm/client/builders/response-processor.d.ts +43 -0
  19. package/dist/esm/client/builders/response-processor.js +175 -0
  20. package/dist/esm/client/builders/response-processor.js.map +1 -0
  21. package/dist/esm/client/builders/select-mixin.d.ts +25 -0
  22. package/dist/esm/client/builders/select-mixin.js +28 -0
  23. package/dist/esm/client/builders/select-mixin.js.map +1 -0
  24. package/dist/esm/client/builders/select-utils.d.ts +18 -0
  25. package/dist/esm/client/builders/select-utils.js +30 -0
  26. package/dist/esm/client/builders/select-utils.js.map +1 -0
  27. package/dist/esm/client/builders/shared-types.d.ts +40 -0
  28. package/dist/esm/client/builders/table-utils.d.ts +35 -0
  29. package/dist/esm/client/builders/table-utils.js +44 -0
  30. package/dist/esm/client/builders/table-utils.js.map +1 -0
  31. package/dist/esm/client/database.d.ts +34 -22
  32. package/dist/esm/client/database.js +48 -84
  33. package/dist/esm/client/database.js.map +1 -1
  34. package/dist/esm/client/delete-builder.d.ts +25 -30
  35. package/dist/esm/client/delete-builder.js +45 -30
  36. package/dist/esm/client/delete-builder.js.map +1 -1
  37. package/dist/esm/client/entity-set.d.ts +35 -43
  38. package/dist/esm/client/entity-set.js +126 -52
  39. package/dist/esm/client/entity-set.js.map +1 -1
  40. package/dist/esm/client/error-parser.d.ts +12 -0
  41. package/dist/esm/client/error-parser.js +25 -0
  42. package/dist/esm/client/error-parser.js.map +1 -0
  43. package/dist/esm/client/filemaker-odata.d.ts +26 -7
  44. package/dist/esm/client/filemaker-odata.js +65 -42
  45. package/dist/esm/client/filemaker-odata.js.map +1 -1
  46. package/dist/esm/client/insert-builder.d.ts +19 -24
  47. package/dist/esm/client/insert-builder.js +94 -58
  48. package/dist/esm/client/insert-builder.js.map +1 -1
  49. package/dist/esm/client/query/expand-builder.d.ts +35 -0
  50. package/dist/esm/client/query/index.d.ts +4 -0
  51. package/dist/esm/client/query/query-builder.d.ts +132 -0
  52. package/dist/esm/client/query/query-builder.js +456 -0
  53. package/dist/esm/client/query/query-builder.js.map +1 -0
  54. package/dist/esm/client/query/response-processor.d.ts +25 -0
  55. package/dist/esm/client/query/types.d.ts +77 -0
  56. package/dist/esm/client/query/url-builder.d.ts +71 -0
  57. package/dist/esm/client/query/url-builder.js +100 -0
  58. package/dist/esm/client/query/url-builder.js.map +1 -0
  59. package/dist/esm/client/query-builder.d.ts +2 -115
  60. package/dist/esm/client/record-builder.d.ts +108 -36
  61. package/dist/esm/client/record-builder.js +284 -119
  62. package/dist/esm/client/record-builder.js.map +1 -1
  63. package/dist/esm/client/response-processor.d.ts +4 -9
  64. package/dist/esm/client/sanitize-json.d.ts +35 -0
  65. package/dist/esm/client/sanitize-json.js +27 -0
  66. package/dist/esm/client/sanitize-json.js.map +1 -0
  67. package/dist/esm/client/schema-manager.d.ts +5 -5
  68. package/dist/esm/client/schema-manager.js +45 -31
  69. package/dist/esm/client/schema-manager.js.map +1 -1
  70. package/dist/esm/client/update-builder.d.ts +34 -40
  71. package/dist/esm/client/update-builder.js +99 -58
  72. package/dist/esm/client/update-builder.js.map +1 -1
  73. package/dist/esm/client/webhook-builder.d.ts +126 -0
  74. package/dist/esm/client/webhook-builder.js +189 -0
  75. package/dist/esm/client/webhook-builder.js.map +1 -0
  76. package/dist/esm/errors.d.ts +19 -2
  77. package/dist/esm/errors.js +39 -4
  78. package/dist/esm/errors.js.map +1 -1
  79. package/dist/esm/index.d.ts +10 -8
  80. package/dist/esm/index.js +40 -10
  81. package/dist/esm/index.js.map +1 -1
  82. package/dist/esm/logger.d.ts +47 -0
  83. package/dist/esm/logger.js +69 -0
  84. package/dist/esm/logger.js.map +1 -0
  85. package/dist/esm/logger.test.d.ts +1 -0
  86. package/dist/esm/orm/column.d.ts +62 -0
  87. package/dist/esm/orm/column.js +63 -0
  88. package/dist/esm/orm/column.js.map +1 -0
  89. package/dist/esm/orm/field-builders.d.ts +164 -0
  90. package/dist/esm/orm/field-builders.js +158 -0
  91. package/dist/esm/orm/field-builders.js.map +1 -0
  92. package/dist/esm/orm/index.d.ts +5 -0
  93. package/dist/esm/orm/operators.d.ts +173 -0
  94. package/dist/esm/orm/operators.js +260 -0
  95. package/dist/esm/orm/operators.js.map +1 -0
  96. package/dist/esm/orm/table.d.ts +355 -0
  97. package/dist/esm/orm/table.js +202 -0
  98. package/dist/esm/orm/table.js.map +1 -0
  99. package/dist/esm/transform.d.ts +20 -21
  100. package/dist/esm/transform.js +44 -45
  101. package/dist/esm/transform.js.map +1 -1
  102. package/dist/esm/types.d.ts +96 -30
  103. package/dist/esm/types.js +7 -0
  104. package/dist/esm/types.js.map +1 -0
  105. package/dist/esm/validation.d.ts +22 -12
  106. package/dist/esm/validation.js +132 -85
  107. package/dist/esm/validation.js.map +1 -1
  108. package/package.json +34 -29
  109. package/src/client/batch-builder.ts +153 -89
  110. package/src/client/batch-request.ts +25 -41
  111. package/src/client/builders/default-select.ts +75 -0
  112. package/src/client/builders/expand-builder.ts +246 -0
  113. package/src/client/builders/index.ts +11 -0
  114. package/src/client/builders/query-string-builder.ts +46 -0
  115. package/src/client/builders/response-processor.ts +279 -0
  116. package/src/client/builders/select-mixin.ts +65 -0
  117. package/src/client/builders/select-utils.ts +59 -0
  118. package/src/client/builders/shared-types.ts +45 -0
  119. package/src/client/builders/table-utils.ts +83 -0
  120. package/src/client/database.ts +89 -183
  121. package/src/client/delete-builder.ts +74 -84
  122. package/src/client/entity-set.ts +286 -293
  123. package/src/client/error-parser.ts +41 -0
  124. package/src/client/filemaker-odata.ts +98 -66
  125. package/src/client/insert-builder.ts +157 -118
  126. package/src/client/query/expand-builder.ts +160 -0
  127. package/src/client/query/index.ts +14 -0
  128. package/src/client/query/query-builder.ts +729 -0
  129. package/src/client/query/response-processor.ts +226 -0
  130. package/src/client/query/types.ts +126 -0
  131. package/src/client/query/url-builder.ts +151 -0
  132. package/src/client/query-builder.ts +10 -1455
  133. package/src/client/record-builder.ts +575 -240
  134. package/src/client/response-processor.ts +15 -42
  135. package/src/client/sanitize-json.ts +64 -0
  136. package/src/client/schema-manager.ts +61 -76
  137. package/src/client/update-builder.ts +161 -143
  138. package/src/client/webhook-builder.ts +265 -0
  139. package/src/errors.ts +49 -16
  140. package/src/index.ts +99 -54
  141. package/src/logger.test.ts +34 -0
  142. package/src/logger.ts +116 -0
  143. package/src/orm/column.ts +106 -0
  144. package/src/orm/field-builders.ts +250 -0
  145. package/src/orm/index.ts +61 -0
  146. package/src/orm/operators.ts +473 -0
  147. package/src/orm/table.ts +741 -0
  148. package/src/transform.ts +90 -70
  149. package/src/types.ts +154 -113
  150. package/src/validation.ts +200 -115
  151. package/dist/esm/client/base-table.d.ts +0 -125
  152. package/dist/esm/client/base-table.js +0 -57
  153. package/dist/esm/client/base-table.js.map +0 -1
  154. package/dist/esm/client/query-builder.js +0 -896
  155. package/dist/esm/client/query-builder.js.map +0 -1
  156. package/dist/esm/client/table-occurrence.d.ts +0 -72
  157. package/dist/esm/client/table-occurrence.js +0 -74
  158. package/dist/esm/client/table-occurrence.js.map +0 -1
  159. package/dist/esm/filter-types.d.ts +0 -76
  160. package/src/client/base-table.ts +0 -175
  161. package/src/client/query-builder.ts.bak +0 -1457
  162. package/src/client/table-occurrence.ts +0 -175
  163. 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
+ }