@objectql/driver-memory 4.0.0 → 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/src/index.ts CHANGED
@@ -1,3 +1,8 @@
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;
1
6
  /**
2
7
  * ObjectQL
3
8
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -9,17 +14,17 @@
9
14
  /**
10
15
  * Memory Driver for ObjectQL (Production-Ready)
11
16
  *
12
- * A high-performance in-memory driver for ObjectQL that stores data in JavaScript Maps.
17
+ * A high-performance in-memory driver for ObjectQL powered by Mingo.
13
18
  * Perfect for testing, development, and environments where persistence is not required.
14
19
  *
15
- * Implements both the legacy Driver interface from @objectql/types and
16
- * the standard DriverInterface from @objectstack/spec for full compatibility
20
+ * Implements the Driver interface from @objectql/types which includes all methods
21
+ * from the standard DriverInterface from @objectstack/spec for full compatibility
17
22
  * with the new kernel-based plugin system.
18
23
  *
19
24
  * ✅ Production-ready features:
20
- * - Zero external dependencies
25
+ * - MongoDB-like query engine powered by Mingo
21
26
  * - Thread-safe operations
22
- * - Full query support (filters, sorting, pagination)
27
+ * - Full query support (filters, sorting, pagination, aggregation)
23
28
  * - Atomic transactions
24
29
  * - High performance (no I/O overhead)
25
30
  *
@@ -30,11 +35,11 @@
30
35
  * - Client-side state management
31
36
  * - Temporary data caching
32
37
  *
33
- * @version 4.0.0 - DriverInterface compliant
38
+ * @version 4.0.0 - DriverInterface compliant with Mingo integration
34
39
  */
35
40
 
36
41
  import { Driver, ObjectQLError } from '@objectql/types';
37
- import { DriverInterface, QueryAST, FilterNode, SortNode } from '@objectstack/spec';
42
+ import { Query } from 'mingo';
38
43
 
39
44
  /**
40
45
  * Command interface for executeCommand method
@@ -78,7 +83,7 @@ export interface MemoryDriverConfig {
78
83
  *
79
84
  * Example: `users:user-123` → `{id: "user-123", name: "Alice", ...}`
80
85
  */
81
- export class MemoryDriver implements Driver, DriverInterface {
86
+ export class MemoryDriver implements Driver {
82
87
  // Driver metadata (ObjectStack-compatible)
83
88
  public readonly name = 'MemoryDriver';
84
89
  public readonly version = '4.0.0';
@@ -87,7 +92,13 @@ export class MemoryDriver implements Driver, DriverInterface {
87
92
  joins: false,
88
93
  fullTextSearch: false,
89
94
  jsonFields: true,
90
- arrayFields: true
95
+ arrayFields: true,
96
+ queryFilters: true,
97
+ queryAggregations: false,
98
+ querySorting: true,
99
+ queryPagination: true,
100
+ queryWindowFunctions: false,
101
+ querySubqueries: false
91
102
  };
92
103
 
93
104
  private store: Map<string, any>;
@@ -136,7 +147,7 @@ export class MemoryDriver implements Driver, DriverInterface {
136
147
 
137
148
  /**
138
149
  * Find multiple records matching the query criteria.
139
- * Supports filtering, sorting, pagination, and field projection.
150
+ * Supports filtering, sorting, pagination, and field projection using Mingo.
140
151
  */
141
152
  async find(objectName: string, query: any = {}, options?: any): Promise<any[]> {
142
153
  // Normalize query to support both legacy and QueryAST formats
@@ -144,38 +155,42 @@ export class MemoryDriver implements Driver, DriverInterface {
144
155
 
145
156
  // Get all records for this object type
146
157
  const pattern = `${objectName}:`;
147
- let results: any[] = [];
158
+ let records: any[] = [];
148
159
 
149
160
  for (const [key, value] of this.store.entries()) {
150
161
  if (key.startsWith(pattern)) {
151
- results.push({ ...value });
162
+ records.push({ ...value });
152
163
  }
153
164
  }
154
165
 
155
- // Apply filters
156
- if (normalizedQuery.filters) {
157
- results = this.applyFilters(results, normalizedQuery.filters);
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();
158
173
  }
159
174
 
160
- // Apply sorting
161
- if (normalizedQuery.sort && Array.isArray(normalizedQuery.sort)) {
162
- results = this.applySort(results, normalizedQuery.sort);
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);
163
178
  }
164
179
 
165
180
  // Apply pagination
166
181
  if (normalizedQuery.skip) {
167
- results = results.slice(normalizedQuery.skip);
182
+ records = records.slice(normalizedQuery.skip);
168
183
  }
169
184
  if (normalizedQuery.limit) {
170
- results = results.slice(0, normalizedQuery.limit);
185
+ records = records.slice(0, normalizedQuery.limit);
171
186
  }
172
187
 
173
188
  // Apply field projection
174
189
  if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
175
- results = results.map(doc => this.projectFields(doc, normalizedQuery.fields));
190
+ records = records.map(doc => this.projectFields(doc, normalizedQuery.fields));
176
191
  }
177
192
 
178
- return results;
193
+ return records;
179
194
  }
180
195
 
181
196
  /**
@@ -276,11 +291,10 @@ export class MemoryDriver implements Driver, DriverInterface {
276
291
  }
277
292
 
278
293
  /**
279
- * Count records matching filters.
294
+ * Count records matching filters using Mingo.
280
295
  */
281
296
  async count(objectName: string, filters: any, options?: any): Promise<number> {
282
297
  const pattern = `${objectName}:`;
283
- let count = 0;
284
298
 
285
299
  // Extract actual filters from query object if needed
286
300
  let actualFilters = filters;
@@ -288,43 +302,59 @@ export class MemoryDriver implements Driver, DriverInterface {
288
302
  actualFilters = filters.filters;
289
303
  }
290
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
+
291
313
  // If no filters, return total count
292
314
  if (!actualFilters || (Array.isArray(actualFilters) && actualFilters.length === 0)) {
293
- for (const key of this.store.keys()) {
294
- if (key.startsWith(pattern)) {
295
- count++;
296
- }
297
- }
298
- return count;
315
+ return records.length;
299
316
  }
300
317
 
301
- // Count only records matching filters
302
- for (const [key, value] of this.store.entries()) {
303
- if (key.startsWith(pattern)) {
304
- if (this.matchesFilters(value, actualFilters)) {
305
- count++;
306
- }
307
- }
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;
308
324
  }
309
325
 
310
- return count;
326
+ return records.length;
311
327
  }
312
328
 
313
329
  /**
314
- * Get distinct values for a field.
330
+ * Get distinct values for a field using Mingo.
315
331
  */
316
332
  async distinct(objectName: string, field: string, filters?: any, options?: any): Promise<any[]> {
317
333
  const pattern = `${objectName}:`;
318
- const values = new Set<any>();
319
334
 
320
- for (const [key, record] of this.store.entries()) {
335
+ // Get all records for this object type
336
+ let records: any[] = [];
337
+ for (const [key, value] of this.store.entries()) {
321
338
  if (key.startsWith(pattern)) {
322
- if (!filters || this.matchesFilters(record, filters)) {
323
- const value = record[field];
324
- if (value !== undefined && value !== null) {
325
- values.add(value);
326
- }
327
- }
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);
328
358
  }
329
359
  }
330
360
 
@@ -344,25 +374,45 @@ export class MemoryDriver implements Driver, DriverInterface {
344
374
  }
345
375
 
346
376
  /**
347
- * Update multiple records matching filters.
377
+ * Update multiple records matching filters using Mingo.
348
378
  */
349
379
  async updateMany(objectName: string, filters: any, data: any, options?: any): Promise<any> {
350
380
  const pattern = `${objectName}:`;
351
- let count = 0;
381
+
382
+ // Get all records for this object type
383
+ let records: any[] = [];
384
+ const recordKeys = new Map<string, string>();
352
385
 
353
386
  for (const [key, record] of this.store.entries()) {
354
387
  if (key.startsWith(pattern)) {
355
- if (this.matchesFilters(record, filters)) {
356
- const updated = {
357
- ...record,
358
- ...data,
359
- id: record.id, // Preserve ID
360
- created_at: record.created_at, // Preserve created_at
361
- updated_at: new Date().toISOString()
362
- };
363
- this.store.set(key, updated);
364
- count++;
365
- }
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++;
366
416
  }
367
417
  }
368
418
 
@@ -370,25 +420,40 @@ export class MemoryDriver implements Driver, DriverInterface {
370
420
  }
371
421
 
372
422
  /**
373
- * Delete multiple records matching filters.
423
+ * Delete multiple records matching filters using Mingo.
374
424
  */
375
425
  async deleteMany(objectName: string, filters: any, options?: any): Promise<any> {
376
426
  const pattern = `${objectName}:`;
377
- const keysToDelete: string[] = [];
427
+
428
+ // Get all records for this object type
429
+ let records: any[] = [];
430
+ const recordKeys = new Map<string, string>();
378
431
 
379
432
  for (const [key, record] of this.store.entries()) {
380
433
  if (key.startsWith(pattern)) {
381
- if (this.matchesFilters(record, filters)) {
382
- keysToDelete.push(key);
383
- }
434
+ records.push(record);
435
+ recordKeys.set(record.id, key);
384
436
  }
385
437
  }
386
438
 
387
- for (const key of keysToDelete) {
388
- this.store.delete(key);
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();
446
+ }
447
+
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
+ }
389
454
  }
390
455
 
391
- return { deletedCount: keysToDelete.length };
456
+ return { deletedCount: matchedRecords.length };
392
457
  }
393
458
 
394
459
  /**
@@ -449,7 +514,7 @@ export class MemoryDriver implements Driver, DriverInterface {
449
514
  }
450
515
 
451
516
  /**
452
- * Apply filters to an array of records (in-memory filtering).
517
+ * Convert ObjectQL filters to MongoDB query format for Mingo.
453
518
  *
454
519
  * Supports ObjectQL filter format:
455
520
  * [
@@ -457,102 +522,139 @@ export class MemoryDriver implements Driver, DriverInterface {
457
522
  * 'or',
458
523
  * ['field2', 'operator', value2]
459
524
  * ]
525
+ *
526
+ * Converts to MongoDB query format:
527
+ * { $or: [{ field: { $operator: value }}, { field2: { $operator: value2 }}] }
460
528
  */
461
- private applyFilters(records: any[], filters: any[]): any[] {
462
- if (!filters || filters.length === 0) {
463
- return records;
464
- }
465
-
466
- return records.filter(record => this.matchesFilters(record, filters));
467
- }
468
-
469
- /**
470
- * Check if a single record matches the filter conditions.
471
- */
472
- private matchesFilters(record: any, filters: any[]): boolean {
529
+ private convertToMongoQuery(filters?: any[]): Record<string, any> {
473
530
  if (!filters || filters.length === 0) {
474
- return true;
531
+ return {};
475
532
  }
476
533
 
477
- let conditions: boolean[] = [];
478
- let operators: string[] = [];
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
+ ];
479
540
 
480
541
  for (const item of filters) {
481
542
  if (typeof item === 'string') {
482
543
  // Logical operator (and/or)
483
- operators.push(item.toLowerCase());
544
+ const newLogic = item.toLowerCase() as 'and' | 'or';
545
+ if (newLogic !== currentLogic) {
546
+ currentLogic = newLogic;
547
+ logicGroups.push({ logic: currentLogic, conditions: [] });
548
+ }
484
549
  } else if (Array.isArray(item)) {
485
550
  const [field, operator, value] = item;
486
551
 
487
- // Handle nested filter groups
488
- if (typeof field !== 'string') {
489
- // Nested group - recursively evaluate
490
- conditions.push(this.matchesFilters(record, item));
491
- } else {
492
- // Single condition
493
- const matches = this.evaluateCondition(record[field], operator, value);
494
- 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);
495
556
  }
496
557
  }
497
558
  }
498
559
 
499
- // Combine conditions with operators
500
- if (conditions.length === 0) {
501
- return true;
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
+ }
502
573
  }
503
574
 
504
- let result = conditions[0];
505
- for (let i = 0; i < operators.length; i++) {
506
- const op = operators[i];
507
- const nextCondition = conditions[i + 1];
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;
508
579
 
509
- if (op === 'or') {
510
- result = result || nextCondition;
511
- } else { // 'and' or default
512
- result = result && nextCondition;
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
+ }
513
588
  }
514
589
  }
515
590
 
516
- return result;
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
+ }
517
604
  }
518
605
 
519
606
  /**
520
- * Evaluate a single filter condition.
607
+ * Convert a single ObjectQL condition to MongoDB operator format.
521
608
  */
522
- private evaluateCondition(fieldValue: any, operator: string, compareValue: any): boolean {
609
+ private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
523
610
  switch (operator) {
524
611
  case '=':
525
612
  case '==':
526
- return fieldValue === compareValue;
613
+ return { [field]: value };
614
+
527
615
  case '!=':
528
616
  case '<>':
529
- return fieldValue !== compareValue;
617
+ return { [field]: { $ne: value } };
618
+
530
619
  case '>':
531
- return fieldValue > compareValue;
620
+ return { [field]: { $gt: value } };
621
+
532
622
  case '>=':
533
- return fieldValue >= compareValue;
623
+ return { [field]: { $gte: value } };
624
+
534
625
  case '<':
535
- return fieldValue < compareValue;
626
+ return { [field]: { $lt: value } };
627
+
536
628
  case '<=':
537
- return fieldValue <= compareValue;
629
+ return { [field]: { $lte: value } };
630
+
538
631
  case 'in':
539
- return Array.isArray(compareValue) && compareValue.includes(fieldValue);
632
+ return { [field]: { $in: value } };
633
+
540
634
  case 'nin':
541
635
  case 'not in':
542
- return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
636
+ return { [field]: { $nin: value } };
637
+
543
638
  case 'contains':
544
639
  case 'like':
545
- return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase());
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
+
546
644
  case 'startswith':
547
645
  case 'starts_with':
548
- return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
646
+ return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
647
+
549
648
  case 'endswith':
550
649
  case 'ends_with':
551
- return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
650
+ return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
651
+
552
652
  case 'between':
553
- return Array.isArray(compareValue) &&
554
- fieldValue >= compareValue[0] &&
555
- fieldValue <= compareValue[1];
653
+ if (Array.isArray(value) && value.length === 2) {
654
+ return { [field]: { $gte: value[0], $lte: value[1] } };
655
+ }
656
+ return null;
657
+
556
658
  default:
557
659
  throw new ObjectQLError({
558
660
  code: 'UNSUPPORTED_OPERATOR',
@@ -562,12 +664,21 @@ export class MemoryDriver implements Driver, DriverInterface {
562
664
  }
563
665
 
564
666
  /**
565
- * Apply sorting to an array of records (in-memory sorting).
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.
566
677
  */
567
- private applySort(records: any[], sort: any[]): any[] {
678
+ private applyManualSort(records: any[], sort: any[]): any[] {
568
679
  const sorted = [...records];
569
680
 
570
- // Apply sorts in reverse order for correct precedence
681
+ // Apply sorts in reverse order for correct multi-field precedence
571
682
  for (let i = sort.length - 1; i >= 0; i--) {
572
683
  const sortItem = sort[i];
573
684
 
@@ -593,8 +704,8 @@ export class MemoryDriver implements Driver, DriverInterface {
593
704
  if (bVal == null) return -1;
594
705
 
595
706
  // Compare values
596
- if (aVal < bVal) return direction === 'asc' ? -1 : 1;
597
- if (aVal > bVal) return direction === 'asc' ? 1 : -1;
707
+ if (aVal < bVal) return direction.toLowerCase() === 'desc' ? 1 : -1;
708
+ if (aVal > bVal) return direction.toLowerCase() === 'desc' ? -1 : 1;
598
709
  return 0;
599
710
  });
600
711
  }