@proofkit/fmodata 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/esm/client/base-table.d.ts +13 -0
- package/dist/esm/client/base-table.js +19 -0
- package/dist/esm/client/base-table.js.map +1 -0
- package/dist/esm/client/database.d.ts +49 -0
- package/dist/esm/client/database.js +90 -0
- package/dist/esm/client/database.js.map +1 -0
- package/dist/esm/client/delete-builder.d.ts +61 -0
- package/dist/esm/client/delete-builder.js +121 -0
- package/dist/esm/client/delete-builder.js.map +1 -0
- package/dist/esm/client/entity-set.d.ts +43 -0
- package/dist/esm/client/entity-set.js +120 -0
- package/dist/esm/client/entity-set.js.map +1 -0
- package/dist/esm/client/filemaker-odata.d.ts +26 -0
- package/dist/esm/client/filemaker-odata.js +85 -0
- package/dist/esm/client/filemaker-odata.js.map +1 -0
- package/dist/esm/client/insert-builder.d.ts +23 -0
- package/dist/esm/client/insert-builder.js +69 -0
- package/dist/esm/client/insert-builder.js.map +1 -0
- package/dist/esm/client/query-builder.d.ts +94 -0
- package/dist/esm/client/query-builder.js +649 -0
- package/dist/esm/client/query-builder.js.map +1 -0
- package/dist/esm/client/record-builder.d.ts +43 -0
- package/dist/esm/client/record-builder.js +121 -0
- package/dist/esm/client/record-builder.js.map +1 -0
- package/dist/esm/client/table-occurrence.d.ts +25 -0
- package/dist/esm/client/table-occurrence.js +47 -0
- package/dist/esm/client/table-occurrence.js.map +1 -0
- package/dist/esm/client/update-builder.d.ts +69 -0
- package/dist/esm/client/update-builder.js +134 -0
- package/dist/esm/client/update-builder.js.map +1 -0
- package/dist/esm/filter-types.d.ts +76 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/types.d.ts +67 -0
- package/dist/esm/validation.d.ts +41 -0
- package/dist/esm/validation.js +270 -0
- package/dist/esm/validation.js.map +1 -0
- package/package.json +68 -0
- package/src/client/base-table.ts +25 -0
- package/src/client/database.ts +177 -0
- package/src/client/delete-builder.ts +193 -0
- package/src/client/entity-set.ts +310 -0
- package/src/client/filemaker-odata.ts +119 -0
- package/src/client/insert-builder.ts +93 -0
- package/src/client/query-builder.ts +1076 -0
- package/src/client/record-builder.ts +240 -0
- package/src/client/table-occurrence.ts +100 -0
- package/src/client/update-builder.ts +212 -0
- package/src/filter-types.ts +97 -0
- package/src/index.ts +17 -0
- package/src/types.ts +123 -0
- package/src/validation.ts +397 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionContext,
|
|
3
|
+
ExecutableBuilder,
|
|
4
|
+
Result,
|
|
5
|
+
ODataRecordMetadata,
|
|
6
|
+
ODataFieldResponse,
|
|
7
|
+
InferSchemaType,
|
|
8
|
+
} from "../types";
|
|
9
|
+
import type { TableOccurrence } from "./table-occurrence";
|
|
10
|
+
import type { BaseTable } from "./base-table";
|
|
11
|
+
import { QueryBuilder } from "./query-builder";
|
|
12
|
+
import { validateSingleResponse } from "../validation";
|
|
13
|
+
import { type FFetchOptions } from "@fetchkit/ffetch";
|
|
14
|
+
import { z } from "zod/v4";
|
|
15
|
+
|
|
16
|
+
// Helper type to extract schema from a TableOccurrence
|
|
17
|
+
type ExtractSchemaFromOccurrence<O> =
|
|
18
|
+
O extends TableOccurrence<infer BT, any, any, any>
|
|
19
|
+
? BT extends BaseTable<infer S, any>
|
|
20
|
+
? S
|
|
21
|
+
: never
|
|
22
|
+
: never;
|
|
23
|
+
|
|
24
|
+
// Helper type to extract navigation relation names from an occurrence
|
|
25
|
+
type ExtractNavigationNames<
|
|
26
|
+
O extends TableOccurrence<any, any, any, any> | undefined,
|
|
27
|
+
> =
|
|
28
|
+
O extends TableOccurrence<any, any, infer Nav, any>
|
|
29
|
+
? Nav extends Record<string, any>
|
|
30
|
+
? keyof Nav
|
|
31
|
+
: never
|
|
32
|
+
: never;
|
|
33
|
+
|
|
34
|
+
// Helper type to resolve a navigation item (handles both direct and lazy-loaded)
|
|
35
|
+
type ResolveNavigationItem<T> = T extends () => infer R ? R : T;
|
|
36
|
+
|
|
37
|
+
// Helper type to find target occurrence by relation name
|
|
38
|
+
type FindNavigationTarget<
|
|
39
|
+
O extends TableOccurrence<any, any, any, any> | undefined,
|
|
40
|
+
Name extends string,
|
|
41
|
+
> =
|
|
42
|
+
O extends TableOccurrence<any, any, infer Nav, any>
|
|
43
|
+
? Name extends keyof Nav
|
|
44
|
+
? ResolveNavigationItem<Nav[Name]>
|
|
45
|
+
: never
|
|
46
|
+
: never;
|
|
47
|
+
|
|
48
|
+
export class RecordBuilder<
|
|
49
|
+
T extends Record<string, any>,
|
|
50
|
+
IsSingleField extends boolean = false,
|
|
51
|
+
FieldKey extends keyof T = keyof T,
|
|
52
|
+
Occ extends TableOccurrence<any, any, any, any> | undefined =
|
|
53
|
+
| TableOccurrence<any, any, any, any>
|
|
54
|
+
| undefined,
|
|
55
|
+
> implements
|
|
56
|
+
ExecutableBuilder<
|
|
57
|
+
IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata
|
|
58
|
+
>
|
|
59
|
+
{
|
|
60
|
+
private occurrence?: Occ;
|
|
61
|
+
private tableName: string;
|
|
62
|
+
private databaseName: string;
|
|
63
|
+
private context: ExecutionContext;
|
|
64
|
+
private recordId: string | number;
|
|
65
|
+
private operation?: "getSingleField" | "navigate";
|
|
66
|
+
private operationParam?: string;
|
|
67
|
+
private isNavigateFromEntitySet?: boolean;
|
|
68
|
+
private navigateRelation?: string;
|
|
69
|
+
private navigateSourceTableName?: string;
|
|
70
|
+
|
|
71
|
+
constructor(config: {
|
|
72
|
+
occurrence?: Occ;
|
|
73
|
+
tableName: string;
|
|
74
|
+
databaseName: string;
|
|
75
|
+
context: ExecutionContext;
|
|
76
|
+
recordId: string | number;
|
|
77
|
+
}) {
|
|
78
|
+
this.occurrence = config.occurrence;
|
|
79
|
+
this.tableName = config.tableName;
|
|
80
|
+
this.databaseName = config.databaseName;
|
|
81
|
+
this.context = config.context;
|
|
82
|
+
this.recordId = config.recordId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getSingleField<K extends keyof T>(field: K): RecordBuilder<T, true, K, Occ> {
|
|
86
|
+
const newBuilder = new RecordBuilder<T, true, K, Occ>({
|
|
87
|
+
occurrence: this.occurrence,
|
|
88
|
+
tableName: this.tableName,
|
|
89
|
+
databaseName: this.databaseName,
|
|
90
|
+
context: this.context,
|
|
91
|
+
recordId: this.recordId,
|
|
92
|
+
});
|
|
93
|
+
newBuilder.operation = "getSingleField";
|
|
94
|
+
newBuilder.operationParam = field.toString();
|
|
95
|
+
// Preserve navigation context
|
|
96
|
+
newBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
|
|
97
|
+
newBuilder.navigateRelation = this.navigateRelation;
|
|
98
|
+
newBuilder.navigateSourceTableName = this.navigateSourceTableName;
|
|
99
|
+
return newBuilder;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Overload for valid relation names - returns typed QueryBuilder
|
|
103
|
+
navigate<RelationName extends ExtractNavigationNames<Occ>>(
|
|
104
|
+
relationName: RelationName,
|
|
105
|
+
): QueryBuilder<
|
|
106
|
+
ExtractSchemaFromOccurrence<
|
|
107
|
+
FindNavigationTarget<Occ, RelationName>
|
|
108
|
+
> extends Record<string, z.ZodType>
|
|
109
|
+
? InferSchemaType<
|
|
110
|
+
ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
|
|
111
|
+
>
|
|
112
|
+
: Record<string, any>
|
|
113
|
+
>;
|
|
114
|
+
// Overload for arbitrary strings - returns generic QueryBuilder with system fields
|
|
115
|
+
navigate(
|
|
116
|
+
relationName: string,
|
|
117
|
+
): QueryBuilder<{ ROWID: number; ROWMODID: number; [key: string]: any }>;
|
|
118
|
+
// Implementation
|
|
119
|
+
navigate(relationName: string): QueryBuilder<any> {
|
|
120
|
+
// Use the target occurrence if available, otherwise allow untyped navigation
|
|
121
|
+
// (useful when types might be incomplete)
|
|
122
|
+
const targetOccurrence = this.occurrence?.navigation[relationName];
|
|
123
|
+
const builder = new QueryBuilder<any>({
|
|
124
|
+
occurrence: targetOccurrence,
|
|
125
|
+
tableName: targetOccurrence?.name ?? relationName,
|
|
126
|
+
databaseName: this.databaseName,
|
|
127
|
+
context: this.context,
|
|
128
|
+
});
|
|
129
|
+
// Store the navigation info - we'll use it in execute
|
|
130
|
+
(builder as any).isNavigate = true;
|
|
131
|
+
(builder as any).navigateRecordId = this.recordId;
|
|
132
|
+
(builder as any).navigateRelation = relationName;
|
|
133
|
+
|
|
134
|
+
// If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path
|
|
135
|
+
if (
|
|
136
|
+
this.isNavigateFromEntitySet &&
|
|
137
|
+
this.navigateSourceTableName &&
|
|
138
|
+
this.navigateRelation
|
|
139
|
+
) {
|
|
140
|
+
// Build the base path: /sourceTable/relation('recordId')/newRelation
|
|
141
|
+
(builder as any).navigateSourceTableName = this.navigateSourceTableName;
|
|
142
|
+
(builder as any).navigateBaseRelation = this.navigateRelation;
|
|
143
|
+
} else {
|
|
144
|
+
// Normal record navigation: /tableName('recordId')/relation
|
|
145
|
+
(builder as any).navigateSourceTableName = this.tableName;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return builder;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async execute(
|
|
152
|
+
options?: RequestInit & FFetchOptions,
|
|
153
|
+
): Promise<
|
|
154
|
+
Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
|
|
155
|
+
> {
|
|
156
|
+
try {
|
|
157
|
+
let url: string;
|
|
158
|
+
|
|
159
|
+
// Build the base URL depending on whether this came from a navigated EntitySet
|
|
160
|
+
if (
|
|
161
|
+
this.isNavigateFromEntitySet &&
|
|
162
|
+
this.navigateSourceTableName &&
|
|
163
|
+
this.navigateRelation
|
|
164
|
+
) {
|
|
165
|
+
// From navigated EntitySet: /sourceTable/relation('recordId')
|
|
166
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
|
|
167
|
+
} else {
|
|
168
|
+
// Normal record: /tableName('recordId')
|
|
169
|
+
url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (this.operation === "getSingleField" && this.operationParam) {
|
|
173
|
+
url += `/${this.operationParam}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const response = await this.context._makeRequest(url, options);
|
|
177
|
+
|
|
178
|
+
// Handle single field operation
|
|
179
|
+
if (this.operation === "getSingleField") {
|
|
180
|
+
// Single field returns a JSON object with @context and value
|
|
181
|
+
const fieldResponse = response as ODataFieldResponse<T>;
|
|
182
|
+
return { data: fieldResponse.value as any, error: undefined };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Get schema from occurrence if available
|
|
186
|
+
const schema = this.occurrence?.baseTable?.schema;
|
|
187
|
+
|
|
188
|
+
// Validate the single record response
|
|
189
|
+
const validation = await validateSingleResponse<any>(
|
|
190
|
+
response,
|
|
191
|
+
schema,
|
|
192
|
+
undefined, // No selected fields for record.get()
|
|
193
|
+
undefined, // No expand configs
|
|
194
|
+
"exact", // Expect exactly one record
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!validation.valid) {
|
|
198
|
+
return { data: undefined, error: validation.error };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Handle null response
|
|
202
|
+
if (validation.data === null) {
|
|
203
|
+
return { data: null as any, error: undefined };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { data: validation.data, error: undefined };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
data: undefined,
|
|
210
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
getRequestConfig(): { method: string; url: string; body?: any } {
|
|
216
|
+
let url: string;
|
|
217
|
+
|
|
218
|
+
// Build the base URL depending on whether this came from a navigated EntitySet
|
|
219
|
+
if (
|
|
220
|
+
this.isNavigateFromEntitySet &&
|
|
221
|
+
this.navigateSourceTableName &&
|
|
222
|
+
this.navigateRelation
|
|
223
|
+
) {
|
|
224
|
+
// From navigated EntitySet: /sourceTable/relation('recordId')
|
|
225
|
+
url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
|
|
226
|
+
} else {
|
|
227
|
+
// Normal record: /tableName('recordId')
|
|
228
|
+
url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.operation === "getSingleField" && this.operationParam) {
|
|
232
|
+
url += `/${this.operationParam}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
method: "GET",
|
|
237
|
+
url,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { BaseTable } from "./base-table";
|
|
2
|
+
|
|
3
|
+
// Helper type to extract schema from BaseTable
|
|
4
|
+
type ExtractSchema<BT> =
|
|
5
|
+
BT extends BaseTable<infer S, any, any, any> ? S : never;
|
|
6
|
+
|
|
7
|
+
// Helper type to resolve navigation functions to their return types
|
|
8
|
+
type ResolveNavigation<T> = {
|
|
9
|
+
[K in keyof T]: T[K] extends () => infer R ? R : T[K];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Helper to create a getter-based navigation object
|
|
13
|
+
function createNavigationGetters<
|
|
14
|
+
Nav extends Record<
|
|
15
|
+
string,
|
|
16
|
+
| TableOccurrence<any, any, any, any>
|
|
17
|
+
| (() => TableOccurrence<any, any, any, any>)
|
|
18
|
+
>,
|
|
19
|
+
>(navConfig: Nav): ResolveNavigation<Nav> {
|
|
20
|
+
const result: any = {};
|
|
21
|
+
|
|
22
|
+
for (const key in navConfig) {
|
|
23
|
+
Object.defineProperty(result, key, {
|
|
24
|
+
get() {
|
|
25
|
+
const navItem = navConfig[key];
|
|
26
|
+
return typeof navItem === "function" ? navItem() : navItem;
|
|
27
|
+
},
|
|
28
|
+
enumerable: true,
|
|
29
|
+
configurable: true,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return result as ResolveNavigation<Nav>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TableOccurrence<
|
|
37
|
+
BT extends BaseTable<any, any, any, any> = any,
|
|
38
|
+
Name extends string = string,
|
|
39
|
+
Nav extends Record<
|
|
40
|
+
string,
|
|
41
|
+
| TableOccurrence<any, any, any, any>
|
|
42
|
+
| (() => TableOccurrence<any, any, any, any>)
|
|
43
|
+
> = {},
|
|
44
|
+
DefSelect extends
|
|
45
|
+
| "all"
|
|
46
|
+
| "schema"
|
|
47
|
+
| readonly (keyof ExtractSchema<BT>)[] = "schema",
|
|
48
|
+
> {
|
|
49
|
+
public readonly name: Name;
|
|
50
|
+
public readonly baseTable: BT;
|
|
51
|
+
private _navigationConfig: Nav;
|
|
52
|
+
public readonly navigation: ResolveNavigation<Nav>;
|
|
53
|
+
public readonly defaultSelect: DefSelect;
|
|
54
|
+
|
|
55
|
+
constructor(config: {
|
|
56
|
+
readonly name: Name;
|
|
57
|
+
readonly baseTable: BT;
|
|
58
|
+
readonly navigation?: Nav;
|
|
59
|
+
readonly defaultSelect?: DefSelect;
|
|
60
|
+
}) {
|
|
61
|
+
this.name = config.name;
|
|
62
|
+
this.baseTable = config.baseTable;
|
|
63
|
+
this._navigationConfig = (config.navigation ?? {}) as Nav;
|
|
64
|
+
this.defaultSelect = (config.defaultSelect ?? "schema") as DefSelect;
|
|
65
|
+
|
|
66
|
+
// Create navigation getters that lazily resolve functions
|
|
67
|
+
this.navigation = createNavigationGetters(this._navigationConfig);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
addNavigation<
|
|
71
|
+
NewNav extends Record<
|
|
72
|
+
string,
|
|
73
|
+
| TableOccurrence<any, any, any, any>
|
|
74
|
+
| (() => TableOccurrence<any, any, any, any>)
|
|
75
|
+
>,
|
|
76
|
+
>(nav: NewNav): TableOccurrence<BT, Name, Nav & NewNav, DefSelect> {
|
|
77
|
+
return new TableOccurrence({
|
|
78
|
+
name: this.name,
|
|
79
|
+
baseTable: this.baseTable,
|
|
80
|
+
navigation: { ...this._navigationConfig, ...nav } as Nav & NewNav,
|
|
81
|
+
defaultSelect: this.defaultSelect,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Helper function to create TableOccurrence with proper type inference
|
|
87
|
+
export function createTableOccurrence<
|
|
88
|
+
const Name extends string,
|
|
89
|
+
BT extends BaseTable<any, any, any, any>,
|
|
90
|
+
DefSelect extends
|
|
91
|
+
| "all"
|
|
92
|
+
| "schema"
|
|
93
|
+
| readonly (keyof ExtractSchema<BT>)[] = "schema",
|
|
94
|
+
>(config: {
|
|
95
|
+
name: Name;
|
|
96
|
+
baseTable: BT;
|
|
97
|
+
defaultSelect?: DefSelect;
|
|
98
|
+
}): TableOccurrence<BT, Name, {}, DefSelect> {
|
|
99
|
+
return new TableOccurrence(config);
|
|
100
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionContext,
|
|
3
|
+
ExecutableBuilder,
|
|
4
|
+
Result,
|
|
5
|
+
WithSystemFields,
|
|
6
|
+
} from "../types";
|
|
7
|
+
import type { TableOccurrence } from "./table-occurrence";
|
|
8
|
+
import type { BaseTable } from "./base-table";
|
|
9
|
+
import { QueryBuilder } from "./query-builder";
|
|
10
|
+
import { type FFetchOptions } from "@fetchkit/ffetch";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initial update builder returned from EntitySet.update(data)
|
|
14
|
+
* Requires calling .byId() or .where() before .execute() is available
|
|
15
|
+
*/
|
|
16
|
+
export class UpdateBuilder<
|
|
17
|
+
T extends Record<string, any>,
|
|
18
|
+
BT extends BaseTable<any, any, any, any>,
|
|
19
|
+
> {
|
|
20
|
+
private tableName: string;
|
|
21
|
+
private databaseName: string;
|
|
22
|
+
private context: ExecutionContext;
|
|
23
|
+
private occurrence?: TableOccurrence<any, any, any, any>;
|
|
24
|
+
private data: Partial<T>;
|
|
25
|
+
|
|
26
|
+
constructor(config: {
|
|
27
|
+
occurrence?: TableOccurrence<any, any, any, any>;
|
|
28
|
+
tableName: string;
|
|
29
|
+
databaseName: string;
|
|
30
|
+
context: ExecutionContext;
|
|
31
|
+
data: Partial<T>;
|
|
32
|
+
}) {
|
|
33
|
+
this.occurrence = config.occurrence;
|
|
34
|
+
this.tableName = config.tableName;
|
|
35
|
+
this.databaseName = config.databaseName;
|
|
36
|
+
this.context = config.context;
|
|
37
|
+
this.data = config.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Update a single record by ID
|
|
42
|
+
* Returns the count of updated records (0 or 1)
|
|
43
|
+
*/
|
|
44
|
+
byId(id: string | number): ExecutableUpdateBuilder<T, true> {
|
|
45
|
+
return new ExecutableUpdateBuilder<T, true>({
|
|
46
|
+
occurrence: this.occurrence,
|
|
47
|
+
tableName: this.tableName,
|
|
48
|
+
databaseName: this.databaseName,
|
|
49
|
+
context: this.context,
|
|
50
|
+
data: this.data,
|
|
51
|
+
mode: "byId",
|
|
52
|
+
recordId: id,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Update records matching a filter query
|
|
58
|
+
* Returns the count of updated records
|
|
59
|
+
* @param fn Callback that receives a QueryBuilder for building the filter
|
|
60
|
+
*/
|
|
61
|
+
where(
|
|
62
|
+
fn: (
|
|
63
|
+
q: QueryBuilder<WithSystemFields<T>>,
|
|
64
|
+
) => QueryBuilder<WithSystemFields<T>>,
|
|
65
|
+
): ExecutableUpdateBuilder<T, true> {
|
|
66
|
+
// Create a QueryBuilder for the user to configure
|
|
67
|
+
const queryBuilder = new QueryBuilder<
|
|
68
|
+
WithSystemFields<T>,
|
|
69
|
+
keyof WithSystemFields<T>,
|
|
70
|
+
false,
|
|
71
|
+
false,
|
|
72
|
+
undefined
|
|
73
|
+
>({
|
|
74
|
+
occurrence: undefined,
|
|
75
|
+
tableName: this.tableName,
|
|
76
|
+
databaseName: this.databaseName,
|
|
77
|
+
context: this.context,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Let the user configure it
|
|
81
|
+
const configuredBuilder = fn(queryBuilder);
|
|
82
|
+
|
|
83
|
+
return new ExecutableUpdateBuilder<T, true>({
|
|
84
|
+
occurrence: this.occurrence,
|
|
85
|
+
tableName: this.tableName,
|
|
86
|
+
databaseName: this.databaseName,
|
|
87
|
+
context: this.context,
|
|
88
|
+
data: this.data,
|
|
89
|
+
mode: "byFilter",
|
|
90
|
+
queryBuilder: configuredBuilder,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Executable update builder - has execute() method
|
|
97
|
+
* Returned after calling .byId() or .where()
|
|
98
|
+
* Both modes return the count of updated records
|
|
99
|
+
*/
|
|
100
|
+
export class ExecutableUpdateBuilder<
|
|
101
|
+
T extends Record<string, any>,
|
|
102
|
+
IsByFilter extends boolean,
|
|
103
|
+
> implements ExecutableBuilder<{ updatedCount: number }>
|
|
104
|
+
{
|
|
105
|
+
private tableName: string;
|
|
106
|
+
private databaseName: string;
|
|
107
|
+
private context: ExecutionContext;
|
|
108
|
+
private occurrence?: TableOccurrence<any, any, any, any>;
|
|
109
|
+
private data: Partial<T>;
|
|
110
|
+
private mode: "byId" | "byFilter";
|
|
111
|
+
private recordId?: string | number;
|
|
112
|
+
private queryBuilder?: QueryBuilder<any>;
|
|
113
|
+
|
|
114
|
+
constructor(config: {
|
|
115
|
+
occurrence?: TableOccurrence<any, any, any, any>;
|
|
116
|
+
tableName: string;
|
|
117
|
+
databaseName: string;
|
|
118
|
+
context: ExecutionContext;
|
|
119
|
+
data: Partial<T>;
|
|
120
|
+
mode: "byId" | "byFilter";
|
|
121
|
+
recordId?: string | number;
|
|
122
|
+
queryBuilder?: QueryBuilder<any>;
|
|
123
|
+
}) {
|
|
124
|
+
this.occurrence = config.occurrence;
|
|
125
|
+
this.tableName = config.tableName;
|
|
126
|
+
this.databaseName = config.databaseName;
|
|
127
|
+
this.context = config.context;
|
|
128
|
+
this.data = config.data;
|
|
129
|
+
this.mode = config.mode;
|
|
130
|
+
this.recordId = config.recordId;
|
|
131
|
+
this.queryBuilder = config.queryBuilder;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async execute(
|
|
135
|
+
options?: RequestInit & FFetchOptions,
|
|
136
|
+
): Promise<Result<{ updatedCount: number }>> {
|
|
137
|
+
try {
|
|
138
|
+
let url: string;
|
|
139
|
+
|
|
140
|
+
if (this.mode === "byId") {
|
|
141
|
+
// Update single record by ID: PATCH /{database}/{table}('id')
|
|
142
|
+
url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
|
|
143
|
+
} else {
|
|
144
|
+
// Update by filter: PATCH /{database}/{table}?$filter=...
|
|
145
|
+
if (!this.queryBuilder) {
|
|
146
|
+
throw new Error("Query builder is required for filter-based update");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Get the query string from the configured QueryBuilder
|
|
150
|
+
const queryString = this.queryBuilder.getQueryString();
|
|
151
|
+
// Remove the leading "/" from the query string as we'll build our own URL
|
|
152
|
+
const queryParams = queryString.startsWith(`/${this.tableName}`)
|
|
153
|
+
? queryString.slice(`/${this.tableName}`.length)
|
|
154
|
+
: queryString;
|
|
155
|
+
|
|
156
|
+
url = `/${this.databaseName}/${this.tableName}${queryParams}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Make PATCH request with JSON body
|
|
160
|
+
const response = await this.context._makeRequest(url, {
|
|
161
|
+
method: "PATCH",
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
},
|
|
165
|
+
body: JSON.stringify(this.data),
|
|
166
|
+
...options,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Both byId and byFilter return affected row count
|
|
170
|
+
let updatedCount = 0;
|
|
171
|
+
|
|
172
|
+
if (typeof response === "number") {
|
|
173
|
+
updatedCount = response;
|
|
174
|
+
} else if (response && typeof response === "object") {
|
|
175
|
+
// Check if the response has a count property (fallback)
|
|
176
|
+
updatedCount = (response as any).updatedCount || 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { data: { updatedCount }, error: undefined };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
data: undefined,
|
|
183
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getRequestConfig(): { method: string; url: string; body?: any } {
|
|
189
|
+
let url: string;
|
|
190
|
+
|
|
191
|
+
if (this.mode === "byId") {
|
|
192
|
+
url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
|
|
193
|
+
} else {
|
|
194
|
+
if (!this.queryBuilder) {
|
|
195
|
+
throw new Error("Query builder is required for filter-based update");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const queryString = this.queryBuilder.getQueryString();
|
|
199
|
+
const queryParams = queryString.startsWith(`/${this.tableName}`)
|
|
200
|
+
? queryString.slice(`/${this.tableName}`.length)
|
|
201
|
+
: queryString;
|
|
202
|
+
|
|
203
|
+
url = `/${this.databaseName}/${this.tableName}${queryParams}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
method: "PATCH",
|
|
208
|
+
url,
|
|
209
|
+
body: JSON.stringify(this.data),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
|
|
3
|
+
// Operator types for each value type
|
|
4
|
+
export type StringOperators =
|
|
5
|
+
| { eq: string | null }
|
|
6
|
+
| { ne: string | null }
|
|
7
|
+
| { gt: string }
|
|
8
|
+
| { ge: string }
|
|
9
|
+
| { lt: string }
|
|
10
|
+
| { le: string }
|
|
11
|
+
| { contains: string }
|
|
12
|
+
| { startswith: string }
|
|
13
|
+
| { endswith: string }
|
|
14
|
+
| { in: string[] };
|
|
15
|
+
|
|
16
|
+
export type NumberOperators =
|
|
17
|
+
| { eq: number | null }
|
|
18
|
+
| { ne: number | null }
|
|
19
|
+
| { gt: number }
|
|
20
|
+
| { ge: number }
|
|
21
|
+
| { lt: number }
|
|
22
|
+
| { le: number }
|
|
23
|
+
| { in: number[] };
|
|
24
|
+
|
|
25
|
+
export type BooleanOperators =
|
|
26
|
+
| { eq: boolean | null }
|
|
27
|
+
| { ne: boolean | null };
|
|
28
|
+
|
|
29
|
+
export type DateOperators =
|
|
30
|
+
| { eq: Date | null }
|
|
31
|
+
| { ne: Date | null }
|
|
32
|
+
| { gt: Date }
|
|
33
|
+
| { ge: Date }
|
|
34
|
+
| { lt: Date }
|
|
35
|
+
| { le: Date }
|
|
36
|
+
| { in: Date[] };
|
|
37
|
+
|
|
38
|
+
// Infer output type from StandardSchemaV1
|
|
39
|
+
export type InferOutput<S> = S extends StandardSchemaV1<any, infer Output>
|
|
40
|
+
? Output
|
|
41
|
+
: never;
|
|
42
|
+
|
|
43
|
+
// Map inferred types to their operators
|
|
44
|
+
export type OperatorsForType<T> =
|
|
45
|
+
T extends string | null | undefined ? StringOperators :
|
|
46
|
+
T extends number | null | undefined ? NumberOperators :
|
|
47
|
+
T extends boolean | null | undefined ? BooleanOperators :
|
|
48
|
+
T extends Date | null | undefined ? DateOperators :
|
|
49
|
+
never;
|
|
50
|
+
|
|
51
|
+
// Get operators for a schema field
|
|
52
|
+
export type OperatorsForSchemaField<S extends StandardSchemaV1> =
|
|
53
|
+
OperatorsForType<InferOutput<S>>;
|
|
54
|
+
|
|
55
|
+
// Field filter: shorthand, single operator, or operator array
|
|
56
|
+
export type FieldFilter<S extends StandardSchemaV1> =
|
|
57
|
+
| InferOutput<S> // Shorthand: { name: "John" }
|
|
58
|
+
| OperatorsForSchemaField<S> // Single operator: { age: { gt: 18 } }
|
|
59
|
+
| Array<OperatorsForSchemaField<S>>; // Multiple operators: { age: [{ gt: 18 }, { lt: 65 }] }
|
|
60
|
+
|
|
61
|
+
// Logical operators (recursive)
|
|
62
|
+
export type LogicalFilter<Schema extends Record<string, StandardSchemaV1>> = {
|
|
63
|
+
and?: Array<TypedFilter<Schema>>;
|
|
64
|
+
or?: Array<TypedFilter<Schema>>;
|
|
65
|
+
not?: TypedFilter<Schema>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Helper to check if Schema is exactly Record<string, StandardSchemaV1> (untyped)
|
|
69
|
+
// Uses double extends check to ensure Schema is exactly the generic type, not a more specific type
|
|
70
|
+
type IsUntypedSchema<Schema> =
|
|
71
|
+
[Record<string, StandardSchemaV1>] extends [Schema]
|
|
72
|
+
? [Schema] extends [Record<string, StandardSchemaV1>]
|
|
73
|
+
? true
|
|
74
|
+
: false
|
|
75
|
+
: false;
|
|
76
|
+
|
|
77
|
+
// Main filter type
|
|
78
|
+
export type TypedFilter<Schema extends Record<string, StandardSchemaV1>> =
|
|
79
|
+
| LogicalFilter<Schema>
|
|
80
|
+
| (
|
|
81
|
+
IsUntypedSchema<Schema> extends true
|
|
82
|
+
? {
|
|
83
|
+
// For untyped schemas, allow arbitrary string keys with empty object intersection (preserves autocomplete)
|
|
84
|
+
[key: string]: FieldFilter<any> | any;
|
|
85
|
+
} & {}
|
|
86
|
+
: {
|
|
87
|
+
// For typed schemas, use specific keys (preserves autocomplete)
|
|
88
|
+
[K in keyof Schema]?: FieldFilter<Schema[K]>;
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Top-level filter (can be array for implicit AND)
|
|
93
|
+
export type Filter<Schema extends Record<string, StandardSchemaV1>> =
|
|
94
|
+
| TypedFilter<Schema>
|
|
95
|
+
| Array<TypedFilter<Schema>>
|
|
96
|
+
| string; // Escape hatch for raw OData expressions
|
|
97
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Barrel file - exports all public API from the client folder
|
|
2
|
+
export { BaseTable } from "./client/base-table";
|
|
3
|
+
export {
|
|
4
|
+
TableOccurrence,
|
|
5
|
+
createTableOccurrence,
|
|
6
|
+
} from "./client/table-occurrence";
|
|
7
|
+
export { FileMakerOData } from "./client/filemaker-odata";
|
|
8
|
+
export type {
|
|
9
|
+
Filter,
|
|
10
|
+
TypedFilter,
|
|
11
|
+
FieldFilter,
|
|
12
|
+
StringOperators,
|
|
13
|
+
NumberOperators,
|
|
14
|
+
BooleanOperators,
|
|
15
|
+
DateOperators,
|
|
16
|
+
LogicalFilter,
|
|
17
|
+
} from "./filter-types";
|