@proofkit/fmodata 0.1.0-alpha.8 → 0.1.0-beta.23

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 +651 -449
  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 +110 -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 +28 -20
  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 +266 -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 -166
  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,246 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import buildQuery, { type QueryOptions } from "odata-query";
3
+ import type { InternalLogger } from "../../logger";
4
+ import { FMTable, getBaseTableConfig, getNavigationPaths, getTableName } from "../../orm/table";
5
+ import type { ExpandValidationConfig } from "../../validation";
6
+ import { getDefaultSelectFields } from "./default-select";
7
+ import { formatSelectFields } from "./select-utils";
8
+ import type { ExpandConfig } from "./shared-types";
9
+
10
+ const FILTER_QUERY_REGEX = /\$filter=([^&]+)/;
11
+
12
+ /**
13
+ * Builds OData expand query strings and validation configs.
14
+ * Handles nested expands recursively and transforms relation names to FMTIDs
15
+ * when using entity IDs.
16
+ */
17
+ export class ExpandBuilder {
18
+ private readonly useEntityIds: boolean;
19
+ private readonly logger: InternalLogger;
20
+
21
+ constructor(useEntityIds: boolean, logger: InternalLogger) {
22
+ this.useEntityIds = useEntityIds;
23
+ this.logger = logger;
24
+ }
25
+
26
+ /**
27
+ * Builds OData $expand query string from expand configurations.
28
+ */
29
+ buildExpandString(configs: ExpandConfig[]): string {
30
+ if (configs.length === 0) {
31
+ return "";
32
+ }
33
+
34
+ return configs.map((config) => this.buildSingleExpand(config)).join(",");
35
+ }
36
+
37
+ /**
38
+ * Builds validation configs for expanded navigation properties.
39
+ */
40
+ buildValidationConfigs(configs: ExpandConfig[]): ExpandValidationConfig[] {
41
+ return configs.map((config) => {
42
+ const targetTable = config.targetTable;
43
+
44
+ let targetSchema: Partial<Record<string, StandardSchemaV1>> | undefined;
45
+ if (targetTable) {
46
+ const baseTableConfig = getBaseTableConfig(targetTable);
47
+ const containerFields = baseTableConfig.containerFields || [];
48
+
49
+ // Filter out container fields from schema
50
+ const schema = { ...baseTableConfig.schema };
51
+ for (const containerField of containerFields) {
52
+ delete schema[containerField as string];
53
+ }
54
+
55
+ targetSchema = schema;
56
+ }
57
+
58
+ let selectedFields: string[] | undefined;
59
+ if (config.options?.select) {
60
+ selectedFields = Array.isArray(config.options.select)
61
+ ? config.options.select.map(String)
62
+ : [String(config.options.select)];
63
+ }
64
+
65
+ // Recursively build validation configs for nested expands
66
+ const nestedExpands = config.nestedExpandConfigs
67
+ ? this.buildValidationConfigs(config.nestedExpandConfigs)
68
+ : undefined;
69
+
70
+ return {
71
+ relation: config.relation,
72
+ targetSchema,
73
+ targetTable,
74
+ table: targetTable,
75
+ selectedFields,
76
+ nestedExpands,
77
+ };
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Process an expand() call and return the expand config.
83
+ * Used by both QueryBuilder and RecordBuilder to eliminate duplication.
84
+ *
85
+ * @param targetTable - The target table to expand to
86
+ * @param sourceTable - The source table (for validation)
87
+ * @param callback - Optional callback to configure the expand query
88
+ * @param builderFactory - Function that creates a QueryBuilder for the target table
89
+ * @returns ExpandConfig to add to the builder's expandConfigs array
90
+ */
91
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration, generic Builder type
92
+ processExpand<TargetTable extends FMTable<any, any>, Builder = any>(
93
+ targetTable: TargetTable,
94
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
95
+ sourceTable: FMTable<any, any> | undefined,
96
+ callback?: (builder: Builder) => Builder,
97
+ builderFactory?: () => Builder,
98
+ ): ExpandConfig {
99
+ // Extract name and validate
100
+ const relationName = getTableName(targetTable);
101
+
102
+ // Runtime validation: Check if relation name is in navigationPaths
103
+ if (sourceTable) {
104
+ const navigationPaths = getNavigationPaths(sourceTable);
105
+ if (navigationPaths && !navigationPaths.includes(relationName)) {
106
+ this.logger.warn(
107
+ `Cannot expand to "${relationName}". Valid navigation paths: ${navigationPaths.length > 0 ? navigationPaths.join(", ") : "none"}`,
108
+ );
109
+ }
110
+ }
111
+
112
+ if (callback && builderFactory) {
113
+ // Create a new QueryBuilder for the target table
114
+ const targetBuilder = builderFactory();
115
+
116
+ // Pass to callback and get configured builder
117
+ const configuredBuilder = callback(targetBuilder);
118
+
119
+ // Extract the builder's query options
120
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryOptions configuration
121
+ const expandOptions: Partial<QueryOptions<any>> = {
122
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal builder property access
123
+ ...(configuredBuilder as any).queryOptions,
124
+ };
125
+
126
+ // If callback didn't provide select, apply defaultSelect from target table
127
+ if (!expandOptions.select) {
128
+ const defaultFields = getDefaultSelectFields(targetTable);
129
+ if (defaultFields) {
130
+ expandOptions.select = defaultFields;
131
+ }
132
+ }
133
+
134
+ // If the configured builder has nested expands, we need to include them
135
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for internal builder property access
136
+ const nestedExpandConfigs = (configuredBuilder as any).expandConfigs;
137
+ if (nestedExpandConfigs?.length > 0) {
138
+ // Build nested expand string from the configured builder's expand configs
139
+ const nestedExpandString = this.buildExpandString(nestedExpandConfigs);
140
+ if (nestedExpandString) {
141
+ // Add nested expand to options
142
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for expand string
143
+ expandOptions.expand = nestedExpandString as any;
144
+ }
145
+ }
146
+
147
+ return {
148
+ relation: relationName,
149
+ options: expandOptions,
150
+ targetTable,
151
+ nestedExpandConfigs: nestedExpandConfigs?.length > 0 ? nestedExpandConfigs : undefined,
152
+ };
153
+ }
154
+ // Simple expand without callback - apply defaultSelect if available
155
+ const defaultFields = getDefaultSelectFields(targetTable);
156
+ if (defaultFields) {
157
+ return {
158
+ relation: relationName,
159
+ options: { select: defaultFields },
160
+ targetTable,
161
+ };
162
+ }
163
+ return {
164
+ relation: relationName,
165
+ targetTable,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Builds a single expand string with its options.
171
+ */
172
+ private buildSingleExpand(config: ExpandConfig): string {
173
+ const relationName = this.resolveRelationName(config);
174
+ const parts = this.buildExpandParts(config);
175
+
176
+ if (parts.length === 0) {
177
+ return relationName;
178
+ }
179
+
180
+ return `${relationName}(${parts.join(";")})`;
181
+ }
182
+
183
+ /**
184
+ * Resolves relation name, using FMTID if entity IDs are enabled.
185
+ */
186
+ private resolveRelationName(config: ExpandConfig): string {
187
+ if (!this.useEntityIds) {
188
+ return config.relation;
189
+ }
190
+
191
+ const targetTable = config.targetTable;
192
+ if (targetTable && FMTable.Symbol.EntityId in targetTable) {
193
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access
194
+ const tableId = (targetTable as any)[FMTable.Symbol.EntityId] as `FMTID:${string}` | undefined;
195
+ if (tableId) {
196
+ return tableId;
197
+ }
198
+ }
199
+
200
+ return config.relation;
201
+ }
202
+
203
+ /**
204
+ * Builds expand parts (select, filter, orderBy, etc.) for a single expand.
205
+ */
206
+ private buildExpandParts(config: ExpandConfig): string[] {
207
+ if (!config.options || Object.keys(config.options).length === 0) {
208
+ return [];
209
+ }
210
+
211
+ const parts: string[] = [];
212
+ const opts = config.options;
213
+
214
+ if (opts.select) {
215
+ const selectArray = Array.isArray(opts.select) ? opts.select.map(String) : [String(opts.select)];
216
+ const selectFields = formatSelectFields(selectArray, config.targetTable, this.useEntityIds);
217
+ parts.push(`$select=${selectFields}`);
218
+ }
219
+
220
+ if (opts.filter) {
221
+ const filterQuery = buildQuery({ filter: opts.filter });
222
+ const match = filterQuery.match(FILTER_QUERY_REGEX);
223
+ if (match) {
224
+ parts.push(`$filter=${match[1]}`);
225
+ }
226
+ }
227
+
228
+ if (opts.orderBy) {
229
+ const orderByValue = Array.isArray(opts.orderBy) ? opts.orderBy.join(",") : String(opts.orderBy);
230
+ parts.push(`$orderby=${orderByValue}`);
231
+ }
232
+
233
+ if (opts.top !== undefined) {
234
+ parts.push(`$top=${opts.top}`);
235
+ }
236
+ if (opts.skip !== undefined) {
237
+ parts.push(`$skip=${opts.skip}`);
238
+ }
239
+
240
+ if (opts.expand && typeof opts.expand === "string") {
241
+ parts.push(`$expand=${opts.expand}`);
242
+ }
243
+
244
+ return parts;
245
+ }
246
+ }
@@ -0,0 +1,11 @@
1
+ // Re-export all shared builder utilities
2
+ /** biome-ignore-all lint/performance/noBarrelFile: Re-exporting all builder utilities */
3
+
4
+ export * from "./default-select";
5
+ export * from "./expand-builder";
6
+ export * from "./query-string-builder";
7
+ export * from "./response-processor";
8
+ export * from "./select-mixin";
9
+ export * from "./select-utils";
10
+ export * from "./shared-types";
11
+ export * from "./table-utils";
@@ -0,0 +1,46 @@
1
+ import type { InternalLogger } from "../../logger";
2
+ import type { FMTable } from "../../orm/table";
3
+ import { ExpandBuilder } from "./expand-builder";
4
+ import { formatSelectFields } from "./select-utils";
5
+ import type { ExpandConfig } from "./shared-types";
6
+
7
+ /**
8
+ * Builds OData query string for $select and $expand parameters.
9
+ * Used by both QueryBuilder and RecordBuilder to eliminate duplication.
10
+ *
11
+ * @param config - Configuration object
12
+ * @returns Query string starting with ? or empty string if no parameters
13
+ */
14
+ export function buildSelectExpandQueryString(config: {
15
+ selectedFields?: string[];
16
+ expandConfigs: ExpandConfig[];
17
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
18
+ table?: FMTable<any, any>;
19
+ useEntityIds: boolean;
20
+ logger: InternalLogger;
21
+ includeSpecialColumns?: boolean;
22
+ }): string {
23
+ const parts: string[] = [];
24
+ const expandBuilder = new ExpandBuilder(config.useEntityIds, config.logger);
25
+
26
+ // Build $select
27
+ if (config.selectedFields && config.selectedFields.length > 0) {
28
+ // Important: do NOT implicitly add system columns (ROWID/ROWMODID) here.
29
+ // - `includeSpecialColumns` controls the Prefer header + response parsing, but should not
30
+ // mutate/expand an explicit `$select` (e.g. when the user calls `.select({ ... })`).
31
+ // - If system columns are desired with `.select()`, they must be explicitly included via
32
+ // the `systemColumns` argument, which will already have added them to `selectedFields`.
33
+ const selectString = formatSelectFields(config.selectedFields, config.table, config.useEntityIds);
34
+ if (selectString) {
35
+ parts.push(`$select=${selectString}`);
36
+ }
37
+ }
38
+
39
+ // Build $expand
40
+ const expandString = expandBuilder.buildExpandString(config.expandConfigs);
41
+ if (expandString) {
42
+ parts.push(`$expand=${expandString}`);
43
+ }
44
+
45
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
46
+ }
@@ -0,0 +1,279 @@
1
+ import { RecordCountMismatchError } from "../../errors";
2
+ import type { InternalLogger } from "../../logger";
3
+ import type { FMTable } from "../../orm/table";
4
+ import { getBaseTableConfig } from "../../orm/table";
5
+ import { transformResponseFields } from "../../transform";
6
+ import type { Result } from "../../types";
7
+ import type { ExpandValidationConfig } from "../../validation";
8
+ import { validateListResponse, validateSingleResponse } from "../../validation";
9
+ import { ExpandBuilder } from "./expand-builder";
10
+ import type { ExpandConfig } from "./shared-types";
11
+
12
+ export interface ProcessResponseConfig {
13
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
14
+ table?: FMTable<any, any>;
15
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration
16
+ schema?: Record<string, any>;
17
+ singleMode: "exact" | "maybe" | false;
18
+ selectedFields?: string[];
19
+ expandValidationConfigs?: ExpandValidationConfig[];
20
+ skipValidation?: boolean;
21
+ useEntityIds?: boolean;
22
+ includeSpecialColumns?: boolean;
23
+ // Mapping from field names to output keys (for renamed fields in select)
24
+ fieldMapping?: Record<string, string>;
25
+ }
26
+
27
+ /**
28
+ * Processes OData response with transformation and validation.
29
+ * Shared by QueryBuilder and RecordBuilder.
30
+ */
31
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
32
+ export async function processODataResponse<T>(rawResponse: any, config: ProcessResponseConfig): Promise<Result<T>> {
33
+ const {
34
+ table,
35
+ schema,
36
+ singleMode,
37
+ selectedFields,
38
+ expandValidationConfigs,
39
+ skipValidation,
40
+ useEntityIds,
41
+ includeSpecialColumns,
42
+ fieldMapping,
43
+ } = config;
44
+
45
+ // Transform field IDs back to names if using entity IDs
46
+ let response = rawResponse;
47
+ if (table && useEntityIds) {
48
+ response = transformResponseFields(response, table, expandValidationConfigs);
49
+ }
50
+
51
+ // Fast path: skip validation
52
+ if (skipValidation) {
53
+ const result = extractRecords(response, singleMode);
54
+ // Rename fields AFTER extraction (but before returning)
55
+ if (result.data && fieldMapping && Object.keys(fieldMapping).length > 0) {
56
+ if (result.error) {
57
+ return { data: undefined, error: result.error } as Result<T>;
58
+ }
59
+ return {
60
+ data: renameFieldsInResponse(result.data, fieldMapping) as T,
61
+ error: undefined,
62
+ };
63
+ }
64
+ return result as Result<T>;
65
+ }
66
+
67
+ // Validation path
68
+ // Note: Special columns are excluded when using QueryBuilder.single() method,
69
+ // but included for RecordBuilder.get() method (both use singleMode: "exact")
70
+ // The exclusion is handled in QueryBuilder's processQueryResponse, not here
71
+ if (singleMode !== false) {
72
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any record shape
73
+ const validation = await validateSingleResponse<any>(
74
+ response,
75
+ schema,
76
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter
77
+ selectedFields as any,
78
+ expandValidationConfigs,
79
+ singleMode,
80
+ includeSpecialColumns,
81
+ );
82
+
83
+ if (!validation.valid) {
84
+ return { data: undefined, error: validation.error };
85
+ }
86
+
87
+ // Rename fields AFTER validation completes
88
+ if (fieldMapping && Object.keys(fieldMapping).length > 0) {
89
+ return {
90
+ data: renameFieldsInResponse(validation.data, fieldMapping) as T,
91
+ error: undefined,
92
+ };
93
+ }
94
+
95
+ return { data: validation.data as T, error: undefined };
96
+ }
97
+
98
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any record shape
99
+ const validation = await validateListResponse<any>(
100
+ response,
101
+ schema,
102
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter
103
+ selectedFields as any,
104
+ expandValidationConfigs,
105
+ includeSpecialColumns,
106
+ );
107
+
108
+ if (!validation.valid) {
109
+ return { data: undefined, error: validation.error };
110
+ }
111
+
112
+ // Rename fields AFTER validation completes
113
+ if (fieldMapping && Object.keys(fieldMapping).length > 0) {
114
+ return {
115
+ data: renameFieldsInResponse(validation.data, fieldMapping) as T,
116
+ error: undefined,
117
+ };
118
+ }
119
+
120
+ return { data: validation.data as T, error: undefined };
121
+ }
122
+
123
+ /**
124
+ * Extracts records from response without validation.
125
+ */
126
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
127
+ function extractRecords<T>(response: any, singleMode: "exact" | "maybe" | false): Result<T> {
128
+ if (singleMode === false) {
129
+ const records = response.value ?? [];
130
+ return { data: records as T, error: undefined };
131
+ }
132
+
133
+ const records = response.value ?? [response];
134
+ const count = Array.isArray(records) ? records.length : 1;
135
+
136
+ if (count > 1) {
137
+ return {
138
+ data: undefined,
139
+ error: new RecordCountMismatchError(singleMode === "exact" ? "one" : "at-most-one", count),
140
+ };
141
+ }
142
+
143
+ if (count === 0) {
144
+ if (singleMode === "exact") {
145
+ return { data: undefined, error: new RecordCountMismatchError("one", 0) };
146
+ }
147
+ return { data: null as T, error: undefined };
148
+ }
149
+
150
+ const record = Array.isArray(records) ? records[0] : records;
151
+ return { data: record as T, error: undefined };
152
+ }
153
+
154
+ /**
155
+ * Gets schema from a table occurrence, excluding container fields.
156
+ * Container fields are never returned in regular responses (only via getSingleField).
157
+ */
158
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration, dynamic schema shape
159
+ export function getSchemaFromTable(table: FMTable<any, any> | undefined): Record<string, any> | undefined {
160
+ if (!table) {
161
+ return undefined;
162
+ }
163
+ const baseTableConfig = getBaseTableConfig(table);
164
+ const containerFields = baseTableConfig.containerFields || [];
165
+
166
+ // Filter out container fields from schema
167
+ const schema = { ...baseTableConfig.schema };
168
+ for (const containerField of containerFields) {
169
+ delete schema[containerField as string];
170
+ }
171
+
172
+ return schema;
173
+ }
174
+
175
+ /**
176
+ * Renames fields in response data according to the field mapping.
177
+ * Used when select() is called with renamed fields (e.g., { userEmail: users.email }).
178
+ */
179
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response data transformation
180
+ function renameFieldsInResponse(data: any, fieldMapping: Record<string, string>): any {
181
+ if (!data || typeof data !== "object") {
182
+ return data;
183
+ }
184
+
185
+ // Handle array responses
186
+ if (Array.isArray(data)) {
187
+ return data.map((item) => renameFieldsInResponse(item, fieldMapping));
188
+ }
189
+
190
+ // Handle OData list response structure
191
+ if ("value" in data && Array.isArray(data.value)) {
192
+ return {
193
+ ...data,
194
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic record transformation
195
+ value: data.value.map((item: any) => renameFieldsInResponse(item, fieldMapping)),
196
+ };
197
+ }
198
+
199
+ // Handle single record
200
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic field transformation
201
+ const renamed: Record<string, any> = {};
202
+ for (const [key, value] of Object.entries(data)) {
203
+ // Check if this field should be renamed
204
+ const outputKey = fieldMapping[key];
205
+ if (outputKey) {
206
+ renamed[outputKey] = value;
207
+ } else {
208
+ renamed[key] = value;
209
+ }
210
+ }
211
+ return renamed;
212
+ }
213
+
214
+ /**
215
+ * Processes query response with expand configs.
216
+ * This is a convenience wrapper that builds validation configs from expand configs.
217
+ */
218
+ export async function processQueryResponse<T>(
219
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
220
+ response: any,
221
+ config: {
222
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
223
+ occurrence?: FMTable<any, any>;
224
+ singleMode: "exact" | "maybe" | false;
225
+ queryOptions: { select?: (keyof T)[] | string[] };
226
+ expandConfigs: ExpandConfig[];
227
+ skipValidation?: boolean;
228
+ useEntityIds?: boolean;
229
+ includeSpecialColumns?: boolean;
230
+ // Mapping from field names to output keys (for renamed fields in select)
231
+ fieldMapping?: Record<string, string>;
232
+ logger: InternalLogger;
233
+ },
234
+ // biome-ignore lint/suspicious/noExplicitAny: Generic return type for interface compliance
235
+ ): Promise<Result<any>> {
236
+ const {
237
+ occurrence,
238
+ singleMode,
239
+ queryOptions,
240
+ expandConfigs,
241
+ skipValidation,
242
+ useEntityIds,
243
+ includeSpecialColumns,
244
+ fieldMapping,
245
+ logger,
246
+ } = config;
247
+
248
+ const expandBuilder = new ExpandBuilder(useEntityIds ?? false, logger);
249
+ const expandValidationConfigs = expandBuilder.buildValidationConfigs(expandConfigs);
250
+
251
+ let selectedFields: string[] | undefined;
252
+ if (queryOptions.select) {
253
+ selectedFields = Array.isArray(queryOptions.select)
254
+ ? queryOptions.select.map(String)
255
+ : [String(queryOptions.select)];
256
+ }
257
+
258
+ // Process the response first
259
+ let processedResponse = await processODataResponse(response, {
260
+ table: occurrence,
261
+ schema: getSchemaFromTable(occurrence),
262
+ singleMode,
263
+ selectedFields,
264
+ expandValidationConfigs,
265
+ skipValidation,
266
+ useEntityIds,
267
+ includeSpecialColumns,
268
+ });
269
+
270
+ // Rename fields if field mapping is provided (for renamed fields in select)
271
+ if (processedResponse.data && fieldMapping && Object.keys(fieldMapping).length > 0) {
272
+ processedResponse = {
273
+ ...processedResponse,
274
+ data: renameFieldsInResponse(processedResponse.data, fieldMapping),
275
+ };
276
+ }
277
+
278
+ return processedResponse;
279
+ }
@@ -0,0 +1,65 @@
1
+ import type { InternalLogger } from "../../logger";
2
+ import { type Column, isColumn } from "../../orm/column";
3
+
4
+ /**
5
+ * Utility function for processing select() calls.
6
+ * Used by both QueryBuilder and RecordBuilder to eliminate duplication.
7
+ *
8
+ * @param fields - Field names or Column references
9
+ * @returns Object with selectedFields array
10
+ */
11
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
12
+ export function processSelectFields(...fields: (string | Column<any, any, string>)[]): { selectedFields: string[] } {
13
+ const fieldNames = fields.map((field) => {
14
+ if (isColumn(field)) {
15
+ return field.fieldName as string;
16
+ }
17
+ return String(field);
18
+ });
19
+ return { selectedFields: [...new Set(fieldNames)] };
20
+ }
21
+
22
+ /**
23
+ * Processes select() calls with field renaming support.
24
+ * Validates columns belong to the correct table and builds field mapping for renamed fields.
25
+ * Used by both QueryBuilder and RecordBuilder to eliminate duplication.
26
+ *
27
+ * @param fields - Object mapping output keys to column references
28
+ * @param tableName - Expected table name for validation
29
+ * @returns Object with selectedFields array and fieldMapping for renamed fields
30
+ */
31
+ export function processSelectWithRenames<TTableName extends string>(
32
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
33
+ fields: Record<string, Column<any, any, TTableName>>,
34
+ tableName: string,
35
+ logger: InternalLogger,
36
+ ): { selectedFields: string[]; fieldMapping: Record<string, string> } {
37
+ const selectedFields: string[] = [];
38
+ const fieldMapping: Record<string, string> = {};
39
+
40
+ for (const [outputKey, column] of Object.entries(fields)) {
41
+ if (!isColumn(column)) {
42
+ throw new Error(`select() expects column references, but got: ${typeof column}`);
43
+ }
44
+
45
+ // Warn (not throw) on table mismatch for consistency
46
+ if (column.tableName !== tableName) {
47
+ logger.warn(
48
+ `Column ${column.toString()} is from table "${column.tableName}", but query is for table "${tableName}"`,
49
+ );
50
+ }
51
+
52
+ const fieldName = column.fieldName;
53
+ selectedFields.push(fieldName);
54
+
55
+ // Build mapping from field name to output key (only if renamed)
56
+ if (fieldName !== outputKey) {
57
+ fieldMapping[fieldName] = outputKey;
58
+ }
59
+ }
60
+
61
+ return {
62
+ selectedFields,
63
+ fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : {},
64
+ };
65
+ }