@objectql/driver-mongo 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectql/driver-mongo",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "description": "MongoDB driver for ObjectQL - Native aggregation pipeline translation for high-performance NoSQL operations",
5
5
  "keywords": [
6
6
  "objectql",
@@ -16,9 +16,9 @@
16
16
  "main": "dist/index.js",
17
17
  "types": "dist/index.d.ts",
18
18
  "dependencies": {
19
- "@objectstack/spec": "^0.3.1",
19
+ "@objectstack/spec": "^0.8.0",
20
20
  "mongodb": "^5.9.2",
21
- "@objectql/types": "4.0.1"
21
+ "@objectql/types": "4.0.3"
22
22
  },
23
23
  "devDependencies": {
24
24
  "mongodb-memory-server": "^11.0.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 = System.DriverInterface;
4
+ type DriverInterface = Data.DriverInterface;
6
5
  /**
7
6
  * ObjectQL
8
7
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -12,7 +11,24 @@ type DriverInterface = System.DriverInterface;
12
11
  */
13
12
 
14
13
  import { Driver } from '@objectql/types';
15
- import { MongoClient, Db, Filter, ObjectId, FindOptions } from 'mongodb';
14
+ import { MongoClient, Db, Filter, ObjectId, FindOptions, ChangeStream, ChangeStreamDocument } from 'mongodb';
15
+
16
+ /**
17
+ * Change stream event handler callback
18
+ */
19
+ export type ChangeStreamHandler = (change: ChangeStreamDocument) => void | Promise<void>;
20
+
21
+ /**
22
+ * Change stream options
23
+ */
24
+ export interface ChangeStreamOptions {
25
+ /** Filter for specific operation types (insert, update, delete, replace) */
26
+ operationTypes?: ('insert' | 'update' | 'delete' | 'replace')[];
27
+ /** Full document lookup for update operations */
28
+ fullDocument?: 'updateLookup' | 'whenAvailable' | 'required';
29
+ /** Pipeline to filter change events */
30
+ pipeline?: any[];
31
+ }
16
32
 
17
33
  /**
18
34
  * Command interface for executeCommand method
@@ -69,11 +85,13 @@ export class MongoDriver implements Driver {
69
85
  private db?: Db;
70
86
  private config: any;
71
87
  private connected: Promise<void>;
88
+ private changeStreams: Map<string, ChangeStream>;
72
89
 
73
90
  constructor(config: { url: string, dbName?: string }) {
74
91
  this.config = config;
75
92
  this.client = new MongoClient(config.url);
76
93
  this.connected = this.internalConnect();
94
+ this.changeStreams = new Map();
77
95
  }
78
96
 
79
97
  /**
@@ -150,12 +168,72 @@ export class MongoDriver implements Driver {
150
168
  }
151
169
 
152
170
  private mapFilters(filters: any): Filter<any> {
153
- if (!filters || filters.length === 0) return {};
171
+ if (!filters) return {};
172
+
173
+ // If filters is an object (FilterCondition format), map id fields to _id
174
+ if (typeof filters === 'object' && !Array.isArray(filters)) {
175
+ return this.mapIdFieldsInFilter(filters);
176
+ }
177
+
178
+ // If filters is an array (legacy format), convert it
179
+ if (Array.isArray(filters) && filters.length === 0) return {};
154
180
 
155
181
  const result = this.buildFilterConditions(filters);
156
182
  return result;
157
183
  }
158
184
 
185
+ /**
186
+ * Recursively map 'id' fields to '_id' in FilterCondition objects
187
+ * and normalize primitive values to use $eq operator when inside logical operators
188
+ */
189
+ private mapIdFieldsInFilter(filter: any, insideLogicalOp: boolean = false): any {
190
+ if (!filter || typeof filter !== 'object') {
191
+ return filter;
192
+ }
193
+
194
+ const result: any = {};
195
+
196
+ for (const [key, value] of Object.entries(filter)) {
197
+ // Handle logical operators
198
+ if (key === '$and' || key === '$or') {
199
+ if (Array.isArray(value)) {
200
+ // Pass true to indicate we're inside a logical operator
201
+ result[key] = value.map(v => this.mapIdFieldsInFilter(v, true));
202
+ }
203
+ }
204
+ // Map 'id' to '_id'
205
+ else if (key === 'id') {
206
+ // If value is already an object with operators, recurse
207
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
208
+ result['_id'] = this.mapIdFieldsInFilter(value, insideLogicalOp);
209
+ } else {
210
+ // Primitive value - wrap with $eq only if inside logical operator
211
+ result['_id'] = insideLogicalOp ? { $eq: value } : value;
212
+ }
213
+ }
214
+ // Skip MongoDB operator keys (starting with $) - but still recurse for nested mappings
215
+ else if (key.startsWith('$')) {
216
+ // Recursively process to handle nested objects that might contain 'id' fields
217
+ if (typeof value === 'object' && value !== null) {
218
+ result[key] = this.mapIdFieldsInFilter(value, insideLogicalOp);
219
+ } else {
220
+ result[key] = value;
221
+ }
222
+ }
223
+ // Recursively handle nested objects (already operator-wrapped values)
224
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
225
+ result[key] = this.mapIdFieldsInFilter(value, insideLogicalOp);
226
+ }
227
+ // Primitive field values
228
+ else {
229
+ // Wrap with $eq only if inside a logical operator
230
+ result[key] = insideLogicalOp ? { $eq: value } : value;
231
+ }
232
+ }
233
+
234
+ return result;
235
+ }
236
+
159
237
  /**
160
238
  * Build MongoDB filter conditions from ObjectQL filter array.
161
239
  * Supports nested filter groups and logical operators (AND/OR).
@@ -258,68 +336,45 @@ export class MongoDriver implements Driver {
258
336
  * QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
259
337
  * QueryAST uses 'aggregations', while legacy uses 'aggregate'.
260
338
  */
261
- private normalizeQuery(query: any): any {
262
- if (!query) return {};
339
+ async find(objectName: string, query: any, options?: any): Promise<any[]> {
340
+ const collection = await this.getCollection(objectName);
263
341
 
264
- const normalized: any = { ...query };
342
+ // Handle both new format (where) and legacy format (filters)
343
+ const filterCondition = query.where || query.filters;
344
+ const filter = this.mapFilters(filterCondition);
265
345
 
266
- // Normalize limit/top
267
- if (normalized.top !== undefined && normalized.limit === undefined) {
268
- normalized.limit = normalized.top;
269
- }
270
-
271
- // Normalize aggregations/aggregate
272
- if (normalized.aggregations !== undefined && normalized.aggregate === undefined) {
273
- // Convert QueryAST aggregations format to legacy aggregate format
274
- normalized.aggregate = normalized.aggregations.map((agg: any) => ({
275
- func: agg.function || agg.func,
276
- field: agg.field,
277
- alias: agg.alias
278
- }));
279
- }
346
+ const findOptions: FindOptions = {};
280
347
 
281
- // Normalize sort format
282
- if (normalized.sort && Array.isArray(normalized.sort)) {
283
- // Check if it's already in the array format [field, order]
284
- const firstSort = normalized.sort[0];
285
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
286
- // Convert from QueryAST format {field, order} to internal format [field, order]
287
- normalized.sort = normalized.sort.map((item: any) => [
288
- item.field,
289
- item.order || item.direction || item.dir || 'asc'
290
- ]);
291
- }
292
- }
348
+ // Handle pagination - support both new format (offset/limit) and legacy format (skip/top)
349
+ const offsetValue = query.offset ?? query.skip;
350
+ const limitValue = query.limit ?? query.top;
293
351
 
294
- return normalized;
295
- }
296
-
297
- async find(objectName: string, query: any, options?: any): Promise<any[]> {
298
- const normalizedQuery = this.normalizeQuery(query);
299
- const collection = await this.getCollection(objectName);
300
- const filter = this.mapFilters(normalizedQuery.filters);
352
+ if (offsetValue !== undefined) findOptions.skip = offsetValue;
353
+ if (limitValue !== undefined) findOptions.limit = limitValue;
301
354
 
302
- const findOptions: FindOptions = {};
303
- if (normalizedQuery.skip) findOptions.skip = normalizedQuery.skip;
304
- if (normalizedQuery.limit) findOptions.limit = normalizedQuery.limit;
305
- if (normalizedQuery.sort) {
306
- // map [['field', 'desc']] to { field: -1 }
355
+ // Handle sort - support both new format (orderBy) and legacy format (sort)
356
+ const sortArray = query.orderBy || query.sort;
357
+ if (sortArray && Array.isArray(sortArray)) {
307
358
  findOptions.sort = {};
308
- for (const [field, order] of normalizedQuery.sort) {
359
+ for (const item of sortArray) {
360
+ // Support both {field, order} object format and [field, order] array format
361
+ const field = item.field || item[0];
362
+ const order = item.order || item[1] || 'asc';
309
363
  // Map both 'id' and '_id' to '_id' for backward compatibility
310
364
  const dbField = (field === 'id' || field === '_id') ? '_id' : field;
311
365
  (findOptions.sort as any)[dbField] = order === 'desc' ? -1 : 1;
312
366
  }
313
367
  }
314
- if (normalizedQuery.fields && normalizedQuery.fields.length > 0) {
368
+
369
+ if (query.fields && query.fields.length > 0) {
315
370
  findOptions.projection = {};
316
- for (const field of normalizedQuery.fields) {
371
+ for (const field of query.fields) {
317
372
  // Map both 'id' and '_id' to '_id' for backward compatibility
318
373
  const dbField = (field === 'id' || field === '_id') ? '_id' : field;
319
374
  (findOptions.projection as any)[dbField] = 1;
320
375
  }
321
376
  // Explicitly exclude _id if 'id' is not in the requested fields
322
- const hasIdField = normalizedQuery.fields.some((f: string) => f === 'id' || f === '_id');
377
+ const hasIdField = query.fields.some((f: string) => f === 'id' || f === '_id');
323
378
  if (!hasIdField) {
324
379
  (findOptions.projection as any)._id = 0;
325
380
  }
@@ -383,9 +438,12 @@ export class MongoDriver implements Driver {
383
438
 
384
439
  async count(objectName: string, filters: any, options?: any): Promise<number> {
385
440
  const collection = await this.getCollection(objectName);
386
- // Normalize to support both filter arrays and full query objects
387
- const normalizedQuery = this.normalizeQuery(filters);
388
- const actualFilters = normalizedQuery.filters || filters;
441
+ // Handle both filter objects and query objects
442
+ let actualFilters = filters;
443
+ if (filters && (filters.where || filters.filters)) {
444
+ // It's a query object with 'where' or 'filters' property
445
+ actualFilters = filters.where || filters.filters;
446
+ }
389
447
  const filter = this.mapFilters(actualFilters);
390
448
  return await collection.countDocuments(filter);
391
449
  }
@@ -435,12 +493,164 @@ export class MongoDriver implements Driver {
435
493
  return this.mapFromMongoArray(results);
436
494
  }
437
495
 
496
+ /**
497
+ * Get distinct values for a field
498
+ * @param objectName - The collection name
499
+ * @param field - The field to get distinct values from
500
+ * @param filters - Optional filters to apply
501
+ * @param options - Optional query options
502
+ * @returns Array of distinct values
503
+ */
504
+ async distinct(objectName: string, field: string, filters?: any, options?: any): Promise<any[]> {
505
+ const collection = await this.getCollection(objectName);
506
+
507
+ // Convert ObjectQL filters to MongoDB query format
508
+ const filter = filters ? this.mapFilters(filters) : {};
509
+
510
+ // Use MongoDB's native distinct method
511
+ const results = await collection.distinct(field, filter);
512
+
513
+ return results;
514
+ }
515
+
516
+ /**
517
+ * Find one document and update it atomically
518
+ * @param objectName - The collection name
519
+ * @param filters - Query filters to find the document
520
+ * @param update - Update operations to apply
521
+ * @param options - Optional query options (e.g., returnDocument, upsert)
522
+ * @returns The updated document
523
+ *
524
+ * @example
525
+ * // Find and update with returnDocument: 'after' to get the updated doc
526
+ * const updated = await driver.findOneAndUpdate('users',
527
+ * { email: 'user@example.com' },
528
+ * { $set: { status: 'active' } },
529
+ * { returnDocument: 'after' }
530
+ * );
531
+ */
532
+ async findOneAndUpdate(objectName: string, filters: any, update: any, options?: any): Promise<any> {
533
+ const collection = await this.getCollection(objectName);
534
+
535
+ // Convert ObjectQL filters to MongoDB query format
536
+ const filter = this.mapFilters(filters);
537
+
538
+ // MongoDB findOneAndUpdate options
539
+ const mongoOptions: any = {
540
+ returnDocument: options?.returnDocument || 'after', // 'before' or 'after'
541
+ upsert: options?.upsert || false
542
+ };
543
+
544
+ // Execute the atomic find and update
545
+ const result = await collection.findOneAndUpdate(filter, update, mongoOptions);
546
+
547
+ // Map MongoDB document to API format (convert _id to id)
548
+ // MongoDB driver v5+ returns { value: document, ok: number }
549
+ // Older versions (v4) return the document directly
550
+ // We handle both for backward compatibility
551
+ const doc = result?.value !== undefined ? result.value : result;
552
+ return doc ? this.mapFromMongo(doc) : null;
553
+ }
554
+
438
555
  async disconnect() {
556
+ // Close all active change streams
557
+ for (const [streamId, stream] of this.changeStreams.entries()) {
558
+ await stream.close();
559
+ }
560
+ this.changeStreams.clear();
561
+
562
+ // Close the MongoDB client
439
563
  if (this.client) {
440
564
  await this.client.close();
441
565
  }
442
566
  }
443
567
 
568
+ /**
569
+ * Watch for changes in a collection using MongoDB Change Streams
570
+ * @param objectName - The collection name to watch
571
+ * @param handler - Callback function to handle change events
572
+ * @param options - Optional change stream configuration
573
+ * @returns Stream ID that can be used to close the stream later
574
+ *
575
+ * @example
576
+ * const streamId = await driver.watch('users', async (change) => {
577
+ * console.log('Change detected:', change.operationType);
578
+ * if (change.operationType === 'insert') {
579
+ * console.log('New document:', change.fullDocument);
580
+ * }
581
+ * }, {
582
+ * operationTypes: ['insert', 'update'],
583
+ * fullDocument: 'updateLookup'
584
+ * });
585
+ *
586
+ * // Later, to stop watching:
587
+ * await driver.unwatchChangeStream(streamId);
588
+ */
589
+ async watch(objectName: string, handler: ChangeStreamHandler, options?: ChangeStreamOptions): Promise<string> {
590
+ const collection = await this.getCollection(objectName);
591
+
592
+ // Build change stream pipeline
593
+ const pipeline: any[] = options?.pipeline || [];
594
+
595
+ // Add operation type filter if specified
596
+ if (options?.operationTypes && options.operationTypes.length > 0) {
597
+ pipeline.unshift({
598
+ $match: {
599
+ operationType: { $in: options.operationTypes }
600
+ }
601
+ });
602
+ }
603
+
604
+ // Configure change stream options
605
+ const streamOptions: any = {};
606
+ if (options?.fullDocument) {
607
+ streamOptions.fullDocument = options.fullDocument;
608
+ }
609
+
610
+ // Create the change stream
611
+ const changeStream = collection.watch(pipeline, streamOptions);
612
+
613
+ // Generate unique stream ID
614
+ const streamId = `${objectName}_${Date.now()}_${Math.random().toString(36).substring(7)}`;
615
+
616
+ // Store the stream
617
+ this.changeStreams.set(streamId, changeStream);
618
+
619
+ // Handle change events
620
+ changeStream.on('change', async (change) => {
621
+ try {
622
+ await handler(change);
623
+ } catch (error) {
624
+ console.error(`[MongoDriver] Error in change stream handler for ${objectName}:`, error);
625
+ }
626
+ });
627
+
628
+ changeStream.on('error', (error) => {
629
+ console.error(`[MongoDriver] Change stream error for ${objectName}:`, error);
630
+ });
631
+
632
+ return streamId;
633
+ }
634
+
635
+ /**
636
+ * Stop watching a change stream
637
+ * @param streamId - The stream ID returned by watch()
638
+ */
639
+ async unwatchChangeStream(streamId: string): Promise<void> {
640
+ const stream = this.changeStreams.get(streamId);
641
+ if (stream) {
642
+ await stream.close();
643
+ this.changeStreams.delete(streamId);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Get all active change stream IDs
649
+ */
650
+ getActiveChangeStreams(): string[] {
651
+ return Array.from(this.changeStreams.keys());
652
+ }
653
+
444
654
  /**
445
655
  * Execute a query using QueryAST (DriverInterface v4.0 method)
446
656
  *
@@ -452,19 +662,9 @@ export class MongoDriver implements Driver {
452
662
  * @returns Query results with value and count
453
663
  */
454
664
  async executeQuery(ast: QueryAST, options?: any): Promise<{ value: any[]; count?: number }> {
455
- const objectName = ast.object || '';
456
-
457
- // Convert QueryAST to legacy query format
458
- const legacyQuery: any = {
459
- fields: ast.fields,
460
- filters: this.convertFilterNodeToLegacy(ast.filters),
461
- sort: ast.sort?.map((s: SortNode) => [s.field, s.order]),
462
- limit: ast.top,
463
- skip: ast.skip,
464
- };
465
-
466
- // Use existing find method
467
- const results = await this.find(objectName, legacyQuery, options);
665
+ // QueryAST is now the same format as our internal query
666
+ // Just pass it directly to find
667
+ const results = await this.find(ast.object || '', ast, options);
468
668
 
469
669
  return {
470
670
  value: results,
@@ -575,54 +775,9 @@ export class MongoDriver implements Driver {
575
775
  }
576
776
 
577
777
  /**
578
- * Convert FilterNode (QueryAST format) to legacy filter array format
778
+ * Convert FilterCondition (QueryAST format) to legacy filter array format
579
779
  * This allows reuse of existing filter logic while supporting new QueryAST
580
780
  *
581
- * @private
582
- */
583
- private convertFilterNodeToLegacy(node?: FilterNode): any {
584
- if (!node) return undefined;
585
-
586
- switch (node.type) {
587
- case 'comparison':
588
- // Convert comparison node to [field, operator, value] format
589
- const operator = node.operator || '=';
590
- return [[node.field, operator, node.value]];
591
-
592
- case 'and':
593
- case 'or':
594
- // Convert AND/OR node to array with separator
595
- if (!node.children || node.children.length === 0) return undefined;
596
- const results: any[] = [];
597
- const separator = node.type; // 'and' or 'or'
598
-
599
- for (const child of node.children) {
600
- const converted = this.convertFilterNodeToLegacy(child);
601
- if (converted) {
602
- if (results.length > 0) {
603
- results.push(separator);
604
- }
605
- results.push(...(Array.isArray(converted) ? converted : [converted]));
606
- }
607
- }
608
- return results.length > 0 ? results : undefined;
609
-
610
- case 'not':
611
- // NOT is not directly supported in the legacy filter format
612
- // MongoDB supports $not, but legacy array format doesn't have a NOT operator
613
- // Use native MongoDB queries with $not instead:
614
- // Example: { field: { $not: { $eq: value } } }
615
- throw new Error(
616
- 'NOT filters are not supported in legacy filter format. ' +
617
- 'Use native MongoDB queries with $not operator instead. ' +
618
- 'Example: { field: { $not: { $eq: value } } }'
619
- );
620
-
621
- default:
622
- return undefined;
623
- }
624
- }
625
-
626
781
  /**
627
782
  * Execute command (alternative signature for compatibility)
628
783
  *