@objectstack/driver-memory 2.0.6 → 3.0.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +20 -0
- package/dist/index.d.mts +165 -12
- package/dist/index.d.ts +165 -12
- package/dist/index.js +742 -159
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +740 -158
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
- package/src/index.ts +4 -12
- package/src/memory-analytics.test.ts +346 -0
- package/src/memory-analytics.ts +518 -0
- package/src/memory-driver.test.ts +473 -1
- package/src/memory-driver.ts +416 -85
package/src/memory-driver.ts
CHANGED
|
@@ -1,27 +1,61 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
-
import { QueryAST, QueryInput } from '@objectstack/spec/data';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data';
|
|
4
|
+
import type { DriverInterface } from '@objectstack/core';
|
|
5
|
+
import { Logger, createLogger } from '@objectstack/core';
|
|
6
|
+
import { Query, Aggregator } from 'mingo';
|
|
7
|
+
import { getValueByPath } from './memory-matcher.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
+
* Configuration options for the InMemory driver.
|
|
11
|
+
* Aligned with @objectstack/spec MemoryConfigSchema.
|
|
12
|
+
*/
|
|
13
|
+
export interface InMemoryDriverConfig {
|
|
14
|
+
/** Optional: Initial data to populate the store */
|
|
15
|
+
initialData?: Record<string, Record<string, unknown>[]>;
|
|
16
|
+
/** Optional: Enable strict mode (throw on missing records) */
|
|
17
|
+
strictMode?: boolean;
|
|
18
|
+
/** Optional: Logger instance */
|
|
19
|
+
logger?: Logger;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Snapshot for in-memory transactions.
|
|
24
|
+
*/
|
|
25
|
+
interface MemoryTransaction {
|
|
26
|
+
id: string;
|
|
27
|
+
snapshot: Record<string, any[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* In-Memory Driver for ObjectStack
|
|
32
|
+
*
|
|
33
|
+
* A production-ready implementation of the ObjectStack Driver Protocol
|
|
34
|
+
* powered by Mingo — a MongoDB-compatible query and aggregation engine.
|
|
10
35
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
36
|
+
* Features:
|
|
37
|
+
* - MongoDB-compatible query engine (Mingo) for filtering, projection, aggregation
|
|
38
|
+
* - Full CRUD and bulk operations
|
|
39
|
+
* - Aggregation pipeline support ($match, $group, $sort, $project, $unwind, etc.)
|
|
40
|
+
* - Snapshot-based transactions (begin/commit/rollback)
|
|
41
|
+
* - Field projection and distinct values
|
|
42
|
+
* - Strict mode and initial data loading
|
|
43
|
+
*
|
|
44
|
+
* Reference: objectql/packages/drivers/memory
|
|
13
45
|
*/
|
|
14
46
|
export class InMemoryDriver implements DriverInterface {
|
|
15
47
|
name = 'com.objectstack.driver.memory';
|
|
16
48
|
type = 'driver';
|
|
17
|
-
version = '0.0
|
|
18
|
-
private config:
|
|
49
|
+
version = '1.0.0';
|
|
50
|
+
private config: InMemoryDriverConfig;
|
|
19
51
|
private logger: Logger;
|
|
52
|
+
private idCounters: Map<string, number> = new Map();
|
|
53
|
+
private transactions: Map<string, MemoryTransaction> = new Map();
|
|
20
54
|
|
|
21
|
-
constructor(config?:
|
|
55
|
+
constructor(config?: InMemoryDriverConfig) {
|
|
22
56
|
this.config = config || {};
|
|
23
57
|
this.logger = config?.logger || createLogger({ level: 'info', format: 'pretty' });
|
|
24
|
-
this.logger.debug('InMemory driver instance created'
|
|
58
|
+
this.logger.debug('InMemory driver instance created');
|
|
25
59
|
}
|
|
26
60
|
|
|
27
61
|
// Duck-typed RuntimePlugin hook
|
|
@@ -37,11 +71,11 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
37
71
|
|
|
38
72
|
supports = {
|
|
39
73
|
// Transaction & Connection Management
|
|
40
|
-
transactions:
|
|
74
|
+
transactions: true, // Snapshot-based transactions
|
|
41
75
|
|
|
42
76
|
// Query Operations
|
|
43
77
|
queryFilters: true, // Implemented via memory-matcher
|
|
44
|
-
queryAggregations: true,
|
|
78
|
+
queryAggregations: true, // Implemented
|
|
45
79
|
querySorting: true, // Implemented via JS sort
|
|
46
80
|
queryPagination: true, // Implemented
|
|
47
81
|
queryWindowFunctions: false, // @planned: Window functions (ROW_NUMBER, RANK, etc.)
|
|
@@ -66,7 +100,21 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
66
100
|
// ===================================
|
|
67
101
|
|
|
68
102
|
async connect() {
|
|
69
|
-
|
|
103
|
+
// Load initial data if provided
|
|
104
|
+
if (this.config.initialData) {
|
|
105
|
+
for (const [objectName, records] of Object.entries(this.config.initialData)) {
|
|
106
|
+
const table = this.getTable(objectName);
|
|
107
|
+
for (const record of records) {
|
|
108
|
+
const id = (record as any).id || this.generateId(objectName);
|
|
109
|
+
table.push({ ...record, id });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.logger.info('InMemory Database Connected with initial data', {
|
|
113
|
+
tables: Object.keys(this.config.initialData).length,
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
this.logger.info('InMemory Database Connected (Virtual)');
|
|
117
|
+
}
|
|
70
118
|
}
|
|
71
119
|
|
|
72
120
|
async disconnect() {
|
|
@@ -105,11 +153,15 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
105
153
|
this.logger.debug('Find operation', { object, query });
|
|
106
154
|
|
|
107
155
|
const table = this.getTable(object);
|
|
108
|
-
let results = table;
|
|
156
|
+
let results = [...table]; // Work on copy
|
|
109
157
|
|
|
110
|
-
// 1. Filter
|
|
158
|
+
// 1. Filter using Mingo
|
|
111
159
|
if (query.where) {
|
|
112
|
-
|
|
160
|
+
const mongoQuery = this.convertToMongoQuery(query.where);
|
|
161
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
162
|
+
const mingoQuery = new Query(mongoQuery);
|
|
163
|
+
results = mingoQuery.find(results).all();
|
|
164
|
+
}
|
|
113
165
|
}
|
|
114
166
|
|
|
115
167
|
// 1.5 Aggregation & Grouping
|
|
@@ -119,21 +171,8 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
119
171
|
|
|
120
172
|
// 2. Sort
|
|
121
173
|
if (query.orderBy) {
|
|
122
|
-
// Normalize sort to array
|
|
123
174
|
const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
|
|
124
|
-
|
|
125
|
-
results.sort((a, b) => {
|
|
126
|
-
for (const { field, order } of sortFields) {
|
|
127
|
-
const valA = getValueByPath(a, field);
|
|
128
|
-
const valB = getValueByPath(b, field);
|
|
129
|
-
|
|
130
|
-
if (valA === valB) continue;
|
|
131
|
-
|
|
132
|
-
const comparison = valA > valB ? 1 : -1;
|
|
133
|
-
return order === 'desc' ? -comparison : comparison;
|
|
134
|
-
}
|
|
135
|
-
return 0;
|
|
136
|
-
});
|
|
175
|
+
results = this.applySort(results, sortFields);
|
|
137
176
|
}
|
|
138
177
|
|
|
139
178
|
// 3. Pagination (Offset)
|
|
@@ -146,6 +185,11 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
146
185
|
results = results.slice(0, query.limit);
|
|
147
186
|
}
|
|
148
187
|
|
|
188
|
+
// 5. Field Projection
|
|
189
|
+
if (query.fields && Array.isArray(query.fields) && query.fields.length > 0) {
|
|
190
|
+
results = results.map(record => this.projectFields(record, query.fields as string[]));
|
|
191
|
+
}
|
|
192
|
+
|
|
149
193
|
this.logger.debug('Find completed', { object, resultCount: results.length });
|
|
150
194
|
return results;
|
|
151
195
|
}
|
|
@@ -174,17 +218,16 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
174
218
|
|
|
175
219
|
const table = this.getTable(object);
|
|
176
220
|
|
|
177
|
-
// COMPATIBILITY: Driver must return 'id' as string
|
|
178
221
|
const newRecord = {
|
|
179
|
-
id: data.id || this.generateId(),
|
|
222
|
+
id: data.id || this.generateId(object),
|
|
180
223
|
...data,
|
|
181
|
-
created_at: data.created_at || new Date(),
|
|
182
|
-
updated_at: data.updated_at || new Date(),
|
|
224
|
+
created_at: data.created_at || new Date().toISOString(),
|
|
225
|
+
updated_at: data.updated_at || new Date().toISOString(),
|
|
183
226
|
};
|
|
184
227
|
|
|
185
228
|
table.push(newRecord);
|
|
186
229
|
this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
|
|
187
|
-
return newRecord;
|
|
230
|
+
return { ...newRecord };
|
|
188
231
|
}
|
|
189
232
|
|
|
190
233
|
async update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions) {
|
|
@@ -194,19 +237,24 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
194
237
|
const index = table.findIndex(r => r.id == id);
|
|
195
238
|
|
|
196
239
|
if (index === -1) {
|
|
197
|
-
this.
|
|
198
|
-
|
|
240
|
+
if (this.config.strictMode) {
|
|
241
|
+
this.logger.warn('Record not found for update', { object, id });
|
|
242
|
+
throw new Error(`Record with ID ${id} not found in ${object}`);
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
199
245
|
}
|
|
200
246
|
|
|
201
247
|
const updatedRecord = {
|
|
202
248
|
...table[index],
|
|
203
249
|
...data,
|
|
204
|
-
|
|
250
|
+
id: table[index].id, // Preserve original ID
|
|
251
|
+
created_at: table[index].created_at, // Preserve created_at
|
|
252
|
+
updated_at: new Date().toISOString(),
|
|
205
253
|
};
|
|
206
254
|
|
|
207
255
|
table[index] = updatedRecord;
|
|
208
256
|
this.logger.debug('Record updated', { object, id });
|
|
209
|
-
return updatedRecord;
|
|
257
|
+
return { ...updatedRecord };
|
|
210
258
|
}
|
|
211
259
|
|
|
212
260
|
async upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions) {
|
|
@@ -237,6 +285,9 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
237
285
|
const index = table.findIndex(r => r.id == id);
|
|
238
286
|
|
|
239
287
|
if (index === -1) {
|
|
288
|
+
if (this.config.strictMode) {
|
|
289
|
+
throw new Error(`Record with ID ${id} not found in ${object}`);
|
|
290
|
+
}
|
|
240
291
|
this.logger.warn('Record not found for deletion', { object, id });
|
|
241
292
|
return false;
|
|
242
293
|
}
|
|
@@ -247,11 +298,15 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
247
298
|
}
|
|
248
299
|
|
|
249
300
|
async count(object: string, query?: QueryInput, options?: DriverOptions) {
|
|
250
|
-
let
|
|
301
|
+
let records = this.getTable(object);
|
|
251
302
|
if (query?.where) {
|
|
252
|
-
|
|
303
|
+
const mongoQuery = this.convertToMongoQuery(query.where);
|
|
304
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
305
|
+
const mingoQuery = new Query(mongoQuery);
|
|
306
|
+
records = mingoQuery.find(records).all();
|
|
307
|
+
}
|
|
253
308
|
}
|
|
254
|
-
const count =
|
|
309
|
+
const count = records.length;
|
|
255
310
|
this.logger.debug('Count operation', { object, count });
|
|
256
311
|
return count;
|
|
257
312
|
}
|
|
@@ -274,20 +329,22 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
274
329
|
let targetRecords = table;
|
|
275
330
|
|
|
276
331
|
if (query && query.where) {
|
|
277
|
-
|
|
332
|
+
const mongoQuery = this.convertToMongoQuery(query.where);
|
|
333
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
334
|
+
const mingoQuery = new Query(mongoQuery);
|
|
335
|
+
targetRecords = mingoQuery.find(targetRecords).all();
|
|
336
|
+
}
|
|
278
337
|
}
|
|
279
338
|
|
|
280
339
|
const count = targetRecords.length;
|
|
281
340
|
|
|
282
|
-
// Update each record
|
|
283
341
|
for (const record of targetRecords) {
|
|
284
|
-
// Find index in original table
|
|
285
342
|
const index = table.findIndex(r => r.id === record.id);
|
|
286
343
|
if (index !== -1) {
|
|
287
344
|
const updated = {
|
|
288
345
|
...table[index],
|
|
289
346
|
...data,
|
|
290
|
-
updated_at: new Date()
|
|
347
|
+
updated_at: new Date().toISOString()
|
|
291
348
|
};
|
|
292
349
|
table[index] = updated;
|
|
293
350
|
}
|
|
@@ -303,20 +360,23 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
303
360
|
const table = this.getTable(object);
|
|
304
361
|
const initialLength = table.length;
|
|
305
362
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
363
|
+
if (query && query.where) {
|
|
364
|
+
const mongoQuery = this.convertToMongoQuery(query.where);
|
|
365
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
366
|
+
const mingoQuery = new Query(mongoQuery);
|
|
367
|
+
const matched = mingoQuery.find(table).all();
|
|
368
|
+
const matchedIds = new Set(matched.map((r: any) => r.id));
|
|
369
|
+
this.db[object] = table.filter(r => !matchedIds.has(r.id));
|
|
370
|
+
} else {
|
|
371
|
+
// Empty query = delete all
|
|
372
|
+
this.db[object] = [];
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
// No where clause = delete all
|
|
376
|
+
this.db[object] = [];
|
|
377
|
+
}
|
|
319
378
|
|
|
379
|
+
const count = initialLength - this.db[object].length;
|
|
320
380
|
this.logger.debug('DeleteMany completed', { object, count });
|
|
321
381
|
return { count };
|
|
322
382
|
}
|
|
@@ -336,30 +396,236 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
336
396
|
}
|
|
337
397
|
|
|
338
398
|
// ===================================
|
|
339
|
-
//
|
|
399
|
+
// Transaction Management
|
|
340
400
|
// ===================================
|
|
341
401
|
|
|
342
|
-
async
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
402
|
+
async beginTransaction() {
|
|
403
|
+
const txId = `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
404
|
+
|
|
405
|
+
// Deep-clone current database state as a snapshot
|
|
406
|
+
const snapshot: Record<string, any[]> = {};
|
|
407
|
+
for (const [table, records] of Object.entries(this.db)) {
|
|
408
|
+
snapshot[table] = records.map(r => ({ ...r }));
|
|
346
409
|
}
|
|
410
|
+
|
|
411
|
+
const transaction: MemoryTransaction = { id: txId, snapshot };
|
|
412
|
+
this.transactions.set(txId, transaction);
|
|
413
|
+
this.logger.debug('Transaction started', { txId });
|
|
414
|
+
return { id: txId };
|
|
347
415
|
}
|
|
348
416
|
|
|
349
|
-
async
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
417
|
+
async commit(txHandle?: unknown) {
|
|
418
|
+
const txId = (txHandle as any)?.id;
|
|
419
|
+
if (!txId || !this.transactions.has(txId)) {
|
|
420
|
+
this.logger.warn('Commit called with unknown transaction');
|
|
421
|
+
return;
|
|
354
422
|
}
|
|
423
|
+
// Data is already in the store; just remove the snapshot
|
|
424
|
+
this.transactions.delete(txId);
|
|
425
|
+
this.logger.debug('Transaction committed', { txId });
|
|
355
426
|
}
|
|
356
427
|
|
|
357
|
-
async
|
|
358
|
-
|
|
428
|
+
async rollback(txHandle?: unknown) {
|
|
429
|
+
const txId = (txHandle as any)?.id;
|
|
430
|
+
if (!txId || !this.transactions.has(txId)) {
|
|
431
|
+
this.logger.warn('Rollback called with unknown transaction');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const tx = this.transactions.get(txId)!;
|
|
435
|
+
// Restore the snapshot
|
|
436
|
+
this.db = tx.snapshot;
|
|
437
|
+
this.transactions.delete(txId);
|
|
438
|
+
this.logger.debug('Transaction rolled back', { txId });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ===================================
|
|
442
|
+
// Utility Methods
|
|
443
|
+
// ===================================
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Remove all data from the store.
|
|
447
|
+
*/
|
|
448
|
+
async clear() {
|
|
449
|
+
this.db = {};
|
|
450
|
+
this.idCounters.clear();
|
|
451
|
+
this.logger.debug('All data cleared');
|
|
359
452
|
}
|
|
360
453
|
|
|
361
|
-
|
|
362
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Get total number of records across all tables.
|
|
456
|
+
*/
|
|
457
|
+
getSize(): number {
|
|
458
|
+
return Object.values(this.db).reduce((sum, table) => sum + table.length, 0);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get distinct values for a field, optionally filtered.
|
|
463
|
+
*/
|
|
464
|
+
async distinct(object: string, field: string, query?: QueryInput): Promise<any[]> {
|
|
465
|
+
let records = this.getTable(object);
|
|
466
|
+
if (query?.where) {
|
|
467
|
+
const mongoQuery = this.convertToMongoQuery(query.where);
|
|
468
|
+
if (mongoQuery && Object.keys(mongoQuery).length > 0) {
|
|
469
|
+
const mingoQuery = new Query(mongoQuery);
|
|
470
|
+
records = mingoQuery.find(records).all();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const values = new Set<any>();
|
|
474
|
+
for (const record of records) {
|
|
475
|
+
const value = getValueByPath(record, field);
|
|
476
|
+
if (value !== undefined && value !== null) {
|
|
477
|
+
values.add(value);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return Array.from(values);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Execute a MongoDB-style aggregation pipeline using Mingo.
|
|
485
|
+
*
|
|
486
|
+
* Supports all standard MongoDB pipeline stages:
|
|
487
|
+
* - $match, $group, $sort, $project, $unwind, $limit, $skip
|
|
488
|
+
* - $addFields, $replaceRoot, $lookup (limited), $count
|
|
489
|
+
* - Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet
|
|
490
|
+
*
|
|
491
|
+
* @example
|
|
492
|
+
* // Group by status and count
|
|
493
|
+
* const results = await driver.aggregate('orders', [
|
|
494
|
+
* { $match: { status: 'completed' } },
|
|
495
|
+
* { $group: { _id: '$customer', totalAmount: { $sum: '$amount' } } }
|
|
496
|
+
* ]);
|
|
497
|
+
*
|
|
498
|
+
* @example
|
|
499
|
+
* // Calculate average with filter
|
|
500
|
+
* const results = await driver.aggregate('products', [
|
|
501
|
+
* { $match: { category: 'electronics' } },
|
|
502
|
+
* { $group: { _id: null, avgPrice: { $avg: '$price' } } }
|
|
503
|
+
* ]);
|
|
504
|
+
*/
|
|
505
|
+
async aggregate(object: string, pipeline: Record<string, any>[], options?: DriverOptions): Promise<any[]> {
|
|
506
|
+
this.logger.debug('Aggregate operation', { object, stageCount: pipeline.length });
|
|
507
|
+
|
|
508
|
+
const records = this.getTable(object).map(r => ({ ...r }));
|
|
509
|
+
const aggregator = new Aggregator(pipeline);
|
|
510
|
+
const results = aggregator.run(records);
|
|
511
|
+
|
|
512
|
+
this.logger.debug('Aggregate completed', { object, resultCount: results.length });
|
|
513
|
+
return results;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ===================================
|
|
517
|
+
// Query Conversion (ObjectQL → MongoDB)
|
|
518
|
+
// ===================================
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Convert ObjectQL filter format to MongoDB query format for Mingo.
|
|
522
|
+
*
|
|
523
|
+
* Supports:
|
|
524
|
+
* 1. AST Comparison Node: { type: 'comparison', field, operator, value }
|
|
525
|
+
* 2. AST Logical Node: { type: 'logical', operator: 'and'|'or', conditions: [...] }
|
|
526
|
+
* 3. Legacy Array Format: [['field', 'op', value], 'and', ['field2', 'op', value2]]
|
|
527
|
+
* 4. MongoDB Format: { field: value } or { field: { $eq: value } } (passthrough)
|
|
528
|
+
*/
|
|
529
|
+
private convertToMongoQuery(filters?: any): Record<string, any> {
|
|
530
|
+
if (!filters) return {};
|
|
531
|
+
|
|
532
|
+
// AST node format (ObjectQL QueryAST)
|
|
533
|
+
if (!Array.isArray(filters) && typeof filters === 'object') {
|
|
534
|
+
if (filters.type === 'comparison') {
|
|
535
|
+
return this.convertConditionToMongo(filters.field, filters.operator, filters.value) || {};
|
|
536
|
+
}
|
|
537
|
+
if (filters.type === 'logical') {
|
|
538
|
+
const conditions = filters.conditions?.map((c: any) => this.convertToMongoQuery(c)) || [];
|
|
539
|
+
if (conditions.length === 0) return {};
|
|
540
|
+
if (conditions.length === 1) return conditions[0];
|
|
541
|
+
const op = filters.operator === 'or' ? '$or' : '$and';
|
|
542
|
+
return { [op]: conditions };
|
|
543
|
+
}
|
|
544
|
+
// MongoDB format passthrough: { field: value } or { field: { $eq: value } }
|
|
545
|
+
return filters;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Legacy array format
|
|
549
|
+
if (!Array.isArray(filters) || filters.length === 0) return {};
|
|
550
|
+
|
|
551
|
+
const logicGroups: { logic: 'and' | 'or'; conditions: Record<string, any>[] }[] = [
|
|
552
|
+
{ logic: 'and', conditions: [] },
|
|
553
|
+
];
|
|
554
|
+
let currentLogic: 'and' | 'or' = 'and';
|
|
555
|
+
|
|
556
|
+
for (const item of filters) {
|
|
557
|
+
if (typeof item === 'string') {
|
|
558
|
+
const newLogic = item.toLowerCase() as 'and' | 'or';
|
|
559
|
+
if (newLogic !== currentLogic) {
|
|
560
|
+
currentLogic = newLogic;
|
|
561
|
+
logicGroups.push({ logic: currentLogic, conditions: [] });
|
|
562
|
+
}
|
|
563
|
+
} else if (Array.isArray(item)) {
|
|
564
|
+
const [field, operator, value] = item;
|
|
565
|
+
const cond = this.convertConditionToMongo(field, operator, value);
|
|
566
|
+
if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const allConditions: Record<string, any>[] = [];
|
|
571
|
+
for (const group of logicGroups) {
|
|
572
|
+
if (group.conditions.length === 0) continue;
|
|
573
|
+
if (group.conditions.length === 1) {
|
|
574
|
+
allConditions.push(group.conditions[0]);
|
|
575
|
+
} else {
|
|
576
|
+
const op = group.logic === 'or' ? '$or' : '$and';
|
|
577
|
+
allConditions.push({ [op]: group.conditions });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (allConditions.length === 0) return {};
|
|
582
|
+
if (allConditions.length === 1) return allConditions[0];
|
|
583
|
+
return { $and: allConditions };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Convert a single ObjectQL condition to MongoDB operator format.
|
|
588
|
+
*/
|
|
589
|
+
private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
|
|
590
|
+
switch (operator) {
|
|
591
|
+
case '=': case '==':
|
|
592
|
+
return { [field]: value };
|
|
593
|
+
case '!=': case '<>':
|
|
594
|
+
return { [field]: { $ne: value } };
|
|
595
|
+
case '>':
|
|
596
|
+
return { [field]: { $gt: value } };
|
|
597
|
+
case '>=':
|
|
598
|
+
return { [field]: { $gte: value } };
|
|
599
|
+
case '<':
|
|
600
|
+
return { [field]: { $lt: value } };
|
|
601
|
+
case '<=':
|
|
602
|
+
return { [field]: { $lte: value } };
|
|
603
|
+
case 'in':
|
|
604
|
+
return { [field]: { $in: value } };
|
|
605
|
+
case 'nin': case 'not in':
|
|
606
|
+
return { [field]: { $nin: value } };
|
|
607
|
+
case 'contains': case 'like':
|
|
608
|
+
return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } };
|
|
609
|
+
case 'startswith': case 'starts_with':
|
|
610
|
+
return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
|
|
611
|
+
case 'endswith': case 'ends_with':
|
|
612
|
+
return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
|
|
613
|
+
case 'between':
|
|
614
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
615
|
+
return { [field]: { $gte: value[0], $lte: value[1] } };
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
default:
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Escape special regex characters for safe literal matching.
|
|
625
|
+
*/
|
|
626
|
+
private escapeRegex(str: string): string {
|
|
627
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
628
|
+
}
|
|
363
629
|
|
|
364
630
|
// ===================================
|
|
365
631
|
// Aggregation Logic
|
|
@@ -385,20 +651,13 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
385
651
|
groups.get(key)!.push(record);
|
|
386
652
|
}
|
|
387
653
|
} else {
|
|
388
|
-
|
|
389
|
-
// If aggregation is requested without group by, it runs on whole set (even if empty)
|
|
390
|
-
if (aggregations && aggregations.length > 0) {
|
|
391
|
-
groups.set('all', records);
|
|
392
|
-
} else {
|
|
393
|
-
// Should not be here if performAggregation called correctly
|
|
394
|
-
groups.set('all', records);
|
|
395
|
-
}
|
|
654
|
+
groups.set('all', records);
|
|
396
655
|
}
|
|
397
656
|
|
|
398
657
|
// 2. Compute aggregates for each group
|
|
399
658
|
const resultRows: any[] = [];
|
|
400
659
|
|
|
401
|
-
for (const [
|
|
660
|
+
for (const [_key, groupRecords] of groups.entries()) {
|
|
402
661
|
const row: any = {};
|
|
403
662
|
|
|
404
663
|
// A. Add Group fields to row (if groupBy exists)
|
|
@@ -473,10 +732,78 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
473
732
|
current[parts[parts.length - 1]] = value;
|
|
474
733
|
}
|
|
475
734
|
|
|
735
|
+
// ===================================
|
|
736
|
+
// Schema Management
|
|
737
|
+
// ===================================
|
|
738
|
+
|
|
739
|
+
async syncSchema(object: string, schema: any, options?: DriverOptions) {
|
|
740
|
+
if (!this.db[object]) {
|
|
741
|
+
this.db[object] = [];
|
|
742
|
+
this.logger.info('Created in-memory table', { object });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async dropTable(object: string, options?: DriverOptions) {
|
|
747
|
+
if (this.db[object]) {
|
|
748
|
+
const recordCount = this.db[object].length;
|
|
749
|
+
delete this.db[object];
|
|
750
|
+
this.logger.info('Dropped in-memory table', { object, recordCount });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
476
754
|
// ===================================
|
|
477
755
|
// Helpers
|
|
478
756
|
// ===================================
|
|
479
757
|
|
|
758
|
+
/**
|
|
759
|
+
* Apply manual sorting (Mingo sort has CJS build issues).
|
|
760
|
+
*/
|
|
761
|
+
private applySort(records: any[], sortFields: any[]): any[] {
|
|
762
|
+
const sorted = [...records];
|
|
763
|
+
for (let i = sortFields.length - 1; i >= 0; i--) {
|
|
764
|
+
const sortItem = sortFields[i];
|
|
765
|
+
let field: string;
|
|
766
|
+
let direction: string;
|
|
767
|
+
if (typeof sortItem === 'object' && !Array.isArray(sortItem)) {
|
|
768
|
+
field = sortItem.field;
|
|
769
|
+
direction = sortItem.order || sortItem.direction || 'asc';
|
|
770
|
+
} else if (Array.isArray(sortItem)) {
|
|
771
|
+
[field, direction] = sortItem;
|
|
772
|
+
} else {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
sorted.sort((a, b) => {
|
|
776
|
+
const aVal = getValueByPath(a, field);
|
|
777
|
+
const bVal = getValueByPath(b, field);
|
|
778
|
+
if (aVal == null && bVal == null) return 0;
|
|
779
|
+
if (aVal == null) return 1;
|
|
780
|
+
if (bVal == null) return -1;
|
|
781
|
+
if (aVal < bVal) return direction === 'desc' ? 1 : -1;
|
|
782
|
+
if (aVal > bVal) return direction === 'desc' ? -1 : 1;
|
|
783
|
+
return 0;
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return sorted;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Project specific fields from a record.
|
|
791
|
+
*/
|
|
792
|
+
private projectFields(record: any, fields: string[]): any {
|
|
793
|
+
const result: any = {};
|
|
794
|
+
for (const field of fields) {
|
|
795
|
+
const value = getValueByPath(record, field);
|
|
796
|
+
if (value !== undefined) {
|
|
797
|
+
result[field] = value;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
// Always include id if not explicitly listed
|
|
801
|
+
if (!fields.includes('id') && record.id !== undefined) {
|
|
802
|
+
result.id = record.id;
|
|
803
|
+
}
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
|
|
480
807
|
private getTable(name: string) {
|
|
481
808
|
if (!this.db[name]) {
|
|
482
809
|
this.db[name] = [];
|
|
@@ -484,7 +811,11 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
484
811
|
return this.db[name];
|
|
485
812
|
}
|
|
486
813
|
|
|
487
|
-
private generateId() {
|
|
488
|
-
|
|
814
|
+
private generateId(objectName?: string) {
|
|
815
|
+
const key = objectName || '_global';
|
|
816
|
+
const counter = (this.idCounters.get(key) || 0) + 1;
|
|
817
|
+
this.idCounters.set(key, counter);
|
|
818
|
+
const timestamp = Date.now();
|
|
819
|
+
return `${key}-${timestamp}-${counter}`;
|
|
489
820
|
}
|
|
490
821
|
}
|