@objectql/driver-memory 4.0.1 → 4.0.3
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +30 -0
- package/dist/index.d.ts +114 -25
- package/dist/index.js +306 -115
- package/dist/index.js.map +1 -1
- package/jest.config.js +13 -1
- package/package.json +3 -3
- package/src/index.ts +375 -134
- package/test/index.test.ts +286 -16
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Data, System } from '@objectstack/spec';
|
|
1
|
+
import { Data, System as SystemSpec } from '@objectstack/spec';
|
|
2
2
|
type QueryAST = Data.QueryAST;
|
|
3
|
-
type FilterNode = Data.FilterNode;
|
|
4
3
|
type SortNode = Data.SortNode;
|
|
5
|
-
type DriverInterface =
|
|
4
|
+
type DriverInterface = Data.DriverInterface;
|
|
6
5
|
/**
|
|
7
6
|
* ObjectQL
|
|
8
7
|
* Copyright (c) 2026-present ObjectStack Inc.
|
|
@@ -39,7 +38,7 @@ type DriverInterface = System.DriverInterface;
|
|
|
39
38
|
*/
|
|
40
39
|
|
|
41
40
|
import { Driver, ObjectQLError } from '@objectql/types';
|
|
42
|
-
import { Query } from 'mingo';
|
|
41
|
+
import { Query, Aggregator } from 'mingo';
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
44
|
* Command interface for executeCommand method
|
|
@@ -73,6 +72,24 @@ export interface MemoryDriverConfig {
|
|
|
73
72
|
initialData?: Record<string, any[]>;
|
|
74
73
|
/** Optional: Enable strict mode (throw on missing objects) */
|
|
75
74
|
strictMode?: boolean;
|
|
75
|
+
/** Optional: Enable persistence to file system */
|
|
76
|
+
persistence?: {
|
|
77
|
+
/** File path to persist data */
|
|
78
|
+
filePath: string;
|
|
79
|
+
/** Auto-save interval in milliseconds (default: 5000) */
|
|
80
|
+
autoSaveInterval?: number;
|
|
81
|
+
};
|
|
82
|
+
/** Optional: Fields to index for faster queries */
|
|
83
|
+
indexes?: Record<string, string[]>; // objectName -> field names
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* In-memory transaction
|
|
88
|
+
*/
|
|
89
|
+
interface MemoryTransaction {
|
|
90
|
+
id: string;
|
|
91
|
+
snapshot: Map<string, any>;
|
|
92
|
+
operations: Array<{ type: 'set' | 'delete'; key: string; value?: any }>;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
/**
|
|
@@ -88,32 +105,47 @@ export class MemoryDriver implements Driver {
|
|
|
88
105
|
public readonly name = 'MemoryDriver';
|
|
89
106
|
public readonly version = '4.0.0';
|
|
90
107
|
public readonly supports = {
|
|
91
|
-
transactions:
|
|
108
|
+
transactions: true,
|
|
92
109
|
joins: false,
|
|
93
110
|
fullTextSearch: false,
|
|
94
111
|
jsonFields: true,
|
|
95
112
|
arrayFields: true,
|
|
96
113
|
queryFilters: true,
|
|
97
|
-
queryAggregations:
|
|
114
|
+
queryAggregations: true,
|
|
98
115
|
querySorting: true,
|
|
99
116
|
queryPagination: true,
|
|
100
117
|
queryWindowFunctions: false,
|
|
101
118
|
querySubqueries: false
|
|
102
119
|
};
|
|
103
120
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
protected store: Map<string, any>;
|
|
122
|
+
protected config: MemoryDriverConfig;
|
|
123
|
+
protected idCounters: Map<string, number>;
|
|
124
|
+
protected transactions: Map<string, MemoryTransaction>;
|
|
125
|
+
protected indexes: Map<string, Map<string, Set<string>>>; // objectName -> field -> Set<recordIds>
|
|
126
|
+
protected persistenceTimer?: NodeJS.Timeout;
|
|
107
127
|
|
|
108
128
|
constructor(config: MemoryDriverConfig = {}) {
|
|
109
129
|
this.config = config;
|
|
110
130
|
this.store = new Map<string, any>();
|
|
111
131
|
this.idCounters = new Map<string, number>();
|
|
132
|
+
this.transactions = new Map();
|
|
133
|
+
this.indexes = new Map();
|
|
112
134
|
|
|
113
135
|
// Load initial data if provided
|
|
114
136
|
if (config.initialData) {
|
|
115
137
|
this.loadInitialData(config.initialData);
|
|
116
138
|
}
|
|
139
|
+
|
|
140
|
+
// Set up persistence if configured
|
|
141
|
+
if (config.persistence) {
|
|
142
|
+
this.setupPersistence();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build indexes if configured
|
|
146
|
+
if (config.indexes) {
|
|
147
|
+
this.buildIndexes(config.indexes);
|
|
148
|
+
}
|
|
117
149
|
}
|
|
118
150
|
|
|
119
151
|
/**
|
|
@@ -135,7 +167,7 @@ export class MemoryDriver implements Driver {
|
|
|
135
167
|
/**
|
|
136
168
|
* Load initial data into the store.
|
|
137
169
|
*/
|
|
138
|
-
|
|
170
|
+
protected loadInitialData(data: Record<string, any[]>): void {
|
|
139
171
|
for (const [objectName, records] of Object.entries(data)) {
|
|
140
172
|
for (const record of records) {
|
|
141
173
|
const id = record.id || this.generateId(objectName);
|
|
@@ -150,9 +182,6 @@ export class MemoryDriver implements Driver {
|
|
|
150
182
|
* Supports filtering, sorting, pagination, and field projection using Mingo.
|
|
151
183
|
*/
|
|
152
184
|
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
|
-
|
|
156
185
|
// Get all records for this object type
|
|
157
186
|
const pattern = `${objectName}:`;
|
|
158
187
|
let records: any[] = [];
|
|
@@ -164,7 +193,7 @@ export class MemoryDriver implements Driver {
|
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
// Convert ObjectQL filters to MongoDB query format
|
|
167
|
-
const mongoQuery = this.convertToMongoQuery(
|
|
196
|
+
const mongoQuery = this.convertToMongoQuery(query.where);
|
|
168
197
|
|
|
169
198
|
// Apply filters using Mingo
|
|
170
199
|
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
@@ -173,21 +202,21 @@ export class MemoryDriver implements Driver {
|
|
|
173
202
|
}
|
|
174
203
|
|
|
175
204
|
// Apply sorting manually (Mingo's sort has issues with CJS builds)
|
|
176
|
-
if (
|
|
177
|
-
records = this.applyManualSort(records,
|
|
205
|
+
if (query.orderBy && Array.isArray(query.orderBy) && query.orderBy.length > 0) {
|
|
206
|
+
records = this.applyManualSort(records, query.orderBy);
|
|
178
207
|
}
|
|
179
208
|
|
|
180
209
|
// Apply pagination
|
|
181
|
-
if (
|
|
182
|
-
records = records.slice(
|
|
210
|
+
if (query.offset) {
|
|
211
|
+
records = records.slice(query.offset);
|
|
183
212
|
}
|
|
184
|
-
if (
|
|
185
|
-
records = records.slice(0,
|
|
213
|
+
if (query.limit) {
|
|
214
|
+
records = records.slice(0, query.limit);
|
|
186
215
|
}
|
|
187
216
|
|
|
188
217
|
// Apply field projection
|
|
189
|
-
if (
|
|
190
|
-
records = records.map(doc => this.projectFields(doc,
|
|
218
|
+
if (query.fields && Array.isArray(query.fields)) {
|
|
219
|
+
records = records.map(doc => this.projectFields(doc, query.fields));
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
return records;
|
|
@@ -239,6 +268,10 @@ export class MemoryDriver implements Driver {
|
|
|
239
268
|
};
|
|
240
269
|
|
|
241
270
|
this.store.set(key, doc);
|
|
271
|
+
|
|
272
|
+
// Update indexes
|
|
273
|
+
this.updateIndex(objectName, id, doc);
|
|
274
|
+
|
|
242
275
|
return { ...doc };
|
|
243
276
|
}
|
|
244
277
|
|
|
@@ -269,6 +302,10 @@ export class MemoryDriver implements Driver {
|
|
|
269
302
|
};
|
|
270
303
|
|
|
271
304
|
this.store.set(key, doc);
|
|
305
|
+
|
|
306
|
+
// Update indexes
|
|
307
|
+
this.updateIndex(objectName, id.toString(), doc);
|
|
308
|
+
|
|
272
309
|
return { ...doc };
|
|
273
310
|
}
|
|
274
311
|
|
|
@@ -279,6 +316,11 @@ export class MemoryDriver implements Driver {
|
|
|
279
316
|
const key = `${objectName}:${id}`;
|
|
280
317
|
const deleted = this.store.delete(key);
|
|
281
318
|
|
|
319
|
+
// Remove from indexes
|
|
320
|
+
if (deleted) {
|
|
321
|
+
this.removeFromIndex(objectName, id.toString());
|
|
322
|
+
}
|
|
323
|
+
|
|
282
324
|
if (!deleted && this.config.strictMode) {
|
|
283
325
|
throw new ObjectQLError({
|
|
284
326
|
code: 'RECORD_NOT_FOUND',
|
|
@@ -296,10 +338,10 @@ export class MemoryDriver implements Driver {
|
|
|
296
338
|
async count(objectName: string, filters: any, options?: any): Promise<number> {
|
|
297
339
|
const pattern = `${objectName}:`;
|
|
298
340
|
|
|
299
|
-
// Extract
|
|
300
|
-
let
|
|
301
|
-
if (filters && !Array.isArray(filters) && filters.
|
|
302
|
-
|
|
341
|
+
// Extract where condition from query object if needed
|
|
342
|
+
let whereCondition = filters;
|
|
343
|
+
if (filters && !Array.isArray(filters) && filters.where) {
|
|
344
|
+
whereCondition = filters.where;
|
|
303
345
|
}
|
|
304
346
|
|
|
305
347
|
// Get all records for this object type
|
|
@@ -311,12 +353,12 @@ export class MemoryDriver implements Driver {
|
|
|
311
353
|
}
|
|
312
354
|
|
|
313
355
|
// If no filters, return total count
|
|
314
|
-
if (!
|
|
356
|
+
if (!whereCondition || (Array.isArray(whereCondition) && whereCondition.length === 0)) {
|
|
315
357
|
return records.length;
|
|
316
358
|
}
|
|
317
359
|
|
|
318
360
|
// Convert to MongoDB query and use Mingo to count
|
|
319
|
-
const mongoQuery = this.convertToMongoQuery(
|
|
361
|
+
const mongoQuery = this.convertToMongoQuery(whereCondition);
|
|
320
362
|
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
321
363
|
const mingoQuery = new Query(mongoQuery);
|
|
322
364
|
const matchedRecords = mingoQuery.find(records).all();
|
|
@@ -472,62 +514,327 @@ export class MemoryDriver implements Driver {
|
|
|
472
514
|
}
|
|
473
515
|
|
|
474
516
|
/**
|
|
475
|
-
* Disconnect
|
|
517
|
+
* Disconnect and cleanup resources.
|
|
476
518
|
*/
|
|
477
519
|
async disconnect(): Promise<void> {
|
|
478
|
-
//
|
|
520
|
+
// Save data to disk if persistence is enabled
|
|
521
|
+
if (this.config.persistence) {
|
|
522
|
+
this.saveToDisk();
|
|
523
|
+
|
|
524
|
+
// Clear the auto-save timer
|
|
525
|
+
if (this.persistenceTimer) {
|
|
526
|
+
clearInterval(this.persistenceTimer);
|
|
527
|
+
this.persistenceTimer = undefined;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
479
530
|
}
|
|
480
531
|
|
|
481
|
-
// ========== Helper Methods ==========
|
|
482
|
-
|
|
483
532
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
533
|
+
* Perform aggregation operations using Mingo
|
|
534
|
+
* @param objectName - The object type to aggregate
|
|
535
|
+
* @param pipeline - MongoDB-style aggregation pipeline
|
|
536
|
+
* @param options - Optional query options
|
|
537
|
+
* @returns Aggregation results
|
|
486
538
|
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
539
|
+
* @example
|
|
540
|
+
* // Group by status and count
|
|
541
|
+
* const results = await driver.aggregate('orders', [
|
|
542
|
+
* { $match: { status: 'completed' } },
|
|
543
|
+
* { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
|
|
544
|
+
* ]);
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* // Calculate average with filter
|
|
548
|
+
* const results = await driver.aggregate('products', [
|
|
549
|
+
* { $match: { category: 'electronics' } },
|
|
550
|
+
* { $group: { _id: null, avgPrice: { $avg: '$price' } } }
|
|
551
|
+
* ]);
|
|
552
|
+
*/
|
|
553
|
+
async aggregate(objectName: string, pipeline: any[], options?: any): Promise<any[]> {
|
|
554
|
+
const pattern = `${objectName}:`;
|
|
555
|
+
|
|
556
|
+
// Get all records for this object type
|
|
557
|
+
let records: any[] = [];
|
|
558
|
+
for (const [key, value] of this.store.entries()) {
|
|
559
|
+
if (key.startsWith(pattern)) {
|
|
560
|
+
records.push({ ...value });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Use Mingo to execute the aggregation pipeline
|
|
565
|
+
const aggregator = new Aggregator(pipeline);
|
|
566
|
+
const results = aggregator.run(records);
|
|
567
|
+
|
|
568
|
+
return results;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Begin a new transaction
|
|
573
|
+
* @returns Transaction object that can be passed to other methods
|
|
489
574
|
*/
|
|
490
|
-
|
|
491
|
-
|
|
575
|
+
async beginTransaction(): Promise<any> {
|
|
576
|
+
const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
577
|
+
|
|
578
|
+
// Create a deep snapshot of the current store to ensure complete transaction isolation
|
|
579
|
+
// This prevents issues with nested objects and arrays being modified during the transaction
|
|
580
|
+
const snapshot = new Map<string, any>();
|
|
581
|
+
for (const [key, value] of this.store.entries()) {
|
|
582
|
+
snapshot.set(key, JSON.parse(JSON.stringify(value)));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const transaction: MemoryTransaction = {
|
|
586
|
+
id: txId,
|
|
587
|
+
snapshot,
|
|
588
|
+
operations: []
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
this.transactions.set(txId, transaction);
|
|
592
|
+
|
|
593
|
+
return { id: txId };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Commit a transaction
|
|
598
|
+
* @param transaction - Transaction object returned by beginTransaction()
|
|
599
|
+
*/
|
|
600
|
+
async commitTransaction(transaction: any): Promise<void> {
|
|
601
|
+
const txId = transaction?.id;
|
|
602
|
+
if (!txId) {
|
|
603
|
+
throw new ObjectQLError({
|
|
604
|
+
code: 'INVALID_TRANSACTION',
|
|
605
|
+
message: 'Invalid transaction object'
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const tx = this.transactions.get(txId);
|
|
610
|
+
if (!tx) {
|
|
611
|
+
throw new ObjectQLError({
|
|
612
|
+
code: 'TRANSACTION_NOT_FOUND',
|
|
613
|
+
message: `Transaction ${txId} not found`
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Operations are already applied to the store during the transaction
|
|
618
|
+
// Just clean up the transaction record
|
|
619
|
+
this.transactions.delete(txId);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Rollback a transaction
|
|
624
|
+
* @param transaction - Transaction object returned by beginTransaction()
|
|
625
|
+
*/
|
|
626
|
+
async rollbackTransaction(transaction: any): Promise<void> {
|
|
627
|
+
const txId = transaction?.id;
|
|
628
|
+
if (!txId) {
|
|
629
|
+
throw new ObjectQLError({
|
|
630
|
+
code: 'INVALID_TRANSACTION',
|
|
631
|
+
message: 'Invalid transaction object'
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const tx = this.transactions.get(txId);
|
|
636
|
+
if (!tx) {
|
|
637
|
+
throw new ObjectQLError({
|
|
638
|
+
code: 'TRANSACTION_NOT_FOUND',
|
|
639
|
+
message: `Transaction ${txId} not found`
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Restore the snapshot
|
|
644
|
+
this.store = new Map(tx.snapshot);
|
|
645
|
+
|
|
646
|
+
// Clean up the transaction
|
|
647
|
+
this.transactions.delete(txId);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Set up persistence to file system
|
|
652
|
+
* @private
|
|
653
|
+
*/
|
|
654
|
+
protected setupPersistence(): void {
|
|
655
|
+
if (!this.config.persistence) return;
|
|
656
|
+
|
|
657
|
+
const { filePath, autoSaveInterval = 5000 } = this.config.persistence;
|
|
658
|
+
|
|
659
|
+
// Try to load existing data from file
|
|
660
|
+
try {
|
|
661
|
+
const fs = require('fs');
|
|
662
|
+
if (fs.existsSync(filePath)) {
|
|
663
|
+
const fileData = fs.readFileSync(filePath, 'utf8');
|
|
664
|
+
const data = JSON.parse(fileData);
|
|
665
|
+
|
|
666
|
+
// Load data into store
|
|
667
|
+
for (const [key, value] of Object.entries(data.store || {})) {
|
|
668
|
+
this.store.set(key, value);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Load ID counters
|
|
672
|
+
if (data.idCounters) {
|
|
673
|
+
for (const [key, value] of Object.entries(data.idCounters)) {
|
|
674
|
+
this.idCounters.set(key, value as number);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} catch (error) {
|
|
679
|
+
console.warn('[MemoryDriver] Failed to load persisted data:', error);
|
|
680
|
+
}
|
|
492
681
|
|
|
493
|
-
|
|
682
|
+
// Set up auto-save timer
|
|
683
|
+
// Use unref() to allow Node.js process to exit gracefully when idle
|
|
684
|
+
this.persistenceTimer = setInterval(() => {
|
|
685
|
+
this.saveToDisk();
|
|
686
|
+
}, autoSaveInterval);
|
|
687
|
+
this.persistenceTimer.unref();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Save current state to disk
|
|
692
|
+
* @private
|
|
693
|
+
*/
|
|
694
|
+
protected saveToDisk(): void {
|
|
695
|
+
if (!this.config.persistence) return;
|
|
494
696
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
697
|
+
try {
|
|
698
|
+
const fs = require('fs');
|
|
699
|
+
const data = {
|
|
700
|
+
store: Object.fromEntries(this.store),
|
|
701
|
+
idCounters: Object.fromEntries(this.idCounters),
|
|
702
|
+
timestamp: new Date().toISOString()
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
fs.writeFileSync(this.config.persistence.filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
706
|
+
} catch (error) {
|
|
707
|
+
console.error('[MemoryDriver] Failed to save data to disk:', error);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Build indexes for faster queries
|
|
713
|
+
* @private
|
|
714
|
+
*/
|
|
715
|
+
protected buildIndexes(indexConfig: Record<string, string[]>): void {
|
|
716
|
+
for (const [objectName, fields] of Object.entries(indexConfig)) {
|
|
717
|
+
const objectIndexes = new Map<string, Set<string>>();
|
|
718
|
+
|
|
719
|
+
for (const field of fields) {
|
|
720
|
+
const fieldIndex = new Set<string>();
|
|
721
|
+
|
|
722
|
+
// Scan all records of this object type
|
|
723
|
+
const pattern = `${objectName}:`;
|
|
724
|
+
for (const [key, record] of this.store.entries()) {
|
|
725
|
+
if (key.startsWith(pattern)) {
|
|
726
|
+
const fieldValue = record[field];
|
|
727
|
+
if (fieldValue !== undefined && fieldValue !== null) {
|
|
728
|
+
// Store the record ID in the index
|
|
729
|
+
fieldIndex.add(record.id);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
objectIndexes.set(field, fieldIndex);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this.indexes.set(objectName, objectIndexes);
|
|
498
738
|
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Update index when a record is created or updated
|
|
743
|
+
* @private
|
|
744
|
+
*/
|
|
745
|
+
protected updateIndex(objectName: string, recordId: string, record: any): void {
|
|
746
|
+
const objectIndexes = this.indexes.get(objectName);
|
|
747
|
+
if (!objectIndexes) return;
|
|
499
748
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
normalized.sort = normalized.sort.map((item: any) => [
|
|
507
|
-
item.field,
|
|
508
|
-
item.order || item.direction || item.dir || 'asc'
|
|
509
|
-
]);
|
|
749
|
+
for (const [field, indexSet] of objectIndexes.entries()) {
|
|
750
|
+
const fieldValue = record[field];
|
|
751
|
+
if (fieldValue !== undefined && fieldValue !== null) {
|
|
752
|
+
indexSet.add(recordId);
|
|
753
|
+
} else {
|
|
754
|
+
indexSet.delete(recordId);
|
|
510
755
|
}
|
|
511
756
|
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Remove record from indexes
|
|
761
|
+
* @private
|
|
762
|
+
*/
|
|
763
|
+
protected removeFromIndex(objectName: string, recordId: string): void {
|
|
764
|
+
const objectIndexes = this.indexes.get(objectName);
|
|
765
|
+
if (!objectIndexes) return;
|
|
512
766
|
|
|
513
|
-
|
|
767
|
+
for (const indexSet of objectIndexes.values()) {
|
|
768
|
+
indexSet.delete(recordId);
|
|
769
|
+
}
|
|
514
770
|
}
|
|
515
771
|
|
|
772
|
+
// ========== Helper Methods ==========
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
|
|
776
|
+
* This ensures backward compatibility while supporting the new @objectstack/spec interface.
|
|
777
|
+
*
|
|
778
|
+
* QueryAST format uses 'top' for limit, while UnifiedQuery uses 'limit'.
|
|
779
|
+
* QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
|
|
780
|
+
*/
|
|
516
781
|
/**
|
|
517
782
|
* Convert ObjectQL filters to MongoDB query format for Mingo.
|
|
518
783
|
*
|
|
519
|
-
* Supports
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
* ]
|
|
784
|
+
* Supports both:
|
|
785
|
+
* 1. Legacy ObjectQL filter format (array):
|
|
786
|
+
* [['field', 'operator', value], 'or', ['field2', 'operator', value2']]
|
|
787
|
+
* 2. New FilterCondition format (object - already MongoDB-like):
|
|
788
|
+
* { $and: [{ field: { $eq: value }}, { field2: { $gt: value2 }}] }
|
|
525
789
|
*
|
|
526
790
|
* Converts to MongoDB query format:
|
|
527
791
|
* { $or: [{ field: { $operator: value }}, { field2: { $operator: value2 }}] }
|
|
528
792
|
*/
|
|
529
|
-
|
|
530
|
-
|
|
793
|
+
/**
|
|
794
|
+
* Convert ObjectQL filter format to MongoDB query format.
|
|
795
|
+
* Supports three input formats:
|
|
796
|
+
*
|
|
797
|
+
* 1. AST Comparison Node: { type: 'comparison', field: string, operator: string, value: any }
|
|
798
|
+
* 2. AST Logical Node: { type: 'logical', operator: 'and' | 'or', conditions: Node[] }
|
|
799
|
+
* 3. Legacy Array Format: [field, operator, value, 'and', field2, operator2, value2]
|
|
800
|
+
* 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
|
|
801
|
+
*
|
|
802
|
+
* @param filters - Filter in any supported format
|
|
803
|
+
* @returns MongoDB query object
|
|
804
|
+
*/
|
|
805
|
+
protected convertToMongoQuery(filters?: any[] | Record<string, any>): Record<string, any> {
|
|
806
|
+
if (!filters) {
|
|
807
|
+
return {};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Handle AST node format (ObjectQL QueryAST)
|
|
811
|
+
if (!Array.isArray(filters) && typeof filters === 'object') {
|
|
812
|
+
// AST Comparison Node: { type: 'comparison', field, operator, value }
|
|
813
|
+
if (filters.type === 'comparison') {
|
|
814
|
+
const mongoCondition = this.convertConditionToMongo(filters.field, filters.operator, filters.value);
|
|
815
|
+
return mongoCondition || {};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// AST Logical Node: { type: 'logical', operator: 'and' | 'or', conditions: [...] }
|
|
819
|
+
else if (filters.type === 'logical') {
|
|
820
|
+
const conditions = filters.conditions?.map((cond: any) => this.convertToMongoQuery(cond)) || [];
|
|
821
|
+
if (conditions.length === 0) {
|
|
822
|
+
return {};
|
|
823
|
+
}
|
|
824
|
+
if (conditions.length === 1) {
|
|
825
|
+
return conditions[0];
|
|
826
|
+
}
|
|
827
|
+
const operator = filters.operator === 'or' ? '$or' : '$and';
|
|
828
|
+
return { [operator]: conditions };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// MongoDB format (passthrough): { field: value } or { field: { $eq: value } }
|
|
832
|
+
// This handles cases where filters is already a proper MongoDB query
|
|
833
|
+
return filters;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Handle legacy array format
|
|
837
|
+
if (filters.length === 0) {
|
|
531
838
|
return {};
|
|
532
839
|
}
|
|
533
840
|
|
|
@@ -606,7 +913,7 @@ export class MemoryDriver implements Driver {
|
|
|
606
913
|
/**
|
|
607
914
|
* Convert a single ObjectQL condition to MongoDB operator format.
|
|
608
915
|
*/
|
|
609
|
-
|
|
916
|
+
protected convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
|
|
610
917
|
switch (operator) {
|
|
611
918
|
case '=':
|
|
612
919
|
case '==':
|
|
@@ -667,7 +974,7 @@ export class MemoryDriver implements Driver {
|
|
|
667
974
|
* Escape special regex characters to prevent ReDoS and ensure literal matching.
|
|
668
975
|
* This is crucial for security when using user input in regex patterns.
|
|
669
976
|
*/
|
|
670
|
-
|
|
977
|
+
protected escapeRegex(str: string): string {
|
|
671
978
|
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
672
979
|
}
|
|
673
980
|
|
|
@@ -675,7 +982,7 @@ export class MemoryDriver implements Driver {
|
|
|
675
982
|
* Apply manual sorting to an array of records.
|
|
676
983
|
* This is used instead of Mingo's sort to avoid CJS build issues.
|
|
677
984
|
*/
|
|
678
|
-
|
|
985
|
+
protected applyManualSort(records: any[], sort: any[]): any[] {
|
|
679
986
|
const sorted = [...records];
|
|
680
987
|
|
|
681
988
|
// Apply sorts in reverse order for correct multi-field precedence
|
|
@@ -716,7 +1023,7 @@ export class MemoryDriver implements Driver {
|
|
|
716
1023
|
/**
|
|
717
1024
|
* Project specific fields from a document.
|
|
718
1025
|
*/
|
|
719
|
-
|
|
1026
|
+
protected projectFields(doc: any, fields: string[]): any {
|
|
720
1027
|
const result: any = {};
|
|
721
1028
|
for (const field of fields) {
|
|
722
1029
|
if (doc[field] !== undefined) {
|
|
@@ -729,7 +1036,7 @@ export class MemoryDriver implements Driver {
|
|
|
729
1036
|
/**
|
|
730
1037
|
* Generate a unique ID for a record.
|
|
731
1038
|
*/
|
|
732
|
-
|
|
1039
|
+
protected generateId(objectName: string): string {
|
|
733
1040
|
const counter = (this.idCounters.get(objectName) || 0) + 1;
|
|
734
1041
|
this.idCounters.set(objectName, counter);
|
|
735
1042
|
|
|
@@ -751,17 +1058,8 @@ export class MemoryDriver implements Driver {
|
|
|
751
1058
|
async executeQuery(ast: QueryAST, options?: any): Promise<{ value: any[]; count?: number }> {
|
|
752
1059
|
const objectName = ast.object || '';
|
|
753
1060
|
|
|
754
|
-
//
|
|
755
|
-
const
|
|
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);
|
|
1061
|
+
// Use existing find method with QueryAST directly
|
|
1062
|
+
const results = await this.find(objectName, ast, options);
|
|
765
1063
|
|
|
766
1064
|
return {
|
|
767
1065
|
value: results,
|
|
@@ -873,63 +1171,6 @@ export class MemoryDriver implements Driver {
|
|
|
873
1171
|
}
|
|
874
1172
|
}
|
|
875
1173
|
|
|
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
1174
|
/**
|
|
934
1175
|
* Execute command (alternative signature for compatibility)
|
|
935
1176
|
*
|