@objectstack/client 0.6.1 → 0.7.2
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/CHANGELOG.md +18 -0
- package/README.md +89 -5
- package/dist/index.d.ts +78 -3
- package/dist/index.js +217 -12
- package/dist/query-builder.d.ts +124 -0
- package/dist/query-builder.js +221 -0
- package/package.json +3 -2
- package/src/index.ts +304 -13
- package/src/query-builder.ts +251 -0
package/src/index.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { QueryAST, SortNode, AggregationNode, WindowFunctionNode } from '@objectstack/spec/data';
|
|
2
|
+
import {
|
|
3
|
+
BatchUpdateRequest,
|
|
4
|
+
BatchUpdateResponse,
|
|
5
|
+
UpdateManyRequest,
|
|
6
|
+
DeleteManyRequest,
|
|
7
|
+
BatchOptions,
|
|
8
|
+
MetadataCacheRequest,
|
|
9
|
+
MetadataCacheResponse,
|
|
10
|
+
StandardErrorCode,
|
|
11
|
+
ErrorCategory,
|
|
12
|
+
CreateViewRequest,
|
|
13
|
+
UpdateViewRequest,
|
|
14
|
+
ListViewsRequest,
|
|
15
|
+
SavedView,
|
|
16
|
+
ListViewsResponse,
|
|
17
|
+
ViewResponse
|
|
18
|
+
} from '@objectstack/spec/api';
|
|
19
|
+
import { Logger, createLogger } from '@objectstack/core';
|
|
2
20
|
|
|
3
21
|
export interface ClientConfig {
|
|
4
22
|
baseUrl: string;
|
|
@@ -7,6 +25,14 @@ export interface ClientConfig {
|
|
|
7
25
|
* Custom fetch implementation (e.g. node-fetch or for Next.js caching)
|
|
8
26
|
*/
|
|
9
27
|
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
28
|
+
/**
|
|
29
|
+
* Logger instance for debugging
|
|
30
|
+
*/
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
/**
|
|
33
|
+
* Enable debug logging
|
|
34
|
+
*/
|
|
35
|
+
debug?: boolean;
|
|
10
36
|
}
|
|
11
37
|
|
|
12
38
|
export interface DiscoveryResult {
|
|
@@ -36,22 +62,42 @@ export interface PaginatedResult<T = any> {
|
|
|
36
62
|
count: number;
|
|
37
63
|
}
|
|
38
64
|
|
|
65
|
+
export interface StandardError {
|
|
66
|
+
code: StandardErrorCode;
|
|
67
|
+
message: string;
|
|
68
|
+
category: ErrorCategory;
|
|
69
|
+
httpStatus: number;
|
|
70
|
+
retryable: boolean;
|
|
71
|
+
details?: Record<string, any>;
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
export class ObjectStackClient {
|
|
40
75
|
private baseUrl: string;
|
|
41
76
|
private token?: string;
|
|
42
77
|
private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
43
78
|
private routes?: DiscoveryResult['routes'];
|
|
79
|
+
private logger: Logger;
|
|
44
80
|
|
|
45
81
|
constructor(config: ClientConfig) {
|
|
46
82
|
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
47
83
|
this.token = config.token;
|
|
48
84
|
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
|
|
85
|
+
|
|
86
|
+
// Initialize logger
|
|
87
|
+
this.logger = config.logger || createLogger({
|
|
88
|
+
level: config.debug ? 'debug' : 'info',
|
|
89
|
+
format: 'pretty'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.logger.debug('ObjectStack client created', { baseUrl: this.baseUrl });
|
|
49
93
|
}
|
|
50
94
|
|
|
51
95
|
/**
|
|
52
96
|
* Initialize the client by discovering server capabilities and routes.
|
|
53
97
|
*/
|
|
54
98
|
async connect() {
|
|
99
|
+
this.logger.debug('Connecting to ObjectStack server', { baseUrl: this.baseUrl });
|
|
100
|
+
|
|
55
101
|
try {
|
|
56
102
|
// Connect to the discovery endpoint
|
|
57
103
|
// During boot, we might not know routes, so we check convention /api/v1 first
|
|
@@ -59,9 +105,15 @@ export class ObjectStackClient {
|
|
|
59
105
|
|
|
60
106
|
const data = await res.json();
|
|
61
107
|
this.routes = data.routes;
|
|
108
|
+
|
|
109
|
+
this.logger.info('Connected to ObjectStack server', {
|
|
110
|
+
routes: Object.keys(data.routes || {}),
|
|
111
|
+
capabilities: data.capabilities
|
|
112
|
+
});
|
|
113
|
+
|
|
62
114
|
return data as DiscoveryResult;
|
|
63
115
|
} catch (e) {
|
|
64
|
-
|
|
116
|
+
this.logger.error('Failed to connect to ObjectStack server', e as Error, { baseUrl: this.baseUrl });
|
|
65
117
|
throw e;
|
|
66
118
|
}
|
|
67
119
|
}
|
|
@@ -76,6 +128,51 @@ export class ObjectStackClient {
|
|
|
76
128
|
return res.json();
|
|
77
129
|
},
|
|
78
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Get object metadata with cache support
|
|
133
|
+
* Supports ETag-based conditional requests for efficient caching
|
|
134
|
+
*/
|
|
135
|
+
getCached: async (name: string, cacheOptions?: MetadataCacheRequest): Promise<MetadataCacheResponse> => {
|
|
136
|
+
const route = this.getRoute('metadata');
|
|
137
|
+
const headers: Record<string, string> = {};
|
|
138
|
+
|
|
139
|
+
if (cacheOptions?.ifNoneMatch) {
|
|
140
|
+
headers['If-None-Match'] = cacheOptions.ifNoneMatch;
|
|
141
|
+
}
|
|
142
|
+
if (cacheOptions?.ifModifiedSince) {
|
|
143
|
+
headers['If-Modified-Since'] = cacheOptions.ifModifiedSince;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const res = await this.fetch(`${this.baseUrl}${route}/object/${name}`, {
|
|
147
|
+
headers
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Check for 304 Not Modified
|
|
151
|
+
if (res.status === 304) {
|
|
152
|
+
return {
|
|
153
|
+
notModified: true,
|
|
154
|
+
etag: cacheOptions?.ifNoneMatch ? {
|
|
155
|
+
value: cacheOptions.ifNoneMatch.replace(/^W\/|"/g, ''),
|
|
156
|
+
weak: cacheOptions.ifNoneMatch.startsWith('W/')
|
|
157
|
+
} : undefined
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = await res.json();
|
|
162
|
+
const etag = res.headers.get('ETag');
|
|
163
|
+
const lastModified = res.headers.get('Last-Modified');
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
data,
|
|
167
|
+
etag: etag ? {
|
|
168
|
+
value: etag.replace(/^W\/|"/g, ''),
|
|
169
|
+
weak: etag.startsWith('W/')
|
|
170
|
+
} : undefined,
|
|
171
|
+
lastModified: lastModified || undefined,
|
|
172
|
+
notModified: false
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
|
|
79
176
|
getView: async (object: string, type: 'list' | 'form' = 'list') => {
|
|
80
177
|
const route = this.getRoute('ui');
|
|
81
178
|
const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`);
|
|
@@ -170,7 +267,7 @@ export class ObjectStackClient {
|
|
|
170
267
|
|
|
171
268
|
createMany: async <T = any>(object: string, data: Partial<T>[]): Promise<T[]> => {
|
|
172
269
|
const route = this.getRoute('data');
|
|
173
|
-
const res = await this.fetch(`${this.baseUrl}${route}/${object}/
|
|
270
|
+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/createMany`, {
|
|
174
271
|
method: 'POST',
|
|
175
272
|
body: JSON.stringify(data)
|
|
176
273
|
});
|
|
@@ -186,13 +283,38 @@ export class ObjectStackClient {
|
|
|
186
283
|
return res.json();
|
|
187
284
|
},
|
|
188
285
|
|
|
189
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Batch update multiple records
|
|
288
|
+
* Uses the new BatchUpdateRequest schema with full control over options
|
|
289
|
+
*/
|
|
290
|
+
batch: async (object: string, request: BatchUpdateRequest): Promise<BatchUpdateResponse> => {
|
|
190
291
|
const route = this.getRoute('data');
|
|
191
292
|
const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, {
|
|
192
|
-
method: '
|
|
193
|
-
body: JSON.stringify(
|
|
293
|
+
method: 'POST',
|
|
294
|
+
body: JSON.stringify(request)
|
|
194
295
|
});
|
|
195
|
-
return res.json();
|
|
296
|
+
return res.json();
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Update multiple records (simplified batch update)
|
|
301
|
+
* Convenience method for batch updates without full BatchUpdateRequest
|
|
302
|
+
*/
|
|
303
|
+
updateMany: async <T = any>(
|
|
304
|
+
object: string,
|
|
305
|
+
records: Array<{ id: string; data: Partial<T> }>,
|
|
306
|
+
options?: BatchOptions
|
|
307
|
+
): Promise<BatchUpdateResponse> => {
|
|
308
|
+
const route = this.getRoute('data');
|
|
309
|
+
const request: UpdateManyRequest = {
|
|
310
|
+
records,
|
|
311
|
+
options
|
|
312
|
+
};
|
|
313
|
+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/updateMany`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
body: JSON.stringify(request)
|
|
316
|
+
});
|
|
317
|
+
return res.json();
|
|
196
318
|
},
|
|
197
319
|
|
|
198
320
|
delete: async (object: string, id: string): Promise<{ success: boolean }> => {
|
|
@@ -203,16 +325,121 @@ export class ObjectStackClient {
|
|
|
203
325
|
return res.json();
|
|
204
326
|
},
|
|
205
327
|
|
|
206
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Delete multiple records by IDs
|
|
330
|
+
*/
|
|
331
|
+
deleteMany: async(object: string, ids: string[], options?: BatchOptions): Promise<BatchUpdateResponse> => {
|
|
207
332
|
const route = this.getRoute('data');
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
333
|
+
const request: DeleteManyRequest = {
|
|
334
|
+
ids,
|
|
335
|
+
options
|
|
336
|
+
};
|
|
337
|
+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/deleteMany`, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
body: JSON.stringify(request)
|
|
211
340
|
});
|
|
212
341
|
return res.json();
|
|
213
342
|
}
|
|
214
343
|
};
|
|
215
344
|
|
|
345
|
+
/**
|
|
346
|
+
* View Storage Operations
|
|
347
|
+
* Save, load, and manage UI view configurations
|
|
348
|
+
*/
|
|
349
|
+
views = {
|
|
350
|
+
/**
|
|
351
|
+
* Create a new saved view
|
|
352
|
+
*/
|
|
353
|
+
create: async (request: CreateViewRequest): Promise<ViewResponse> => {
|
|
354
|
+
const route = this.getRoute('ui');
|
|
355
|
+
const res = await this.fetch(`${this.baseUrl}${route}/views`, {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
body: JSON.stringify(request)
|
|
358
|
+
});
|
|
359
|
+
return res.json();
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get a saved view by ID
|
|
364
|
+
*/
|
|
365
|
+
get: async (id: string): Promise<ViewResponse> => {
|
|
366
|
+
const route = this.getRoute('ui');
|
|
367
|
+
const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`);
|
|
368
|
+
return res.json();
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* List saved views with optional filters
|
|
373
|
+
*/
|
|
374
|
+
list: async (request?: ListViewsRequest): Promise<ListViewsResponse> => {
|
|
375
|
+
const route = this.getRoute('ui');
|
|
376
|
+
const queryParams = new URLSearchParams();
|
|
377
|
+
|
|
378
|
+
if (request?.object) queryParams.set('object', request.object);
|
|
379
|
+
if (request?.type) queryParams.set('type', request.type);
|
|
380
|
+
if (request?.visibility) queryParams.set('visibility', request.visibility);
|
|
381
|
+
if (request?.createdBy) queryParams.set('createdBy', request.createdBy);
|
|
382
|
+
if (request?.isDefault !== undefined) queryParams.set('isDefault', String(request.isDefault));
|
|
383
|
+
if (request?.limit) queryParams.set('limit', String(request.limit));
|
|
384
|
+
if (request?.offset) queryParams.set('offset', String(request.offset));
|
|
385
|
+
|
|
386
|
+
const url = queryParams.toString()
|
|
387
|
+
? `${this.baseUrl}${route}/views?${queryParams.toString()}`
|
|
388
|
+
: `${this.baseUrl}${route}/views`;
|
|
389
|
+
|
|
390
|
+
const res = await this.fetch(url);
|
|
391
|
+
return res.json();
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Update an existing view
|
|
396
|
+
*/
|
|
397
|
+
update: async (request: UpdateViewRequest): Promise<ViewResponse> => {
|
|
398
|
+
const route = this.getRoute('ui');
|
|
399
|
+
const { id, ...updateData } = request;
|
|
400
|
+
const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`, {
|
|
401
|
+
method: 'PATCH',
|
|
402
|
+
body: JSON.stringify(updateData)
|
|
403
|
+
});
|
|
404
|
+
return res.json();
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Delete a saved view
|
|
409
|
+
*/
|
|
410
|
+
delete: async (id: string): Promise<{ success: boolean }> => {
|
|
411
|
+
const route = this.getRoute('ui');
|
|
412
|
+
const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`, {
|
|
413
|
+
method: 'DELETE'
|
|
414
|
+
});
|
|
415
|
+
return res.json();
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Share a view with users/teams
|
|
420
|
+
*/
|
|
421
|
+
share: async (id: string, userIds: string[]): Promise<ViewResponse> => {
|
|
422
|
+
const route = this.getRoute('ui');
|
|
423
|
+
const res = await this.fetch(`${this.baseUrl}${route}/views/${id}/share`, {
|
|
424
|
+
method: 'POST',
|
|
425
|
+
body: JSON.stringify({ sharedWith: userIds })
|
|
426
|
+
});
|
|
427
|
+
return res.json();
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Set a view as default for an object
|
|
432
|
+
*/
|
|
433
|
+
setDefault: async (id: string, object: string): Promise<ViewResponse> => {
|
|
434
|
+
const route = this.getRoute('ui');
|
|
435
|
+
const res = await this.fetch(`${this.baseUrl}${route}/views/${id}/set-default`, {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
body: JSON.stringify({ object })
|
|
438
|
+
});
|
|
439
|
+
return res.json();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
216
443
|
/**
|
|
217
444
|
* Private Helpers
|
|
218
445
|
*/
|
|
@@ -225,6 +452,12 @@ export class ObjectStackClient {
|
|
|
225
452
|
}
|
|
226
453
|
|
|
227
454
|
private async fetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
455
|
+
this.logger.debug('HTTP request', {
|
|
456
|
+
method: options.method || 'GET',
|
|
457
|
+
url,
|
|
458
|
+
hasBody: !!options.body
|
|
459
|
+
});
|
|
460
|
+
|
|
228
461
|
const headers: Record<string, string> = {
|
|
229
462
|
'Content-Type': 'application/json',
|
|
230
463
|
...(options.headers as Record<string, string> || {}),
|
|
@@ -236,14 +469,41 @@ export class ObjectStackClient {
|
|
|
236
469
|
|
|
237
470
|
const res = await this.fetchImpl(url, { ...options, headers });
|
|
238
471
|
|
|
472
|
+
this.logger.debug('HTTP response', {
|
|
473
|
+
method: options.method || 'GET',
|
|
474
|
+
url,
|
|
475
|
+
status: res.status,
|
|
476
|
+
ok: res.ok
|
|
477
|
+
});
|
|
478
|
+
|
|
239
479
|
if (!res.ok) {
|
|
240
|
-
let errorBody;
|
|
480
|
+
let errorBody: any;
|
|
241
481
|
try {
|
|
242
482
|
errorBody = await res.json();
|
|
243
483
|
} catch {
|
|
244
484
|
errorBody = { message: res.statusText };
|
|
245
485
|
}
|
|
246
|
-
|
|
486
|
+
|
|
487
|
+
this.logger.error('HTTP request failed', undefined, {
|
|
488
|
+
method: options.method || 'GET',
|
|
489
|
+
url,
|
|
490
|
+
status: res.status,
|
|
491
|
+
error: errorBody
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Create a standardized error if the response includes error details
|
|
495
|
+
const errorMessage = errorBody?.message || errorBody?.error?.message || res.statusText;
|
|
496
|
+
const errorCode = errorBody?.code || errorBody?.error?.code;
|
|
497
|
+
const error = new Error(`[ObjectStack] ${errorCode ? `${errorCode}: ` : ''}${errorMessage}`) as any;
|
|
498
|
+
|
|
499
|
+
// Attach error details for programmatic access
|
|
500
|
+
error.code = errorCode;
|
|
501
|
+
error.category = errorBody?.category;
|
|
502
|
+
error.httpStatus = res.status;
|
|
503
|
+
error.retryable = errorBody?.retryable;
|
|
504
|
+
error.details = errorBody?.details || errorBody;
|
|
505
|
+
|
|
506
|
+
throw error;
|
|
247
507
|
}
|
|
248
508
|
|
|
249
509
|
return res;
|
|
@@ -252,9 +512,40 @@ export class ObjectStackClient {
|
|
|
252
512
|
private getRoute(key: keyof DiscoveryResult['routes']): string {
|
|
253
513
|
if (!this.routes) {
|
|
254
514
|
// Fallback for strictness, but we allow bootstrapping
|
|
255
|
-
|
|
515
|
+
this.logger.warn('Accessing route before connect()', {
|
|
516
|
+
route: key,
|
|
517
|
+
fallback: `/api/v1/${key}`
|
|
518
|
+
});
|
|
256
519
|
return `/api/v1/${key}`;
|
|
257
520
|
}
|
|
258
521
|
return this.routes[key] || `/api/v1/${key}`;
|
|
259
522
|
}
|
|
260
523
|
}
|
|
524
|
+
|
|
525
|
+
// Re-export type-safe query builder
|
|
526
|
+
export { QueryBuilder, FilterBuilder, createQuery, createFilter } from './query-builder';
|
|
527
|
+
|
|
528
|
+
// Re-export commonly used types from @objectstack/spec/api for convenience
|
|
529
|
+
export type {
|
|
530
|
+
BatchUpdateRequest,
|
|
531
|
+
BatchUpdateResponse,
|
|
532
|
+
UpdateManyRequest,
|
|
533
|
+
DeleteManyRequest,
|
|
534
|
+
BatchOptions,
|
|
535
|
+
BatchRecord,
|
|
536
|
+
BatchOperationResult,
|
|
537
|
+
MetadataCacheRequest,
|
|
538
|
+
MetadataCacheResponse,
|
|
539
|
+
StandardErrorCode,
|
|
540
|
+
ErrorCategory,
|
|
541
|
+
CreateViewRequest,
|
|
542
|
+
UpdateViewRequest,
|
|
543
|
+
ListViewsRequest,
|
|
544
|
+
SavedView,
|
|
545
|
+
ViewResponse,
|
|
546
|
+
ListViewsResponse,
|
|
547
|
+
ViewType,
|
|
548
|
+
ViewVisibility,
|
|
549
|
+
ViewColumn,
|
|
550
|
+
ViewLayout
|
|
551
|
+
} from '@objectstack/spec/api';
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-Safe Query Builder
|
|
3
|
+
*
|
|
4
|
+
* Provides a fluent API for building ObjectStack queries with:
|
|
5
|
+
* - Compile-time type checking
|
|
6
|
+
* - Intelligent code completion
|
|
7
|
+
* - Runtime validation
|
|
8
|
+
* - Type-safe filters and selections
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { QueryAST, FilterCondition, SortNode } from '@objectstack/spec/data';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Type-safe filter builder
|
|
15
|
+
*/
|
|
16
|
+
export class FilterBuilder<T = any> {
|
|
17
|
+
private conditions: FilterCondition[] = [];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Equality filter: field = value
|
|
21
|
+
*/
|
|
22
|
+
equals<K extends keyof T>(field: K, value: T[K]): this {
|
|
23
|
+
this.conditions.push([field as string, '=', value]);
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Not equals filter: field != value
|
|
29
|
+
*/
|
|
30
|
+
notEquals<K extends keyof T>(field: K, value: T[K]): this {
|
|
31
|
+
this.conditions.push([field as string, '!=', value]);
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Greater than filter: field > value
|
|
37
|
+
*/
|
|
38
|
+
greaterThan<K extends keyof T>(field: K, value: T[K]): this {
|
|
39
|
+
this.conditions.push([field as string, '>', value]);
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Greater than or equal filter: field >= value
|
|
45
|
+
*/
|
|
46
|
+
greaterThanOrEqual<K extends keyof T>(field: K, value: T[K]): this {
|
|
47
|
+
this.conditions.push([field as string, '>=', value]);
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Less than filter: field < value
|
|
53
|
+
*/
|
|
54
|
+
lessThan<K extends keyof T>(field: K, value: T[K]): this {
|
|
55
|
+
this.conditions.push([field as string, '<', value]);
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Less than or equal filter: field <= value
|
|
61
|
+
*/
|
|
62
|
+
lessThanOrEqual<K extends keyof T>(field: K, value: T[K]): this {
|
|
63
|
+
this.conditions.push([field as string, '<=', value]);
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* IN filter: field IN (value1, value2, ...)
|
|
69
|
+
*/
|
|
70
|
+
in<K extends keyof T>(field: K, values: T[K][]): this {
|
|
71
|
+
this.conditions.push([field as string, 'in', values]);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* NOT IN filter: field NOT IN (value1, value2, ...)
|
|
77
|
+
*/
|
|
78
|
+
notIn<K extends keyof T>(field: K, values: T[K][]): this {
|
|
79
|
+
this.conditions.push([field as string, 'not_in', values]);
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* LIKE filter: field LIKE pattern
|
|
85
|
+
*/
|
|
86
|
+
like<K extends keyof T>(field: K, pattern: string): this {
|
|
87
|
+
this.conditions.push([field as string, 'like', pattern]);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* IS NULL filter: field IS NULL
|
|
93
|
+
*/
|
|
94
|
+
isNull<K extends keyof T>(field: K): this {
|
|
95
|
+
this.conditions.push([field as string, 'is_null', null]);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* IS NOT NULL filter: field IS NOT NULL
|
|
101
|
+
*/
|
|
102
|
+
isNotNull<K extends keyof T>(field: K): this {
|
|
103
|
+
this.conditions.push([field as string, 'is_not_null', null]);
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the filter condition
|
|
109
|
+
*/
|
|
110
|
+
build(): FilterCondition {
|
|
111
|
+
if (this.conditions.length === 0) {
|
|
112
|
+
throw new Error('Filter builder has no conditions');
|
|
113
|
+
}
|
|
114
|
+
if (this.conditions.length === 1) {
|
|
115
|
+
return this.conditions[0];
|
|
116
|
+
}
|
|
117
|
+
// Combine multiple conditions with AND
|
|
118
|
+
return ['and', ...this.conditions];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get raw conditions array
|
|
123
|
+
*/
|
|
124
|
+
getConditions(): FilterCondition[] {
|
|
125
|
+
return this.conditions;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Type-safe query builder
|
|
131
|
+
*/
|
|
132
|
+
export class QueryBuilder<T = any> {
|
|
133
|
+
private query: Partial<QueryAST> = {};
|
|
134
|
+
private _object: string;
|
|
135
|
+
|
|
136
|
+
constructor(object: string) {
|
|
137
|
+
this._object = object;
|
|
138
|
+
this.query.object = object;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Select specific fields
|
|
143
|
+
*/
|
|
144
|
+
select<K extends keyof T>(...fields: K[]): this {
|
|
145
|
+
this.query.fields = fields as string[];
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Add filters using a builder function
|
|
151
|
+
*/
|
|
152
|
+
where(builderFn: (builder: FilterBuilder<T>) => void): this {
|
|
153
|
+
const builder = new FilterBuilder<T>();
|
|
154
|
+
builderFn(builder);
|
|
155
|
+
const conditions = builder.getConditions();
|
|
156
|
+
|
|
157
|
+
if (conditions.length === 1) {
|
|
158
|
+
this.query.where = conditions[0];
|
|
159
|
+
} else if (conditions.length > 1) {
|
|
160
|
+
this.query.where = ['and', ...conditions] as FilterCondition;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Add raw filter condition
|
|
168
|
+
*/
|
|
169
|
+
filter(condition: FilterCondition): this {
|
|
170
|
+
this.query.where = condition;
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Sort by fields
|
|
176
|
+
*/
|
|
177
|
+
orderBy<K extends keyof T>(field: K, order: 'asc' | 'desc' = 'asc'): this {
|
|
178
|
+
if (!this.query.orderBy) {
|
|
179
|
+
this.query.orderBy = [];
|
|
180
|
+
}
|
|
181
|
+
(this.query.orderBy as SortNode[]).push({
|
|
182
|
+
field: field as string,
|
|
183
|
+
order
|
|
184
|
+
});
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Limit the number of results
|
|
190
|
+
*/
|
|
191
|
+
limit(count: number): this {
|
|
192
|
+
this.query.limit = count;
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Skip records (for pagination)
|
|
198
|
+
*/
|
|
199
|
+
skip(count: number): this {
|
|
200
|
+
this.query.offset = count;
|
|
201
|
+
return this;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Paginate results
|
|
206
|
+
*/
|
|
207
|
+
paginate(page: number, pageSize: number): this {
|
|
208
|
+
this.query.limit = pageSize;
|
|
209
|
+
this.query.offset = (page - 1) * pageSize;
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Group by fields
|
|
215
|
+
*/
|
|
216
|
+
groupBy<K extends keyof T>(...fields: K[]): this {
|
|
217
|
+
this.query.groupBy = fields as string[];
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build the final query AST
|
|
223
|
+
*/
|
|
224
|
+
build(): QueryAST {
|
|
225
|
+
return {
|
|
226
|
+
object: this._object,
|
|
227
|
+
...this.query
|
|
228
|
+
} as QueryAST;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get the current query state
|
|
233
|
+
*/
|
|
234
|
+
getQuery(): Partial<QueryAST> {
|
|
235
|
+
return { ...this.query };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a type-safe query builder for an object
|
|
241
|
+
*/
|
|
242
|
+
export function createQuery<T = any>(object: string): QueryBuilder<T> {
|
|
243
|
+
return new QueryBuilder<T>(object);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Create a type-safe filter builder
|
|
248
|
+
*/
|
|
249
|
+
export function createFilter<T = any>(): FilterBuilder<T> {
|
|
250
|
+
return new FilterBuilder<T>();
|
|
251
|
+
}
|