@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
@@ -1,104 +1,163 @@
1
+ /** biome-ignore-all lint/complexity/noBannedTypes: Empty object type represents no expands by default */
2
+ import type { FFetchOptions } from "@fetchkit/ffetch";
3
+ import { createLogger, type InternalLogger } from "../logger";
4
+ import type { Column } from "../orm/column";
5
+ import type { ExtractTableName, FMTable, InferSchemaOutputFromFMTable, ValidExpandTarget } from "../orm/table";
6
+ import { getNavigationPaths, getTableName } from "../orm/table";
1
7
  import type {
2
- ExecutionContext,
8
+ ConditionallyWithODataAnnotations,
9
+ ConditionallyWithSpecialColumns,
3
10
  ExecutableBuilder,
4
- Result,
5
- ODataRecordMetadata,
6
- ODataFieldResponse,
7
- InferSchemaType,
11
+ ExecuteMethodOptions,
8
12
  ExecuteOptions,
13
+ ExecutionContext,
14
+ NormalizeIncludeSpecialColumns,
15
+ ODataFieldResponse,
16
+ Result,
9
17
  } from "../types";
10
- import type { TableOccurrence } from "./table-occurrence";
11
- import type { BaseTable } from "./base-table";
12
- import { transformTableName, transformResponseFields, getTableIdentifiers } from "../transform";
18
+ import {
19
+ buildSelectExpandQueryString,
20
+ createODataRequest,
21
+ ExpandBuilder,
22
+ type ExpandConfig,
23
+ type ExpandedRelations,
24
+ getSchemaFromTable,
25
+ mergeExecuteOptions,
26
+ processODataResponse,
27
+ processSelectWithRenames,
28
+ resolveTableId,
29
+ } from "./builders/index";
30
+ import { parseErrorResponse } from "./error-parser";
31
+ import type { ResolveExpandedRelations, SystemColumnsFromOption, SystemColumnsOption } from "./query/types";
13
32
  import { QueryBuilder } from "./query-builder";
14
- import { validateSingleResponse } from "../validation";
15
- import { type FFetchOptions } from "@fetchkit/ffetch";
16
- import { StandardSchemaV1 } from "@standard-schema/spec";
17
- // import type { z } from "zod/v4";
18
-
19
- // Helper type to extract schema from a TableOccurrence
20
- type ExtractSchemaFromOccurrence<O> =
21
- O extends TableOccurrence<infer BT, any, any, any>
22
- ? BT extends BaseTable<infer S, any, any, any>
23
- ? S
24
- : never
25
- : never;
26
-
27
- // Helper type to extract navigation relation names from an occurrence
28
- type ExtractNavigationNames<
29
- O extends TableOccurrence<any, any, any, any> | undefined,
30
- > =
31
- O extends TableOccurrence<any, any, infer Nav, any>
32
- ? Nav extends Record<string, any>
33
- ? keyof Nav
34
- : never
35
- : never;
36
-
37
- // Helper type to resolve a navigation item (handles both direct and lazy-loaded)
38
- type ResolveNavigationItem<T> = T extends () => infer R ? R : T;
39
-
40
- // Helper type to find target occurrence by relation name
41
- type FindNavigationTarget<
42
- O extends TableOccurrence<any, any, any, any> | undefined,
43
- Name extends string,
44
- > =
45
- O extends TableOccurrence<any, any, infer Nav, any>
46
- ? Name extends keyof Nav
47
- ? ResolveNavigationItem<Nav[Name]>
48
- : never
49
- : never;
33
+ import { safeJsonParse } from "./sanitize-json";
34
+
35
+ /**
36
+ * Extract the value type from a Column.
37
+ * This uses the phantom type stored in Column to get the actual value type.
38
+ */
39
+ // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
40
+ type ExtractColumnType<C> = C extends Column<infer T, any> ? T : never;
41
+
42
+ /**
43
+ * Map a select object to its return type.
44
+ * For each key in the select object, extract the type from the corresponding Column.
45
+ */
46
+ type MapSelectToReturnType<
47
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
48
+ TSelect extends Record<string, Column<any, any, any, any>>,
49
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any schema shape
50
+ _TSchema extends Record<string, any>,
51
+ > = {
52
+ [K in keyof TSelect]: ExtractColumnType<TSelect[K]>;
53
+ };
54
+
55
+ // Return type for RecordBuilder execute
56
+ export type RecordReturnType<
57
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any schema shape
58
+ Schema extends Record<string, any>,
59
+ IsSingleField extends boolean,
60
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
61
+ FieldColumn extends Column<any, any, any, any> | undefined,
62
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration, accepts any FMTable
63
+ Selected extends keyof Schema | Record<string, Column<any, any, ExtractTableName<FMTable<any, any>>>>,
64
+ Expands extends ExpandedRelations,
65
+ SystemCols extends SystemColumnsOption | undefined = undefined,
66
+ > = IsSingleField extends true
67
+ ? // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
68
+ FieldColumn extends Column<infer TOutput, any, any, any>
69
+ ? TOutput
70
+ : never
71
+ : // Use tuple wrapping [Selected] extends [...] to prevent distribution over unions
72
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
73
+ [Selected] extends [Record<string, Column<any, any, any, any>>]
74
+ ? MapSelectToReturnType<Selected, Schema> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>
75
+ : // Use tuple wrapping to prevent distribution over union of keys
76
+ [Selected] extends [keyof Schema]
77
+ ? Pick<Schema, Selected> & ResolveExpandedRelations<Expands> & SystemColumnsFromOption<SystemCols>
78
+ : never;
50
79
 
51
80
  export class RecordBuilder<
52
- T extends Record<string, any>,
81
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration, default allows untyped tables
82
+ Occ extends FMTable<any, any> = FMTable<any, any>,
53
83
  IsSingleField extends boolean = false,
54
- FieldKey extends keyof T = keyof T,
55
- Occ extends TableOccurrence<any, any, any, any> | undefined =
56
- | TableOccurrence<any, any, any, any>
57
- | undefined,
84
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
85
+ FieldColumn extends Column<any, any, any, any> | undefined = undefined,
86
+ Selected extends
87
+ | keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>
88
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
89
+ | Record<string, Column<any, any, ExtractTableName<NonNullable<Occ>>>> = keyof InferSchemaOutputFromFMTable<
90
+ NonNullable<Occ>
91
+ >,
92
+ Expands extends ExpandedRelations = {},
93
+ DatabaseIncludeSpecialColumns extends boolean = false,
94
+ SystemCols extends SystemColumnsOption | undefined = undefined,
58
95
  > implements
59
96
  ExecutableBuilder<
60
- IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata
97
+ RecordReturnType<
98
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>,
99
+ IsSingleField,
100
+ FieldColumn,
101
+ Selected,
102
+ Expands,
103
+ SystemCols
104
+ >
61
105
  >
62
106
  {
63
- private occurrence?: Occ;
64
- private tableName: string;
65
- private databaseName: string;
66
- private context: ExecutionContext;
67
- private recordId: string | number;
68
- private operation?: "getSingleField" | "navigate";
69
- private operationParam?: string;
70
- private isNavigateFromEntitySet?: boolean;
71
- private navigateRelation?: string;
72
- private navigateSourceTableName?: string;
73
-
74
- private databaseUseEntityIds: boolean;
107
+ private readonly table: Occ;
108
+ private readonly databaseName: string;
109
+ private readonly context: ExecutionContext;
110
+ private readonly recordId: string | number;
111
+ private readonly operation?: "getSingleField" | "navigate";
112
+ private readonly operationParam?: string;
113
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
114
+ private readonly operationColumn?: Column<any, any, any, any>;
115
+ private readonly isNavigateFromEntitySet?: boolean;
116
+ private readonly navigateRelation?: string;
117
+ private readonly navigateSourceTableName?: string;
118
+
119
+ private readonly databaseUseEntityIds: boolean;
120
+ private readonly databaseIncludeSpecialColumns: boolean;
121
+
122
+ // Properties for select/expand support
123
+ private readonly selectedFields?: string[];
124
+ private readonly expandConfigs: ExpandConfig[] = [];
125
+ // Mapping from field names to output keys (for renamed fields in select)
126
+ private readonly fieldMapping?: Record<string, string>;
127
+ // System columns requested via select() second argument
128
+ private readonly systemColumns?: SystemColumnsOption;
129
+
130
+ private readonly logger: InternalLogger;
75
131
 
76
132
  constructor(config: {
77
- occurrence?: Occ;
78
- tableName: string;
133
+ occurrence: Occ;
79
134
  databaseName: string;
80
135
  context: ExecutionContext;
81
136
  recordId: string | number;
82
137
  databaseUseEntityIds?: boolean;
138
+ databaseIncludeSpecialColumns?: boolean;
83
139
  }) {
84
- this.occurrence = config.occurrence;
85
- this.tableName = config.tableName;
140
+ this.table = config.occurrence;
86
141
  this.databaseName = config.databaseName;
87
142
  this.context = config.context;
88
143
  this.recordId = config.recordId;
89
144
  this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
145
+ this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false;
146
+ this.logger = config.context?._getLogger?.() ?? createLogger();
90
147
  }
91
148
 
92
149
  /**
93
- * Helper to merge database-level useEntityIds with per-request options
150
+ * Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options
94
151
  */
95
- private mergeExecuteOptions(
96
- options?: RequestInit & FFetchOptions & ExecuteOptions,
97
- ): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
98
- // If useEntityIds is not set in options, use the database-level setting
152
+ private mergeExecuteOptions(options?: RequestInit & FFetchOptions & ExecuteOptions): RequestInit &
153
+ FFetchOptions & {
154
+ useEntityIds?: boolean;
155
+ includeSpecialColumns?: boolean;
156
+ } {
157
+ const merged = mergeExecuteOptions(options, this.databaseUseEntityIds);
99
158
  return {
100
- ...options,
101
- useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
159
+ ...merged,
160
+ includeSpecialColumns: options?.includeSpecialColumns ?? this.databaseIncludeSpecialColumns,
102
161
  };
103
162
  }
104
163
 
@@ -107,114 +166,376 @@ export class RecordBuilder<
107
166
  * @param useEntityIds - Optional override for entity ID usage
108
167
  */
109
168
  private getTableId(useEntityIds?: boolean): string {
110
- if (!this.occurrence) {
111
- return this.tableName;
169
+ if (!this.table) {
170
+ throw new Error("Table occurrence is required");
112
171
  }
172
+ return resolveTableId(this.table, getTableName(this.table), this.context, useEntityIds);
173
+ }
113
174
 
114
- const contextDefault = this.context._getUseEntityIds?.() ?? false;
115
- const shouldUseIds = useEntityIds ?? contextDefault;
175
+ /**
176
+ * Creates a new RecordBuilder with modified configuration.
177
+ * Used by select() to create new instances.
178
+ */
179
+ private cloneWithChanges<
180
+ NewSelected extends
181
+ | keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>
182
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
183
+ | Record<string, Column<any, any, ExtractTableName<NonNullable<Occ>>>> = Selected,
184
+ NewSystemCols extends SystemColumnsOption | undefined = SystemCols,
185
+ >(changes: {
186
+ selectedFields?: string[];
187
+ fieldMapping?: Record<string, string>;
188
+ systemColumns?: NewSystemCols;
189
+ }): RecordBuilder<Occ, false, FieldColumn, NewSelected, Expands, DatabaseIncludeSpecialColumns, NewSystemCols> {
190
+ const newBuilder = new RecordBuilder<
191
+ Occ,
192
+ false,
193
+ FieldColumn,
194
+ NewSelected,
195
+ Expands,
196
+ DatabaseIncludeSpecialColumns,
197
+ NewSystemCols
198
+ >({
199
+ occurrence: this.table,
200
+ databaseName: this.databaseName,
201
+ context: this.context,
202
+ recordId: this.recordId,
203
+ databaseUseEntityIds: this.databaseUseEntityIds,
204
+ databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns,
205
+ });
206
+ // Use type assertion to allow assignment to readonly properties on new instance
207
+
208
+ // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
209
+ const mutableBuilder = newBuilder as any;
210
+ mutableBuilder.selectedFields = changes.selectedFields ?? this.selectedFields;
211
+ mutableBuilder.fieldMapping = changes.fieldMapping ?? this.fieldMapping;
212
+ mutableBuilder.systemColumns = changes.systemColumns !== undefined ? changes.systemColumns : this.systemColumns;
213
+ mutableBuilder.expandConfigs = [...this.expandConfigs];
214
+ // Preserve navigation context
215
+ mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
216
+ mutableBuilder.navigateRelation = this.navigateRelation;
217
+ mutableBuilder.navigateSourceTableName = this.navigateSourceTableName;
218
+ mutableBuilder.operationColumn = this.operationColumn;
219
+ return newBuilder;
220
+ }
116
221
 
117
- if (shouldUseIds) {
118
- const identifiers = getTableIdentifiers(this.occurrence);
119
- if (!identifiers.id) {
120
- throw new Error(
121
- `useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
122
- );
123
- }
124
- return identifiers.id;
222
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
223
+ getSingleField<TColumn extends Column<any, any, ExtractTableName<NonNullable<Occ>>, any>>(
224
+ column: TColumn,
225
+ ): RecordBuilder<
226
+ Occ,
227
+ true,
228
+ TColumn,
229
+ keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>,
230
+ {},
231
+ DatabaseIncludeSpecialColumns
232
+ > {
233
+ // Runtime validation: ensure column is from the correct table
234
+ const tableName = getTableName(this.table);
235
+ if (!column.isFromTable(tableName)) {
236
+ throw new Error(`Column ${column.toString()} is not from table ${tableName}`);
125
237
  }
126
238
 
127
- return this.occurrence.getTableName();
128
- }
129
-
130
- getSingleField<K extends keyof T>(field: K): RecordBuilder<T, true, K, Occ> {
131
- const newBuilder = new RecordBuilder<T, true, K, Occ>({
132
- occurrence: this.occurrence,
133
- tableName: this.tableName,
239
+ const newBuilder = new RecordBuilder<
240
+ Occ,
241
+ true,
242
+ TColumn,
243
+ keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>,
244
+ {},
245
+ DatabaseIncludeSpecialColumns
246
+ >({
247
+ occurrence: this.table,
134
248
  databaseName: this.databaseName,
135
249
  context: this.context,
136
250
  recordId: this.recordId,
251
+ databaseUseEntityIds: this.databaseUseEntityIds,
252
+ databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns,
137
253
  });
138
- newBuilder.operation = "getSingleField";
139
- newBuilder.operationParam = field.toString();
254
+ // Use type assertion to allow assignment to readonly properties on new instance
255
+
256
+ // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
257
+ const mutableBuilder = newBuilder as any;
258
+ mutableBuilder.operation = "getSingleField";
259
+ mutableBuilder.operationColumn = column;
260
+ mutableBuilder.operationParam = column.getFieldIdentifier(this.databaseUseEntityIds);
140
261
  // Preserve navigation context
141
- newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
142
- newBuilder.navigateRelation = this.navigateRelation;
143
- newBuilder.navigateSourceTableName = this.navigateSourceTableName;
262
+ mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
263
+ mutableBuilder.navigateRelation = this.navigateRelation;
264
+ mutableBuilder.navigateSourceTableName = this.navigateSourceTableName;
144
265
  return newBuilder;
145
266
  }
146
267
 
147
- // Overload for valid relation names - returns typed QueryBuilder
148
- navigate<RelationName extends ExtractNavigationNames<Occ>>(
149
- relationName: RelationName,
268
+ /**
269
+ * Select fields using column references.
270
+ * Allows renaming fields by using different keys in the object.
271
+ * Container fields cannot be selected and will cause a type error.
272
+ *
273
+ * @example
274
+ * db.from(contacts).get("uuid").select({
275
+ * name: contacts.name,
276
+ * userEmail: contacts.email // renamed!
277
+ * })
278
+ *
279
+ * @example
280
+ * // Include system columns (ROWID, ROWMODID) when using select()
281
+ * db.from(contacts).get("uuid").select(
282
+ * { name: contacts.name },
283
+ * { ROWID: true, ROWMODID: true }
284
+ * )
285
+ *
286
+ * @param fields - Object mapping output keys to column references (container fields excluded)
287
+ * @param systemColumns - Optional object to request system columns (ROWID, ROWMODID)
288
+ * @returns RecordBuilder with updated selected fields
289
+ */
290
+ select<
291
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
292
+ TSelect extends Record<string, Column<any, any, ExtractTableName<Occ>, false>>,
293
+ TSystemCols extends SystemColumnsOption = {},
294
+ >(
295
+ fields: TSelect,
296
+ systemColumns?: TSystemCols,
297
+ ): RecordBuilder<Occ, false, FieldColumn, TSelect, Expands, DatabaseIncludeSpecialColumns, TSystemCols> {
298
+ const tableName = getTableName(this.table);
299
+ const { selectedFields, fieldMapping } = processSelectWithRenames(fields, tableName, this.logger);
300
+
301
+ // Add system columns to selectedFields if requested
302
+ const finalSelectedFields = [...selectedFields];
303
+ if (systemColumns?.ROWID) {
304
+ finalSelectedFields.push("ROWID");
305
+ }
306
+ if (systemColumns?.ROWMODID) {
307
+ finalSelectedFields.push("ROWMODID");
308
+ }
309
+
310
+ return this.cloneWithChanges({
311
+ selectedFields: finalSelectedFields,
312
+ fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : undefined,
313
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter
314
+ systemColumns: systemColumns as any,
315
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
316
+ }) as any;
317
+ }
318
+
319
+ /**
320
+ * Expand a navigation property to include related records.
321
+ * Supports nested select, filter, orderBy, and expand operations.
322
+ *
323
+ * @example
324
+ * ```typescript
325
+ * // Simple expand with FMTable object
326
+ * const contact = await db.from(contacts).get("uuid").expand(users).execute();
327
+ *
328
+ * // Expand with select
329
+ * const contact = await db.from(contacts).get("uuid")
330
+ * .expand(users, b => b.select({ username: users.username, email: users.email }))
331
+ * .execute();
332
+ * ```
333
+ */
334
+ expand<
335
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
336
+ TargetTable extends FMTable<any, any>,
337
+ TSelected extends
338
+ | keyof InferSchemaOutputFromFMTable<TargetTable>
339
+ | Record<
340
+ string,
341
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
342
+ Column<any, any, ExtractTableName<TargetTable>>
343
+ > = keyof InferSchemaOutputFromFMTable<TargetTable>,
344
+ TNestedExpands extends ExpandedRelations = {},
345
+ >(
346
+ targetTable: ValidExpandTarget<Occ, TargetTable>,
347
+ callback?: (
348
+ builder: QueryBuilder<TargetTable, keyof InferSchemaOutputFromFMTable<TargetTable>, false, false, {}>,
349
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration
350
+ ) => QueryBuilder<TargetTable, TSelected, any, any, TNestedExpands>,
351
+ ): RecordBuilder<
352
+ Occ,
353
+ false,
354
+ FieldColumn,
355
+ Selected,
356
+ Expands & {
357
+ [K in ExtractTableName<TargetTable>]: {
358
+ schema: InferSchemaOutputFromFMTable<TargetTable>;
359
+ selected: TSelected;
360
+ nested: TNestedExpands;
361
+ };
362
+ },
363
+ DatabaseIncludeSpecialColumns,
364
+ SystemCols
365
+ > {
366
+ // Create new builder with updated types
367
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExpandedRelations
368
+ const newBuilder = new RecordBuilder<Occ, false, FieldColumn, Selected, any, DatabaseIncludeSpecialColumns>({
369
+ occurrence: this.table,
370
+ databaseName: this.databaseName,
371
+ context: this.context,
372
+ recordId: this.recordId,
373
+ databaseUseEntityIds: this.databaseUseEntityIds,
374
+ databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns,
375
+ });
376
+
377
+ // Use type assertion to allow assignment to readonly properties on new instance
378
+ // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
379
+ const mutableBuilder = newBuilder as any;
380
+ // Copy existing state
381
+ mutableBuilder.selectedFields = this.selectedFields;
382
+ mutableBuilder.fieldMapping = this.fieldMapping;
383
+ mutableBuilder.systemColumns = this.systemColumns;
384
+ mutableBuilder.expandConfigs = [...this.expandConfigs];
385
+ mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
386
+ mutableBuilder.navigateRelation = this.navigateRelation;
387
+ mutableBuilder.navigateSourceTableName = this.navigateSourceTableName;
388
+ mutableBuilder.operationColumn = this.operationColumn;
389
+
390
+ // Use ExpandBuilder.processExpand to handle the expand logic
391
+ const expandBuilder = new ExpandBuilder(this.databaseUseEntityIds, this.logger);
392
+ type TargetBuilder = QueryBuilder<TargetTable, keyof InferSchemaOutputFromFMTable<TargetTable>, false, false, {}>;
393
+ const expandConfig = expandBuilder.processExpand<TargetTable, TargetBuilder>(
394
+ targetTable,
395
+ this.table ?? undefined,
396
+ callback as ((builder: TargetBuilder) => TargetBuilder) | undefined,
397
+ () =>
398
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration
399
+ new QueryBuilder<TargetTable, any, any, any, any, DatabaseIncludeSpecialColumns, undefined>({
400
+ occurrence: targetTable,
401
+ databaseName: this.databaseName,
402
+ context: this.context,
403
+ databaseUseEntityIds: this.databaseUseEntityIds,
404
+ databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns,
405
+ }),
406
+ );
407
+
408
+ mutableBuilder.expandConfigs.push(expandConfig);
409
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion needed as expand changes generic parameters in complex way that TypeScript cannot infer
410
+ return newBuilder as any;
411
+ }
412
+
413
+ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
414
+ navigate<TargetTable extends FMTable<any, any>>(
415
+ targetTable: ValidExpandTarget<Occ, TargetTable>,
150
416
  ): QueryBuilder<
151
- ExtractSchemaFromOccurrence<
152
- FindNavigationTarget<Occ, RelationName>
153
- > extends Record<string, StandardSchemaV1>
154
- ? InferSchemaType<
155
- ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
156
- >
157
- : Record<string, any>
158
- >;
159
- // Overload for arbitrary strings - returns generic QueryBuilder with system fields
160
- navigate(
161
- relationName: string,
162
- ): QueryBuilder<{ ROWID: number; ROWMODID: number; [key: string]: any }>;
163
- // Implementation
164
- navigate(relationName: string): QueryBuilder<any> {
165
- // Use the target occurrence if available, otherwise allow untyped navigation
166
- // (useful when types might be incomplete)
167
- const targetOccurrence = this.occurrence?.navigation[relationName];
168
- const builder = new QueryBuilder<any>({
169
- occurrence: targetOccurrence,
170
- tableName: targetOccurrence?.name ?? relationName,
417
+ TargetTable,
418
+ keyof InferSchemaOutputFromFMTable<TargetTable>,
419
+ false,
420
+ false,
421
+ {},
422
+ DatabaseIncludeSpecialColumns,
423
+ undefined
424
+ > {
425
+ // Extract name and validate
426
+ const relationName = getTableName(targetTable);
427
+
428
+ // Runtime validation: Check if relation name is in navigationPaths
429
+ if (this.table) {
430
+ const navigationPaths = getNavigationPaths(this.table);
431
+ if (navigationPaths && !navigationPaths.includes(relationName)) {
432
+ this.logger.warn(
433
+ `Cannot navigate to "${relationName}". Valid navigation paths: ${navigationPaths.length > 0 ? navigationPaths.join(", ") : "none"}`,
434
+ );
435
+ }
436
+ }
437
+
438
+ // Create QueryBuilder with target table
439
+ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration
440
+ const builder = new QueryBuilder<TargetTable, any, any, any, any, DatabaseIncludeSpecialColumns, undefined>({
441
+ occurrence: targetTable,
171
442
  databaseName: this.databaseName,
172
443
  context: this.context,
444
+ databaseUseEntityIds: this.databaseUseEntityIds,
445
+ databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns,
173
446
  });
174
- // Store the navigation info - we'll use it in execute
175
- // Transform relation name to FMTID if using entity IDs
176
- const relationId = targetOccurrence
177
- ? transformTableName(targetOccurrence)
178
- : relationName;
179
447
 
180
- (builder as any).isNavigate = true;
181
- (builder as any).navigateRecordId = this.recordId;
182
- (builder as any).navigateRelation = relationId;
448
+ // Store the navigation info - we'll use it in execute
449
+ // Use relation name as-is (entity ID handling is done in QueryBuilder)
450
+ const relationId = relationName;
183
451
 
184
452
  // If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path
185
- if (
186
- this.isNavigateFromEntitySet &&
187
- this.navigateSourceTableName &&
188
- this.navigateRelation
189
- ) {
453
+ let sourceTableName: string;
454
+ let baseRelation: string | undefined;
455
+ if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
190
456
  // Build the base path: /sourceTable/relation('recordId')/newRelation
191
- (builder as any).navigateSourceTableName = this.navigateSourceTableName;
192
- (builder as any).navigateBaseRelation = this.navigateRelation;
457
+ sourceTableName = this.navigateSourceTableName;
458
+ baseRelation = this.navigateRelation;
193
459
  } else {
194
460
  // Normal record navigation: /tableName('recordId')/relation
195
- // Transform source table name to FMTID if using entity IDs
196
- const sourceTableId = this.occurrence
197
- ? transformTableName(this.occurrence)
198
- : this.tableName;
199
- (builder as any).navigateSourceTableName = sourceTableId;
461
+ // Use table ID if available, otherwise table name
462
+ if (!this.table) {
463
+ throw new Error("Table occurrence is required for navigation");
464
+ }
465
+ sourceTableName = resolveTableId(this.table, getTableName(this.table), this.context, this.databaseUseEntityIds);
200
466
  }
201
467
 
468
+ // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
469
+ (builder as any).navigation = {
470
+ recordId: this.recordId,
471
+ relation: relationId,
472
+ sourceTableName,
473
+ baseRelation,
474
+ };
475
+ // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
476
+ (builder as any).navigation = {
477
+ recordId: this.recordId,
478
+ relation: relationId,
479
+ sourceTableName,
480
+ baseRelation,
481
+ };
482
+
202
483
  return builder;
203
484
  }
204
485
 
205
- async execute(
206
- options?: RequestInit & FFetchOptions & { useEntityIds?: boolean },
486
+ /**
487
+ * Builds the complete query string including $select and $expand parameters.
488
+ */
489
+ private buildQueryString(includeSpecialColumns?: boolean, useEntityIds?: boolean): string {
490
+ // Use merged includeSpecialColumns if provided, otherwise use database-level default
491
+ const finalIncludeSpecialColumns = includeSpecialColumns ?? this.databaseIncludeSpecialColumns;
492
+ // Use merged useEntityIds if provided, otherwise use database-level default
493
+ const finalUseEntityIds = useEntityIds ?? this.databaseUseEntityIds;
494
+
495
+ return buildSelectExpandQueryString({
496
+ selectedFields: this.selectedFields,
497
+ expandConfigs: this.expandConfigs,
498
+ table: this.table,
499
+ useEntityIds: finalUseEntityIds,
500
+ logger: this.logger,
501
+ includeSpecialColumns: finalIncludeSpecialColumns,
502
+ });
503
+ }
504
+
505
+ async execute<EO extends ExecuteOptions>(
506
+ options?: ExecuteMethodOptions<EO>,
207
507
  ): Promise<
208
- Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
508
+ Result<
509
+ ConditionallyWithODataAnnotations<
510
+ ConditionallyWithSpecialColumns<
511
+ RecordReturnType<
512
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>,
513
+ IsSingleField,
514
+ FieldColumn,
515
+ Selected,
516
+ Expands,
517
+ SystemCols
518
+ >,
519
+ // Use the merged value: if explicitly provided in options, use that; otherwise use database default
520
+ NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
521
+ // Check if select was applied: if Selected is Record (object select) or a subset of keys, select was applied
522
+ IsSingleField extends true
523
+ ? false // Single field operations don't include special columns
524
+ : // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
525
+ Selected extends Record<string, Column<any, any, any>>
526
+ ? true
527
+ : Selected extends keyof InferSchemaOutputFromFMTable<NonNullable<Occ>>
528
+ ? false
529
+ : true
530
+ >,
531
+ EO["includeODataAnnotations"] extends true ? true : false
532
+ >
533
+ >
209
534
  > {
210
535
  let url: string;
211
536
 
212
537
  // Build the base URL depending on whether this came from a navigated EntitySet
213
- if (
214
- this.isNavigateFromEntitySet &&
215
- this.navigateSourceTableName &&
216
- this.navigateRelation
217
- ) {
538
+ if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
218
539
  // From navigated EntitySet: /sourceTable/relation('recordId')
219
540
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
220
541
  } else {
@@ -223,71 +544,56 @@ export class RecordBuilder<
223
544
  url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
224
545
  }
225
546
 
547
+ const mergedOptions = this.mergeExecuteOptions(options);
548
+
226
549
  if (this.operation === "getSingleField" && this.operationParam) {
227
550
  url += `/${this.operationParam}`;
551
+ } else {
552
+ // Add query string for select/expand (only when not getting a single field)
553
+ const queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds);
554
+ url += queryString;
228
555
  }
229
-
230
- const mergedOptions = this.mergeExecuteOptions(options);
231
556
  const result = await this.context._makeRequest(url, mergedOptions);
232
557
 
233
558
  if (result.error) {
234
559
  return { data: undefined, error: result.error };
235
560
  }
236
561
 
237
- let response = result.data;
562
+ const response = result.data;
238
563
 
239
564
  // Handle single field operation
240
565
  if (this.operation === "getSingleField") {
241
566
  // Single field returns a JSON object with @context and value
242
- const fieldResponse = response as ODataFieldResponse<T>;
567
+ // The type is extracted from the Column stored in FieldColumn generic
568
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
569
+ const fieldResponse = response as ODataFieldResponse<any>;
570
+ // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type extraction
243
571
  return { data: fieldResponse.value as any, error: undefined };
244
572
  }
245
573
 
246
- // Transform response field IDs back to names if using entity IDs
247
- // Only transform if useEntityIds resolves to true (respects per-request override)
248
- const shouldUseIds = mergedOptions.useEntityIds ?? false;
249
-
250
- if (this.occurrence?.baseTable && shouldUseIds) {
251
- response = transformResponseFields(
252
- response,
253
- this.occurrence.baseTable,
254
- undefined, // No expand configs for simple get
255
- );
256
- }
257
-
258
- // Get schema from occurrence if available
259
- const schema = this.occurrence?.baseTable?.schema;
260
-
261
- // Validate the single record response
262
- const validation = await validateSingleResponse<any>(
263
- response,
264
- schema,
265
- undefined, // No selected fields for record.get()
266
- undefined, // No expand configs
267
- "exact", // Expect exactly one record
268
- );
269
-
270
- if (!validation.valid) {
271
- return { data: undefined, error: validation.error };
272
- }
273
-
274
- // Handle null response
275
- if (validation.data === null) {
276
- return { data: null as any, error: undefined };
277
- }
278
-
279
- return { data: validation.data, error: undefined };
574
+ // Use shared response processor
575
+ const expandBuilder = new ExpandBuilder(mergedOptions.useEntityIds ?? false, this.logger);
576
+ const expandValidationConfigs = expandBuilder.buildValidationConfigs(this.expandConfigs);
577
+
578
+ return processODataResponse(response, {
579
+ table: this.table,
580
+ schema: getSchemaFromTable(this.table),
581
+ singleMode: "exact",
582
+ selectedFields: this.selectedFields,
583
+ expandValidationConfigs,
584
+ skipValidation: options?.skipValidation,
585
+ useEntityIds: mergedOptions.useEntityIds,
586
+ includeSpecialColumns: mergedOptions.includeSpecialColumns,
587
+ fieldMapping: this.fieldMapping,
588
+ });
280
589
  }
281
590
 
591
+ // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
282
592
  getRequestConfig(): { method: string; url: string; body?: any } {
283
593
  let url: string;
284
594
 
285
595
  // Build the base URL depending on whether this came from a navigated EntitySet
286
- if (
287
- this.isNavigateFromEntitySet &&
288
- this.navigateSourceTableName &&
289
- this.navigateRelation
290
- ) {
596
+ if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
291
597
  // From navigated EntitySet: /sourceTable/relation('recordId')
292
598
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
293
599
  } else {
@@ -296,8 +602,16 @@ export class RecordBuilder<
296
602
  url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
297
603
  }
298
604
 
299
- if (this.operation === "getSingleField" && this.operationParam) {
605
+ if (this.operation === "getSingleField" && this.operationColumn) {
606
+ // Use the column's getFieldIdentifier to support entity IDs
607
+ url += `/${this.operationColumn.getFieldIdentifier(this.databaseUseEntityIds)}`;
608
+ } else if (this.operation === "getSingleField" && this.operationParam) {
609
+ // Fallback for backwards compatibility (shouldn't happen in normal flow)
300
610
  url += `/${this.operationParam}`;
611
+ } else {
612
+ // Add query string for select/expand (only when not getting a single field)
613
+ const queryString = this.buildQueryString();
614
+ url += queryString;
301
615
  }
302
616
 
303
617
  return {
@@ -306,68 +620,89 @@ export class RecordBuilder<
306
620
  };
307
621
  }
308
622
 
309
- toRequest(baseUrl: string): Request {
623
+ /**
624
+ * Returns the query string for this record builder (for testing purposes).
625
+ */
626
+ getQueryString(options?: { useEntityIds?: boolean }): string {
627
+ const useEntityIds = options?.useEntityIds ?? this.databaseUseEntityIds;
628
+ let path: string;
629
+
630
+ // Build the path depending on navigation context
631
+ if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
632
+ path = `/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
633
+ } else {
634
+ // Use getTableId to respect entity ID settings (same as getRequestConfig)
635
+ const tableId = this.getTableId(useEntityIds);
636
+ path = `/${tableId}('${this.recordId}')`;
637
+ }
638
+
639
+ if (this.operation === "getSingleField" && this.operationColumn) {
640
+ return `${path}/${this.operationColumn.getFieldIdentifier(useEntityIds)}`;
641
+ }
642
+ if (this.operation === "getSingleField" && this.operationParam) {
643
+ // Fallback for backwards compatibility (shouldn't happen in normal flow)
644
+ return `${path}/${this.operationParam}`;
645
+ }
646
+
647
+ const queryString = this.buildQueryString(undefined, useEntityIds);
648
+ return `${path}${queryString}`;
649
+ }
650
+
651
+ toRequest(baseUrl: string, options?: ExecuteOptions): Request {
310
652
  const config = this.getRequestConfig();
311
- const fullUrl = `${baseUrl}${config.url}`;
312
-
313
- return new Request(fullUrl, {
314
- method: config.method,
315
- headers: {
316
- "Content-Type": "application/json",
317
- Accept: "application/json",
318
- },
319
- });
653
+ return createODataRequest(baseUrl, config, options);
320
654
  }
321
655
 
322
656
  async processResponse(
323
657
  response: Response,
324
658
  options?: ExecuteOptions,
325
659
  ): Promise<
326
- Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
660
+ Result<
661
+ RecordReturnType<
662
+ InferSchemaOutputFromFMTable<NonNullable<Occ>>,
663
+ IsSingleField,
664
+ FieldColumn,
665
+ Selected,
666
+ Expands,
667
+ SystemCols
668
+ >
669
+ >
327
670
  > {
328
- const rawResponse = await response.json();
671
+ // Check for error responses (important for batch operations)
672
+ if (!response.ok) {
673
+ const tableName = this.table ? getTableName(this.table) : "unknown";
674
+ const error = await parseErrorResponse(response, response.url || `/${this.databaseName}/${tableName}`);
675
+ return { data: undefined, error };
676
+ }
677
+
678
+ // Use safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values
679
+ const rawResponse = await safeJsonParse(response);
329
680
 
330
681
  // Handle single field operation
331
682
  if (this.operation === "getSingleField") {
332
683
  // Single field returns a JSON object with @context and value
333
- const fieldResponse = rawResponse as ODataFieldResponse<T>;
684
+ // The type is extracted from the Column stored in FieldColumn generic
685
+ // biome-ignore lint/suspicious/noExplicitAny: Type parameter inferred from FieldColumn generic
686
+ const fieldResponse = rawResponse as ODataFieldResponse<any>;
687
+ // biome-ignore lint/suspicious/noExplicitAny: Type parameter inferred from FieldColumn generic
334
688
  return { data: fieldResponse.value as any, error: undefined };
335
689
  }
336
690
 
337
- // Transform response field IDs back to names if using entity IDs
338
- // Only transform if useEntityIds resolves to true (respects per-request override)
339
- const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
340
-
341
- let transformedResponse = rawResponse;
342
- if (this.occurrence?.baseTable && shouldUseIds) {
343
- transformedResponse = transformResponseFields(
344
- rawResponse,
345
- this.occurrence.baseTable,
346
- undefined, // No expand configs for simple get
347
- );
348
- }
349
-
350
- // Get schema from occurrence if available
351
- const schema = this.occurrence?.baseTable?.schema;
352
-
353
- // Validate the single record response
354
- const validation = await validateSingleResponse<any>(
355
- transformedResponse,
356
- schema,
357
- undefined, // No selected fields for record.get()
358
- undefined, // No expand configs
359
- "exact", // Expect exactly one record
360
- );
361
-
362
- if (!validation.valid) {
363
- return { data: undefined, error: validation.error };
364
- }
365
-
366
- // Handle null response
367
- if (validation.data === null) {
368
- return { data: null as any, error: undefined };
369
- }
370
-
371
- return { data: validation.data, error: undefined };
691
+ // Use shared response processor
692
+ const mergedOptions = this.mergeExecuteOptions(options);
693
+ const expandBuilder = new ExpandBuilder(mergedOptions.useEntityIds ?? false, this.logger);
694
+ const expandValidationConfigs = expandBuilder.buildValidationConfigs(this.expandConfigs);
695
+
696
+ return processODataResponse(rawResponse, {
697
+ table: this.table,
698
+ schema: getSchemaFromTable(this.table),
699
+ singleMode: "exact",
700
+ selectedFields: this.selectedFields,
701
+ expandValidationConfigs,
702
+ skipValidation: options?.skipValidation,
703
+ useEntityIds: mergedOptions.useEntityIds,
704
+ includeSpecialColumns: mergedOptions.includeSpecialColumns,
705
+ fieldMapping: this.fieldMapping,
706
+ });
372
707
  }
373
708
  }