@objectql/driver-memory 3.0.1 → 4.0.1
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 +17 -3
- package/MIGRATION.md +468 -0
- package/MINGO_INTEGRATION.md +116 -0
- package/README.md +3 -3
- package/REFACTORING_SUMMARY.md +186 -0
- package/dist/index.d.ts +135 -16
- package/dist/index.js +471 -122
- package/dist/index.js.map +1 -1
- package/jest.config.js +8 -0
- package/package.json +5 -3
- package/src/index.ts +541 -123
- package/test/index.test.ts +8 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
+
import { Data, System } from '@objectstack/spec';
|
|
2
|
+
type QueryAST = Data.QueryAST;
|
|
3
|
+
type FilterNode = Data.FilterNode;
|
|
4
|
+
type SortNode = Data.SortNode;
|
|
5
|
+
type DriverInterface = System.DriverInterface;
|
|
6
|
+
/**
|
|
7
|
+
* ObjectQL
|
|
8
|
+
* Copyright (c) 2026-present ObjectStack Inc.
|
|
9
|
+
*
|
|
10
|
+
* This source code is licensed under the MIT license found in the
|
|
11
|
+
* LICENSE file in the root directory of this source tree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
1
14
|
/**
|
|
2
15
|
* Memory Driver for ObjectQL (Production-Ready)
|
|
3
16
|
*
|
|
4
|
-
* A high-performance in-memory driver for ObjectQL
|
|
17
|
+
* A high-performance in-memory driver for ObjectQL powered by Mingo.
|
|
5
18
|
* Perfect for testing, development, and environments where persistence is not required.
|
|
6
19
|
*
|
|
20
|
+
* Implements the Driver interface from @objectql/types which includes all methods
|
|
21
|
+
* from the standard DriverInterface from @objectstack/spec for full compatibility
|
|
22
|
+
* with the new kernel-based plugin system.
|
|
23
|
+
*
|
|
7
24
|
* ✅ Production-ready features:
|
|
8
|
-
* -
|
|
25
|
+
* - MongoDB-like query engine powered by Mingo
|
|
9
26
|
* - Thread-safe operations
|
|
10
|
-
* - Full query support (filters, sorting, pagination)
|
|
27
|
+
* - Full query support (filters, sorting, pagination, aggregation)
|
|
11
28
|
* - Atomic transactions
|
|
12
29
|
* - High performance (no I/O overhead)
|
|
13
30
|
*
|
|
@@ -17,9 +34,36 @@
|
|
|
17
34
|
* - Edge/Worker environments (Cloudflare Workers, Deno Deploy)
|
|
18
35
|
* - Client-side state management
|
|
19
36
|
* - Temporary data caching
|
|
37
|
+
*
|
|
38
|
+
* @version 4.0.0 - DriverInterface compliant with Mingo integration
|
|
20
39
|
*/
|
|
21
40
|
|
|
22
41
|
import { Driver, ObjectQLError } from '@objectql/types';
|
|
42
|
+
import { Query } from 'mingo';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Command interface for executeCommand method
|
|
46
|
+
*/
|
|
47
|
+
export interface Command {
|
|
48
|
+
type: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkUpdate' | 'bulkDelete';
|
|
49
|
+
object: string;
|
|
50
|
+
data?: any;
|
|
51
|
+
id?: string | number;
|
|
52
|
+
ids?: Array<string | number>;
|
|
53
|
+
records?: any[];
|
|
54
|
+
updates?: Array<{id: string | number, data: any}>;
|
|
55
|
+
options?: any;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Command result interface
|
|
60
|
+
*/
|
|
61
|
+
export interface CommandResult {
|
|
62
|
+
success: boolean;
|
|
63
|
+
data?: any;
|
|
64
|
+
affected: number; // Required (changed from optional)
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
23
67
|
|
|
24
68
|
/**
|
|
25
69
|
* Configuration options for the Memory driver.
|
|
@@ -40,6 +84,23 @@ export interface MemoryDriverConfig {
|
|
|
40
84
|
* Example: `users:user-123` → `{id: "user-123", name: "Alice", ...}`
|
|
41
85
|
*/
|
|
42
86
|
export class MemoryDriver implements Driver {
|
|
87
|
+
// Driver metadata (ObjectStack-compatible)
|
|
88
|
+
public readonly name = 'MemoryDriver';
|
|
89
|
+
public readonly version = '4.0.0';
|
|
90
|
+
public readonly supports = {
|
|
91
|
+
transactions: false,
|
|
92
|
+
joins: false,
|
|
93
|
+
fullTextSearch: false,
|
|
94
|
+
jsonFields: true,
|
|
95
|
+
arrayFields: true,
|
|
96
|
+
queryFilters: true,
|
|
97
|
+
queryAggregations: false,
|
|
98
|
+
querySorting: true,
|
|
99
|
+
queryPagination: true,
|
|
100
|
+
queryWindowFunctions: false,
|
|
101
|
+
querySubqueries: false
|
|
102
|
+
};
|
|
103
|
+
|
|
43
104
|
private store: Map<string, any>;
|
|
44
105
|
private config: MemoryDriverConfig;
|
|
45
106
|
private idCounters: Map<string, number>;
|
|
@@ -55,6 +116,22 @@ export class MemoryDriver implements Driver {
|
|
|
55
116
|
}
|
|
56
117
|
}
|
|
57
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Connect to the database (for DriverInterface compatibility)
|
|
121
|
+
* This is a no-op for memory driver as there's no external connection.
|
|
122
|
+
*/
|
|
123
|
+
async connect(): Promise<void> {
|
|
124
|
+
// No-op: Memory driver doesn't need connection
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check database connection health
|
|
129
|
+
*/
|
|
130
|
+
async checkHealth(): Promise<boolean> {
|
|
131
|
+
// Memory driver is always healthy if it exists
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
58
135
|
/**
|
|
59
136
|
* Load initial data into the store.
|
|
60
137
|
*/
|
|
@@ -70,43 +147,50 @@ export class MemoryDriver implements Driver {
|
|
|
70
147
|
|
|
71
148
|
/**
|
|
72
149
|
* Find multiple records matching the query criteria.
|
|
73
|
-
* Supports filtering, sorting, pagination, and field projection.
|
|
150
|
+
* Supports filtering, sorting, pagination, and field projection using Mingo.
|
|
74
151
|
*/
|
|
75
152
|
async find(objectName: string, query: any = {}, options?: any): Promise<any[]> {
|
|
153
|
+
// Normalize query to support both legacy and QueryAST formats
|
|
154
|
+
const normalizedQuery = this.normalizeQuery(query);
|
|
155
|
+
|
|
76
156
|
// Get all records for this object type
|
|
77
157
|
const pattern = `${objectName}:`;
|
|
78
|
-
let
|
|
158
|
+
let records: any[] = [];
|
|
79
159
|
|
|
80
160
|
for (const [key, value] of this.store.entries()) {
|
|
81
161
|
if (key.startsWith(pattern)) {
|
|
82
|
-
|
|
162
|
+
records.push({ ...value });
|
|
83
163
|
}
|
|
84
164
|
}
|
|
85
165
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
166
|
+
// Convert ObjectQL filters to MongoDB query format
|
|
167
|
+
const mongoQuery = this.convertToMongoQuery(normalizedQuery.filters);
|
|
168
|
+
|
|
169
|
+
// Apply filters using Mingo
|
|
170
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
171
|
+
const mingoQuery = new Query(mongoQuery);
|
|
172
|
+
records = mingoQuery.find(records).all();
|
|
89
173
|
}
|
|
90
174
|
|
|
91
|
-
// Apply sorting
|
|
92
|
-
if (
|
|
93
|
-
|
|
175
|
+
// Apply sorting manually (Mingo's sort has issues with CJS builds)
|
|
176
|
+
if (normalizedQuery.sort && Array.isArray(normalizedQuery.sort) && normalizedQuery.sort.length > 0) {
|
|
177
|
+
records = this.applyManualSort(records, normalizedQuery.sort);
|
|
94
178
|
}
|
|
95
179
|
|
|
96
180
|
// Apply pagination
|
|
97
|
-
if (
|
|
98
|
-
|
|
181
|
+
if (normalizedQuery.skip) {
|
|
182
|
+
records = records.slice(normalizedQuery.skip);
|
|
99
183
|
}
|
|
100
|
-
if (
|
|
101
|
-
|
|
184
|
+
if (normalizedQuery.limit) {
|
|
185
|
+
records = records.slice(0, normalizedQuery.limit);
|
|
102
186
|
}
|
|
103
187
|
|
|
104
188
|
// Apply field projection
|
|
105
|
-
if (
|
|
106
|
-
|
|
189
|
+
if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
|
|
190
|
+
records = records.map(doc => this.projectFields(doc, normalizedQuery.fields));
|
|
107
191
|
}
|
|
108
192
|
|
|
109
|
-
return
|
|
193
|
+
return records;
|
|
110
194
|
}
|
|
111
195
|
|
|
112
196
|
/**
|
|
@@ -207,11 +291,10 @@ export class MemoryDriver implements Driver {
|
|
|
207
291
|
}
|
|
208
292
|
|
|
209
293
|
/**
|
|
210
|
-
* Count records matching filters.
|
|
294
|
+
* Count records matching filters using Mingo.
|
|
211
295
|
*/
|
|
212
296
|
async count(objectName: string, filters: any, options?: any): Promise<number> {
|
|
213
297
|
const pattern = `${objectName}:`;
|
|
214
|
-
let count = 0;
|
|
215
298
|
|
|
216
299
|
// Extract actual filters from query object if needed
|
|
217
300
|
let actualFilters = filters;
|
|
@@ -219,43 +302,59 @@ export class MemoryDriver implements Driver {
|
|
|
219
302
|
actualFilters = filters.filters;
|
|
220
303
|
}
|
|
221
304
|
|
|
305
|
+
// Get all records for this object type
|
|
306
|
+
let records: any[] = [];
|
|
307
|
+
for (const [key, value] of this.store.entries()) {
|
|
308
|
+
if (key.startsWith(pattern)) {
|
|
309
|
+
records.push(value);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
222
313
|
// If no filters, return total count
|
|
223
314
|
if (!actualFilters || (Array.isArray(actualFilters) && actualFilters.length === 0)) {
|
|
224
|
-
|
|
225
|
-
if (key.startsWith(pattern)) {
|
|
226
|
-
count++;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return count;
|
|
315
|
+
return records.length;
|
|
230
316
|
}
|
|
231
317
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
318
|
+
// Convert to MongoDB query and use Mingo to count
|
|
319
|
+
const mongoQuery = this.convertToMongoQuery(actualFilters);
|
|
320
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
321
|
+
const mingoQuery = new Query(mongoQuery);
|
|
322
|
+
const matchedRecords = mingoQuery.find(records).all();
|
|
323
|
+
return matchedRecords.length;
|
|
239
324
|
}
|
|
240
325
|
|
|
241
|
-
return
|
|
326
|
+
return records.length;
|
|
242
327
|
}
|
|
243
328
|
|
|
244
329
|
/**
|
|
245
|
-
* Get distinct values for a field.
|
|
330
|
+
* Get distinct values for a field using Mingo.
|
|
246
331
|
*/
|
|
247
332
|
async distinct(objectName: string, field: string, filters?: any, options?: any): Promise<any[]> {
|
|
248
333
|
const pattern = `${objectName}:`;
|
|
249
|
-
const values = new Set<any>();
|
|
250
334
|
|
|
251
|
-
|
|
335
|
+
// Get all records for this object type
|
|
336
|
+
let records: any[] = [];
|
|
337
|
+
for (const [key, value] of this.store.entries()) {
|
|
252
338
|
if (key.startsWith(pattern)) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
339
|
+
records.push(value);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Apply filters using Mingo if provided
|
|
344
|
+
if (filters) {
|
|
345
|
+
const mongoQuery = this.convertToMongoQuery(filters);
|
|
346
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
347
|
+
const mingoQuery = new Query(mongoQuery);
|
|
348
|
+
records = mingoQuery.find(records).all();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Extract distinct values
|
|
353
|
+
const values = new Set<any>();
|
|
354
|
+
for (const record of records) {
|
|
355
|
+
const value = record[field];
|
|
356
|
+
if (value !== undefined && value !== null) {
|
|
357
|
+
values.add(value);
|
|
259
358
|
}
|
|
260
359
|
}
|
|
261
360
|
|
|
@@ -275,25 +374,45 @@ export class MemoryDriver implements Driver {
|
|
|
275
374
|
}
|
|
276
375
|
|
|
277
376
|
/**
|
|
278
|
-
* Update multiple records matching filters.
|
|
377
|
+
* Update multiple records matching filters using Mingo.
|
|
279
378
|
*/
|
|
280
379
|
async updateMany(objectName: string, filters: any, data: any, options?: any): Promise<any> {
|
|
281
380
|
const pattern = `${objectName}:`;
|
|
282
|
-
|
|
381
|
+
|
|
382
|
+
// Get all records for this object type
|
|
383
|
+
let records: any[] = [];
|
|
384
|
+
const recordKeys = new Map<string, string>();
|
|
283
385
|
|
|
284
386
|
for (const [key, record] of this.store.entries()) {
|
|
285
387
|
if (key.startsWith(pattern)) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
388
|
+
records.push(record);
|
|
389
|
+
recordKeys.set(record.id, key);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Apply filters using Mingo
|
|
394
|
+
const mongoQuery = this.convertToMongoQuery(filters);
|
|
395
|
+
let matchedRecords = records;
|
|
396
|
+
|
|
397
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
398
|
+
const mingoQuery = new Query(mongoQuery);
|
|
399
|
+
matchedRecords = mingoQuery.find(records).all();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Update matched records
|
|
403
|
+
let count = 0;
|
|
404
|
+
for (const record of matchedRecords) {
|
|
405
|
+
const key = recordKeys.get(record.id);
|
|
406
|
+
if (key) {
|
|
407
|
+
const updated = {
|
|
408
|
+
...record,
|
|
409
|
+
...data,
|
|
410
|
+
id: record.id, // Preserve ID
|
|
411
|
+
created_at: record.created_at, // Preserve created_at
|
|
412
|
+
updated_at: new Date().toISOString()
|
|
413
|
+
};
|
|
414
|
+
this.store.set(key, updated);
|
|
415
|
+
count++;
|
|
297
416
|
}
|
|
298
417
|
}
|
|
299
418
|
|
|
@@ -301,25 +420,40 @@ export class MemoryDriver implements Driver {
|
|
|
301
420
|
}
|
|
302
421
|
|
|
303
422
|
/**
|
|
304
|
-
* Delete multiple records matching filters.
|
|
423
|
+
* Delete multiple records matching filters using Mingo.
|
|
305
424
|
*/
|
|
306
425
|
async deleteMany(objectName: string, filters: any, options?: any): Promise<any> {
|
|
307
426
|
const pattern = `${objectName}:`;
|
|
308
|
-
|
|
427
|
+
|
|
428
|
+
// Get all records for this object type
|
|
429
|
+
let records: any[] = [];
|
|
430
|
+
const recordKeys = new Map<string, string>();
|
|
309
431
|
|
|
310
432
|
for (const [key, record] of this.store.entries()) {
|
|
311
433
|
if (key.startsWith(pattern)) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
434
|
+
records.push(record);
|
|
435
|
+
recordKeys.set(record.id, key);
|
|
315
436
|
}
|
|
316
437
|
}
|
|
317
438
|
|
|
318
|
-
|
|
319
|
-
|
|
439
|
+
// Apply filters using Mingo
|
|
440
|
+
const mongoQuery = this.convertToMongoQuery(filters);
|
|
441
|
+
let matchedRecords = records;
|
|
442
|
+
|
|
443
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
444
|
+
const mingoQuery = new Query(mongoQuery);
|
|
445
|
+
matchedRecords = mingoQuery.find(records).all();
|
|
320
446
|
}
|
|
321
447
|
|
|
322
|
-
|
|
448
|
+
// Delete matched records
|
|
449
|
+
for (const record of matchedRecords) {
|
|
450
|
+
const key = recordKeys.get(record.id);
|
|
451
|
+
if (key) {
|
|
452
|
+
this.store.delete(key);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { deletedCount: matchedRecords.length };
|
|
323
457
|
}
|
|
324
458
|
|
|
325
459
|
/**
|
|
@@ -347,110 +481,180 @@ export class MemoryDriver implements Driver {
|
|
|
347
481
|
// ========== Helper Methods ==========
|
|
348
482
|
|
|
349
483
|
/**
|
|
350
|
-
*
|
|
484
|
+
* Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
|
|
485
|
+
* This ensures backward compatibility while supporting the new @objectstack/spec interface.
|
|
351
486
|
*
|
|
352
|
-
*
|
|
353
|
-
* [
|
|
354
|
-
* ['field', 'operator', value],
|
|
355
|
-
* 'or',
|
|
356
|
-
* ['field2', 'operator', value2]
|
|
357
|
-
* ]
|
|
487
|
+
* QueryAST format uses 'top' for limit, while UnifiedQuery uses 'limit'.
|
|
488
|
+
* QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
|
|
358
489
|
*/
|
|
359
|
-
private
|
|
360
|
-
if (!
|
|
361
|
-
|
|
490
|
+
private normalizeQuery(query: any): any {
|
|
491
|
+
if (!query) return {};
|
|
492
|
+
|
|
493
|
+
const normalized: any = { ...query };
|
|
494
|
+
|
|
495
|
+
// Normalize limit/top
|
|
496
|
+
if (normalized.top !== undefined && normalized.limit === undefined) {
|
|
497
|
+
normalized.limit = normalized.top;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Normalize sort format
|
|
501
|
+
if (normalized.sort && Array.isArray(normalized.sort)) {
|
|
502
|
+
// Check if it's already in the array format [field, order]
|
|
503
|
+
const firstSort = normalized.sort[0];
|
|
504
|
+
if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
|
|
505
|
+
// Convert from QueryAST format {field, order} to internal format [field, order]
|
|
506
|
+
normalized.sort = normalized.sort.map((item: any) => [
|
|
507
|
+
item.field,
|
|
508
|
+
item.order || item.direction || item.dir || 'asc'
|
|
509
|
+
]);
|
|
510
|
+
}
|
|
362
511
|
}
|
|
363
512
|
|
|
364
|
-
return
|
|
513
|
+
return normalized;
|
|
365
514
|
}
|
|
366
515
|
|
|
367
516
|
/**
|
|
368
|
-
*
|
|
517
|
+
* Convert ObjectQL filters to MongoDB query format for Mingo.
|
|
518
|
+
*
|
|
519
|
+
* Supports ObjectQL filter format:
|
|
520
|
+
* [
|
|
521
|
+
* ['field', 'operator', value],
|
|
522
|
+
* 'or',
|
|
523
|
+
* ['field2', 'operator', value2]
|
|
524
|
+
* ]
|
|
525
|
+
*
|
|
526
|
+
* Converts to MongoDB query format:
|
|
527
|
+
* { $or: [{ field: { $operator: value }}, { field2: { $operator: value2 }}] }
|
|
369
528
|
*/
|
|
370
|
-
private
|
|
529
|
+
private convertToMongoQuery(filters?: any[]): Record<string, any> {
|
|
371
530
|
if (!filters || filters.length === 0) {
|
|
372
|
-
return
|
|
531
|
+
return {};
|
|
373
532
|
}
|
|
374
533
|
|
|
375
|
-
|
|
376
|
-
|
|
534
|
+
// Process the filter array to build MongoDB query
|
|
535
|
+
const conditions: Record<string, any>[] = [];
|
|
536
|
+
let currentLogic: 'and' | 'or' = 'and';
|
|
537
|
+
const logicGroups: { logic: 'and' | 'or', conditions: Record<string, any>[] }[] = [
|
|
538
|
+
{ logic: 'and', conditions: [] }
|
|
539
|
+
];
|
|
377
540
|
|
|
378
541
|
for (const item of filters) {
|
|
379
542
|
if (typeof item === 'string') {
|
|
380
543
|
// Logical operator (and/or)
|
|
381
|
-
|
|
544
|
+
const newLogic = item.toLowerCase() as 'and' | 'or';
|
|
545
|
+
if (newLogic !== currentLogic) {
|
|
546
|
+
currentLogic = newLogic;
|
|
547
|
+
logicGroups.push({ logic: currentLogic, conditions: [] });
|
|
548
|
+
}
|
|
382
549
|
} else if (Array.isArray(item)) {
|
|
383
550
|
const [field, operator, value] = item;
|
|
384
551
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
conditions.push(
|
|
389
|
-
} else {
|
|
390
|
-
// Single condition
|
|
391
|
-
const matches = this.evaluateCondition(record[field], operator, value);
|
|
392
|
-
conditions.push(matches);
|
|
552
|
+
// Convert single condition to MongoDB operator
|
|
553
|
+
const mongoCondition = this.convertConditionToMongo(field, operator, value);
|
|
554
|
+
if (mongoCondition) {
|
|
555
|
+
logicGroups[logicGroups.length - 1].conditions.push(mongoCondition);
|
|
393
556
|
}
|
|
394
557
|
}
|
|
395
558
|
}
|
|
396
559
|
|
|
397
|
-
//
|
|
398
|
-
if (conditions.length ===
|
|
399
|
-
return
|
|
560
|
+
// Build final query from logic groups
|
|
561
|
+
if (logicGroups.length === 1 && logicGroups[0].conditions.length === 1) {
|
|
562
|
+
return logicGroups[0].conditions[0];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// If there's only one group with multiple conditions, use its logic operator
|
|
566
|
+
if (logicGroups.length === 1) {
|
|
567
|
+
const group = logicGroups[0];
|
|
568
|
+
if (group.logic === 'or') {
|
|
569
|
+
return { $or: group.conditions };
|
|
570
|
+
} else {
|
|
571
|
+
return { $and: group.conditions };
|
|
572
|
+
}
|
|
400
573
|
}
|
|
401
574
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
575
|
+
// Multiple groups - flatten all conditions and determine the top-level operator
|
|
576
|
+
const allConditions: Record<string, any>[] = [];
|
|
577
|
+
for (const group of logicGroups) {
|
|
578
|
+
if (group.conditions.length === 0) continue;
|
|
406
579
|
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
} else {
|
|
410
|
-
|
|
580
|
+
if (group.conditions.length === 1) {
|
|
581
|
+
allConditions.push(group.conditions[0]);
|
|
582
|
+
} else {
|
|
583
|
+
if (group.logic === 'or') {
|
|
584
|
+
allConditions.push({ $or: group.conditions });
|
|
585
|
+
} else {
|
|
586
|
+
allConditions.push({ $and: group.conditions });
|
|
587
|
+
}
|
|
411
588
|
}
|
|
412
589
|
}
|
|
413
590
|
|
|
414
|
-
|
|
591
|
+
if (allConditions.length === 0) {
|
|
592
|
+
return {};
|
|
593
|
+
} else if (allConditions.length === 1) {
|
|
594
|
+
return allConditions[0];
|
|
595
|
+
} else {
|
|
596
|
+
// Determine top-level operator: use OR if any non-empty group has OR logic
|
|
597
|
+
const hasOrLogic = logicGroups.some(g => g.logic === 'or' && g.conditions.length > 0);
|
|
598
|
+
if (hasOrLogic) {
|
|
599
|
+
return { $or: allConditions };
|
|
600
|
+
} else {
|
|
601
|
+
return { $and: allConditions };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
415
604
|
}
|
|
416
605
|
|
|
417
606
|
/**
|
|
418
|
-
*
|
|
607
|
+
* Convert a single ObjectQL condition to MongoDB operator format.
|
|
419
608
|
*/
|
|
420
|
-
private
|
|
609
|
+
private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
|
|
421
610
|
switch (operator) {
|
|
422
611
|
case '=':
|
|
423
612
|
case '==':
|
|
424
|
-
return
|
|
613
|
+
return { [field]: value };
|
|
614
|
+
|
|
425
615
|
case '!=':
|
|
426
616
|
case '<>':
|
|
427
|
-
return
|
|
617
|
+
return { [field]: { $ne: value } };
|
|
618
|
+
|
|
428
619
|
case '>':
|
|
429
|
-
return
|
|
620
|
+
return { [field]: { $gt: value } };
|
|
621
|
+
|
|
430
622
|
case '>=':
|
|
431
|
-
return
|
|
623
|
+
return { [field]: { $gte: value } };
|
|
624
|
+
|
|
432
625
|
case '<':
|
|
433
|
-
return
|
|
626
|
+
return { [field]: { $lt: value } };
|
|
627
|
+
|
|
434
628
|
case '<=':
|
|
435
|
-
return
|
|
629
|
+
return { [field]: { $lte: value } };
|
|
630
|
+
|
|
436
631
|
case 'in':
|
|
437
|
-
return
|
|
632
|
+
return { [field]: { $in: value } };
|
|
633
|
+
|
|
438
634
|
case 'nin':
|
|
439
635
|
case 'not in':
|
|
440
|
-
return
|
|
636
|
+
return { [field]: { $nin: value } };
|
|
637
|
+
|
|
441
638
|
case 'contains':
|
|
442
639
|
case 'like':
|
|
443
|
-
|
|
640
|
+
// MongoDB regex for case-insensitive contains
|
|
641
|
+
// Escape special regex characters to prevent ReDoS and ensure literal matching
|
|
642
|
+
return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } };
|
|
643
|
+
|
|
444
644
|
case 'startswith':
|
|
445
645
|
case 'starts_with':
|
|
446
|
-
return
|
|
646
|
+
return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
|
|
647
|
+
|
|
447
648
|
case 'endswith':
|
|
448
649
|
case 'ends_with':
|
|
449
|
-
return
|
|
650
|
+
return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
|
|
651
|
+
|
|
450
652
|
case 'between':
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
653
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
654
|
+
return { [field]: { $gte: value[0], $lte: value[1] } };
|
|
655
|
+
}
|
|
656
|
+
return null;
|
|
657
|
+
|
|
454
658
|
default:
|
|
455
659
|
throw new ObjectQLError({
|
|
456
660
|
code: 'UNSUPPORTED_OPERATOR',
|
|
@@ -460,12 +664,21 @@ export class MemoryDriver implements Driver {
|
|
|
460
664
|
}
|
|
461
665
|
|
|
462
666
|
/**
|
|
463
|
-
*
|
|
667
|
+
* Escape special regex characters to prevent ReDoS and ensure literal matching.
|
|
668
|
+
* This is crucial for security when using user input in regex patterns.
|
|
669
|
+
*/
|
|
670
|
+
private escapeRegex(str: string): string {
|
|
671
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Apply manual sorting to an array of records.
|
|
676
|
+
* This is used instead of Mingo's sort to avoid CJS build issues.
|
|
464
677
|
*/
|
|
465
|
-
private
|
|
678
|
+
private applyManualSort(records: any[], sort: any[]): any[] {
|
|
466
679
|
const sorted = [...records];
|
|
467
680
|
|
|
468
|
-
// Apply sorts in reverse order for correct precedence
|
|
681
|
+
// Apply sorts in reverse order for correct multi-field precedence
|
|
469
682
|
for (let i = sort.length - 1; i >= 0; i--) {
|
|
470
683
|
const sortItem = sort[i];
|
|
471
684
|
|
|
@@ -491,8 +704,8 @@ export class MemoryDriver implements Driver {
|
|
|
491
704
|
if (bVal == null) return -1;
|
|
492
705
|
|
|
493
706
|
// Compare values
|
|
494
|
-
if (aVal < bVal) return direction === '
|
|
495
|
-
if (aVal > bVal) return direction === '
|
|
707
|
+
if (aVal < bVal) return direction.toLowerCase() === 'desc' ? 1 : -1;
|
|
708
|
+
if (aVal > bVal) return direction.toLowerCase() === 'desc' ? -1 : 1;
|
|
496
709
|
return 0;
|
|
497
710
|
});
|
|
498
711
|
}
|
|
@@ -524,4 +737,209 @@ export class MemoryDriver implements Driver {
|
|
|
524
737
|
const timestamp = Date.now();
|
|
525
738
|
return `${objectName}-${timestamp}-${counter}`;
|
|
526
739
|
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Execute a query using QueryAST (DriverInterface v4.0 method)
|
|
743
|
+
*
|
|
744
|
+
* This is the new standard method for query execution using the
|
|
745
|
+
* ObjectStack QueryAST format.
|
|
746
|
+
*
|
|
747
|
+
* @param ast - The QueryAST representing the query
|
|
748
|
+
* @param options - Optional execution options
|
|
749
|
+
* @returns Query results with value and count
|
|
750
|
+
*/
|
|
751
|
+
async executeQuery(ast: QueryAST, options?: any): Promise<{ value: any[]; count?: number }> {
|
|
752
|
+
const objectName = ast.object || '';
|
|
753
|
+
|
|
754
|
+
// Convert QueryAST to legacy query format
|
|
755
|
+
const legacyQuery: any = {
|
|
756
|
+
fields: ast.fields,
|
|
757
|
+
filters: this.convertFilterNodeToLegacy(ast.filters),
|
|
758
|
+
sort: ast.sort?.map((s: SortNode) => [s.field, s.order]),
|
|
759
|
+
limit: ast.top,
|
|
760
|
+
offset: ast.skip,
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// Use existing find method
|
|
764
|
+
const results = await this.find(objectName, legacyQuery, options);
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
value: results,
|
|
768
|
+
count: results.length
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Execute a command (DriverInterface v4.0 method)
|
|
774
|
+
*
|
|
775
|
+
* This method handles all mutation operations (create, update, delete)
|
|
776
|
+
* using a unified command interface.
|
|
777
|
+
*
|
|
778
|
+
* @param command - The command to execute
|
|
779
|
+
* @param parameters - Optional command parameters (unused in this driver)
|
|
780
|
+
* @param options - Optional execution options
|
|
781
|
+
* @returns Command execution result
|
|
782
|
+
*/
|
|
783
|
+
async executeCommand(command: Command, options?: any): Promise<CommandResult> {
|
|
784
|
+
try {
|
|
785
|
+
const cmdOptions = { ...options, ...command.options };
|
|
786
|
+
|
|
787
|
+
switch (command.type) {
|
|
788
|
+
case 'create':
|
|
789
|
+
if (!command.data) {
|
|
790
|
+
throw new Error('Create command requires data');
|
|
791
|
+
}
|
|
792
|
+
const created = await this.create(command.object, command.data, cmdOptions);
|
|
793
|
+
return {
|
|
794
|
+
success: true,
|
|
795
|
+
data: created,
|
|
796
|
+
affected: 1
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
case 'update':
|
|
800
|
+
if (!command.id || !command.data) {
|
|
801
|
+
throw new Error('Update command requires id and data');
|
|
802
|
+
}
|
|
803
|
+
const updated = await this.update(command.object, command.id, command.data, cmdOptions);
|
|
804
|
+
return {
|
|
805
|
+
success: true,
|
|
806
|
+
data: updated,
|
|
807
|
+
affected: 1
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
case 'delete':
|
|
811
|
+
if (!command.id) {
|
|
812
|
+
throw new Error('Delete command requires id');
|
|
813
|
+
}
|
|
814
|
+
await this.delete(command.object, command.id, cmdOptions);
|
|
815
|
+
return {
|
|
816
|
+
success: true,
|
|
817
|
+
affected: 1
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
case 'bulkCreate':
|
|
821
|
+
if (!command.records || !Array.isArray(command.records)) {
|
|
822
|
+
throw new Error('BulkCreate command requires records array');
|
|
823
|
+
}
|
|
824
|
+
const bulkCreated = [];
|
|
825
|
+
for (const record of command.records) {
|
|
826
|
+
const created = await this.create(command.object, record, cmdOptions);
|
|
827
|
+
bulkCreated.push(created);
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
success: true,
|
|
831
|
+
data: bulkCreated,
|
|
832
|
+
affected: command.records.length
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
case 'bulkUpdate':
|
|
836
|
+
if (!command.updates || !Array.isArray(command.updates)) {
|
|
837
|
+
throw new Error('BulkUpdate command requires updates array');
|
|
838
|
+
}
|
|
839
|
+
const updateResults = [];
|
|
840
|
+
for (const update of command.updates) {
|
|
841
|
+
const result = await this.update(command.object, update.id, update.data, cmdOptions);
|
|
842
|
+
updateResults.push(result);
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
success: true,
|
|
846
|
+
data: updateResults,
|
|
847
|
+
affected: command.updates.length
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
case 'bulkDelete':
|
|
851
|
+
if (!command.ids || !Array.isArray(command.ids)) {
|
|
852
|
+
throw new Error('BulkDelete command requires ids array');
|
|
853
|
+
}
|
|
854
|
+
let deleted = 0;
|
|
855
|
+
for (const id of command.ids) {
|
|
856
|
+
const result = await this.delete(command.object, id, cmdOptions);
|
|
857
|
+
if (result) deleted++;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
success: true,
|
|
861
|
+
affected: deleted
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
default:
|
|
865
|
+
throw new Error(`Unknown command type: ${(command as any).type}`);
|
|
866
|
+
}
|
|
867
|
+
} catch (error: any) {
|
|
868
|
+
return {
|
|
869
|
+
success: false,
|
|
870
|
+
error: error.message || 'Command execution failed',
|
|
871
|
+
affected: 0
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Convert FilterNode (QueryAST format) to legacy filter array format
|
|
878
|
+
* This allows reuse of existing filter logic while supporting new QueryAST
|
|
879
|
+
*
|
|
880
|
+
* @private
|
|
881
|
+
*/
|
|
882
|
+
private convertFilterNodeToLegacy(node?: FilterNode): any {
|
|
883
|
+
if (!node) return undefined;
|
|
884
|
+
|
|
885
|
+
switch (node.type) {
|
|
886
|
+
case 'comparison':
|
|
887
|
+
// Convert comparison node to [field, operator, value] format
|
|
888
|
+
const operator = node.operator || '=';
|
|
889
|
+
return [[node.field, operator, node.value]];
|
|
890
|
+
|
|
891
|
+
case 'and':
|
|
892
|
+
// Convert AND node to array with 'and' separator
|
|
893
|
+
if (!node.children || node.children.length === 0) return undefined;
|
|
894
|
+
const andResults: any[] = [];
|
|
895
|
+
for (const child of node.children) {
|
|
896
|
+
const converted = this.convertFilterNodeToLegacy(child);
|
|
897
|
+
if (converted) {
|
|
898
|
+
if (andResults.length > 0) {
|
|
899
|
+
andResults.push('and');
|
|
900
|
+
}
|
|
901
|
+
andResults.push(...(Array.isArray(converted) ? converted : [converted]));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return andResults.length > 0 ? andResults : undefined;
|
|
905
|
+
|
|
906
|
+
case 'or':
|
|
907
|
+
// Convert OR node to array with 'or' separator
|
|
908
|
+
if (!node.children || node.children.length === 0) return undefined;
|
|
909
|
+
const orResults: any[] = [];
|
|
910
|
+
for (const child of node.children) {
|
|
911
|
+
const converted = this.convertFilterNodeToLegacy(child);
|
|
912
|
+
if (converted) {
|
|
913
|
+
if (orResults.length > 0) {
|
|
914
|
+
orResults.push('or');
|
|
915
|
+
}
|
|
916
|
+
orResults.push(...(Array.isArray(converted) ? converted : [converted]));
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return orResults.length > 0 ? orResults : undefined;
|
|
920
|
+
|
|
921
|
+
case 'not':
|
|
922
|
+
// NOT is complex - we'll just process the first child for now
|
|
923
|
+
if (node.children && node.children.length > 0) {
|
|
924
|
+
return this.convertFilterNodeToLegacy(node.children[0]);
|
|
925
|
+
}
|
|
926
|
+
return undefined;
|
|
927
|
+
|
|
928
|
+
default:
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Execute command (alternative signature for compatibility)
|
|
935
|
+
*
|
|
936
|
+
* @param command - Command string or object
|
|
937
|
+
* @param parameters - Command parameters
|
|
938
|
+
* @param options - Execution options
|
|
939
|
+
*/
|
|
940
|
+
async execute(command: any, parameters?: any[], options?: any): Promise<any> {
|
|
941
|
+
// For memory driver, this is primarily for compatibility
|
|
942
|
+
// We don't support raw SQL/commands
|
|
943
|
+
throw new Error('Memory driver does not support raw command execution. Use executeCommand() instead.');
|
|
944
|
+
}
|
|
527
945
|
}
|