@objectql/driver-mongo 4.0.0 → 4.0.2

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,7 @@
1
+ import { Data, Driver as DriverSpec } from '@objectstack/spec';
2
+ type QueryAST = Data.QueryAST;
3
+ type SortNode = Data.SortNode;
4
+ type DriverInterface = DriverSpec.DriverInterface;
1
5
  /**
2
6
  * ObjectQL
3
7
  * Copyright (c) 2026-present ObjectStack Inc.
@@ -7,7 +11,6 @@
7
11
  */
8
12
 
9
13
  import { Driver } from '@objectql/types';
10
- import { DriverInterface, QueryAST, FilterNode, SortNode } from '@objectstack/spec';
11
14
  import { MongoClient, Db, Filter, ObjectId, FindOptions } from 'mongodb';
12
15
 
13
16
  /**
@@ -43,7 +46,7 @@ export interface CommandResult {
43
46
  *
44
47
  * The driver internally converts QueryAST format to MongoDB query format.
45
48
  */
46
- export class MongoDriver implements Driver, DriverInterface {
49
+ export class MongoDriver implements Driver {
47
50
  // Driver metadata (ObjectStack-compatible)
48
51
  public readonly name = 'MongoDriver';
49
52
  public readonly version = '3.0.1';
@@ -52,7 +55,13 @@ export class MongoDriver implements Driver, DriverInterface {
52
55
  joins: false,
53
56
  fullTextSearch: true,
54
57
  jsonFields: true,
55
- arrayFields: true
58
+ arrayFields: true,
59
+ queryFilters: true,
60
+ queryAggregations: true,
61
+ querySorting: true,
62
+ queryPagination: true,
63
+ queryWindowFunctions: false,
64
+ querySubqueries: false
56
65
  };
57
66
 
58
67
  private client: MongoClient;
@@ -140,12 +149,72 @@ export class MongoDriver implements Driver, DriverInterface {
140
149
  }
141
150
 
142
151
  private mapFilters(filters: any): Filter<any> {
143
- if (!filters || filters.length === 0) return {};
152
+ if (!filters) return {};
153
+
154
+ // If filters is an object (FilterCondition format), map id fields to _id
155
+ if (typeof filters === 'object' && !Array.isArray(filters)) {
156
+ return this.mapIdFieldsInFilter(filters);
157
+ }
158
+
159
+ // If filters is an array (legacy format), convert it
160
+ if (Array.isArray(filters) && filters.length === 0) return {};
144
161
 
145
162
  const result = this.buildFilterConditions(filters);
146
163
  return result;
147
164
  }
148
165
 
166
+ /**
167
+ * Recursively map 'id' fields to '_id' in FilterCondition objects
168
+ * and normalize primitive values to use $eq operator when inside logical operators
169
+ */
170
+ private mapIdFieldsInFilter(filter: any, insideLogicalOp: boolean = false): any {
171
+ if (!filter || typeof filter !== 'object') {
172
+ return filter;
173
+ }
174
+
175
+ const result: any = {};
176
+
177
+ for (const [key, value] of Object.entries(filter)) {
178
+ // Handle logical operators
179
+ if (key === '$and' || key === '$or') {
180
+ if (Array.isArray(value)) {
181
+ // Pass true to indicate we're inside a logical operator
182
+ result[key] = value.map(v => this.mapIdFieldsInFilter(v, true));
183
+ }
184
+ }
185
+ // Map 'id' to '_id'
186
+ else if (key === 'id') {
187
+ // If value is already an object with operators, recurse
188
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
189
+ result['_id'] = this.mapIdFieldsInFilter(value, insideLogicalOp);
190
+ } else {
191
+ // Primitive value - wrap with $eq only if inside logical operator
192
+ result['_id'] = insideLogicalOp ? { $eq: value } : value;
193
+ }
194
+ }
195
+ // Skip MongoDB operator keys (starting with $) - but still recurse for nested mappings
196
+ else if (key.startsWith('$')) {
197
+ // Recursively process to handle nested objects that might contain 'id' fields
198
+ if (typeof value === 'object' && value !== null) {
199
+ result[key] = this.mapIdFieldsInFilter(value, insideLogicalOp);
200
+ } else {
201
+ result[key] = value;
202
+ }
203
+ }
204
+ // Recursively handle nested objects (already operator-wrapped values)
205
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
206
+ result[key] = this.mapIdFieldsInFilter(value, insideLogicalOp);
207
+ }
208
+ // Primitive field values
209
+ else {
210
+ // Wrap with $eq only if inside a logical operator
211
+ result[key] = insideLogicalOp ? { $eq: value } : value;
212
+ }
213
+ }
214
+
215
+ return result;
216
+ }
217
+
149
218
  /**
150
219
  * Build MongoDB filter conditions from ObjectQL filter array.
151
220
  * Supports nested filter groups and logical operators (AND/OR).
@@ -248,68 +317,45 @@ export class MongoDriver implements Driver, DriverInterface {
248
317
  * QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
249
318
  * QueryAST uses 'aggregations', while legacy uses 'aggregate'.
250
319
  */
251
- private normalizeQuery(query: any): any {
252
- if (!query) return {};
253
-
254
- const normalized: any = { ...query };
320
+ async find(objectName: string, query: any, options?: any): Promise<any[]> {
321
+ const collection = await this.getCollection(objectName);
255
322
 
256
- // Normalize limit/top
257
- if (normalized.top !== undefined && normalized.limit === undefined) {
258
- normalized.limit = normalized.top;
259
- }
323
+ // Handle both new format (where) and legacy format (filters)
324
+ const filterCondition = query.where || query.filters;
325
+ const filter = this.mapFilters(filterCondition);
260
326
 
261
- // Normalize aggregations/aggregate
262
- if (normalized.aggregations !== undefined && normalized.aggregate === undefined) {
263
- // Convert QueryAST aggregations format to legacy aggregate format
264
- normalized.aggregate = normalized.aggregations.map((agg: any) => ({
265
- func: agg.function || agg.func,
266
- field: agg.field,
267
- alias: agg.alias
268
- }));
269
- }
327
+ const findOptions: FindOptions = {};
270
328
 
271
- // Normalize sort format
272
- if (normalized.sort && Array.isArray(normalized.sort)) {
273
- // Check if it's already in the array format [field, order]
274
- const firstSort = normalized.sort[0];
275
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
276
- // Convert from QueryAST format {field, order} to internal format [field, order]
277
- normalized.sort = normalized.sort.map((item: any) => [
278
- item.field,
279
- item.order || item.direction || item.dir || 'asc'
280
- ]);
281
- }
282
- }
329
+ // Handle pagination - support both new format (offset/limit) and legacy format (skip/top)
330
+ const offsetValue = query.offset ?? query.skip;
331
+ const limitValue = query.limit ?? query.top;
283
332
 
284
- return normalized;
285
- }
286
-
287
- async find(objectName: string, query: any, options?: any): Promise<any[]> {
288
- const normalizedQuery = this.normalizeQuery(query);
289
- const collection = await this.getCollection(objectName);
290
- const filter = this.mapFilters(normalizedQuery.filters);
333
+ if (offsetValue !== undefined) findOptions.skip = offsetValue;
334
+ if (limitValue !== undefined) findOptions.limit = limitValue;
291
335
 
292
- const findOptions: FindOptions = {};
293
- if (normalizedQuery.skip) findOptions.skip = normalizedQuery.skip;
294
- if (normalizedQuery.limit) findOptions.limit = normalizedQuery.limit;
295
- if (normalizedQuery.sort) {
296
- // map [['field', 'desc']] to { field: -1 }
336
+ // Handle sort - support both new format (orderBy) and legacy format (sort)
337
+ const sortArray = query.orderBy || query.sort;
338
+ if (sortArray && Array.isArray(sortArray)) {
297
339
  findOptions.sort = {};
298
- for (const [field, order] of normalizedQuery.sort) {
340
+ for (const item of sortArray) {
341
+ // Support both {field, order} object format and [field, order] array format
342
+ const field = item.field || item[0];
343
+ const order = item.order || item[1] || 'asc';
299
344
  // Map both 'id' and '_id' to '_id' for backward compatibility
300
345
  const dbField = (field === 'id' || field === '_id') ? '_id' : field;
301
346
  (findOptions.sort as any)[dbField] = order === 'desc' ? -1 : 1;
302
347
  }
303
348
  }
304
- if (normalizedQuery.fields && normalizedQuery.fields.length > 0) {
349
+
350
+ if (query.fields && query.fields.length > 0) {
305
351
  findOptions.projection = {};
306
- for (const field of normalizedQuery.fields) {
352
+ for (const field of query.fields) {
307
353
  // Map both 'id' and '_id' to '_id' for backward compatibility
308
354
  const dbField = (field === 'id' || field === '_id') ? '_id' : field;
309
355
  (findOptions.projection as any)[dbField] = 1;
310
356
  }
311
357
  // Explicitly exclude _id if 'id' is not in the requested fields
312
- const hasIdField = normalizedQuery.fields.some((f: string) => f === 'id' || f === '_id');
358
+ const hasIdField = query.fields.some((f: string) => f === 'id' || f === '_id');
313
359
  if (!hasIdField) {
314
360
  (findOptions.projection as any)._id = 0;
315
361
  }
@@ -373,9 +419,12 @@ export class MongoDriver implements Driver, DriverInterface {
373
419
 
374
420
  async count(objectName: string, filters: any, options?: any): Promise<number> {
375
421
  const collection = await this.getCollection(objectName);
376
- // Normalize to support both filter arrays and full query objects
377
- const normalizedQuery = this.normalizeQuery(filters);
378
- const actualFilters = normalizedQuery.filters || filters;
422
+ // Handle both filter objects and query objects
423
+ let actualFilters = filters;
424
+ if (filters && (filters.where || filters.filters)) {
425
+ // It's a query object with 'where' or 'filters' property
426
+ actualFilters = filters.where || filters.filters;
427
+ }
379
428
  const filter = this.mapFilters(actualFilters);
380
429
  return await collection.countDocuments(filter);
381
430
  }
@@ -442,19 +491,9 @@ export class MongoDriver implements Driver, DriverInterface {
442
491
  * @returns Query results with value and count
443
492
  */
444
493
  async executeQuery(ast: QueryAST, options?: any): Promise<{ value: any[]; count?: number }> {
445
- const objectName = ast.object || '';
446
-
447
- // Convert QueryAST to legacy query format
448
- const legacyQuery: any = {
449
- fields: ast.fields,
450
- filters: this.convertFilterNodeToLegacy(ast.filters),
451
- sort: ast.sort?.map((s: SortNode) => [s.field, s.order]),
452
- limit: ast.top,
453
- skip: ast.skip,
454
- };
455
-
456
- // Use existing find method
457
- const results = await this.find(objectName, legacyQuery, options);
494
+ // QueryAST is now the same format as our internal query
495
+ // Just pass it directly to find
496
+ const results = await this.find(ast.object || '', ast, options);
458
497
 
459
498
  return {
460
499
  value: results,
@@ -565,54 +604,9 @@ export class MongoDriver implements Driver, DriverInterface {
565
604
  }
566
605
 
567
606
  /**
568
- * Convert FilterNode (QueryAST format) to legacy filter array format
607
+ * Convert FilterCondition (QueryAST format) to legacy filter array format
569
608
  * This allows reuse of existing filter logic while supporting new QueryAST
570
609
  *
571
- * @private
572
- */
573
- private convertFilterNodeToLegacy(node?: FilterNode): any {
574
- if (!node) return undefined;
575
-
576
- switch (node.type) {
577
- case 'comparison':
578
- // Convert comparison node to [field, operator, value] format
579
- const operator = node.operator || '=';
580
- return [[node.field, operator, node.value]];
581
-
582
- case 'and':
583
- case 'or':
584
- // Convert AND/OR node to array with separator
585
- if (!node.children || node.children.length === 0) return undefined;
586
- const results: any[] = [];
587
- const separator = node.type; // 'and' or 'or'
588
-
589
- for (const child of node.children) {
590
- const converted = this.convertFilterNodeToLegacy(child);
591
- if (converted) {
592
- if (results.length > 0) {
593
- results.push(separator);
594
- }
595
- results.push(...(Array.isArray(converted) ? converted : [converted]));
596
- }
597
- }
598
- return results.length > 0 ? results : undefined;
599
-
600
- case 'not':
601
- // NOT is not directly supported in the legacy filter format
602
- // MongoDB supports $not, but legacy array format doesn't have a NOT operator
603
- // Use native MongoDB queries with $not instead:
604
- // Example: { field: { $not: { $eq: value } } }
605
- throw new Error(
606
- 'NOT filters are not supported in legacy filter format. ' +
607
- 'Use native MongoDB queries with $not operator instead. ' +
608
- 'Example: { field: { $not: { $eq: value } } }'
609
- );
610
-
611
- default:
612
- return undefined;
613
- }
614
- }
615
-
616
610
  /**
617
611
  * Execute command (alternative signature for compatibility)
618
612
  *
@@ -62,23 +62,16 @@ describe('MongoDriver', () => {
62
62
 
63
63
  it('should find objects with query', async () => {
64
64
  const query = {
65
- filters: [['age', '>', 18]],
66
- sort: [['name', 'asc']],
67
- skip: 10,
65
+ where: { age: { $gt: 18 } },
66
+ orderBy: [{ field: 'name', order: 'asc' as const }],
67
+ offset: 10,
68
68
  limit: 5
69
69
  };
70
70
 
71
71
  await driver.find('users', query);
72
72
 
73
- // Debugging what was actually called
74
- // console.log('Find calls:', mockCollection.find.mock.calls);
75
-
76
73
  expect(mockDb.collection).toHaveBeenCalledWith('users');
77
74
 
78
- // We expect: find(filter, options)
79
- // filter = { $and: [{ age: { $gt: 18 } }] }
80
- // options = { limit: 5, skip: 10, sort: { name: 1 } }
81
-
82
75
  expect(mockCollection.find).toHaveBeenCalledWith(
83
76
  { age: { $gt: 18 } },
84
77
  expect.objectContaining({
@@ -92,7 +85,12 @@ describe('MongoDriver', () => {
92
85
 
93
86
  it('should handle OR filters', async () => {
94
87
  const query = {
95
- filters: [['age', '>', 18], 'or', ['role', '=', 'admin']]
88
+ where: {
89
+ $or: [
90
+ { age: { $gt: 18 } },
91
+ { role: 'admin' }
92
+ ]
93
+ }
96
94
  };
97
95
  await driver.find('users', query);
98
96
 
@@ -109,7 +107,7 @@ describe('MongoDriver', () => {
109
107
 
110
108
  it('should map "id" field to "_id" in filters', async () => {
111
109
  const query = {
112
- filters: [['id', '=', '12345']]
110
+ where: { id: '12345' }
113
111
  };
114
112
  await driver.find('users', query);
115
113
 
@@ -121,7 +119,7 @@ describe('MongoDriver', () => {
121
119
 
122
120
  it('should map "id" to "_id" in sorting', async () => {
123
121
  const query = {
124
- sort: [['id', 'desc']]
122
+ orderBy: [{ field: 'id', order: 'desc' as const }]
125
123
  };
126
124
  await driver.find('users', query);
127
125
 
@@ -192,7 +190,7 @@ describe('MongoDriver', () => {
192
190
  // Backward compatibility tests for legacy '_id' usage
193
191
  it('should accept "_id" field in filters for backward compatibility', async () => {
194
192
  const query = {
195
- filters: [['_id', '=', '12345']]
193
+ where: { _id: '12345' }
196
194
  };
197
195
  await driver.find('users', query);
198
196
 
@@ -204,7 +202,7 @@ describe('MongoDriver', () => {
204
202
 
205
203
  it('should accept "_id" in sorting for backward compatibility', async () => {
206
204
  const query = {
207
- sort: [['_id', 'asc']]
205
+ orderBy: [{ field: '_id', order: 'asc' as const }]
208
206
  };
209
207
  await driver.find('users', query);
210
208
 
@@ -237,19 +235,22 @@ describe('MongoDriver', () => {
237
235
 
238
236
  it('should handle nested filter groups', async () => {
239
237
  const query = {
240
- filters: [
241
- [
242
- ['status', '=', 'completed'],
243
- 'and',
244
- ['amount', '>', 100]
245
- ],
246
- 'or',
247
- [
248
- ['customer', '=', 'Alice'],
249
- 'and',
250
- ['status', '=', 'pending']
238
+ where: {
239
+ $or: [
240
+ {
241
+ $and: [
242
+ { status: 'completed' },
243
+ { amount: { $gt: 100 } }
244
+ ]
245
+ },
246
+ {
247
+ $and: [
248
+ { customer: 'Alice' },
249
+ { status: 'pending' }
250
+ ]
251
+ }
251
252
  ]
252
- ]
253
+ }
253
254
  };
254
255
  await driver.find('orders', query);
255
256
 
@@ -271,19 +272,22 @@ describe('MongoDriver', () => {
271
272
 
272
273
  it('should handle deeply nested filter groups', async () => {
273
274
  const query = {
274
- filters: [
275
- [
276
- [
277
- ['age', '>', 22],
278
- 'and',
279
- ['status', '=', 'active']
280
- ],
281
- 'or',
282
- ['role', '=', 'admin']
283
- ],
284
- 'and',
285
- ['name', '!=', 'Bob']
286
- ]
275
+ where: {
276
+ $and: [
277
+ {
278
+ $or: [
279
+ {
280
+ $and: [
281
+ { age: { $gt: 22 } },
282
+ { status: 'active' }
283
+ ]
284
+ },
285
+ { role: 'admin' }
286
+ ]
287
+ },
288
+ { name: { $ne: 'Bob' } }
289
+ ]
290
+ }
287
291
  };
288
292
  await driver.find('users', query);
289
293
 
@@ -313,13 +317,17 @@ describe('MongoDriver', () => {
313
317
 
314
318
  it('should handle nested groups with implicit AND', async () => {
315
319
  const query = {
316
- filters: [
317
- [
318
- ['status', '=', 'active'],
319
- ['role', '=', 'admin']
320
- ],
321
- ['age', '>', 25]
322
- ]
320
+ where: {
321
+ $and: [
322
+ {
323
+ $and: [
324
+ { status: 'active' },
325
+ { role: 'admin' }
326
+ ]
327
+ },
328
+ { age: { $gt: 25 } }
329
+ ]
330
+ }
323
331
  };
324
332
  await driver.find('users', query);
325
333
 
@@ -341,14 +349,11 @@ describe('MongoDriver', () => {
341
349
  const ast = {
342
350
  object: 'users',
343
351
  fields: ['name', 'email'],
344
- filters: {
345
- type: 'comparison' as const,
346
- field: 'status',
347
- operator: '=',
348
- value: 'active'
352
+ where: {
353
+ status: 'active'
349
354
  },
350
- top: 10,
351
- skip: 0
355
+ limit: 10,
356
+ offset: 0
352
357
  };
353
358
 
354
359
  mockCollection.toArray.mockResolvedValue([
@@ -366,21 +371,10 @@ describe('MongoDriver', () => {
366
371
  it('should handle complex QueryAST with AND filters', async () => {
367
372
  const ast = {
368
373
  object: 'users',
369
- filters: {
370
- type: 'and' as const,
371
- children: [
372
- {
373
- type: 'comparison' as const,
374
- field: 'status',
375
- operator: '=',
376
- value: 'active'
377
- },
378
- {
379
- type: 'comparison' as const,
380
- field: 'age',
381
- operator: '>',
382
- value: 18
383
- }
374
+ where: {
375
+ $and: [
376
+ { status: 'active' },
377
+ { age: { $gt: 18 } }
384
378
  ]
385
379
  }
386
380
  };
@@ -396,7 +390,7 @@ describe('MongoDriver', () => {
396
390
  it('should handle QueryAST with sort', async () => {
397
391
  const ast = {
398
392
  object: 'users',
399
- sort: [
393
+ orderBy: [
400
394
  { field: 'name', order: 'asc' as const }
401
395
  ]
402
396
  };