@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/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 that stores data in JavaScript Maps.
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
- * - Zero external dependencies
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 results: any[] = [];
158
+ let records: any[] = [];
79
159
 
80
160
  for (const [key, value] of this.store.entries()) {
81
161
  if (key.startsWith(pattern)) {
82
- results.push({ ...value });
162
+ records.push({ ...value });
83
163
  }
84
164
  }
85
165
 
86
- // Apply filters
87
- if (query.filters) {
88
- results = this.applyFilters(results, query.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();
89
173
  }
90
174
 
91
- // Apply sorting
92
- if (query.sort && Array.isArray(query.sort)) {
93
- results = this.applySort(results, query.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);
94
178
  }
95
179
 
96
180
  // Apply pagination
97
- if (query.skip) {
98
- results = results.slice(query.skip);
181
+ if (normalizedQuery.skip) {
182
+ records = records.slice(normalizedQuery.skip);
99
183
  }
100
- if (query.limit) {
101
- results = results.slice(0, query.limit);
184
+ if (normalizedQuery.limit) {
185
+ records = records.slice(0, normalizedQuery.limit);
102
186
  }
103
187
 
104
188
  // Apply field projection
105
- if (query.fields && Array.isArray(query.fields)) {
106
- results = results.map(doc => this.projectFields(doc, query.fields));
189
+ if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
190
+ records = records.map(doc => this.projectFields(doc, normalizedQuery.fields));
107
191
  }
108
192
 
109
- return results;
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
- for (const key of this.store.keys()) {
225
- if (key.startsWith(pattern)) {
226
- count++;
227
- }
228
- }
229
- return count;
315
+ return records.length;
230
316
  }
231
317
 
232
- // Count only records matching filters
233
- for (const [key, value] of this.store.entries()) {
234
- if (key.startsWith(pattern)) {
235
- if (this.matchesFilters(value, actualFilters)) {
236
- count++;
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 count;
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
- 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()) {
252
338
  if (key.startsWith(pattern)) {
253
- if (!filters || this.matchesFilters(record, filters)) {
254
- const value = record[field];
255
- if (value !== undefined && value !== null) {
256
- values.add(value);
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
- let count = 0;
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
- if (this.matchesFilters(record, filters)) {
287
- const updated = {
288
- ...record,
289
- ...data,
290
- id: record.id, // Preserve ID
291
- created_at: record.created_at, // Preserve created_at
292
- updated_at: new Date().toISOString()
293
- };
294
- this.store.set(key, updated);
295
- count++;
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
- const keysToDelete: string[] = [];
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
- if (this.matchesFilters(record, filters)) {
313
- keysToDelete.push(key);
314
- }
434
+ records.push(record);
435
+ recordKeys.set(record.id, key);
315
436
  }
316
437
  }
317
438
 
318
- for (const key of keysToDelete) {
319
- 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();
320
446
  }
321
447
 
322
- return { deletedCount: keysToDelete.length };
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
- * Apply filters to an array of records (in-memory filtering).
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
- * Supports ObjectQL filter format:
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 applyFilters(records: any[], filters: any[]): any[] {
360
- if (!filters || filters.length === 0) {
361
- return records;
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 records.filter(record => this.matchesFilters(record, filters));
513
+ return normalized;
365
514
  }
366
515
 
367
516
  /**
368
- * Check if a single record matches the filter conditions.
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 matchesFilters(record: any, filters: any[]): boolean {
529
+ private convertToMongoQuery(filters?: any[]): Record<string, any> {
371
530
  if (!filters || filters.length === 0) {
372
- return true;
531
+ return {};
373
532
  }
374
533
 
375
- let conditions: boolean[] = [];
376
- 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
+ ];
377
540
 
378
541
  for (const item of filters) {
379
542
  if (typeof item === 'string') {
380
543
  // Logical operator (and/or)
381
- 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
+ }
382
549
  } else if (Array.isArray(item)) {
383
550
  const [field, operator, value] = item;
384
551
 
385
- // Handle nested filter groups
386
- if (typeof field !== 'string') {
387
- // Nested group - recursively evaluate
388
- conditions.push(this.matchesFilters(record, item));
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
- // Combine conditions with operators
398
- if (conditions.length === 0) {
399
- 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
+ }
400
573
  }
401
574
 
402
- let result = conditions[0];
403
- for (let i = 0; i < operators.length; i++) {
404
- const op = operators[i];
405
- 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;
406
579
 
407
- if (op === 'or') {
408
- result = result || nextCondition;
409
- } else { // 'and' or default
410
- 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
+ }
411
588
  }
412
589
  }
413
590
 
414
- 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
+ }
415
604
  }
416
605
 
417
606
  /**
418
- * Evaluate a single filter condition.
607
+ * Convert a single ObjectQL condition to MongoDB operator format.
419
608
  */
420
- private evaluateCondition(fieldValue: any, operator: string, compareValue: any): boolean {
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 fieldValue === compareValue;
613
+ return { [field]: value };
614
+
425
615
  case '!=':
426
616
  case '<>':
427
- return fieldValue !== compareValue;
617
+ return { [field]: { $ne: value } };
618
+
428
619
  case '>':
429
- return fieldValue > compareValue;
620
+ return { [field]: { $gt: value } };
621
+
430
622
  case '>=':
431
- return fieldValue >= compareValue;
623
+ return { [field]: { $gte: value } };
624
+
432
625
  case '<':
433
- return fieldValue < compareValue;
626
+ return { [field]: { $lt: value } };
627
+
434
628
  case '<=':
435
- return fieldValue <= compareValue;
629
+ return { [field]: { $lte: value } };
630
+
436
631
  case 'in':
437
- return Array.isArray(compareValue) && compareValue.includes(fieldValue);
632
+ return { [field]: { $in: value } };
633
+
438
634
  case 'nin':
439
635
  case 'not in':
440
- return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
636
+ return { [field]: { $nin: value } };
637
+
441
638
  case 'contains':
442
639
  case 'like':
443
- 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
+
444
644
  case 'startswith':
445
645
  case 'starts_with':
446
- return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
646
+ return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
647
+
447
648
  case 'endswith':
448
649
  case 'ends_with':
449
- return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
650
+ return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
651
+
450
652
  case 'between':
451
- return Array.isArray(compareValue) &&
452
- fieldValue >= compareValue[0] &&
453
- 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
+
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
- * 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.
464
677
  */
465
- private applySort(records: any[], sort: any[]): any[] {
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 === 'asc' ? -1 : 1;
495
- 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;
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
  }